@@ -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,