diff --git a/apps/extension/entrypoints/sidepanel/agent-conversation-events.test.tsx b/apps/extension/entrypoints/sidepanel/agent-conversation-events.test.tsx index a48629bdb6..4b2375c660 100644 --- a/apps/extension/entrypoints/sidepanel/agent-conversation-events.test.tsx +++ b/apps/extension/entrypoints/sidepanel/agent-conversation-events.test.tsx @@ -1,4 +1,4 @@ -/* eslint-disable max-expects, jsx-no-new-object-as-prop -- test rendering helpers and fixture assertions */ +/* eslint-disable max-expects, jsx-no-new-object-as-prop, max-lines -- test rendering helpers and fixture assertions */ // @vitest-environment jsdom import { describe, expect, it, vi } from 'vitest'; @@ -186,3 +186,123 @@ describe('workflow tool exchange rendering', () => { expect(container.textContent).toContain('completed'); }); }); + +describe('agent tool exchange rendering', () => { + it('renders a completed agent tool with its title, arguments, and result', () => { + const toolCall = { + arguments: { filePath: 'src/auth.ts' }, + id: 'tc-agent-1', + name: 'read', + source: 'agent' as const, + title: 'src/auth.ts', + type: 'tool-call' as const, + }; + const result = { + id: 'tr-agent-1', + ok: true, + toolCallId: 'tc-agent-1', + type: 'tool-result' as const, + value: 'export const guard = () => true;', + }; + const item: GroupedConversationItem = { + result, + toolCall, + type: 'tool-exchange', + }; + + const { container } = render(); + + expect(container.textContent).toContain('read'); + expect(container.textContent).toContain('completed'); + expect(container.textContent).toContain('src/auth.ts'); + expect(container.textContent).toContain('Arguments'); + expect(container.textContent).toContain('filePath'); + expect(container.textContent).toContain('Result'); + expect(container.textContent).toContain('export const guard = () => true;'); + }); + + it('renders a failed agent tool with the error label and reason', () => { + const toolCall = { + arguments: { filePath: 'src/auth.ts' }, + id: 'tc-agent-2', + name: 'read', + source: 'agent' as const, + title: 'src/auth.ts', + type: 'tool-call' as const, + }; + const result = { + error: 'File not found.', + id: 'tr-agent-2', + ok: false, + toolCallId: 'tc-agent-2', + type: 'tool-result' as const, + }; + const item: GroupedConversationItem = { + result, + toolCall, + type: 'tool-exchange', + }; + + const { container } = render(); + + expect(container.textContent).toContain('read'); + expect(container.textContent).toContain('failed'); + expect(container.textContent).toContain('Error'); + expect(container.textContent).toContain('File not found.'); + }); + + it('renders a running agent tool without a result block', () => { + const toolCall = { + arguments: { filePath: 'src/auth.ts' }, + id: 'tc-agent-3', + name: 'read', + source: 'agent' as const, + title: 'src/auth.ts', + type: 'tool-call' as const, + }; + const item: GroupedConversationItem = { + toolCall, + type: 'tool-exchange', + }; + + const { container } = render(); + + expect(container.textContent).toContain('read'); + expect(container.textContent).toContain('running'); + expect(container.textContent).toContain('Arguments'); + expect(container.textContent).not.toContain('Result'); + }); + + it('renders an agent tool image and no result pre', () => { + const toolCall = { + arguments: { fullPage: false }, + id: 'tc-agent-4', + name: 'browser_screenshot', + source: 'agent' as const, + title: 'viewport', + type: 'tool-call' as const, + }; + const result = { + id: 'tr-agent-4', + imageDataUrl: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + ok: true, + toolCallId: 'tc-agent-4', + type: 'tool-result' as const, + value: 'captured', + }; + const item: GroupedConversationItem = { + result, + toolCall, + type: 'tool-exchange', + }; + + const { container } = render(); + + const image = container.querySelector('img'); + expect(image).not.toBeNull(); + expect(image?.getAttribute('src')).toContain('data:image/png;base64,'); + expect(image?.getAttribute('alt')).toBe('Image produced by browser_screenshot'); + expect(container.textContent).not.toContain('captured'); + }); +}); diff --git a/apps/extension/entrypoints/sidepanel/agent-conversation-events.tsx b/apps/extension/entrypoints/sidepanel/agent-conversation-events.tsx index 9dfc5e74d6..6e586d5c1c 100644 --- a/apps/extension/entrypoints/sidepanel/agent-conversation-events.tsx +++ b/apps/extension/entrypoints/sidepanel/agent-conversation-events.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Shared tool panel and per-state mappers for both conversation renderers */ import type { JSX, ReactNode } from 'react'; import { isValidElement } from 'react'; import type { Components } from 'react-markdown'; @@ -155,15 +156,29 @@ const ThinkingEvent = ({ ); -const ToolExchangeEvent = ({ - item, +const ToolExchangePanel = ({ + argumentsText, + codeText, + imageDataUrl, + imageAlt, + resultText, + status, + subtitle, + title, }: { - item: Extract; + /** Rendered under "Arguments". Omit to hide the block. */ + argumentsText?: string | undefined; + /** Rendered under "Code". Omit to hide the block. */ + codeText?: string | undefined; + imageDataUrl?: string | undefined; + imageAlt: string; + /** Rendered under "Result" or "Error". Omit while the tool still runs. */ + resultText?: string | undefined; + status: 'completed' | 'failed' | 'running'; + subtitle: string; + title: string; }): JSX.Element => { - const isSuccessful = item.result.ok; - const screenshotDataUrl = isSuccessful - ? getViewportScreenshotDataUrl(item.toolCall.name, item.result.value) - : undefined; + const isSuccessful = status !== 'failed'; const panelClassName = isSuccessful ? 'group min-w-0 rounded-lg border border-border bg-surface-inset px-3 py-2' @@ -191,44 +206,103 @@ const ToolExchangeEvent = ({
- {item.toolCall.name} {isSuccessful ? 'completed' : 'failed'} - - - {'serverName' in item.toolCall ? item.toolCall.serverName : `tab ${item.toolCall.tabId}`} + {title} {status} + {subtitle}
- {item.toolCall.name === 'eval' ? ( + {codeText === undefined ? null : (

Code

-
{item.toolCall.code}
+
{codeText}
- ) : null} - {'arguments' in item.toolCall ? ( + )} + {argumentsText === undefined ? null : (

Arguments

-
{formatToolValue(item.toolCall.arguments)}
+
{argumentsText}
- ) : null} -
-

{isSuccessful ? 'Result' : 'Error'}

- {screenshotDataUrl === undefined ? ( -
-              {isSuccessful ? formatToolValue(item.result.value) : item.result.error}
-            
- ) : ( - Viewport screenshot captured by get_viewport_screenshot - )} -
+ )} + {resultText === undefined && imageDataUrl === undefined ? null : ( +
+

{status === 'failed' ? 'Error' : 'Result'}

+ {imageDataUrl === undefined ? ( +
{resultText}
+ ) : ( + {imageAlt} + )} +
+ )}
); }; +type ToolResultEvent = Extract; + +const getToolExchangeStatus = ( + result: ToolResultEvent | undefined +): 'completed' | 'failed' | 'running' => { + if (result === undefined) { + return 'running'; + } + + return result.ok ? 'completed' : 'failed'; +}; + +const getToolExchangeResultText = ( + result: ToolResultEvent | undefined, + hasResultImage: boolean +): string | undefined => { + if (result === undefined || hasResultImage) { + return undefined; + } + + return result.ok ? formatToolValue(result.value) : (result.error ?? ''); +}; + +const ToolExchangeEvent = ({ + item, +}: { + item: Extract; +}): JSX.Element => { + const { result, toolCall } = item; + + if ('source' in toolCall) { + return ( + + ); + } + + const screenshotDataUrl = + result?.ok === true ? getViewportScreenshotDataUrl(toolCall.name, result.value) : undefined; + + return ( + + ); +}; + const StandaloneToolEvent = ({ event, }: { diff --git a/apps/extension/entrypoints/sidepanel/agents-composer.tsx b/apps/extension/entrypoints/sidepanel/agents-composer.tsx index af2fbf503e..9191481b78 100644 --- a/apps/extension/entrypoints/sidepanel/agents-composer.tsx +++ b/apps/extension/entrypoints/sidepanel/agents-composer.tsx @@ -95,19 +95,24 @@ export const AgentsComposer = ({ placeholder="Send a message…" value={draft} /> -
+
+ {isStreaming ? ( + + ) : null}
); diff --git a/apps/extension/entrypoints/sidepanel/agents-conversation-adapter.test.ts b/apps/extension/entrypoints/sidepanel/agents-conversation-adapter.test.ts new file mode 100644 index 0000000000..c9d143704c --- /dev/null +++ b/apps/extension/entrypoints/sidepanel/agents-conversation-adapter.test.ts @@ -0,0 +1,646 @@ +/* eslint-disable jest/no-hooks, max-lines, sort-keys -- beforeEach clears the shared image store; fixture literals mirror SDK field order */ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { AssistantMessage, StoredMessage, UserMessage } from '@kilocode/cloud-agent-sdk'; +import { clearToolImages, rememberToolImage } from '@/src/shared/agent-tool-images'; +import { + getStreamingTextPartId, + isMessageStreaming, + isSnapshotOnlyMessage, + shouldShowWorkingIndicator, + toAgentConversationItems, +} from './agents-conversation-adapter'; + +const userInfo = (id: string, overrides: Partial = {}): UserMessage => ({ + agent: '', + id, + model: { modelID: 'test', providerID: 'kilo' }, + role: 'user', + sessionID: 'ses-1', + time: { created: 1000 }, + ...overrides, +}); + +const assistantInfo = ( + id: string, + overrides: Partial = {} +): AssistantMessage => ({ + agent: '', + cost: 0, + id, + modelID: 'test', + mode: 'code', + parentID: 'msg-parent', + path: { cwd: '/', root: '/' }, + providerID: 'kilo', + role: 'assistant', + sessionID: 'ses-1', + time: { created: 2000 }, + tokens: { cache: { read: 0, write: 0 }, input: 0, output: 0, reasoning: 0 }, + ...overrides, +}); + +describe('agent conversation mapping', () => { + beforeEach(() => { + clearToolImages(); + }); + + it('drops the synthetic snapshot progress part', () => { + const messages: StoredMessage[] = [ + { + info: assistantInfo('msg-snap'), + parts: [ + { + id: 'p-snap', + messageID: 'msg-snap', + sessionID: 'ses-1', + synthetic: true, + text: '⠋ Initializing snapshot…', + type: 'text' as const, + }, + ], + }, + ]; + expect(toAgentConversationItems(messages)).toStrictEqual([]); + }); + + it('keeps a synthetic user text part that is not snapshot progress', () => { + const messages: StoredMessage[] = [ + { + info: userInfo('msg-queued'), + parts: [ + { + id: 'p-queued', + messageID: 'msg-queued', + sessionID: 'ses-1', + synthetic: true, + text: 'queued message', + type: 'text' as const, + }, + ], + }, + ]; + expect(toAgentConversationItems(messages)).toStrictEqual([ + { + event: { id: 'p-queued', role: 'user', text: 'queued message', type: 'message' }, + type: 'event', + }, + ]); + }); + + it('drops a blank text part', () => { + const messages: StoredMessage[] = [ + { + info: userInfo('msg-blank'), + parts: [ + { + id: 'p-blank', + messageID: 'msg-blank', + sessionID: 'ses-1', + text: ' ', + type: 'text' as const, + }, + ], + }, + ]; + expect(toAgentConversationItems(messages)).toStrictEqual([]); + }); + + it('drops a blank reasoning part', () => { + const messages: StoredMessage[] = [ + { + info: assistantInfo('msg-blank-think'), + parts: [ + { + id: 'p-blank-think', + messageID: 'msg-blank-think', + sessionID: 'ses-1', + text: ' \n ', + time: { start: 2000 }, + type: 'reasoning' as const, + }, + ], + }, + ]; + expect(toAgentConversationItems(messages)).toStrictEqual([]); + }); + + it('maps a non-blank reasoning part to a thinking event', () => { + const messages: StoredMessage[] = [ + { + info: assistantInfo('msg-think'), + parts: [ + { + id: 'p-think', + messageID: 'msg-think', + sessionID: 'ses-1', + text: 'Let me think', + time: { start: 2000 }, + type: 'reasoning' as const, + }, + ], + }, + ]; + expect(toAgentConversationItems(messages)).toStrictEqual([ + { event: { id: 'p-think', text: 'Let me think', type: 'thinking' }, type: 'event' }, + ]); + }); + + it('maps text, reasoning, and tool parts in stored order', () => { + const messages: StoredMessage[] = [ + { + info: assistantInfo('msg-order'), + parts: [ + { + id: 'p-tool', + callID: 'call-order', + messageID: 'msg-order', + sessionID: 'ses-1', + state: { + input: {}, + metadata: {}, + output: 'done', + status: 'completed' as const, + time: { end: 2100, start: 2000 }, + title: 'read', + }, + tool: 'read', + type: 'tool' as const, + }, + { + id: 'p-think', + messageID: 'msg-order', + sessionID: 'ses-1', + text: 'thinking', + time: { start: 2000 }, + type: 'reasoning' as const, + }, + { + id: 'p-text', + messageID: 'msg-order', + sessionID: 'ses-1', + text: 'result text', + type: 'text' as const, + }, + ], + }, + ]; + const items = toAgentConversationItems(messages); + expect(items[0]?.type).toBe('tool-exchange'); + expect(items[1]).toStrictEqual({ + event: { id: 'p-think', text: 'thinking', type: 'thinking' }, + type: 'event', + }); + expect(items[2]).toStrictEqual({ + event: { id: 'p-text', role: 'assistant', text: 'result text', type: 'message' }, + type: 'event', + }); + }); + + it('drops a step-start part', () => { + const messages: StoredMessage[] = [ + { + info: assistantInfo('msg-step'), + parts: [ + { + id: 'p-step', + messageID: 'msg-step', + sessionID: 'ses-1', + type: 'step-start' as const, + }, + ], + }, + ]; + expect(toAgentConversationItems(messages)).toStrictEqual([]); + }); + + it('maps a running tool to a tool-exchange with no result', () => { + const messages: StoredMessage[] = [ + { + info: assistantInfo('msg-running'), + parts: [ + { + id: 'p-running', + callID: 'call-running', + messageID: 'msg-running', + sessionID: 'ses-1', + state: { + input: { filePath: 'src/a.ts' }, + status: 'running' as const, + time: { start: 2000 }, + }, + tool: 'read', + type: 'tool' as const, + }, + ], + }, + ]; + const items = toAgentConversationItems(messages); + expect(items).toHaveLength(1); + expect(items[0]).toStrictEqual({ + toolCall: { + arguments: { filePath: 'src/a.ts' }, + id: 'p-running', + name: 'read', + source: 'agent', + type: 'tool-call', + }, + type: 'tool-exchange', + }); + expect('result' in items[0]!).toBe(false); + }); + + it('maps a completed tool with its title and output', () => { + const messages: StoredMessage[] = [ + { + info: assistantInfo('msg-completed'), + parts: [ + { + id: 'p-completed', + callID: 'call-completed', + messageID: 'msg-completed', + sessionID: 'ses-1', + state: { + input: { filePath: 'src/a.ts' }, + metadata: {}, + output: 'file contents', + status: 'completed' as const, + time: { end: 2100, start: 2000 }, + title: 'src/a.ts', + }, + tool: 'read', + type: 'tool' as const, + }, + ], + }, + ]; + expect(toAgentConversationItems(messages)).toStrictEqual([ + { + result: { + id: 'p-completed-result', + ok: true, + toolCallId: 'p-completed', + type: 'tool-result', + value: 'file contents', + }, + toolCall: { + arguments: { filePath: 'src/a.ts' }, + id: 'p-completed', + name: 'read', + source: 'agent', + title: 'src/a.ts', + type: 'tool-call', + }, + type: 'tool-exchange', + }, + ]); + }); + + it('maps an errored tool to a failed exchange with the error text', () => { + const messages: StoredMessage[] = [ + { + info: assistantInfo('msg-error'), + parts: [ + { + id: 'p-error', + callID: 'call-error', + messageID: 'msg-error', + sessionID: 'ses-1', + state: { + error: 'command not found', + input: {}, + status: 'error' as const, + time: { end: 2100, start: 2000 }, + }, + tool: 'bash', + type: 'tool' as const, + }, + ], + }, + ]; + expect(toAgentConversationItems(messages)).toStrictEqual([ + { + result: { + error: 'command not found', + id: 'p-error-result', + ok: false, + toolCallId: 'p-error', + type: 'tool-result', + }, + toolCall: { + arguments: {}, + id: 'p-error', + name: 'bash', + source: 'agent', + type: 'tool-call', + }, + type: 'tool-exchange', + }, + ]); + }); + + it('adds imageDataUrl for a completed tool with a remembered image', () => { + rememberToolImage('p-shot', { + dataUrl: 'data:image/png;base64,AAAA', + mime: 'image/png', + }); + const messages: StoredMessage[] = [ + { + info: assistantInfo('msg-shot'), + parts: [ + { + id: 'p-shot', + callID: 'call-shot', + messageID: 'msg-shot', + sessionID: 'ses-1', + state: { + input: { fullPage: false }, + metadata: {}, + output: 'captured', + status: 'completed' as const, + time: { end: 2100, start: 2000 }, + title: 'viewport', + }, + tool: 'browser_screenshot', + type: 'tool' as const, + }, + ], + }, + ]; + const items = toAgentConversationItems(messages); + expect(items[0]).toMatchObject({ + result: { + imageDataUrl: 'data:image/png;base64,AAAA', + ok: true, + toolCallId: 'p-shot', + type: 'tool-result', + value: 'captured', + }, + }); + }); +}); + +describe('streaming text part id', () => { + it('returns the last text part id of the streaming assistant tail', () => { + const messages: StoredMessage[] = [ + { + info: userInfo('msg-user'), + parts: [ + { + id: 'p-user', + messageID: 'msg-user', + sessionID: 'ses-1', + text: 'hello', + type: 'text' as const, + }, + ], + }, + { + info: assistantInfo('msg-stream'), + parts: [ + { + id: 'p-think', + messageID: 'msg-stream', + sessionID: 'ses-1', + text: 'thinking', + type: 'text' as const, + }, + { + id: 'p-tail', + messageID: 'msg-stream', + sessionID: 'ses-1', + text: 'final', + type: 'text' as const, + }, + ], + }, + ]; + expect(getStreamingTextPartId(messages)).toBe('p-tail'); + }); + + it('skips a synthetic snapshot part that follows active assistant text', () => { + const messages: StoredMessage[] = [ + { + info: assistantInfo('msg-stream'), + parts: [ + { + id: 'p-real', + messageID: 'msg-stream', + sessionID: 'ses-1', + text: 'real output', + type: 'text' as const, + }, + { + id: 'p-snap', + messageID: 'msg-stream', + sessionID: 'ses-1', + synthetic: true, + text: '⠋ Initializing snapshot…', + type: 'text' as const, + }, + ], + }, + ]; + expect(getStreamingTextPartId(messages)).toBe('p-real'); + }); + + it('returns undefined when the streaming message has only a synthetic snapshot part', () => { + const messages: StoredMessage[] = [ + { + info: assistantInfo('msg-snap'), + parts: [ + { + id: 'p-snap', + messageID: 'msg-snap', + sessionID: 'ses-1', + synthetic: true, + text: '⠋ Initializing snapshot…', + type: 'text' as const, + }, + ], + }, + ]; + expect(getStreamingTextPartId(messages)).toBeUndefined(); + }); + + it('returns undefined on a completed transcript', () => { + const messages: StoredMessage[] = [ + { + info: assistantInfo('msg-done', { + time: { completed: 2100, created: 2000 }, + }), + parts: [ + { + id: 'p-done', + messageID: 'msg-done', + sessionID: 'ses-1', + text: 'done', + type: 'text' as const, + }, + ], + }, + ]; + expect(getStreamingTextPartId(messages)).toBeUndefined(); + }); + + it('returns undefined for an errored assistant message with no completed time', () => { + const messages: StoredMessage[] = [ + { + info: assistantInfo('msg-errored', { + error: { data: { isRetryable: false, message: 'boom' }, name: 'APIError' }, + }), + parts: [ + { + id: 'p-errored', + messageID: 'msg-errored', + sessionID: 'ses-1', + text: 'partial', + type: 'text' as const, + }, + ], + }, + ]; + expect(getStreamingTextPartId(messages)).toBeUndefined(); + expect(isMessageStreaming(messages[0]!)).toBe(false); + }); +}); + +describe('working indicator', () => { + it('shows while streaming a message with only synthetic snapshot progress', () => { + const messages: StoredMessage[] = [ + { + info: assistantInfo('msg-snap-live'), + parts: [ + { + id: 'p-snap-live', + messageID: 'msg-snap-live', + sessionID: 'ses-1', + synthetic: true, + text: '⠋ Initializing snapshot…', + type: 'text' as const, + }, + ], + }, + ]; + expect(shouldShowWorkingIndicator(true, messages)).toBe(true); + }); + + it('hides while streaming a message with live assistant output', () => { + const messages: StoredMessage[] = [ + { + info: assistantInfo('msg-live'), + parts: [ + { + id: 'p-snap', + messageID: 'msg-live', + sessionID: 'ses-1', + synthetic: true, + text: '⠋ Initializing snapshot…', + type: 'text' as const, + }, + { + id: 'p-live', + messageID: 'msg-live', + sessionID: 'ses-1', + text: 'real output', + type: 'text' as const, + }, + ], + }, + ]; + expect(shouldShowWorkingIndicator(true, messages)).toBe(false); + }); + + it('hides when the session is not streaming', () => { + const messages: StoredMessage[] = [ + { + info: assistantInfo('msg-idle'), + parts: [ + { + id: 'p-idle', + messageID: 'msg-idle', + sessionID: 'ses-1', + synthetic: true, + text: '⠋ Initializing snapshot…', + type: 'text' as const, + }, + ], + }, + ]; + expect(shouldShowWorkingIndicator(false, messages)).toBe(false); + }); + + it('shows while streaming before any message exists', () => { + expect(shouldShowWorkingIndicator(true, [])).toBe(true); + }); + + it('shows when the newest message is a completed assistant message', () => { + const messages: StoredMessage[] = [ + { + info: assistantInfo('msg-done', { + time: { completed: 2100, created: 2000 }, + }), + parts: [ + { + id: 'p-done', + messageID: 'msg-done', + sessionID: 'ses-1', + text: 'done', + type: 'text' as const, + }, + ], + }, + ]; + expect(shouldShowWorkingIndicator(true, messages)).toBe(true); + }); +}); + +describe('snapshot-only message detection', () => { + it('is true for a message made only of snapshot progress parts', () => { + const message: StoredMessage = { + info: assistantInfo('msg-snap'), + parts: [ + { + id: 'p-snap-1', + messageID: 'msg-snap', + sessionID: 'ses-1', + synthetic: true, + text: '⠋ Initializing snapshot…', + type: 'text' as const, + }, + ], + }; + expect(isSnapshotOnlyMessage(message)).toBe(true); + }); + + it('is false when a message also carries live text', () => { + const message: StoredMessage = { + info: assistantInfo('msg-mixed'), + parts: [ + { + id: 'p-snap-2', + messageID: 'msg-mixed', + sessionID: 'ses-1', + synthetic: true, + text: '⠋ Initializing snapshot…', + type: 'text' as const, + }, + { + id: 'p-mixed', + messageID: 'msg-mixed', + sessionID: 'ses-1', + text: 'result', + type: 'text' as const, + }, + ], + }; + expect(isSnapshotOnlyMessage(message)).toBe(false); + }); + + it('is false for a message with no parts', () => { + const message: StoredMessage = { + info: assistantInfo('msg-empty'), + parts: [], + }; + expect(isSnapshotOnlyMessage(message)).toBe(false); + }); +}); diff --git a/apps/extension/entrypoints/sidepanel/agents-conversation-adapter.ts b/apps/extension/entrypoints/sidepanel/agents-conversation-adapter.ts new file mode 100644 index 0000000000..6d9ec9fd45 --- /dev/null +++ b/apps/extension/entrypoints/sidepanel/agents-conversation-adapter.ts @@ -0,0 +1,144 @@ +import type { Part, StoredMessage } from '@kilocode/cloud-agent-sdk'; +import type { GroupedConversationItem } from '@/src/shared/agent-conversation'; +import { getToolImage } from '@/src/shared/agent-tool-images'; + +/** CLI snapshot-init progress injected as a synthetic text part. + * Extension-owned mirror of `apps/mobile/src/components/agents/part-types.ts:15`. */ +export const isSnapshotProgressPart = (part: Part): boolean => + part.type === 'text' && part.synthetic === true && part.text.includes('Initializing snapshot'); + +/** True while this assistant message is still producing output. */ +export const isMessageStreaming = (message: StoredMessage): boolean => + message.info.role === 'assistant' && + message.info.time.completed === undefined && + !message.info.error; + +/** True when every part is the hidden synthetic snapshot progress, so the + * message shows no live output while it streams. */ +export const isSnapshotOnlyMessage = (message: StoredMessage): boolean => + message.parts.length > 0 && message.parts.every(part => isSnapshotProgressPart(part)); + +/** + * True when the agent is running but the newest message shows no live + * assistant output — the gap between sending a prompt and the first + * assistant token. A streaming message with only the hidden synthetic + * snapshot progress also shows no live output. The indicator fills the gap. + */ +export const shouldShowWorkingIndicator = ( + isStreaming: boolean, + messages: StoredMessage[] +): boolean => { + if (!isStreaming) { + return false; + } + const last = messages.at(-1); + if (last === undefined) { + return true; + } + return !isMessageStreaming(last) || isSnapshotOnlyMessage(last); +}; + +const buildToolExchange = ( + part: Extract +): Extract => { + const toolCall = { + arguments: part.state.input, + id: part.id, + name: part.tool, + source: 'agent' as const, + ...('title' in part.state && part.state.title !== undefined ? { title: part.state.title } : {}), + type: 'tool-call' as const, + }; + + if (part.state.status === 'pending' || part.state.status === 'running') { + return { toolCall, type: 'tool-exchange' }; + } + + if (part.state.status === 'error') { + return { + result: { + error: part.state.error, + id: `${part.id}-result`, + ok: false, + toolCallId: part.id, + type: 'tool-result', + }, + toolCall, + type: 'tool-exchange', + }; + } + + const imageDataUrl = getToolImage(part.id); + return { + result: { + id: `${part.id}-result`, + ok: true, + toolCallId: part.id, + type: 'tool-result', + value: part.state.output, + ...(imageDataUrl === undefined ? {} : { imageDataUrl }), + }, + toolCall, + type: 'tool-exchange', + }; +}; + +const toConversationItem = ( + message: StoredMessage, + part: Part +): GroupedConversationItem | undefined => { + if (part.type === 'text') { + if (isSnapshotProgressPart(part) || part.text.trim() === '') { + return undefined; + } + return { + event: { id: part.id, role: message.info.role, text: part.text, type: 'message' }, + type: 'event', + }; + } + + if (part.type === 'reasoning') { + if (part.text.trim() === '') { + return undefined; + } + return { + event: { id: part.id, text: part.text, type: 'thinking' }, + type: 'event', + }; + } + + if (part.type === 'tool') { + return buildToolExchange(part); + } + + return undefined; +}; + +/** + * Map stored agent messages to the shared conversation items the browser + * renderer consumes. Parts keep their stored order within each message. + */ +export const toAgentConversationItems = (messages: StoredMessage[]): GroupedConversationItem[] => { + const items: GroupedConversationItem[] = []; + + for (const message of messages) { + for (const part of message.parts) { + const item = toConversationItem(message, part); + if (item !== undefined) { + items.push(item); + } + } + } + + return items; +}; + +/** + * Id of the last text part of the last message still streaming. Feeds + * `ConversationList`'s `streamingMessageId`, which force-expands code blocks + * while they stream. `undefined` when no assistant message is live. + */ +export const getStreamingTextPartId = (messages: StoredMessage[]): string | undefined => { + const message = messages.findLast(candidate => isMessageStreaming(candidate)); + return message?.parts.findLast(part => part.type === 'text' && !isSnapshotProgressPart(part))?.id; +}; diff --git a/apps/extension/entrypoints/sidepanel/agents-message-list.tsx b/apps/extension/entrypoints/sidepanel/agents-message-list.tsx index 41efdd02f7..709e7782a4 100644 --- a/apps/extension/entrypoints/sidepanel/agents-message-list.tsx +++ b/apps/extension/entrypoints/sidepanel/agents-message-list.tsx @@ -1,189 +1,12 @@ -import { useCallback, useLayoutEffect, useRef } from 'react'; +import { useMemo } from 'react'; import type { JSX } from 'react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import { Loader2 } from 'lucide-react'; -import type { StoredMessage, Part } from '@kilocode/cloud-agent-sdk'; - -/** Distance from the bottom (px) still treated as "at the bottom". */ -const BOTTOM_PIN_THRESHOLD_PX = 32; - -const remarkPlugins = [remarkGfm]; - -const toolStatusLabel = (status: string): string => { - if (status === 'pending') { - return 'pending'; - } - if (status === 'running') { - return 'running'; - } - if (status === 'completed') { - return 'completed'; - } - if (status === 'error') { - return 'error'; - } - return status; -}; - -const toolStatusColor = (status: string): string => { - if (status === 'error') { - return 'text-status-red-400'; - } - if (status === 'completed') { - return 'text-status-green-500'; - } - if (status === 'running') { - return 'text-foreground-muted'; - } - return 'text-foreground-muted'; -}; - -/** Longest tool error rendered inline; the rest would swamp the transcript. */ -const TOOL_ERROR_MAX_LENGTH = 200; - -/** - * First meaningful line of a tool error, clamped. Tool failures arrive as - * anything from one line to a stack trace, and the transcript must stay - * readable in a narrow panel. - */ -export const toolErrorSummary = (error: string): string => { - const firstLine = error - .split('\n') - .map(line => line.trim()) - .find(line => line !== ''); - if (firstLine === undefined) { - return ''; - } - return firstLine.length > TOOL_ERROR_MAX_LENGTH - ? `${firstLine.slice(0, TOOL_ERROR_MAX_LENGTH)}…` - : firstLine; -}; - -const ToolPartRow = ({ part }: { part: Extract }): JSX.Element => { - const { state } = part; - const { status } = state; - const isActive = status === 'running' || status === 'pending'; - // The tool's own summary of the call — a path, a command. Absent while pending. - const title = 'title' in state ? state.title : undefined; - const errorSummary = state.status === 'error' ? toolErrorSummary(state.error) : ''; - - return ( -
-
- {isActive ? ( -
- {errorSummary === '' ? null : ( -

{errorSummary}

- )} -
- ); -}; - -const ReasoningPartRow = (): JSX.Element => ( -
Reasoning
-); - -const TextPartContent = ({ part }: { part: Extract }): JSX.Element => ( -
- {part.text} -
-); - -const PartRow = ({ part }: { part: Part }): JSX.Element | null => { - if (part.type === 'text') { - return ; - } - if (part.type === 'reasoning') { - return ; - } - if (part.type === 'tool') { - return ; - } - // All other part types render nothing per decision 14. - return null; -}; - -/** - * Real transcripts carry a reasoning part before nearly every step. Keep a - * single reasoning row only while the message still streams; a completed - * message shows its tools and text without the noise. - */ -export const visibleParts = (parts: Part[], isStreaming: boolean): Part[] => { - if (isStreaming) { - return parts.filter( - (part, index) => part.type !== 'reasoning' || parts[index + 1] === undefined - ); - } - return parts.filter(part => part.type !== 'reasoning'); -}; - -const MessageRow = ({ message }: { message: StoredMessage }): JSX.Element => { - const isUser = message.info.role === 'user'; - const isStreaming = - message.info.role === 'assistant' && - message.info.time.completed === undefined && - !message.info.error; - const parts = visibleParts(message.parts, isStreaming); - const hasContent = parts.length > 0; - - return ( -
-
- {hasContent ? ( -
- {/* Parts render in stored order: tools come before the text they produced. */} - {parts.map(part => ( - - ))} -
- ) : ( -
- - {isStreaming ? 'Thinking…' : 'No content'} -
- )} -
-
- ); -}; - -/** - * True when the agent is running but the newest message shows no live - * assistant output — the gap between sending a prompt and the first - * assistant token. The indicator fills that gap. - */ -export const shouldShowWorkingIndicator = ( - isStreaming: boolean, - messages: StoredMessage[] -): boolean => { - if (!isStreaming) { - return false; - } - const last = messages.at(-1); - if (last === undefined) { - return true; - } - return last.info.role !== 'assistant' || last.info.time.completed !== undefined; -}; +import type { StoredMessage } from '@kilocode/cloud-agent-sdk'; +import { + getStreamingTextPartId, + shouldShowWorkingIndicator, + toAgentConversationItems, +} from './agents-conversation-adapter'; +import { ConversationList } from './conversation-list'; const WorkingIndicatorRow = (): JSX.Element => (
@@ -201,28 +24,11 @@ export const AgentsMessageList = ({ messages: StoredMessage[]; isStreaming?: boolean; }): JSX.Element => { + const items = useMemo(() => toAgentConversationItems(messages), [messages]); + const streamingMessageId = useMemo(() => getStreamingTextPartId(messages), [messages]); const showWorking = shouldShowWorkingIndicator(isStreaming, messages); - const scrollRef = useRef(null); - // Follow the bottom until the user scrolls up; re-arm when they return. - const pinnedRef = useRef(true); - - const handleScroll = useCallback(() => { - const element = scrollRef.current; - if (!element) { - return; - } - pinnedRef.current = - element.scrollTop + element.clientHeight >= element.scrollHeight - BOTTOM_PIN_THRESHOLD_PX; - }, []); - useLayoutEffect(() => { - const element = scrollRef.current; - if (element && pinnedRef.current) { - element.scrollTop = element.scrollHeight; - } - }); - - if (messages.length === 0 && !showWorking) { + if (items.length === 0 && !showWorking) { return (

No messages yet

@@ -231,17 +37,11 @@ export const AgentsMessageList = ({ } return ( -
-
- {messages.map(message => ( - - ))} - {showWorking ? : null} -
+
+ {items.length === 0 ? null : ( + + )} + {showWorking ? : null}
); }; diff --git a/apps/extension/entrypoints/sidepanel/agents-session-view.test.ts b/apps/extension/entrypoints/sidepanel/agents-session-view.test.ts index 544184ba35..9b6def57bf 100644 --- a/apps/extension/entrypoints/sidepanel/agents-session-view.test.ts +++ b/apps/extension/entrypoints/sidepanel/agents-session-view.test.ts @@ -4,231 +4,25 @@ import { createElement as h, StrictMode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render } from '@testing-library/react'; -import type { - StandaloneQuestion, - StandalonePermission, - StoredMessage, - UserMessage, - AssistantMessage, -} from '@kilocode/cloud-agent-sdk'; +import type { StandalonePermission, StandaloneQuestion } from '@kilocode/cloud-agent-sdk'; import { AgentsBlockingCards } from './agents-blocking-cards'; import { AgentsComposer } from './agents-composer'; -import { AgentsMessageList, toolErrorSummary } from './agents-message-list'; +import { AgentsMessageList } from './agents-message-list'; // ---- AgentsMessageList rendering ---- describe('agents message list rendering', () => { + /* + * Transcript rendering is covered elsewhere: the pure mapping in + * agents-conversation-adapter.test.ts, the shared item markup in + * agent-conversation-events.test.tsx, and the composed result in the Chrome + * E2E (tests/e2e/agents-mode.test.ts). The virtualizer produces no rows + * under jsdom, so no unit test asserts message text through AgentsMessageList. + */ it('renders empty state when no messages', () => { const { container } = render(h(AgentsMessageList, { messages: [] })); expect(container.textContent).toContain('No messages yet'); }); - - it('renders user message as right-aligned', () => { - const messages: StoredMessage[] = [ - { - info: { - id: 'msg-1', - sessionID: 'ses-1', - role: 'user', - time: { created: 1000 }, - agent: '', - model: { providerID: 'kilo', modelID: 'test' }, - } satisfies UserMessage, - parts: [ - { - id: 'p-1', - sessionID: 'ses-1', - messageID: 'msg-1', - type: 'text' as const, - text: 'Hello', - }, - ], - }, - ]; - - const { container } = render(h(AgentsMessageList, { messages })); - expect(container.querySelector('.justify-end')).not.toBeNull(); - expect(container.textContent).toContain('Hello'); - }); - - it('renders assistant message as left-aligned markdown', () => { - const messages: StoredMessage[] = [ - { - info: { - id: 'msg-2', - sessionID: 'ses-1', - role: 'assistant', - time: { created: 2000, completed: 2000 }, - parentID: 'msg-1', - modelID: 'test', - providerID: 'kilo', - mode: 'code', - agent: '', - path: { cwd: '/', root: '/' }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - } satisfies AssistantMessage, - parts: [ - { - id: 'p-2', - sessionID: 'ses-1', - messageID: 'msg-2', - type: 'text' as const, - text: '**bold**', - }, - ], - }, - ]; - - const { container } = render(h(AgentsMessageList, { messages })); - expect(container.querySelector('strong')).not.toBeNull(); - }); - - it('renders tool parts as name + status row', () => { - const messages: StoredMessage[] = [ - { - info: { - id: 'msg-3', - sessionID: 'ses-1', - role: 'assistant', - time: { created: 3000 }, - parentID: 'msg-2', - modelID: 'test', - providerID: 'kilo', - mode: 'code', - agent: '', - path: { cwd: '/', root: '/' }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - } satisfies AssistantMessage, - parts: [ - { - id: 'p-3', - sessionID: 'ses-1', - messageID: 'msg-3', - type: 'tool' as const, - callID: 'call-1', - tool: 'read_file', - state: { - status: 'completed' as const, - input: {}, - output: '', - title: '', - metadata: {}, - time: { start: 3000, end: 3100 }, - }, - }, - ], - }, - ]; - - const { container } = render(h(AgentsMessageList, { messages })); - expect(container.textContent).toContain('read_file'); - expect(container.textContent).toContain('completed'); - }); - - it("shows the tool's own title and surfaces a failed tool's reason", () => { - const info = { - id: 'msg-tool', - sessionID: 'ses-1', - role: 'assistant', - time: { created: 3000, completed: 3200 }, - parentID: 'msg-2', - modelID: 'test', - providerID: 'kilo', - mode: 'code', - agent: '', - path: { cwd: '/', root: '/' }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - } satisfies AssistantMessage; - const messages: StoredMessage[] = [ - { - info, - parts: [ - { - id: 'p-ok', - sessionID: 'ses-1', - messageID: 'msg-tool', - type: 'tool' as const, - callID: 'call-ok', - tool: 'read', - state: { - status: 'completed' as const, - input: {}, - output: 'contents', - title: 'README.md', - metadata: {}, - time: { start: 3000, end: 3100 }, - }, - }, - { - id: 'p-err', - sessionID: 'ses-1', - messageID: 'msg-tool', - type: 'tool' as const, - callID: 'call-err', - tool: 'read', - state: { - status: 'error' as const, - input: {}, - error: 'ENOENT: no such file or directory\n at open (fs.js:1)', - time: { start: 3100, end: 3150 }, - }, - }, - ], - }, - ]; - - const { container } = render(h(AgentsMessageList, { messages })); - // The title says which file, so the row is not just "read completed". - expect(container.textContent).toContain('README.md'); - // A failed tool states its reason instead of a bare "error". - expect(container.textContent).toContain('ENOENT: no such file or directory'); - // The stack frame is dropped. - expect(container.textContent).not.toContain('at open (fs.js:1)'); - }); - - it('hides reasoning parts on a completed message and shows the tail while streaming', () => { - const completedInfo = { - id: 'msg-4', - sessionID: 'ses-1', - role: 'assistant', - time: { created: 4000, completed: 4000 }, - parentID: 'msg-3', - modelID: 'test', - providerID: 'kilo', - mode: 'code', - agent: '', - path: { cwd: '/', root: '/' }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - } satisfies AssistantMessage; - const reasoningParts = [ - { - id: 'p-4', - sessionID: 'ses-1', - messageID: 'msg-4', - type: 'reasoning' as const, - text: 'Let me think...', - time: { start: 4000, end: 4100 }, - }, - ]; - const messages: StoredMessage[] = [{ info: completedInfo, parts: reasoningParts }]; - - // Completed message: reasoning is noise and stays hidden. - const { container } = render(h(AgentsMessageList, { messages })); - expect(container.textContent).not.toContain('Reasoning'); - - // Streaming message: the trailing reasoning part renders as a live label. - const streamingInfo = { - ...completedInfo, - time: { created: 4000 }, - } satisfies AssistantMessage; - const streaming: StoredMessage[] = [{ info: streamingInfo, parts: reasoningParts }]; - const { container: streamingContainer } = render(h(AgentsMessageList, { messages: streaming })); - expect(streamingContainer.textContent).toContain('Reasoning'); - }); }); // ---- AgentsComposer rendering ---- @@ -294,6 +88,57 @@ describe('agents composer rendering', () => { expect(container.textContent).toContain('Stop'); }); + it('enables Send while streaming when canSend is true and the draft is non-empty', () => { + const { container } = render( + h(AgentsComposer, { + canSend: true, + canInterrupt: true, + isStreaming: true, + isReadOnly: false, + isLoading: false, + onSend: () => {}, + onStop: () => {}, + }) + ); + const textarea = container.querySelector('textarea'); + expect(textarea).not.toBeNull(); + fireEvent.change(textarea!, { target: { value: 'hello' } }); + + const sendBtn = [...container.querySelectorAll('button')].find( + b => b.textContent === 'Send message' + ); + expect(sendBtn).not.toBeNull(); + expect(sendBtn!.getAttribute('disabled')).toBeNull(); + + const stopBtn = [...container.querySelectorAll('button')].find(b => b.textContent === 'Stop'); + expect(stopBtn).not.toBeNull(); + expect(stopBtn!.getAttribute('disabled')).toBeNull(); + }); + + it('disables Send while streaming with a blank draft and keeps Stop enabled', () => { + const { container } = render( + h(AgentsComposer, { + canSend: true, + canInterrupt: true, + isStreaming: true, + isReadOnly: false, + isLoading: false, + onSend: () => {}, + onStop: () => {}, + }) + ); + + const sendBtn = [...container.querySelectorAll('button')].find( + b => b.textContent === 'Send message' + ); + expect(sendBtn).not.toBeNull(); + expect(sendBtn!.getAttribute('disabled')).not.toBeNull(); + + const stopBtn = [...container.querySelectorAll('button')].find(b => b.textContent === 'Stop'); + expect(stopBtn).not.toBeNull(); + expect(stopBtn!.getAttribute('disabled')).toBeNull(); + }); + it('renders Stop button when isStreaming is true regardless of canSend', () => { const { container } = render( h(AgentsComposer, { @@ -1603,23 +1448,3 @@ describe('agents session view integration', () => { vi.useRealTimers(); }); }); - -describe('toolErrorSummary helper', () => { - it('returns a short single-line error unchanged', () => { - expect(toolErrorSummary('ENOENT: no such file')).toBe('ENOENT: no such file'); - }); - - it('keeps only the first meaningful line of a stack trace', () => { - expect(toolErrorSummary('\n Error: boom\n at foo (bar.ts:1)\n')).toBe('Error: boom'); - }); - - it('clamps a very long line so the transcript stays readable', () => { - const summary = toolErrorSummary('x'.repeat(500)); - expect(summary).toHaveLength(201); - expect(summary.endsWith('…')).toBe(true); - }); - - it('returns an empty string for whitespace-only errors', () => { - expect(toolErrorSummary(' \n ')).toBe(''); - }); -}); diff --git a/apps/extension/src/shared/agent-context-compaction.ts b/apps/extension/src/shared/agent-context-compaction.ts index 361e4ce18e..86a2945c4d 100644 --- a/apps/extension/src/shared/agent-context-compaction.ts +++ b/apps/extension/src/shared/agent-context-compaction.ts @@ -77,6 +77,10 @@ const stringifyToolValue = (value: unknown): string => { const getToolCallDetail = ( event: Extract ): string | undefined => { + if ('source' in event) { + return stringifyToolValue(event.arguments); + } + if (event.name === 'eval') { return event.code; } diff --git a/apps/extension/src/shared/agent-conversation.test.ts b/apps/extension/src/shared/agent-conversation.test.ts index f9e9e1a0ae..0a87d982cf 100644 --- a/apps/extension/src/shared/agent-conversation.test.ts +++ b/apps/extension/src/shared/agent-conversation.test.ts @@ -7,9 +7,10 @@ import { createToolResult, createUserMessage, createWorkflowToolCall, - groupConversationEvents, getConversationScrollKey, + groupConversationEvents, } from './agent-conversation'; +import type { GroupedConversationItem } from './agent-conversation'; describe('agent conversation events', () => { it('creates stable conversation events for messages and eval tools', () => { @@ -132,6 +133,37 @@ describe('agent conversation events', () => { expect(nextKey).not.toBe(firstKey); }); + it('marks an in-flight agent tool exchange in the scroll key', () => { + const toolCall = { + arguments: { filePath: 'src/auth.ts' }, + id: 'tc-agent', + name: 'read', + source: 'agent' as const, + type: 'tool-call' as const, + }; + const items: GroupedConversationItem[] = [{ toolCall, type: 'tool-exchange' }]; + + expect(getConversationScrollKey(items)).toBe('tc-agent:running'); + }); + + it('keeps the result id in the scroll key once the tool exchange completes', () => { + const toolCall = { + arguments: { filePath: 'src/auth.ts' }, + id: 'tc-agent', + name: 'read', + source: 'agent' as const, + type: 'tool-call' as const, + }; + const result = createToolResult({ + ok: true, + toolCallId: toolCall.id, + value: 'export const guard = () => true;', + }); + const items: GroupedConversationItem[] = [{ result, toolCall, type: 'tool-exchange' }]; + + expect(getConversationScrollKey(items)).toBe(`tc-agent:${result.id}`); + }); + it('creates remote MCP tool-call events', () => { const toolCall = createRemoteMcpToolCall({ arguments: { query: 'kilo' }, diff --git a/apps/extension/src/shared/agent-conversation.ts b/apps/extension/src/shared/agent-conversation.ts index b8699dc956..7441cfefcf 100644 --- a/apps/extension/src/shared/agent-conversation.ts +++ b/apps/extension/src/shared/agent-conversation.ts @@ -70,9 +70,22 @@ export type AgentConversationEvent = readonly tabId: number; readonly type: 'tool-call'; } + | { + readonly arguments: Record; + readonly id: string; + /** The agent's own tool name (`read`, `bash`, …), not an extension tool. */ + readonly name: string; + /** Marks the agent-tool member so the renderer can branch on it. */ + readonly source: 'agent'; + /** The tool's own one-line summary of the call. */ + readonly title?: string; + readonly type: 'tool-call'; + } | { readonly error?: string; readonly id: string; + /** Image bytes for an agent tool result, from `agent-tool-images`. */ + readonly imageDataUrl?: string; readonly ok: boolean; readonly toolCallId: string; readonly type: 'tool-result'; @@ -98,7 +111,7 @@ export type GroupedConversationItem = readonly type: 'event'; } | { - readonly result: Extract; + readonly result?: Extract; readonly toolCall: Extract; readonly type: 'tool-exchange'; }; @@ -290,7 +303,7 @@ export const getConversationScrollKey = (items: GroupedConversationItem[]): stri items .map(item => { if (item.type === 'tool-exchange') { - return `${item.toolCall.id}:${item.result.id}`; + return `${item.toolCall.id}:${item.result?.id ?? 'running'}`; } const { event } = item; diff --git a/apps/extension/src/shared/agent-llm-harness.ts b/apps/extension/src/shared/agent-llm-harness.ts index 57682b16d9..50f4149cc2 100644 --- a/apps/extension/src/shared/agent-llm-harness.ts +++ b/apps/extension/src/shared/agent-llm-harness.ts @@ -3,6 +3,7 @@ import type { KiloGatewayChatMessage, KiloGatewayToolDefinition } from './kilo-a import type { AgentConversationEvent, AgentMode } from './agent-conversation'; type ToolCallEvent = Extract; +type ExtensionToolCall = Exclude; type MessageEvent = Extract; type ToolResultEvent = Extract; export const EXTENSION_AGENT_SYSTEM_PROMPT = [ @@ -369,7 +370,7 @@ export const createWorkflowToolDefinitions = ({ }; const getProviderToolCallId = (toolCall: ToolCallEvent): string => - toolCall.providerToolCallId ?? toolCall.id; + 'source' in toolCall ? toolCall.id : (toolCall.providerToolCallId ?? toolCall.id); const screenshotValueSchema = { safeParse( @@ -493,6 +494,10 @@ const getGatewayMessageText = (event: MessageEvent): string => : event.text; const getToolCallArguments = (toolCall: ToolCallEvent): string => { + if ('source' in toolCall) { + return JSON.stringify(toolCall.arguments); + } + if (toolCall.name === 'eval') { return JSON.stringify({ code: toolCall.code }); } @@ -531,28 +536,37 @@ export const buildGatewayMessagesFromEvents = ( break; } case 'tool-call': { - const toolCalls = getConsecutiveToolCalls(events, index); + const consecutiveToolCalls = getConsecutiveToolCalls(events, index); + // Agent-source tool calls carry arbitrary agent names, not gateway tool names. + // Keep them out of the gateway replay without changing extension tool behaviour. + const toolCalls = consecutiveToolCalls.filter( + (toolCall): toolCall is ExtensionToolCall => !('source' in toolCall) + ); + for (const toolCall of toolCalls) { toolCallsById.set(toolCall.id, toolCall); } - index += toolCalls.length - 1; + index += consecutiveToolCalls.length - 1; const reasoningDetails = toolCalls.find( toolCall => toolCall.reasoningDetails !== undefined )?.reasoningDetails; - messages.push({ - content: null, - ...(reasoningDetails === undefined ? {} : { reasoning_details: reasoningDetails }), - role: 'assistant', - tool_calls: toolCalls.map(toolCall => ({ - function: { - arguments: getToolCallArguments(toolCall), - name: toolCall.name, - }, - id: getProviderToolCallId(toolCall), - type: 'function', - })), - }); + + if (toolCalls.length > 0) { + messages.push({ + content: null, + ...(reasoningDetails === undefined ? {} : { reasoning_details: reasoningDetails }), + role: 'assistant', + tool_calls: toolCalls.map(toolCall => ({ + function: { + arguments: getToolCallArguments(toolCall), + name: toolCall.name, + }, + id: getProviderToolCallId(toolCall), + type: 'function', + })), + }); + } break; } case 'tool-result': { diff --git a/apps/extension/src/shared/agent-tool-images.test.ts b/apps/extension/src/shared/agent-tool-images.test.ts new file mode 100644 index 0000000000..fd3440adb0 --- /dev/null +++ b/apps/extension/src/shared/agent-tool-images.test.ts @@ -0,0 +1,69 @@ +/* eslint-disable jest/no-hooks -- beforeEach resets the module-level image store */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + clearToolImages, + getToolImage, + MAX_TOOL_IMAGES, + rememberToolImage, +} from './agent-tool-images'; + +describe('agent tool images', () => { + beforeEach(() => { + clearToolImages(); + }); + + it('drops non-image mime types', () => { + rememberToolImage('part-1', { dataUrl: 'data:text/plain;base64,SGVsbG8=', mime: 'text/plain' }); + expect(getToolImage('part-1')).toBeUndefined(); + }); + + it('drops blank data URLs', () => { + rememberToolImage('part-2', { dataUrl: '', mime: 'image/png' }); + expect(getToolImage('part-2')).toBeUndefined(); + }); + + it('drops an external image URL', () => { + rememberToolImage('part-ext', { + dataUrl: 'https://example.com/screenshot.png', + mime: 'image/png', + }); + expect(getToolImage('part-ext')).toBeUndefined(); + }); + + it('reads back a stored image', () => { + rememberToolImage('part-3', { dataUrl: 'data:image/png;base64,AAA=', mime: 'image/png' }); + expect(getToolImage('part-3')).toBe('data:image/png;base64,AAA='); + }); + + it('evicts the oldest image beyond MAX_TOOL_IMAGES', () => { + for (let index = 0; index < MAX_TOOL_IMAGES + 1; index += 1) { + rememberToolImage(`part-${index}`, { + dataUrl: `data:image/png;base64,${index}`, + mime: 'image/png', + }); + } + expect(getToolImage('part-0')).toBeUndefined(); + for (let index = 1; index <= MAX_TOOL_IMAGES; index += 1) { + expect(getToolImage(`part-${index}`)).toBe(`data:image/png;base64,${index}`); + } + }); + + it('moves a re-stored image to the newest position', () => { + for (let index = 0; index < MAX_TOOL_IMAGES; index += 1) { + rememberToolImage(`part-${index}`, { + dataUrl: `data:image/png;base64,${index}`, + mime: 'image/png', + }); + } + rememberToolImage('part-0', { + dataUrl: 'data:image/png;base64,renewed', + mime: 'image/png', + }); + rememberToolImage('part-extra', { + dataUrl: 'data:image/png;base64,extra', + mime: 'image/png', + }); + expect(getToolImage('part-0')).toBe('data:image/png;base64,renewed'); + expect(getToolImage('part-1')).toBeUndefined(); + }); +}); diff --git a/apps/extension/src/shared/agent-tool-images.ts b/apps/extension/src/shared/agent-tool-images.ts new file mode 100644 index 0000000000..f2b6900b78 --- /dev/null +++ b/apps/extension/src/shared/agent-tool-images.ts @@ -0,0 +1,40 @@ +/* + * Newest tool images kept in memory, per panel load. A screenshot data URL is + * often megabytes, and the side panel is long-lived, so this cannot grow + * without a bound. Past the cap the oldest entry is evicted and that tool + * panel falls back to rendering its text output — the image does not come + * back, because the bytes were stripped before storage and are not refetched. + * ponytail: fixed count cap; switch to a byte budget if large images start + * evicting useful ones early. + */ +export const MAX_TOOL_IMAGES = 50; + +const toolImages = new Map(); + +export const rememberToolImage = ( + partId: string, + attachment: { mime: string; filename?: string; dataUrl: string } +): void => { + if (!attachment.mime.startsWith('image/')) { + return; + } + if (!attachment.dataUrl.startsWith('data:image/')) { + return; + } + toolImages.delete(partId); + toolImages.set(partId, attachment.dataUrl); + while (toolImages.size > MAX_TOOL_IMAGES) { + const [oldest] = toolImages.keys(); + if (oldest === undefined) { + break; + } + toolImages.delete(oldest); + } +}; + +export const getToolImage = (partId: string): string | undefined => toolImages.get(partId); + +/** Test-only reset. */ +export const clearToolImages = (): void => { + toolImages.clear(); +}; diff --git a/apps/extension/src/shared/extension-agent-session-manager.test.ts b/apps/extension/src/shared/extension-agent-session-manager.test.ts index ce32b017a0..3f852ba282 100644 --- a/apps/extension/src/shared/extension-agent-session-manager.test.ts +++ b/apps/extension/src/shared/extension-agent-session-manager.test.ts @@ -559,10 +559,150 @@ describe('createExtensionAgentSessionManager', () => { describe('fetchSnapshotPage', () => { it('returns empty success when history is null', async () => { + vi.useFakeTimers(); + try { + const trpc = makeTrpcMock(); + const pageQuery = mockQuery({ history: null, kiloSessionId: SESSION_ID }); + trpc.cliSessionsV2.getSessionMessagesPage.query = pageQuery; + const opts = { ...makeDefaultOptions(), trpcClient: trpc as never }; + createExtensionAgentSessionManager(opts); + const resultPromise = capturedConfig!.fetchSnapshotPage!(SESSION_ID, {}); + // Let the bounded liveness grace probe expire without a page retry. + await vi.advanceTimersByTimeAsync(10_000); + const result = await resultPromise; + expect(result).toStrictEqual({ + info: { id: SESSION_ID }, + kind: 'success', + messages: [], + nextCursor: null, + omittedItemCount: 0, + }); + // An inactive session must not trigger the delayed-page retry. + expect(pageQuery).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps the empty first page usable when the liveness probe rejects', async () => { + const trpc = makeTrpcMock(); + const probeError = new Error('active sessions unavailable'); + vi.spyOn(trpc.activeSessions.list, 'query').mockImplementation(async () => { + throw probeError; + }); + const pageQuery = mockQuery({ history: null, kiloSessionId: SESSION_ID }); + trpc.cliSessionsV2.getSessionMessagesPage.query = pageQuery; + const opts = { ...makeDefaultOptions(), trpcClient: trpc as never }; + createExtensionAgentSessionManager(opts); + + const result = await capturedConfig!.fetchSnapshotPage!(SESSION_ID, {}); + expect(result).toStrictEqual({ + info: { id: SESSION_ID }, + kind: 'success', + messages: [], + nextCursor: null, + omittedItemCount: 0, + }); + // The rejected probe must not trigger a page retry. + expect(pageQuery).toHaveBeenCalledTimes(1); + }); + + it('returns the original empty snapshot when the grace liveness probe rejects after a missing active row', async () => { + vi.useFakeTimers(); + try { + const trpc = makeTrpcMock(); + /* + * Regression state: the gate's first active-sessions read misses the + * running session's row (registration or read-model refresh race), so + * the grace liveness probe runs — and that probe rejects. The old + * code propagated the probe rejection and rejected the snapshot, + * blocking the WebSocket replay. The rejected grace probe must + * resolve the original empty page instead. + */ + const listQuery = vi + .fn() + .mockResolvedValueOnce({ sessions: [] }) + .mockRejectedValueOnce(new Error('active sessions unavailable')); + trpc.activeSessions.list.query = listQuery; + const pageQuery = mockQuery({ history: null, kiloSessionId: SESSION_ID }); + trpc.cliSessionsV2.getSessionMessagesPage.query = pageQuery; + const opts = { ...makeDefaultOptions(), trpcClient: trpc as never }; + createExtensionAgentSessionManager(opts); + + const resultPromise = capturedConfig!.fetchSnapshotPage!(SESSION_ID, {}); + // The single grace probe sleep elapses; the probe then rejects. + await vi.advanceTimersByTimeAsync(1000); + + const result = await resultPromise; + expect(result).toStrictEqual({ + info: { id: SESSION_ID }, + kind: 'success', + messages: [], + nextCursor: null, + omittedItemCount: 0, + }); + // The rejected grace probe must not trigger a page retry. + expect(pageQuery).toHaveBeenCalledTimes(1); + // Gate read plus the one rejected grace probe read. + expect(listQuery).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('forwards the event-log watermark on an empty page so a reopened running session replays persisted events', async () => { + vi.useFakeTimers(); + try { + const trpc = makeTrpcMock(); + /* + * Round-8 regression state: the reopened CLI session's initial page + * read is still empty (session-ingest batches until the turn ends), + * but the ingest DO already carries the nonce-bearing user message — + * the tRPC result reports that high-water mark as `watermarkEventId`. + * The transport seeds its first WebSocket connect from this page + * watermark (`fromId=0`), so every persisted event replays and the + * user message reaches the renderer even though the page is empty. + * Dropping the watermark would connect with `replay=false` and lose + * the already-persisted message. + */ + trpc.cliSessionsV2.getSessionMessagesPage.query = mockQuery({ + history: null, + kiloSessionId: SESSION_ID, + watermarkEventId: 42, + }); + const opts = { ...makeDefaultOptions(), trpcClient: trpc as never }; + createExtensionAgentSessionManager(opts); + const resultPromise = capturedConfig!.fetchSnapshotPage!(SESSION_ID, {}); + await vi.advanceTimersByTimeAsync(10_000); + const result = await resultPromise; + expect(result).toStrictEqual({ + info: { id: SESSION_ID }, + kind: 'success', + messages: [], + nextCursor: null, + omittedItemCount: 0, + watermarkEventId: 42, + }); + } finally { + vi.useRealTimers(); + } + }); + + it('forwards the event-log watermark on a message page', async () => { const trpc = makeTrpcMock(); trpc.cliSessionsV2.getSessionMessagesPage.query = mockQuery({ - history: null, + history: { + messages: [ + { + info: { role: 'user', sessionID: SESSION_ID, time: {} }, + parts: [], + }, + ], + nextCursor: null, + omittedItemCount: 0, + }, kiloSessionId: SESSION_ID, + watermarkEventId: 7, }); const opts = { ...makeDefaultOptions(), trpcClient: trpc as never }; createExtensionAgentSessionManager(opts); @@ -570,9 +710,10 @@ describe('createExtensionAgentSessionManager', () => { expect(result).toStrictEqual({ info: { id: SESSION_ID }, kind: 'success', - messages: [], + messages: [{ info: { role: 'user', sessionID: SESSION_ID, time: {} }, parts: [] }], nextCursor: null, omittedItemCount: 0, + watermarkEventId: 7, }); }); @@ -580,7 +721,20 @@ describe('createExtensionAgentSessionManager', () => { const trpc = makeTrpcMock(); trpc.cliSessionsV2.getSessionMessagesPage.query = mockQuery({ history: { - messages: [{ info: { role: 'user', time: {} }, parts: [] }], + messages: [ + { + info: { role: 'user', sessionID: SESSION_ID, time: {} }, + parts: [ + { + id: 'part-1', + messageID: 'msg-1', + sessionID: SESSION_ID, + text: 'hello', + type: 'text', + }, + ], + }, + ], nextCursor: 'cursor-1', omittedItemCount: 5, }, @@ -592,13 +746,75 @@ describe('createExtensionAgentSessionManager', () => { expect(result).toStrictEqual({ info: { id: SESSION_ID }, kind: 'success', - messages: [{ info: { role: 'user', time: {} }, parts: [] }], + messages: [ + { + info: { role: 'user', sessionID: SESSION_ID, time: {} }, + parts: [ + { + id: 'part-1', + messageID: 'msg-1', + sessionID: SESSION_ID, + text: 'hello', + type: 'text', + }, + ], + }, + ], nextCursor: 'cursor-1', omittedItemCount: 5, }); }); - it('passes cursor to query', async () => { + it('normalizes a mismatched server session id to the requested id', async () => { + const trpc = makeTrpcMock(); + const serverSessionId = 'ses_server_mismatched_0000000001' as KiloSessionId; + trpc.cliSessionsV2.getSessionMessagesPage.query = mockQuery({ + history: { + messages: [ + { + info: { role: 'user', sessionID: serverSessionId, time: {} }, + parts: [ + { + id: 'part-1', + messageID: 'msg-1', + sessionID: serverSessionId, + text: 'hello', + type: 'text', + }, + ], + }, + ], + nextCursor: 'cursor-1', + omittedItemCount: 2, + }, + kiloSessionId: serverSessionId, + }); + const opts = { ...makeDefaultOptions(), trpcClient: trpc as never }; + createExtensionAgentSessionManager(opts); + const result = await capturedConfig!.fetchSnapshotPage!(SESSION_ID, {}); + expect(result).toStrictEqual({ + info: { id: SESSION_ID }, + kind: 'success', + messages: [ + { + info: { role: 'user', sessionID: SESSION_ID, time: {} }, + parts: [ + { + id: 'part-1', + messageID: 'msg-1', + sessionID: SESSION_ID, + text: 'hello', + type: 'text', + }, + ], + }, + ], + nextCursor: 'cursor-1', + omittedItemCount: 2, + }); + }); + + it('passes cursor to query without retrying', async () => { const trpc = makeTrpcMock(); const pageQuery = mockQuery({ history: null, kiloSessionId: SESSION_ID }); trpc.cliSessionsV2.getSessionMessagesPage.query = pageQuery; @@ -606,6 +822,412 @@ describe('createExtensionAgentSessionManager', () => { createExtensionAgentSessionManager(opts); await capturedConfig!.fetchSnapshotPage!(SESSION_ID, { cursor: 'my-cursor' }); expect(pageQuery).toHaveBeenCalledWith({ cursor: 'my-cursor', session_id: SESSION_ID }); + // Cursor pages must not be retried or checked against active sessions. + expect(pageQuery).toHaveBeenCalledTimes(1); + expect(trpc.activeSessions.list.query).not.toHaveBeenCalled(); + }); + + it('returns the empty snapshot promptly for a running session with a watermark without blocking on the retry loop', async () => { + const trpc = makeTrpcMock(); + const listQuery = mockQuery({ sessions: [{ id: SESSION_ID, status: 'busy' }] }); + trpc.activeSessions.list.query = listQuery; + const pageQuery = mockQuery({ + history: null, + kiloSessionId: SESSION_ID, + watermarkEventId: 42, + }); + trpc.cliSessionsV2.getSessionMessagesPage.query = pageQuery; + const opts = { ...makeDefaultOptions(), trpcClient: trpc as never }; + createExtensionAgentSessionManager(opts); + + const resultPromise = capturedConfig!.fetchSnapshotPage!(SESSION_ID, {}); + /* + * Round-14 regression state: a reopened running CLI session's initial + * page is empty and the session is confirmed working. The old code ran + * the bounded 120-second history retry, blocking the session switch and + * the WebSocket transport that would replay the persisted events. When + * the page carries a usable event-log watermark, the empty page must + * resolve immediately so the transport can connect and replay every + * stored event from that watermark. + */ + const result = await resultPromise; + expect(result).toStrictEqual({ + info: { id: SESSION_ID }, + kind: 'success', + messages: [], + nextCursor: null, + omittedItemCount: 0, + watermarkEventId: 42, + }); + // The confirmed-running session with a watermark must not start the delayed-page retry. + expect(pageQuery).toHaveBeenCalledTimes(1); + expect(listQuery).toHaveBeenCalledTimes(1); + }); + + it('retries a confirmed-running empty page without a watermark until persisted messages appear', async () => { + vi.useFakeTimers(); + try { + const trpc = makeTrpcMock(); + /* + * Cumulative-review regression state: the reopened running CLI session + * lists busy on the gate read and its initial page is empty, but the + * tRPC result carries no `watermarkEventId` (remote CLI transport has + * no watermark replay path, and Cloud Agent can omit the watermark + * when its optional lookup fails). With no watermark there is nothing + * for the transport to replay, so resolving the empty page immediately + * would hide the already-persisted messages. The bounded history + * recovery must keep reading until the page carries them. + */ + const listQuery = mockQuery({ sessions: [{ id: SESSION_ID, status: 'busy' }] }); + trpc.activeSessions.list.query = listQuery; + /* + * The persisted page lags the running turn: the initial read and the + * first retry read are empty, then the persisted user message arrives. + */ + const pageQuery = vi + .fn() + .mockResolvedValueOnce({ history: null, kiloSessionId: SESSION_ID }) + .mockResolvedValueOnce({ history: null, kiloSessionId: SESSION_ID }) + .mockResolvedValueOnce({ + history: { + messages: [ + { + info: { role: 'user', sessionID: SESSION_ID, time: {} }, + parts: [], + }, + ], + nextCursor: null, + omittedItemCount: 0, + }, + kiloSessionId: SESSION_ID, + }); + trpc.cliSessionsV2.getSessionMessagesPage.query = pageQuery; + const opts = { ...makeDefaultOptions(), trpcClient: trpc as never }; + createExtensionAgentSessionManager(opts); + + const resultPromise = capturedConfig!.fetchSnapshotPage!(SESSION_ID, {}); + // The initial read resolves immediately; two retry sleeps then elapse. + await vi.advanceTimersByTimeAsync(2000); + + const result = await resultPromise; + expect(result).toMatchObject({ + kind: 'success', + messages: [{ info: { sessionID: SESSION_ID } }], + }); + // Initial read plus the two retry reads; the message page ends the retry. + expect(pageQuery).toHaveBeenCalledTimes(3); + // Only the gate read confirms the session; the retry rechecks liveness later. + expect(listQuery).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps retrying through the liveness probe until a late-persisted page arrives', async () => { + vi.useFakeTimers(); + try { + const trpc = makeTrpcMock(); + /* + * The session is not confirmed working on the gate's first read (a + * starting session can miss its active row while registration or the + * read model refreshes), then turns busy on the liveness probe read, + * which is the only path that runs the bounded history recovery. + */ + const listQuery = vi + .fn() + .mockResolvedValueOnce({ sessions: [] }) + .mockResolvedValue({ sessions: [{ id: SESSION_ID, status: 'busy' }] }); + trpc.activeSessions.list.query = listQuery; + const pageQuery = vi.fn(); + /* + * The persisted page can lag the whole running turn: only the + * eleventh read carries messages while the first ten stay empty. + */ + for (let index = 0; index < 10; index += 1) { + pageQuery.mockResolvedValueOnce({ history: null, kiloSessionId: SESSION_ID }); + } + pageQuery.mockResolvedValueOnce({ + history: { + messages: [ + { + info: { role: 'user', sessionID: SESSION_ID, time: {} }, + parts: [], + }, + ], + nextCursor: null, + omittedItemCount: 0, + }, + kiloSessionId: SESSION_ID, + }); + trpc.cliSessionsV2.getSessionMessagesPage.query = pageQuery; + const opts = { ...makeDefaultOptions(), trpcClient: trpc as never }; + createExtensionAgentSessionManager(opts); + + const resultPromise = capturedConfig!.fetchSnapshotPage!(SESSION_ID, {}); + // Liveness probe sleep plus the ten retry sleeps. + await vi.advanceTimersByTimeAsync(15_000); + + const result = await resultPromise; + expect(result).toMatchObject({ + kind: 'success', + messages: [{ info: { sessionID: SESSION_ID } }], + }); + // Initial read plus ten retry reads. + expect(pageQuery).toHaveBeenCalledTimes(11); + // Gate read, probe read, and one retry liveness recheck. + expect(listQuery).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + + it('returns the empty snapshot promptly when the running session page carries zero messages and a watermark', async () => { + const trpc = makeTrpcMock(); + const listQuery = mockQuery({ sessions: [{ id: SESSION_ID, status: 'busy' }] }); + trpc.activeSessions.list.query = listQuery; + /* + * A page with zero messages (a session row exists but no message has + * been materialized) is empty just like a null history, so a + * confirmed-running session with a usable watermark must resolve it + * immediately too and let the transport replay from the watermark. + */ + const pageQuery = mockQuery({ + history: { messages: [], nextCursor: null, omittedItemCount: 0 }, + kiloSessionId: SESSION_ID, + watermarkEventId: 9, + }); + trpc.cliSessionsV2.getSessionMessagesPage.query = pageQuery; + const opts = { ...makeDefaultOptions(), trpcClient: trpc as never }; + createExtensionAgentSessionManager(opts); + + const result = await capturedConfig!.fetchSnapshotPage!(SESSION_ID, {}); + expect(result).toStrictEqual({ + info: { id: SESSION_ID }, + kind: 'success', + messages: [], + nextCursor: null, + omittedItemCount: 0, + watermarkEventId: 9, + }); + expect(pageQuery).toHaveBeenCalledTimes(1); + expect(listQuery).toHaveBeenCalledTimes(1); + }); + + it('keeps checking liveness when the first lookup misses the active row', async () => { + vi.useFakeTimers(); + try { + const trpc = makeTrpcMock(); + /* + * The active row is missing on the gate's first read (registration + * or refresh race), then appears as busy on the liveness grace read. + */ + const listQuery = vi + .fn() + .mockResolvedValueOnce({ sessions: [] }) + .mockResolvedValueOnce({ sessions: [{ id: SESSION_ID, status: 'busy' }] }); + trpc.activeSessions.list.query = listQuery; + /* + * The initial page is empty and stays empty through the first retry + * read, then the persisted messages arrive on the third read. + */ + const pageQuery = vi + .fn() + .mockResolvedValueOnce({ history: null, kiloSessionId: SESSION_ID }) + .mockResolvedValueOnce({ history: null, kiloSessionId: SESSION_ID }) + .mockResolvedValueOnce({ + history: { + messages: [ + { + info: { role: 'user', sessionID: SESSION_ID, time: {} }, + parts: [], + }, + ], + nextCursor: null, + omittedItemCount: 0, + }, + kiloSessionId: SESSION_ID, + }); + trpc.cliSessionsV2.getSessionMessagesPage.query = pageQuery; + const opts = { ...makeDefaultOptions(), trpcClient: trpc as never }; + createExtensionAgentSessionManager(opts); + + const resultPromise = capturedConfig!.fetchSnapshotPage!(SESSION_ID, {}); + // Liveness grace sleep + the first retry sleep + the second retry sleep. + await vi.advanceTimersByTimeAsync(3000); + + const result = await resultPromise; + expect(result).toMatchObject({ + kind: 'success', + messages: [{ info: { sessionID: SESSION_ID } }], + }); + expect(pageQuery).toHaveBeenCalledTimes(3); + expect(listQuery).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('probes a listed-idle session that becomes busy and returns the persisted user message', async () => { + vi.useFakeTimers(); + try { + const trpc = makeTrpcMock(); + /* + * Round-9 regression state: the reopened CLI session lists idle on + * the gate's first read (session-ingest persistence has not caught up + * yet), then turns busy on the bounded liveness probe read. The old + * code latched the empty page after that single idle read, so the + * batched nonce-bearing user message never reached the reopened UI. + */ + const listQuery = vi + .fn() + .mockResolvedValueOnce({ sessions: [{ id: SESSION_ID, status: 'idle' }] }) + .mockResolvedValueOnce({ sessions: [{ id: SESSION_ID, status: 'busy' }] }); + trpc.activeSessions.list.query = listQuery; + /* + * The persisted page lags the turn: the initial read and the first + * retry read are empty, then the persisted user message arrives. + */ + const pageQuery = vi + .fn() + .mockResolvedValueOnce({ history: null, kiloSessionId: SESSION_ID }) + .mockResolvedValueOnce({ history: null, kiloSessionId: SESSION_ID }) + .mockResolvedValueOnce({ + history: { + messages: [ + { + info: { role: 'user', sessionID: SESSION_ID, time: {} }, + parts: [], + }, + ], + nextCursor: null, + omittedItemCount: 0, + }, + kiloSessionId: SESSION_ID, + }); + trpc.cliSessionsV2.getSessionMessagesPage.query = pageQuery; + const opts = { ...makeDefaultOptions(), trpcClient: trpc as never }; + createExtensionAgentSessionManager(opts); + + const resultPromise = capturedConfig!.fetchSnapshotPage!(SESSION_ID, {}); + // Liveness probe sleep + the two retry sleeps. + await vi.advanceTimersByTimeAsync(3000); + + const result = await resultPromise; + expect(result).toMatchObject({ + kind: 'success', + messages: [{ info: { sessionID: SESSION_ID } }], + }); + expect(pageQuery).toHaveBeenCalledTimes(3); + expect(listQuery).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('returns the empty page within the bound when the session stays stably idle', async () => { + vi.useFakeTimers(); + try { + const trpc = makeTrpcMock(); + const listQuery = mockQuery({ sessions: [{ id: SESSION_ID, status: 'idle' }] }); + trpc.activeSessions.list.query = listQuery; + const pageQuery = mockQuery({ history: null, kiloSessionId: SESSION_ID }); + trpc.cliSessionsV2.getSessionMessagesPage.query = pageQuery; + const opts = { ...makeDefaultOptions(), trpcClient: trpc as never }; + createExtensionAgentSessionManager(opts); + + const resultPromise = capturedConfig!.fetchSnapshotPage!(SESSION_ID, {}); + // The bounded liveness probe: one gate read plus three probe reads. + await vi.advanceTimersByTimeAsync(10_000); + + const result = await resultPromise; + expect(result).toStrictEqual({ + info: { id: SESSION_ID }, + kind: 'success', + messages: [], + nextCursor: null, + omittedItemCount: 0, + }); + /* + * No page retry runs for a stably idle session: the page resolves on + * the first read, so the fresh session's own first send is not held + * beyond the liveness bound. + */ + expect(pageQuery).toHaveBeenCalledTimes(1); + /* + * The bounded probe rechecks activeSessions.list for the whole window + * before latching the empty page. + */ + expect(listQuery).toHaveBeenCalledTimes(4); + } finally { + vi.useRealTimers(); + } + }); + + it('stops retrying a short time after the session stops running', async () => { + vi.useFakeTimers(); + try { + const trpc = makeTrpcMock(); + /* + * The session misses the gate's first read, turns busy on the + * liveness probe read (starting the bounded history recovery), then + * turns idle on the retry's liveness recheck, which ends the retry. + */ + const listQuery = vi + .fn() + .mockResolvedValueOnce({ sessions: [] }) + .mockResolvedValueOnce({ sessions: [{ id: SESSION_ID, status: 'busy' }] }) + .mockResolvedValueOnce({ sessions: [{ id: SESSION_ID, status: 'idle' }] }); + trpc.activeSessions.list.query = listQuery; + const pageQuery = mockQuery({ history: null, kiloSessionId: SESSION_ID }); + trpc.cliSessionsV2.getSessionMessagesPage.query = pageQuery; + const opts = { ...makeDefaultOptions(), trpcClient: trpc as never }; + createExtensionAgentSessionManager(opts); + + const resultPromise = capturedConfig!.fetchSnapshotPage!(SESSION_ID, {}); + // Liveness probe sleep, five retry sleeps, and five grace-window sleeps. + await vi.advanceTimersByTimeAsync(15_000); + + const result = await resultPromise; + expect(result).toMatchObject({ kind: 'success', messages: [] }); + /* + * The retry stops once the session is no longer running. The expected + * page count is the initial read, five retry reads, and five grace + * reads; the idle re-check is an active-sessions call. + */ + expect(pageQuery).toHaveBeenCalledTimes(11); + // Gate read, probe read, and the retry liveness recheck. + expect(listQuery).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + + it('passes organizationId and includeCloudAgentSessions in the retry active lookup when org is set', async () => { + vi.useFakeTimers(); + try { + const trpc = makeTrpcMock(); + const listQuery = mockQuery({ sessions: [] }); + trpc.activeSessions.list.query = listQuery; + trpc.cliSessionsV2.getSessionMessagesPage.query = mockQuery({ + history: null, + kiloSessionId: SESSION_ID, + }); + const opts = { + ...makeDefaultOptions(), + organizationId: '550e8400-e29b-41d4-a716-446655440000', + trpcClient: trpc as never, + }; + createExtensionAgentSessionManager(opts); + const resultPromise = capturedConfig!.fetchSnapshotPage!(SESSION_ID, {}); + await vi.advanceTimersByTimeAsync(10_000); + await resultPromise; + expect(listQuery).toHaveBeenCalledWith({ + includeCloudAgentSessions: true, + organizationId: '550e8400-e29b-41d4-a716-446655440000', + }); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/apps/extension/src/shared/extension-agent-session-manager.ts b/apps/extension/src/shared/extension-agent-session-manager.ts index 7bc9cb1e1f..0804615d88 100644 --- a/apps/extension/src/shared/extension-agent-session-manager.ts +++ b/apps/extension/src/shared/extension-agent-session-manager.ts @@ -16,17 +16,49 @@ import type { UserWebConnection, } from '@kilocode/cloud-agent-sdk'; import type { MobileRouter, inferRouterOutputs } from '@kilocode/trpc/mobile'; +import { rememberToolImage } from './agent-tool-images'; import { getCloudAgentWsUrl } from './cloud-agent-config'; /** Flat 1s cadence — same budget as the session-detail route's spawned retry. */ const FETCH_SESSION_NOT_FOUND_RETRY_DELAY_MS = 1000; /** Match the mobile `SPAWNED_NOT_FOUND_MAX_ATTEMPTS` for NOT_FOUND retry parity. */ const SPAWNED_NOT_FOUND_MAX_ATTEMPTS = 8; +/** + * Safety cap for the running-session history retry. The retry normally ends + * when the persisted page carries messages or the session stops running (see + * `ACTIVE_HISTORY_RECHECK_INTERVAL`); this bound only limits how long a + * genuinely long-running turn can hold the initial history read open. + */ +const ACTIVE_HISTORY_MAX_RETRIES = 120; +/** Re-verify the session is still running every N retry reads. */ +const ACTIVE_HISTORY_RECHECK_INTERVAL = 5; +/** + * Extra reads after the session stops running. The CLI batches session-ingest + * until the turn completes, and the ingest can land a moment after the + * busy→idle status change, so keep polling briefly before applying the empty + * page. + */ +const ACTIVE_HISTORY_END_GRACE_READS = 5; +/** + * Bound for the not-yet-listed liveness probe. A running session can miss + * its own row in `activeSessions.list` while the row registers or while the + * active-sessions read model refreshes. A one-shot miss would latch an empty + * page for a session that is actually running, so the gate re-checks liveness + * for this many reads before treating the session as inactive. + */ +const ACTIVE_HISTORY_LIVENESS_GRACE_READS = 3; const skipBatchOptions = { context: { skipBatch: true } } as const; type TrpcClient = ReturnType>; +/** Shape of the paged `getSessionMessagesPage` query result. */ +type SessionMessagesPageResult = Awaited< + ReturnType +>; +/** Shape of the `activeSessions.list` query result used for liveness rechecks. */ +type ActiveSessionsResult = Awaited>; + // --------------------------------------------------------------------------- // Error code extraction — extension-owned copy of the mobile classifier // --------------------------------------------------------------------------- @@ -117,42 +149,319 @@ function isHistoryPage(history: KiloSdkMessageHistory): history is KiloSdkMessag return 'messages' in history && Array.isArray(history.messages); } +/** + * True when the page carries no persisted SDK messages yet. + * + * Both a `null` history (the ingest DO has no session or message rows) and a + * page with zero messages (a session row exists but no message has been + * materialized) mean session-ingest persistence is still catching up while a + * live CLI turn runs. Typed failure variants are not "empty" — they pass + * through so the caller can surface retry semantics. + */ +function isPageWithoutPersistedMessages(history: KiloSdkMessageHistory | null): boolean { + if (history === null) { + return true; + } + return isHistoryPage(history) && history.messages.length === 0; +} + +/** + * Read the paged query's history in its server-validated shape. The tRPC + * result carries typed failure variants alongside the page, so the shape is + * narrowed at the transport boundary. + */ +function pageHistory(result: SessionMessagesPageResult): KiloSdkMessageHistory | null { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- tRPC result shape is server-validated + return result.history as KiloSdkMessageHistory | null; +} + +/** + * Keep reading a running session's history page until it carries persisted + * messages, the session stops running, or the safety bound is reached. + */ +async function readActiveHistoryWithRetry( + initialResult: SessionMessagesPageResult, + { + fetchActiveSessions, + isSessionWorking, + queryPage, + }: { + queryPage: () => Promise; + fetchActiveSessions: () => Promise; + isSessionWorking: (active: ActiveSessionsResult) => boolean; + } +): Promise { + let result = initialResult; + for (let attempt = 0; attempt < ACTIVE_HISTORY_MAX_RETRIES; attempt += 1) { + // eslint-disable-next-line no-await-in-loop -- retry persistence after a fixed delay + await defaultFetchSessionSleep(FETCH_SESSION_NOT_FOUND_RETRY_DELAY_MS); + // eslint-disable-next-line no-await-in-loop -- retries must observe ordered history + result = await queryPage(); + if (!isPageWithoutPersistedMessages(pageHistory(result))) { + return result; + } + if ((attempt + 1) % ACTIVE_HISTORY_RECHECK_INTERVAL === 0) { + // eslint-disable-next-line no-await-in-loop -- ordered liveness recheck between retries + const current = await fetchActiveSessions(); + if (!isSessionWorking(current)) { + return readPageInEndGraceWindow(result, queryPage); + } + } + } + return result; +} + +/** + * Extra reads after the session stops running. The CLI batches session-ingest + * until the turn completes, and the ingest can land a moment after the + * busy→idle status change, so keep polling briefly before applying the empty + * page. + */ +async function readPageInEndGraceWindow( + initialResult: SessionMessagesPageResult, + queryPage: () => Promise +): Promise { + let result = initialResult; + for (let grace = 0; grace < ACTIVE_HISTORY_END_GRACE_READS; grace += 1) { + // eslint-disable-next-line no-await-in-loop -- bounded grace reads after turn end + await defaultFetchSessionSleep(FETCH_SESSION_NOT_FOUND_RETRY_DELAY_MS); + // eslint-disable-next-line no-await-in-loop -- bounded grace reads after turn end + result = await queryPage(); + if (!isPageWithoutPersistedMessages(pageHistory(result))) { + break; + } + } + return result; +} + +/** + * Re-read liveness briefly when the initial page is empty and the session is + * not confirmed working. + * + * A reopened running session can be listed idle or miss its active row while + * the active-sessions read model refreshes or while the CLI batches + * session-ingest until the turn completes. A one-shot idle or missing read + * would latch an empty transcript that later persistence can never fill. This + * bounded probe re-checks `activeSessions.list` for a fixed number of reads: + * the session becomes busy → run the full active-history retry; the session + * stays stably idle (or stays absent) for the whole window → resolve the empty + * page. A rejected liveness read leaves the working state unknown, so the + * empty page resolves without the probe. It re-reads liveness only; the + * history page is not re-read here, so an inactive session still resolves its + * empty page without a page retry. + */ +async function readActiveHistoryWithLivenessGrace( + initialResult: SessionMessagesPageResult, + { + fetchActiveSessions, + isSessionWorking, + queryPage, + }: { + fetchActiveSessions: () => Promise; + isSessionWorking: (active: ActiveSessionsResult) => boolean; + queryPage: () => Promise; + } +): Promise { + for (let attempt = 0; attempt < ACTIVE_HISTORY_LIVENESS_GRACE_READS; attempt += 1) { + // eslint-disable-next-line no-await-in-loop -- bounded liveness re-reads before latching empty + await defaultFetchSessionSleep(FETCH_SESSION_NOT_FOUND_RETRY_DELAY_MS); + /* + * A rejected grace liveness read leaves the session's working state + * unknown. Resolve the original empty page without the probe, matching + * the rejected initial probe behavior, instead of letting the probe + * rejection block the transcript. + */ + // eslint-disable-next-line no-await-in-loop -- bounded liveness re-reads before latching empty + const current = await fetchActiveSessions().catch(() => null); + if (current === null) { + return initialResult; + } + if (isSessionWorking(current)) { + return readActiveHistoryWithRetry(initialResult, { + fetchActiveSessions, + isSessionWorking, + queryPage, + }); + } + } + return initialResult; +} + +/** + * Pin a replayed page to the active kilo session. + * + * The manager renders the root transcript only for messages whose + * `sessionID` equals the adopted root session id, which `onSessionCreated` + * seeds from the page's `info.id`. The session-ingest worker can persist + * messages under a session id that differs from the one the extension opened + * (the session-scoped page fetch is authoritative for the viewer), so without + * this normalization every replayed message is filtered out and the reopened + * live transcript renders empty. Rewriting the page id and each message and + * part session id to the requested `kiloSessionId` keeps live and replayed + * messages in the same root transcript while leaving the pagination cursor + * and older-message behavior untouched. + */ +function pinPageToSession( + page: SessionSnapshotPage & { kind: 'success' }, + kiloSessionId: KiloSessionId +): SessionSnapshotPage & { kind: 'success' } { + return { + ...page, + info: { ...page.info, id: kiloSessionId }, + messages: page.messages.map(message => ({ + info: { ...message.info, sessionID: kiloSessionId }, + parts: message.parts.map(part => ({ ...part, sessionID: kiloSessionId })), + })), + }; +} + /** * Adapt `cliSessionsV2.getSessionMessagesPage` result to the SDK's * `SessionSnapshotPageOutcome` union. Extension-owned; mirrors the mobile - * `fetchMobileSessionSnapshotPage` adapter. + * `fetchMobileSessionSnapshotPage` adapter and pins the replayed page to the + * requested `kiloSessionId` so the manager's root transcript filter keeps the + * loaded history on screen. + * + * A running CLI session can read empty for a long stretch: the CLI batches + * session-ingest until the turn completes, so the persisted page lags the + * live turn. A confirmed-running session resolves that empty page + * immediately only when the page carries a usable event-log watermark: the + * transport replays every stored event from that watermark, so the snapshot + * (and the session switch) is not held for up to `ACTIVE_HISTORY_MAX_RETRIES` + * seconds while the page stays empty for the whole turn. Without a watermark + * there is no transport replay path, so the bounded history recovery keeps + * reading until the page carries the persisted messages or the session stops + * running. The liveness-grace recovery still runs for a session that is not + * yet confirmed working and turns busy within the liveness window, where + * persistence typically catches up quickly. + * + * Both page outcomes forward the tRPC result's `watermarkEventId` (when the + * server returned one) so the transport seeds its first WebSocket connect + * with `fromId=0` and the ingest DO replays every stored event. That replay + * renders the persisted transcript for a reopened running session whose page + * resolved empty. */ async function fetchExtensionSessionSnapshotPage( trpcClient: TrpcClient, kiloSessionId: KiloSessionId, - options: { cursor?: string } + options: { cursor?: string; organizationId: string | null } ): Promise { - const result = await trpcClient.cliSessionsV2.getSessionMessagesPage.query({ - session_id: kiloSessionId, - ...(options.cursor === undefined ? {} : { cursor: options.cursor }), - }); + const queryPage = (): Promise => + trpcClient.cliSessionsV2.getSessionMessagesPage.query({ + session_id: kiloSessionId, + ...(options.cursor === undefined ? {} : { cursor: options.cursor }), + }); + const fetchActiveSessions = (): Promise => + trpcClient.activeSessions.list.query({ + includeCloudAgentSessions: true, + organizationId: options.organizationId, + }); + const isSessionWorking = (active: ActiveSessionsResult): boolean => + active.sessions.some(session => session.id === kiloSessionId && session.status !== 'idle'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- tRPC result shape is server-validated - const history = result.history as KiloSdkMessageHistory | null; + let result = await queryPage(); + if (options.cursor === undefined && isPageWithoutPersistedMessages(pageHistory(result))) { + /* + * An empty first page can mean a freshly created idle session (empty by + * definition, whose first prompt is sent immediately after this page + * resolves and rendered via the live CLI echo) or a reopened running + * session whose persisted page has not been materialized yet. The CLI + * batches session-ingest until the turn completes and the active-sessions + * read model can briefly list a starting session as idle or omit it, so a + * single idle or missing liveness read must not latch the empty + * transcript. + * + * A confirmed-working session resolves its empty page immediately only + * when the page carries a usable event-log watermark: the transport + * seeds its first WebSocket connect from that watermark, so every stored + * event replays and the persisted transcript renders without the page + * retry. Without a watermark there is no replay path — the already + * persisted messages would stay invisible — so the bounded history + * recovery keeps reading until the page carries them or the session stops + * running. + * + * A session that is not confirmed working gets the bounded liveness + * probe: it re-checks `activeSessions.list` for a fixed number of reads + * and runs the bounded history recovery when the session becomes busy; a + * stably idle or absent session resolves the empty page when the window + * expires. A rejected liveness read leaves the working state unknown, so + * the empty page is resolved without the probe. + */ + let active: ActiveSessionsResult | null = null; + try { + active = await fetchActiveSessions(); + } catch { + /* + * A rejected liveness read leaves the session's working state unknown. + * Treat it as inactive so the empty page stays usable: resolve the + * original empty page without a page retry instead of letting the probe + * rejection block the transcript. Only the probe is guarded here; the + * initial page query and every later retry page query still propagate + * their own failures. + */ + } + if (active !== null && !isSessionWorking(active)) { + result = await readActiveHistoryWithLivenessGrace(result, { + fetchActiveSessions, + isSessionWorking, + queryPage, + }); + } else if ( + active !== null && + isSessionWorking(active) && + (result.watermarkEventId === null || result.watermarkEventId === undefined) + ) { + result = await readActiveHistoryWithRetry(result, { + fetchActiveSessions, + isSessionWorking, + queryPage, + }); + } + } + + const history = pageHistory(result); + /* + * Forward the event-log watermark from the tRPC result. The transport + * seeds its first WebSocket connect's `fromId` from the page watermark: a + * present watermark makes the DO replay every stored event, closing the + * gap between the page snapshot and the live stream when session-ingest + * materialization lags a running turn. Dropping it here connects with + * `replay=false`, and a reopened running session's already-persisted user + * message never reaches the renderer even though the message API carries + * it. Absent or null watermarks (fresh sessions, failed watermark read) + * stay absent to keep `replay=false` for sessions with nothing to replay. + */ + const watermark = + result.watermarkEventId === null || result.watermarkEventId === undefined + ? {} + : { watermarkEventId: result.watermarkEventId }; if (history === null) { - return { - info: { id: result.kiloSessionId }, - kind: 'success', - messages: [], - nextCursor: null, - omittedItemCount: 0, - }; + return pinPageToSession( + { + info: { id: result.kiloSessionId }, + kind: 'success', + messages: [], + nextCursor: null, + omittedItemCount: 0, + ...watermark, + }, + kiloSessionId + ); } if (isHistoryPage(history)) { - return { - info: { id: result.kiloSessionId }, - kind: 'success', - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- server-validated shape - messages: history.messages as SessionSnapshotPage['messages'], - nextCursor: history.nextCursor, - omittedItemCount: history.omittedItemCount, - }; + return pinPageToSession( + { + info: { id: result.kiloSessionId }, + kind: 'success', + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- server-validated shape + messages: history.messages as SessionSnapshotPage['messages'], + nextCursor: history.nextCursor, + omittedItemCount: history.omittedItemCount, + ...watermark, + }, + kiloSessionId + ); } return history; @@ -334,7 +643,10 @@ export function createExtensionAgentSessionManager({ // ---- fetchSnapshotPage ---- fetchSnapshotPage: (kiloSessionId, options) => - fetchExtensionSessionSnapshotPage(trpcClient, kiloSessionId, options), + fetchExtensionSessionSnapshotPage(trpcClient, kiloSessionId, { + ...options, + organizationId, + }), // ---- getTicket ---- getTicket: async (sessionId: CloudAgentSessionId): Promise => { @@ -379,6 +691,9 @@ export function createExtensionAgentSessionManager({ lifecycleHooks: createBrowserLifecycleHooks(), + // Tool attachment bytes are stripped before storage; keep the images here. + onToolAttachment: rememberToolImage, + // ---- prepare ---- prepare: async input => { // Reject initialPayload with a clear v1 error before any tRPC call. diff --git a/apps/extension/tests/e2e/agents-fixture.ts b/apps/extension/tests/e2e/agents-fixture.ts index 15653fc3a1..e26ecb8b28 100644 --- a/apps/extension/tests/e2e/agents-fixture.ts +++ b/apps/extension/tests/e2e/agents-fixture.ts @@ -454,11 +454,61 @@ export const mockAgentsApi = async ( } if (proc === 'cliSessionsV2.getSessionMessagesPage') { + const inputRecord = isRecordObject(input) ? input : {}; + const requestedId = + hasStringOptional(inputRecord, 'session_id') ?? 'ses_cloudsession00000000001'; + const cloudSession = activeSessions.find( + (session): session is CloudAgentSessionSeed => + isCloudAgentSessionSeed(session) && session.kiloSessionId === requestedId + ); + if (cloudSession !== undefined) { + /** + * A running cloud session's persisted page must carry its first + * user message. The session manager retries an empty first page + * while the session is still listed as running, and an + * always-empty fixture page would keep the default cloud session + * on its loading skeleton. The message and part ids match the + * default stream's own user message, so the SDK upserts merge the + * page and the live stream without duplicating transcript rows. + */ + return { + result: { + data: { + history: { + messages: [ + { + info: { + agent: 'build', + id: 'msg-u-1', + model: { modelID: 'claude-sonnet-4', providerID: 'anthropic' }, + role: 'user', + sessionID: requestedId, + time: { created: Date.now() }, + }, + parts: [ + { + id: 'p-u-1', + messageID: 'msg-u-1', + sessionID: requestedId, + text: 'Fix the login bug', + type: 'text', + }, + ], + }, + ], + nextCursor: null, + omittedItemCount: 0, + }, + kiloSessionId: requestedId, + }, + }, + }; + } return { result: { data: { history: { messages: [], nextCursor: null, omittedItemCount: 0 }, - kiloSessionId: 'ses_cloudsession00000000001', + kiloSessionId: requestedId, }, }, }; @@ -675,6 +725,16 @@ export const mockAgentsApi = async ( let _eventCounter = 0; +/** + * A fenced code block of exactly 20 lines: more than COLLAPSE_LINE_THRESHOLD + * (15), matching the "Show more (20 lines)" label the shared code block + * already asserts. + */ +const longCodeBlock = (): string => { + const lines = Array.from({ length: 20 }, (_unused, index) => `line ${index + 1}`); + return `\`\`\`ts\n${lines.join('\n')}\n\`\`\``; +}; + const buildDefaultCloudAgentStream = (): Record[] => { _eventCounter = 0; const sessionId = 'ses_cloudsession00000000001'; @@ -691,6 +751,8 @@ const buildDefaultCloudAgentStream = (): Record[] => { const kilocode = (type: string, properties: unknown): Record => ev('kilocode', { properties, type }); + const assistantText = `I found the issue.\n\n${longCodeBlock()}`; + return [ kilocode('session.created', { info: { id: sessionId } }), kilocode('session.status', { sessionID: sessionId, status: { type: 'busy' } }), @@ -730,28 +792,117 @@ const buildDefaultCloudAgentStream = (): Record[] => { }, }), kilocode('message.part.delta', { - delta: 'I found', + delta: 'I found the issue.', field: 'text', messageID: 'msg-a-1', partID: 'p-a-1', sessionID: sessionId, }), kilocode('message.part.delta', { - delta: ' the issue.', + delta: `\n\n${longCodeBlock()}`, field: 'text', messageID: 'msg-a-1', partID: 'p-a-1', sessionID: sessionId, }), + // Synthetic snapshot progress — the adapter must never render this. + kilocode('message.part.updated', { + part: { + id: 'p-a-snap', + messageID: 'msg-a-1', + sessionID: sessionId, + synthetic: true, + text: '⠋ Initializing snapshot…', + type: 'text', + }, + }), + kilocode('message.part.updated', { + part: { + id: 'p-a-think', + messageID: 'msg-a-1', + sessionID: sessionId, + text: 'Checking the auth guard first.', + time: { start: Date.now() }, + type: 'reasoning', + }, + }), + kilocode('message.part.updated', { + part: { + callID: 'call-1', + id: 'p-a-tool', + messageID: 'msg-a-1', + sessionID: sessionId, + state: { + input: { filePath: 'src/auth.ts' }, + metadata: {}, + output: 'export const guard = () => true;', + status: 'completed', + time: { end: Date.now(), start: Date.now() }, + title: 'src/auth.ts', + }, + tool: 'read', + type: 'tool', + }, + }), + // A screenshot tool carrying a real PNG attachment exercises the whole + // Image chain: the SDK onToolAttachment sink, the bounded store, the + // Adapter lookup, and the shared renderer branch. + kilocode('message.part.updated', { + part: { + callID: 'call-2', + id: 'p-a-shot', + messageID: 'msg-a-1', + sessionID: sessionId, + state: { + attachments: [ + { + id: 'att-1', + messageID: 'msg-a-1', + mime: 'image/png', + sessionID: sessionId, + type: 'file', + url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + }, + ], + input: { fullPage: false }, + metadata: {}, + output: 'captured', + status: 'completed', + time: { end: Date.now(), start: Date.now() }, + title: 'viewport', + }, + tool: 'browser_screenshot', + type: 'tool', + }, + }), kilocode('message.part.updated', { part: { id: 'p-a-1', messageID: 'msg-a-1', sessionID: sessionId, - text: 'I found the issue.', + text: assistantText, type: 'text', }, }), + // Finalize the assistant message. Without time.completed the adapter keeps + // Treating it as streaming, force-expands the code block, and the shared + // Component never renders its "Show more" control. + kilocode('message.updated', { + info: { + agent: 'build', + cost: 0, + id: 'msg-a-1', + mode: 'code', + modelID: 'claude-sonnet-4', + parentID: 'msg-u-1', + path: { cwd: '/', root: '/' }, + providerID: 'anthropic', + role: 'assistant', + sessionID: sessionId, + time: { completed: Date.now(), created: Date.now() }, + tokens: { cache: { read: 0, write: 0 }, input: 0, output: 0, reasoning: 0 }, + }, + }), ev('complete', { currentBranch: 'main' }), ]; }; diff --git a/apps/extension/tests/e2e/agents-mode-live.test.ts b/apps/extension/tests/e2e/agents-mode-live.test.ts index 340b436b4f..77236e6dbc 100644 --- a/apps/extension/tests/e2e/agents-mode-live.test.ts +++ b/apps/extension/tests/e2e/agents-mode-live.test.ts @@ -81,11 +81,358 @@ const signInWithLocalDeviceAuth = async ({ await authPage.close(); }; +// --------------------------------------------------------------------------- +// Shared phase helpers +// --------------------------------------------------------------------------- + +/** + * A prompt whose output keeps the session busy for the whole reopen round + * trip. A short prompt would finish before phase "reopen while running" and + * the Stop assertion would fail on a finished run, not on a broken control. + * Measured live, "1 to 100" streamed the full list in ~33s, which finished + * before the reopen round trip completed; 300 keeps the run alive past the + * reopen, queue, and stop phases. + */ +const longPrompt = (nonce: string): string => + `Nonce ${nonce}. Count from 1 to 300. Put each number on its own line with one short sentence about it.`; + +const readSessionTitle = async (sidePanel: Page): Promise => { + const titleText = await sidePanel.locator('h1').first().textContent(); + return titleText?.trim() ?? ''; +}; + +/** + * Wait for the session view to settle into either a composer or the read-only + * banner, then fail loudly when the session is read-only. + */ +const failIfReadOnly = async (sidePanel: Page): Promise => { + const composer = sidePanel.locator('#agents-message'); + const readOnlyBanner = sidePanel.getByText('This session is read-only'); + + await expect + .poll( + async () => { + const composerVisible = await composer.isVisible().catch(() => false); + const readOnlyVisible = await readOnlyBanner.isVisible().catch(() => false); + return composerVisible || readOnlyVisible; + }, + { timeout: 30_000 } + ) + .toBe(true); + + if (await readOnlyBanner.isVisible().catch(() => false)) { + throw new Error('Agent session opened read-only — expected an interactive session'); + } +}; + +/** + * Leave the running session, return to the list, and reopen the same session + * from the Active list. The nonce, not the title, proves the right session + * opened: a cloud agent can rename its session mid-run, so a stale title + * falls back to the newest active row — the API lists active sessions newest + * first — and the nonce assertion still decides. + */ +const reopenRunningSession = async ({ + sidePanel, + nonce, + sessionTitle, + platformLabel, +}: { + sidePanel: Page; + nonce: string; + sessionTitle: string; + platformLabel: 'Cloud agent' | 'CLI'; +}): Promise => { + await sidePanel.getByLabel('Back to sessions').click(); + await expect(sidePanel.getByRole('button', { exact: true, name: 'New session' })).toBeVisible({ + timeout: 15_000, + }); + + const platformRows = sidePanel + .locator('button') + .filter({ has: sidePanel.locator(`svg[aria-label="${platformLabel}"]`) }); + await expect.poll(() => platformRows.count(), { timeout: 30_000 }).toBeGreaterThan(0); + + const titleRow = sessionTitle === '' ? undefined : platformRows.filter({ hasText: sessionTitle }); + const titleRowCount = titleRow === undefined ? 0 : await titleRow.count().catch(() => 0); + // A stale title falls back to the newest running row, then the newest row. + const runningRows = platformRows.filter({ + has: sidePanel.getByText('Running', { exact: true }), + }); + const runningRowCount = await runningRows.count().catch(() => 0); + let rowToOpen = platformRows.first(); + if (titleRow !== undefined && titleRowCount > 0) { + rowToOpen = titleRow.first(); + } else if (runningRowCount > 0) { + rowToOpen = runningRows.first(); + } + await rowToOpen.click(); + + await expect(sidePanel.getByLabel('Back to sessions')).toBeVisible({ timeout: 15_000 }); + const openedTitle = await readSessionTitle(sidePanel); + + /* + * The conversation list virtualizes rows and pins to the newest message, so + * a nonce-bearing user message can stay out of the DOM until the transcript + * scrolls to it. The nonce sits at the top in the start flow (the loaded + * first user message) and near the bottom in the existing-session fallback + * (the queued follow-up), so check both ends and poll while the paged + * history streams in. The first scroll up releases auto-scroll. + */ + const conversationPane = sidePanel.getByLabel('Agent conversation'); + + try { + await expect + .poll( + async () => { + try { + if (!(await conversationPane.isVisible().catch(() => false))) { + return false; + } + + const nonceRow = sidePanel.getByText(nonce).first(); + + if (await nonceRow.isVisible().catch(() => false)) { + return true; + } + + await conversationPane.evaluate( + element => + new Promise(resolve => { + let remainingFrames = 6; + const forceTop = (): void => { + element.scrollTop = 0; + remainingFrames -= 1; + if (remainingFrames === 0) { + resolve(); + return; + } + requestAnimationFrame(forceTop); + }; + requestAnimationFrame(forceTop); + }) + ); + + if (await nonceRow.isVisible().catch(() => false)) { + return true; + } + + await conversationPane.evaluate(element => { + element.scrollTop = element.scrollHeight; + }); + + return false; + } catch { + return false; + } + }, + { + message: `the reopened transcript never rendered the loaded user message (nonce "${nonce}")`, + timeout: 60_000, + } + ) + .toBe(true); + } catch (error) { + throw new Error( + `Reopened the wrong session: expected nonce "${nonce}" but the opened session is "${openedTitle}". ${error instanceof Error ? error.message : ''}`, + { cause: error } + ); + } + + /* + * A scroll-up releases auto-scroll and shows the Jump to latest control; + * re-engage it so the running output and the queued follow-up stay in view + * for the queue and stop phases. When the nonce was already visible at the + * bottom — the existing-session fallback — no scroll-up happened and the + * list is still following, so there is nothing to re-engage. + */ + const jumpToLatest = sidePanel.getByRole('button', { name: 'Jump to latest' }); + if (await jumpToLatest.isVisible().catch(() => false)) { + await jumpToLatest.click(); + } +}; + +/** + * Queue a follow-up prompt while the agent runs: the composer clears, the + * send does not end the run, and the live backend echoes the queued user + * message into the transcript. + */ +const queueFollowUp = async ({ + sidePanel, + nonce, +}: { + sidePanel: Page; + nonce: string; +}): Promise => { + const composer = sidePanel.locator('#agents-message'); + await expect(composer).toBeVisible({ timeout: 30_000 }); + const followUp = `Nonce ${nonce} follow-up: what number are you on? Reply in one short sentence.`; + + await composer.fill(followUp); + await composer.press('Enter'); + + await expect(composer).toHaveValue('', { timeout: 10_000 }); + await expect(sidePanel.getByRole('button', { name: 'Stop' })).toBeVisible(); + await expect(sidePanel.getByText(followUp).first()).toBeVisible({ timeout: 60_000 }); +}; + +/** + * Full phase sequence once the session view is open with the long prompt + * already sent: read-only guard, wait for the run, reopen while running + * (verified by the nonce), queue a follow-up, stop. + */ +const runOpenSessionPhases = async ({ + sidePanel, + nonce, + platformLabel, +}: { + sidePanel: Page; + nonce: string; + platformLabel: 'Cloud agent' | 'CLI'; +}): Promise => { + await failIfReadOnly(sidePanel); + + const stopButton = sidePanel.getByRole('button', { name: 'Stop' }); + await expect(stopButton).toBeVisible({ timeout: 90_000 }); + + const sessionTitle = await readSessionTitle(sidePanel); + await reopenRunningSession({ nonce, platformLabel, sessionTitle, sidePanel }); + + const reopenedStop = sidePanel.getByRole('button', { name: 'Stop' }); + await expect(reopenedStop).toBeVisible({ timeout: 90_000 }); + + await queueFollowUp({ nonce, sidePanel }); + + await reopenedStop.click(); + await expect(reopenedStop).toBeHidden({ timeout: 30_000 }); +}; + +/** + * Prove the opened session is still running by waiting for the Stop control. + * Reports the environment gap — with uncovered phases — instead of asserting + * Stop, so the fallback never queues or stops against a finished run. + */ +const proveSessionRunning = async ({ + sidePanel, + phase, + unavailableReason, +}: { + sidePanel: Page; + phase: string; + unavailableReason: string; +}): Promise => { + const stopButton = sidePanel.getByRole('button', { name: 'Stop' }); + const isRunning = await stopButton + .waitFor({ state: 'visible', timeout: 30_000 }) + .then(() => true) + .catch(() => false); + + if (!isRunning) { + throw new Error( + `Cloud agent session creation is unavailable (${unavailableReason}) and the ${phase} session is not running. Phases uncovered: start, reopen, queue, stop.` + ); + } +}; + +/** + * Fallback for a cloud agent when session creation is unavailable: open an + * existing running cloud-agent session and cover view-in-progress, queue, and + * stop. Start is not covered in this tier. The session must prove it is + * running before queue and stop; an idle-only list or a run that stops early + * reports the environment gap instead of sending blindly. The follow-up is + * queued before the reopen so the reopened transcript carries the nonce to + * verify with. + */ +const runExistingCloudSessionFallback = async ({ + sidePanel, + nonce, + unavailableReason, +}: { + sidePanel: Page; + nonce: string; + unavailableReason: string; +}): Promise => { + await failIfReadOnly(sidePanel); + + await proveSessionRunning({ phase: 'opened', sidePanel, unavailableReason }); + + await queueFollowUp({ nonce, sidePanel }); + + const sessionTitle = await readSessionTitle(sidePanel); + await reopenRunningSession({ nonce, platformLabel: 'Cloud agent', sessionTitle, sidePanel }); + + await proveSessionRunning({ phase: 'reopened', sidePanel, unavailableReason }); + + const reopenedStop = sidePanel.getByRole('button', { name: 'Stop' }); + await reopenedStop.click(); + await expect(reopenedStop).toBeHidden({ timeout: 30_000 }); +}; + +// --------------------------------------------------------------------------- +// Cloud new-session form helpers +// --------------------------------------------------------------------------- + +const readFormError = async (sidePanel: Page): Promise => { + const errorText = await sidePanel + .locator('p.text-status-red-400') + .first() + .textContent() + .catch(() => null); + + return errorText === null || errorText.trim() === '' ? null : errorText.trim(); +}; + +/** + * Wait for the new-session form to settle and decide whether a cloud session + * can be created. Returns the exact reason it cannot, or null when the form + * is ready to submit with a repository picked. + */ +const decideCloudForm = async (sidePanel: Page): Promise => { + const repoButton = sidePanel.getByLabel('Select repository'); + await expect(repoButton).toBeEnabled({ timeout: 30_000 }); + + const blockedReason = sidePanel + .locator('p') + .filter({ + hasText: /Connect GitHub to start a cloud session|No repositories available on this account/u, + }) + .first(); + if (await blockedReason.isVisible().catch(() => false)) { + return (await blockedReason.textContent()) ?? 'cloud session creation is blocked'; + } + + // A repository is needed; pick the first one when none is auto-selected. + const repoLabel = (await repoButton.textContent()) ?? ''; + if (repoLabel.trim() !== 'Repository') { + return null; + } + + await repoButton.click(); + const connectGitHub = sidePanel.getByText('GitHub integration not connected'); + if (await connectGitHub.isVisible().catch(() => false)) { + return 'GitHub integration not connected'; + } + const repoError = sidePanel.getByText('Failed to load repositories'); + if (await repoError.isVisible().catch(() => false)) { + return 'Failed to load repositories'; + } + const noRepos = sidePanel.getByText('No repositories found'); + if (await noRepos.isVisible().catch(() => false)) { + return 'No repositories found'; + } + const repoOption = sidePanel.locator('button').filter({ hasText: /\//u }).first(); + if ((await repoOption.count()) === 0) { + return 'No repositories found'; + } + await repoOption.click(); + return null; +}; + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -test('live local backend: open remote CLI session, send, assert reply, send long prompt, interrupt', async () => { +test('live local backend: remote CLI agent covers start, reopen, queue, and stop', async () => { const fixture = await startFixtureServer({ title: 'Kilo live agents session target' }); const { context, extensionId, userDataDir } = await launchExtensionContext(); @@ -97,67 +444,112 @@ test('live local backend: open remote CLI session, send, assert reply, send long await signInWithLocalDeviceAuth({ context, extensionId, sidePanel }); await expect(sidePanel.getByLabel('Message agent')).toBeVisible({ timeout: 15_000 }); - // Switch to Agents mode await sidePanel.getByRole('tab', { name: 'Agents' }).click(); - await expect(sidePanel.getByRole('button', { name: 'New session' })).toBeVisible({ + await expect(sidePanel.getByRole('button', { exact: true, name: 'New session' })).toBeVisible({ timeout: 10_000, }); - // Wait for active sessions to load. Sessions poll every ~30s; bound to 60s. - // CLI rows carry the "CLI" platform icon (cloud rows carry "Cloud agent"). - const remoteRows = sidePanel.locator('button', { - has: sidePanel.locator('svg[aria-label="CLI"]'), - }); - await expect - .poll(() => remoteRows.count(), { - message: 'No remote CLI sessions found — expected at least one active CLI session', - timeout: 60_000, - }) - .toBeGreaterThan(0); + const nonce = `p${process.pid}-${Date.now().toString(36)}`; - await remoteRows.first().click(); - await expect(sidePanel.getByLabel('Back to sessions')).toBeVisible({ timeout: 15_000 }); + // Start: spawn onto the connected CLI instance. + await sidePanel.getByRole('button', { exact: true, name: 'New session' }).click(); + const runOn = sidePanel.getByLabel('Run on'); + await expect(runOn).toBeVisible({ timeout: 60_000 }); + const cliOptionValue = await runOn + .locator('option') + .evaluateAll(options => + options + .map(option => (option instanceof HTMLOptionElement ? option.value : '')) + .find(value => value !== 'cloud') + ); + if (cliOptionValue === undefined || cliOptionValue === '') { + throw new Error( + 'No connected CLI instance appeared in the Run on picker — start the Kilo CLI first.' + ); + } + await runOn.selectOption(cliOptionValue); - // Wait for the initial transcript to replace the session-loading skeleton. - const composer = sidePanel.locator('#agents-message'); - await expect(composer).toBeVisible({ timeout: 30_000 }); + await sidePanel.getByLabel('What would you like to do?').fill(longPrompt(nonce)); + await sidePanel.getByRole('button', { name: 'Start session' }).click(); + await expect(sidePanel.getByLabel('Back to sessions')).toBeVisible({ timeout: 30_000 }); - // The remote CLI session should be interactive (not read-only) - const isReadOnly = await sidePanel - .getByText('This session is read-only') - .isVisible() - .catch(() => false); - if (isReadOnly) { - throw new Error('Remote CLI session is read-only — expected an interactive session'); - } + await runOpenSessionPhases({ nonce, platformLabel: 'CLI', sidePanel }); + } finally { + await context.close(); + await fixture.close(); + await rm(userDataDir, { force: true, recursive: true }); + } +}); + +test('live local backend: cloud agent covers start, reopen, queue, and stop', async () => { + const fixture = await startFixtureServer({ title: 'Kilo live agents session target' }); + const { context, extensionId, userDataDir } = await launchExtensionContext(); - // ---- Phase 1: Send a short prompt and assert the assistant replies with a distinct response ---- - // Count existing assistant messages containing '4' before sending, so a - // Pre-existing remote transcript doesn't cause a false pass. - const assistant4 = sidePanel.locator('.flex.justify-start').filter({ hasText: '4' }); - const countBefore = await assistant4.count(); + try { + const targetPage = await context.newPage(); + await targetPage.goto(fixture.url); - await composer.fill('What is 2+2? Output only the number.'); - await composer.press('Enter'); + const sidePanel = await context.newPage(); + await signInWithLocalDeviceAuth({ context, extensionId, sidePanel }); + await expect(sidePanel.getByLabel('Message agent')).toBeVisible({ timeout: 15_000 }); - // Assert at least one new assistant message containing '4' appears. - await expect.poll(() => assistant4.count(), { timeout: 120_000 }).toBeGreaterThan(countBefore); + await sidePanel.getByRole('tab', { name: 'Agents' }).click(); + await expect(sidePanel.getByRole('button', { exact: true, name: 'New session' })).toBeVisible({ + timeout: 10_000, + }); - // ---- Phase 2: Send a long prompt, interrupt, assert recovery ---- - await composer.fill('Write a very detailed explanation of TypeScript generics with examples.'); - await composer.press('Enter'); + const nonce = `p${process.pid}-${Date.now().toString(36)}`; - // Stop button appears while streaming - const stopButton = sidePanel.getByRole('button', { name: 'Stop' }); - await expect(stopButton).toBeVisible({ timeout: 30_000 }); + // Start: prepare a cloud session through the form. + await sidePanel.getByRole('button', { exact: true, name: 'New session' }).click(); + await sidePanel.getByLabel('What would you like to do?').fill(longPrompt(nonce)); + const blockedReason = await decideCloudForm(sidePanel); - // Interrupt - await stopButton.click(); + if (blockedReason === null) { + const startButton = sidePanel.getByRole('button', { name: 'Start session' }); + await expect(startButton).toBeEnabled({ timeout: 30_000 }); + await startButton.click(); + const started = await sidePanel + .getByLabel('Back to sessions') + .waitFor({ state: 'visible', timeout: 30_000 }) + .then(() => true) + .catch(() => false); + if (started) { + await runOpenSessionPhases({ nonce, platformLabel: 'Cloud agent', sidePanel }); + return; + } + } - // After interrupt, the send button reappears - await expect(sidePanel.getByRole('button', { name: 'Send message' })).toBeVisible({ - timeout: 30_000, + // Cloud creation is unavailable. Fall back to an existing running cloud-agent session. + const unavailableReason = + blockedReason ?? (await readFormError(sidePanel)) ?? 'the form reported an error'; + await sidePanel.getByLabel('Back', { exact: true }).click(); + await expect(sidePanel.getByRole('button', { exact: true, name: 'New session' })).toBeVisible({ + timeout: 15_000, }); + const cloudSessionRows = sidePanel + .locator('button') + .filter({ has: sidePanel.locator('svg[aria-label="Cloud agent"]') }); + await expect + .poll(() => cloudSessionRows.count(), { + message: `Cloud agent session creation is unavailable (${unavailableReason}) and no existing cloud-agent session is present. Phases uncovered: start, reopen, queue, stop.`, + timeout: 30_000, + }) + .toBeGreaterThan(0); + // The API lists active sessions newest first. Pick the newest running row, which has the Stop control the fallback needs. + const runningCloudRows = cloudSessionRows.filter({ + has: sidePanel.getByText('Running', { exact: true }), + }); + await expect + .poll(() => runningCloudRows.count(), { + message: `Cloud agent session creation is unavailable (${unavailableReason}) and no cloud-agent session is running. Phases uncovered: start, reopen, queue, stop.`, + timeout: 30_000, + }) + .toBeGreaterThan(0); + await runningCloudRows.first().click({ timeout: 30_000 }); + await expect(sidePanel.getByLabel('Back to sessions')).toBeVisible({ timeout: 30_000 }); + + await runExistingCloudSessionFallback({ nonce, sidePanel, unavailableReason }); } finally { await context.close(); await fixture.close(); diff --git a/apps/extension/tests/e2e/agents-mode.test.ts b/apps/extension/tests/e2e/agents-mode.test.ts index a37f0af1eb..19c6864a05 100644 --- a/apps/extension/tests/e2e/agents-mode.test.ts +++ b/apps/extension/tests/e2e/agents-mode.test.ts @@ -201,6 +201,51 @@ test('Agents opens a cloud session and streams the transcript', async () => { } }); +test('Agents transcript renders tool, reasoning, and code with the browser components', async () => { + const { cleanup, getSidePanel } = await setupAgentsTest(); + try { + const sidePanel = await getSidePanel(); + await navigateToAgentsMode(sidePanel); + await sidePanel.getByText('Fix login bug').click(); + await expect(sidePanel.getByLabel('Back to sessions')).toBeVisible({ timeout: 10_000 }); + + // The synthetic snapshot progress part must never render. + await expect(sidePanel.getByText('Initializing snapshot')).toBeHidden({ timeout: 10_000 }); + + // The tool and reasoning panels use the shared browser markup. + const readPanel = sidePanel + .locator('details.bg-surface-inset') + .filter({ hasText: 'read completed' }); + await expect(readPanel).toBeVisible({ timeout: 10_000 }); + await expect( + sidePanel.locator('details.bg-surface-inset').filter({ hasText: 'thinking' }) + ).toBeVisible(); + + // The 20-line assistant code fence renders through the shared + // CollapsibleCodeBlock with its collapse control. + const showMore = sidePanel.getByRole('button', { name: 'Show more (20 lines)' }); + await expect(showMore).toBeVisible({ timeout: 10_000 }); + await expect(showMore).toHaveAttribute('aria-expanded', 'false'); + + // Expanding the tool panel shows Arguments, the tool's title, and Result. + await readPanel.locator('summary').click(); + await expect(readPanel.getByText('Arguments')).toBeVisible(); + await expect(readPanel.getByText('src/auth.ts').first()).toBeVisible(); + await expect(readPanel.getByText('Result')).toBeVisible(); + + // The screenshot tool renders the remembered image bytes as an . + const shotPanel = sidePanel + .locator('details.bg-surface-inset') + .filter({ hasText: 'browser_screenshot completed' }); + await expect(shotPanel).toBeVisible({ timeout: 10_000 }); + await expect(shotPanel.locator('img')).toHaveAttribute('src', /^data:image\/png;base64,/u); + // The image replaced the tool's text output, so the panel never shows it. + await expect(shotPanel).not.toContainText('captured'); + } finally { + await cleanup(); + } +}); + test('Agents can send a message on a cloud session', async () => { const { cleanup, getSidePanel } = await setupAgentsTest(); try { @@ -215,8 +260,8 @@ test('Agents can send a message on a cloud session', async () => { await composer.press('Enter'); await expect(composer).toHaveValue(''); - // After stream completes, composer reverts to Send - await expect(sidePanel.getByRole('button', { name: 'Send message' })).toBeVisible({ + // After the stream completes the run ends: Stop must be gone. + await expect(sidePanel.getByRole('button', { name: 'Stop' })).toBeHidden({ timeout: 10_000, }); } finally { @@ -241,8 +286,8 @@ test('Agents can interrupt a running cloud session', async () => { // Click Stop await stopButton.click(); - // After interrupt, composer reverts to Send message - await expect(sidePanel.getByRole('button', { name: 'Send message' })).toBeVisible({ + // After interrupt the run ends: Stop must be gone. + await expect(stopButton).toBeHidden({ timeout: 10_000, }); @@ -642,11 +687,15 @@ test('Agents composer queues a send while the agent runs', async () => { await expect(sidePanel.getByRole('button', { name: 'Stop' })).toBeVisible({ timeout: 15_000, }); + // Parity: the Send control stays reachable while the run streams. + await expect(sidePanel.getByRole('button', { name: 'Send message' })).toBeVisible(); await composer.fill('Also update the changelog'); await composer.press('Enter'); // The draft clears and the send reaches the API despite the run. await expect(composer).toHaveValue('', { timeout: 10_000 }); + // The queued send did not end the run: Stop is still present. + await expect(sidePanel.getByRole('button', { name: 'Stop' })).toBeVisible(); await expect .poll( () => mockResult.calledProcedures.filter(call => call.proc.includes('sendMessage')).length,