Skip to content

Commit 05e768d

Browse files
committed
fix(web-ui): harden streamed params and pet activity
1 parent e491491 commit 05e768d

11 files changed

Lines changed: 298 additions & 27 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import type { AgentCompanionActivityPayload } from '../utils/agentCompanionActivity';
3+
import { emitAgentCompanionActivity } from './AgentCompanionActivityBridge';
4+
5+
const tauriEvent = vi.hoisted(() => ({
6+
emit: vi.fn(),
7+
}));
8+
9+
vi.mock('@tauri-apps/api/event', () => ({
10+
emit: tauriEvent.emit,
11+
}));
12+
13+
vi.mock('@/infrastructure/runtime', () => ({
14+
isTauriRuntime: () => true,
15+
}));
16+
17+
function hasLoneSurrogate(value: string): boolean {
18+
for (let index = 0; index < value.length; index += 1) {
19+
const code = value.charCodeAt(index);
20+
if (code >= 0xd800 && code <= 0xdbff) {
21+
const next = value.charCodeAt(index + 1);
22+
if (!(next >= 0xdc00 && next <= 0xdfff)) {
23+
return true;
24+
}
25+
index += 1;
26+
continue;
27+
}
28+
if (code >= 0xdc00 && code <= 0xdfff) {
29+
return true;
30+
}
31+
}
32+
33+
return false;
34+
}
35+
36+
describe('emitAgentCompanionActivity', () => {
37+
afterEach(() => {
38+
tauriEvent.emit.mockReset();
39+
});
40+
41+
it('normalizes activity strings before crossing the desktop event boundary', async () => {
42+
await emitAgentCompanionActivity({
43+
mood: 'working',
44+
tasks: [{
45+
sessionId: 'session-\uD800',
46+
title: 'Broken \uD800 title',
47+
mood: 'working',
48+
state: 'running',
49+
labelKey: 'agentCompanion.activity.working',
50+
defaultLabel: 'Working \uDC00',
51+
latestOutput: 'Output \uD800',
52+
startedAt: 1000,
53+
updatedAt: 1200,
54+
}],
55+
});
56+
57+
const emittedActivity = tauriEvent.emit.mock.calls[0]?.[1] as AgentCompanionActivityPayload;
58+
59+
expect(tauriEvent.emit).toHaveBeenCalledWith('agent-companion://activity-updated', expect.any(Object));
60+
expect(hasLoneSurrogate(emittedActivity.tasks[0].sessionId)).toBe(false);
61+
expect(hasLoneSurrogate(emittedActivity.tasks[0].title)).toBe(false);
62+
expect(hasLoneSurrogate(emittedActivity.tasks[0].defaultLabel)).toBe(false);
63+
expect(hasLoneSurrogate(emittedActivity.tasks[0].latestOutput!)).toBe(false);
64+
});
65+
});

src/web-ui/src/flow_chat/services/AgentCompanionActivityBridge.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,36 @@
11
import { isTauriRuntime } from '@/infrastructure/runtime';
22
import { createLogger } from '@/shared/utils/logger';
3-
import type { AgentCompanionActivityPayload } from '../utils/agentCompanionActivity';
3+
import { toWellFormedText } from '@/shared/utils/wellFormedText';
4+
import type { AgentCompanionActivityPayload, AgentCompanionTaskStatus } from '../utils/agentCompanionActivity';
45

56
const log = createLogger('AgentCompanionActivityBridge');
67
let activitySequence = 0;
78

9+
function sanitizeTaskForEmit(task: AgentCompanionTaskStatus): AgentCompanionTaskStatus {
10+
return {
11+
...task,
12+
sessionId: toWellFormedText(task.sessionId),
13+
title: toWellFormedText(task.title),
14+
labelKey: toWellFormedText(task.labelKey),
15+
defaultLabel: toWellFormedText(task.defaultLabel),
16+
latestOutput: task.latestOutput === undefined ? undefined : toWellFormedText(task.latestOutput),
17+
};
18+
}
19+
20+
function sanitizeActivityForEmit(activity: AgentCompanionActivityPayload): AgentCompanionActivityPayload {
21+
return {
22+
...activity,
23+
tasks: activity.tasks.map(sanitizeTaskForEmit),
24+
};
25+
}
26+
827
export async function emitAgentCompanionActivity(
928
activity: AgentCompanionActivityPayload,
1029
): Promise<void> {
1130
if (!isTauriRuntime()) return;
1231

1332
const sequencedActivity: AgentCompanionActivityPayload = {
14-
...activity,
33+
...sanitizeActivityForEmit(activity),
1534
sequence: activitySequence += 1,
1635
emittedAt: Date.now(),
1736
};

src/web-ui/src/flow_chat/services/EventBatcher.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,10 @@ export interface ParamsPartialToolEvent extends BaseToolEvent<'ParamsPartial'> {
214214
params: string;
215215
}
216216

217+
export function normalizeParamsPartialFragment(params: unknown): string {
218+
return typeof params === 'string' ? params : '';
219+
}
220+
217221
export interface QueuedToolEvent extends BaseToolEvent<'Queued'> {
218222
position: number;
219223
}

src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { agenticEventListener, type AgenticEventCallbacks } from '../AgenticEven
1010
import {
1111
generateTextChunkKey,
1212
generateToolEventKey,
13+
normalizeParamsPartialFragment,
1314
parseEventKey,
1415
type FlowToolEvent,
1516
type SubagentParentInfo,
@@ -1573,8 +1574,8 @@ function handleToolEvent(
15731574
toolEvent: {
15741575
...(existing.toolEvent as ParamsPartialToolEvent),
15751576
params:
1576-
(existing.toolEvent as ParamsPartialToolEvent).params +
1577-
(incoming.toolEvent as ParamsPartialToolEvent).params
1577+
normalizeParamsPartialFragment((existing.toolEvent as ParamsPartialToolEvent).params) +
1578+
normalizeParamsPartialFragment((incoming.toolEvent as ParamsPartialToolEvent).params)
15781579
}
15791580
})
15801581
);
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { afterEach, describe, expect, it } from 'vitest';
2+
import { FlowChatStore } from '../../store/FlowChatStore';
3+
import type { DialogTurn, FlowToolItem, ModelRound, Session } from '../../types/flow-chat';
4+
import { processToolParamsPartialInternal } from './ToolEventModule';
5+
6+
function resetStore(): void {
7+
FlowChatStore.getInstance().setState(() => ({
8+
sessions: new Map(),
9+
activeSessionId: null,
10+
}));
11+
}
12+
13+
function createSessionWithTool(tool: FlowToolItem): Session {
14+
const round: ModelRound = {
15+
id: 'round-1',
16+
index: 0,
17+
items: [tool],
18+
isStreaming: true,
19+
isComplete: false,
20+
status: 'streaming',
21+
startTime: 1000,
22+
};
23+
const turn: DialogTurn = {
24+
id: 'turn-1',
25+
sessionId: 'session-1',
26+
userMessage: {
27+
id: 'user-1',
28+
content: 'Inspect this file',
29+
timestamp: 900,
30+
},
31+
modelRounds: [round],
32+
status: 'processing',
33+
startTime: 900,
34+
};
35+
36+
return {
37+
sessionId: 'session-1',
38+
title: 'Session 1',
39+
dialogTurns: [turn],
40+
status: 'active',
41+
config: { agentType: 'agentic' },
42+
createdAt: 800,
43+
lastActiveAt: 1000,
44+
error: null,
45+
sessionKind: 'normal',
46+
};
47+
}
48+
49+
describe('processToolParamsPartialInternal', () => {
50+
afterEach(() => {
51+
resetStore();
52+
});
53+
54+
it('drops malformed non-string params fragments without replacing existing preview state', () => {
55+
const existingParams = { file_path: 'src/main.rs' };
56+
const tool: FlowToolItem = {
57+
id: 'tool-1',
58+
type: 'tool',
59+
toolName: 'Read',
60+
timestamp: 1001,
61+
status: 'streaming',
62+
toolCall: {
63+
id: 'tool-1',
64+
input: existingParams,
65+
},
66+
isParamsStreaming: true,
67+
partialParams: existingParams,
68+
_paramsBuffer: '{"file_path":"src/main.rs"}',
69+
};
70+
71+
FlowChatStore.getInstance().setState(() => ({
72+
sessions: new Map([['session-1', createSessionWithTool(tool)]]),
73+
activeSessionId: 'session-1',
74+
}));
75+
76+
expect(() => {
77+
processToolParamsPartialInternal('session-1', 'turn-1', {
78+
event_type: 'ParamsPartial',
79+
tool_id: 'tool-1',
80+
tool_name: 'Read',
81+
params: { file_path: 'src/lib.rs' } as any,
82+
});
83+
}).not.toThrow();
84+
85+
const updatedTool = FlowChatStore.getInstance()
86+
.findToolItem('session-1', 'turn-1', 'tool-1') as FlowToolItem;
87+
88+
expect(updatedTool._paramsBuffer).toBe('{"file_path":"src/main.rs"}');
89+
expect(updatedTool.partialParams).toEqual(existingParams);
90+
expect(updatedTool.toolCall.input).toEqual(existingParams);
91+
});
92+
});

src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createLogger } from '@/shared/utils/logger';
99
import type { FlowChatContext, FlowToolItem, ToolEventOptions, DialogTurn } from './types';
1010
import { immediateSaveDialogTurn } from './PersistenceModule';
1111
import { applyPendingAcpPermissionForTool } from './AcpPermissionToolCardModule';
12+
import { normalizeParamsPartialFragment } from '../EventBatcher';
1213
import type {
1314
CancelledToolEvent,
1415
CompletedToolEvent,
@@ -180,7 +181,10 @@ function applyParamsPartial(
180181
return;
181182
}
182183

183-
const incomingParams = toolEvent.params || '';
184+
const incomingParams = normalizeParamsPartialFragment(toolEvent.params);
185+
if (!incomingParams) {
186+
return;
187+
}
184188
const isWriteFullParamsSnapshot = isWriteTool && incomingParams.trimStart().startsWith('{');
185189
const newBuffer = isWriteFullParamsSnapshot ? incomingParams : prevBuffer + incomingParams;
186190

src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,25 @@ import { ProcessingPhase, SessionExecutionEvent, SessionExecutionState } from '.
55
import type { DialogTurn, Session } from '../types/flow-chat';
66
import { buildAgentCompanionActivity } from './agentCompanionActivity';
77

8+
function hasLoneSurrogate(value: string): boolean {
9+
for (let index = 0; index < value.length; index += 1) {
10+
const code = value.charCodeAt(index);
11+
if (code >= 0xd800 && code <= 0xdbff) {
12+
const next = value.charCodeAt(index + 1);
13+
if (!(next >= 0xdc00 && next <= 0xdfff)) {
14+
return true;
15+
}
16+
index += 1;
17+
continue;
18+
}
19+
if (code >= 0xdc00 && code <= 0xdfff) {
20+
return true;
21+
}
22+
}
23+
24+
return false;
25+
}
26+
827
function resetState(): void {
928
flowChatStore.setState(() => ({
1029
sessions: new Map(),
@@ -166,6 +185,20 @@ describe('buildAgentCompanionActivity', () => {
166185
expect(activity.tasks[0].latestOutput?.endsWith('...')).toBe(false);
167186
});
168187

188+
it('keeps truncated latest output well-formed for desktop pet events', async () => {
189+
const content = '\uD83D\uDE00' + 'a'.repeat(511);
190+
flowChatStore.setState(() => ({
191+
sessions: new Map([['session-1', createStreamingSessionWithText(content)]]),
192+
activeSessionId: 'session-1',
193+
}));
194+
await putStateMachineInStreaming();
195+
196+
const latestOutput = buildAgentCompanionActivity().tasks[0].latestOutput;
197+
198+
expect(latestOutput).toBeDefined();
199+
expect(hasLoneSurrogate(latestOutput!)).toBe(false);
200+
});
201+
169202
it('keeps the final assistant output visible after completion', () => {
170203
const finalText = 'Final analysis summary remains visible in the companion bubble.';
171204
flowChatStore.setState(() => ({

src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { stateMachineManager } from '../state-machine/SessionStateMachineManager
33
import { ProcessingPhase, type SessionStateMachine } from '../state-machine/types';
44
import { deriveChatInputPetMood, type ChatInputPetMood } from './chatInputPetMood';
55
import type { DialogTurn, FlowTextItem, FlowThinkingItem, Session } from '../types/flow-chat';
6+
import { toWellFormedText } from '@/shared/utils/wellFormedText';
67

78
export type AgentCompanionTaskState =
89
| 'running'
@@ -69,7 +70,7 @@ function pruneTaskOrder(activeTasks: AgentCompanionTaskStatus[]): void {
6970
}
7071

7172
function sessionTitle(session: Session): string {
72-
return session.title?.trim() || 'Session';
73+
return toWellFormedText(session.title?.trim() || 'Session');
7374
}
7475

7576
function markdownToPlainText(markdown: string): string {
@@ -89,11 +90,12 @@ function markdownToPlainText(markdown: string): string {
8990
}
9091

9192
function truncateLatestOutput(text: string): string {
92-
if (text.length <= LATEST_OUTPUT_MAX_CHARS) {
93-
return text;
93+
const wellFormedText = toWellFormedText(text);
94+
if (wellFormedText.length <= LATEST_OUTPUT_MAX_CHARS) {
95+
return wellFormedText;
9496
}
9597

96-
return text.slice(-LATEST_OUTPUT_MAX_CHARS);
98+
return toWellFormedText(wellFormedText.slice(-LATEST_OUTPUT_MAX_CHARS));
9799
}
98100

99101
function latestAssistantSnippet(turn: DialogTurn | undefined): string | undefined {
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
getFirstAvailableField,
4+
isFieldComplete,
5+
parsePartialJson,
6+
} from './partialJsonParser';
7+
8+
describe('partialJsonParser', () => {
9+
it('treats non-object partial fragments as empty params', () => {
10+
const partialString = '"from';
11+
12+
expect(parsePartialJson(partialString)).toEqual({});
13+
expect(isFieldComplete(partialString, 'content')).toBe(false);
14+
expect(getFirstAvailableField(partialString, ['content', 'contents'])).toBeUndefined();
15+
});
16+
17+
it('treats valid non-object JSON values as empty params', () => {
18+
expect(parsePartialJson('["content"]')).toEqual({});
19+
expect(parsePartialJson('true')).toEqual({});
20+
expect(parsePartialJson('42')).toEqual({});
21+
});
22+
23+
it('treats non-string parser input as empty params', () => {
24+
expect(parsePartialJson({ content: 'not a JSON string' } as any)).toEqual({});
25+
});
26+
});

0 commit comments

Comments
 (0)