From 64e4d6389c57d9b920e34c41362a90ffd8cab33a Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:35:09 +0000 Subject: [PATCH 1/8] fix(ws): forward background task lifecycle through the pre-turn mute gate A background task started before a new user turn never delivered its terminal task_notification to the renderer. bindClientSessionOutput's pre-turn mute gate (shouldForward) dropped every message until userMessageSent flipped, including task lifecycle, so a task that completed inside the admission window left its card running forever and the awaited callback never arrived. Exempt task lifecycle from that gate, matching the exemption shouldSuppressCliOutputDuringStop already makes for the same reason. Tested: bun test src/server/__tests__/websocket-handler.test.ts --- .../__tests__/websocket-handler.test.ts | 66 +++++++++++++++++++ src/server/ws/handler.ts | 12 +++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/server/__tests__/websocket-handler.test.ts b/src/server/__tests__/websocket-handler.test.ts index bb56f84eae..f623c90aa9 100644 --- a/src/server/__tests__/websocket-handler.test.ts +++ b/src/server/__tests__/websocket-handler.test.ts @@ -832,6 +832,72 @@ describe('WebSocket handler session isolation', () => { await flushMicrotasks(30) }) + it('forwards background task completion while a foreground admission awaits send acknowledgement', async () => { + const sessionId = `task-notification-during-admission-${crypto.randomUUID()}` + const ws = makeClientSocket(sessionId) + const outputCallbacks = new Set<(cliMsg: any) => void>() + let resolveSend!: (sent: boolean) => void + spyOn(conversationService, 'hasSession').mockReturnValue(true) + spyOn(conversationService, 'getPendingPermissionRequests').mockReturnValue([]) + spyOn(conversationService, 'onOutput').mockImplementation((_sid, callback) => { + outputCallbacks.add(callback) + }) + spyOn(conversationService, 'removeOutputCallback').mockImplementation((_sid, callback) => { + outputCallbacks.delete(callback) + }) + spyOn(sessionService, 'getCustomTitle').mockResolvedValue('Existing title') + spyOn(sessionService, 'appendSessionTaskNotification').mockResolvedValue() + spyOn(conversationService, 'sendMessage').mockImplementation( + () => new Promise((resolve) => { + resolveSend = resolve + }), + ) + + handleWebSocket.open(ws) + for (const callback of [...outputCallbacks]) { + callback({ + type: 'system', + subtype: 'task_started', + task_id: 'admission-bash-task', + tool_use_id: 'admission-bash-tool', + description: 'bun test', + task_type: 'bash', + }) + } + + handleWebSocket.message(ws, JSON.stringify({ + type: 'user_message', + content: 'Ask something while the background command is running', + })) + await flushMicrotasks(30) + ws.sent.length = 0 + + for (const callback of [...outputCallbacks]) { + callback({ + type: 'system', + subtype: 'task_notification', + task_id: 'admission-bash-task', + tool_use_id: 'admission-bash-tool', + status: 'completed', + summary: 'Background command "bun test" completed', + task_type: 'bash', + }) + } + await flushMicrotasks(30) + + expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({ + type: 'system_notification', + subtype: 'task_notification', + data: expect.objectContaining({ + task_id: 'admission-bash-task', + status: 'completed', + }), + }) + + resolveSend(true) + await flushMicrotasks(30) + }) + it('lets directed Agent terminals through the stop fence after suppressing late content', () => { const sessionId = `agent-run-terminal-after-stop-${crypto.randomUUID()}` const ws = makeClientSocket(sessionId) diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index fafdc8426d..bb77822d3b 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -4270,7 +4270,17 @@ function bindClientSessionOutput( // Agents, and permission resolutions must pass so open prompts can close. return } - if (options?.shouldForward && !options.shouldForward(cliMsg)) { + if ( + taskLifecycle === null && + options?.shouldForward && + !options.shouldForward(cliMsg) + ) { + // The pre-turn mute gate exists to keep pre-turn SDK chatter out of the + // new turn's history. Background task lifecycle is not chatter: a task + // started before this turn still owes the renderer its terminal + // notification, and dropping it here strands the task card as running + // forever. The stop fence above already exempts lifecycle for the same + // reason. return } From 05c8b24ebcda604eaf3ade0bf8384047f0faf0d8 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:54:53 +0000 Subject: [PATCH 2/8] test(ws): cover every lifecycle variant exempted from the pre-turn mute gate Pin the full surface the exemption opens: the failed/stopped/killed and running notification variants, a task that starts inside the admission window, and delivery to every client bound to the session. Add two controls proving the gate still mutes non-lifecycle task chatter and that an authoritatively stopped Agent cannot be revived by a late terminal. --- .../__tests__/websocket-handler.test.ts | 262 +++++++++++++++++- 1 file changed, 261 insertions(+), 1 deletion(-) diff --git a/src/server/__tests__/websocket-handler.test.ts b/src/server/__tests__/websocket-handler.test.ts index f623c90aa9..5c3c4d13c4 100644 --- a/src/server/__tests__/websocket-handler.test.ts +++ b/src/server/__tests__/websocket-handler.test.ts @@ -25,7 +25,10 @@ import { computerUseApprovalService } from '../services/computerUseApprovalServi import { sessionService } from '../services/sessionService.js' import * as titleService from '../services/titleService.js' import { SettingsService } from '../services/settingsService.js' -import { activeBackgroundTaskIds } from '../ws/agentTaskState.js' +import { + activeBackgroundTaskIds, + markTaskAuthoritativelyStopped, +} from '../ws/agentTaskState.js' import * as teleportApi from '../../utils/teleport/api.js' import { resetSettingsCache, setSessionSettingsCache } from '../../utils/settings/settingsCache.js' @@ -898,6 +901,263 @@ describe('WebSocket handler session isolation', () => { await flushMicrotasks(30) }) + async function openAdmissionWindow(sessionId: string) { + const ws = makeClientSocket(sessionId) + const outputCallbacks = new Set<(cliMsg: any) => void>() + let resolveSend!: (sent: boolean) => void + spyOn(conversationService, 'hasSession').mockReturnValue(true) + spyOn(conversationService, 'getPendingPermissionRequests').mockReturnValue([]) + spyOn(conversationService, 'onOutput').mockImplementation((_sid, callback) => { + outputCallbacks.add(callback) + }) + spyOn(conversationService, 'removeOutputCallback').mockImplementation((_sid, callback) => { + outputCallbacks.delete(callback) + }) + spyOn(sessionService, 'getCustomTitle').mockResolvedValue('Existing title') + spyOn(sessionService, 'appendSessionTaskNotification').mockResolvedValue() + spyOn(conversationService, 'sendMessage').mockImplementation( + () => new Promise((resolve) => { + resolveSend = resolve + }), + ) + + const emit = async (cliMsg: any) => { + for (const callback of [...outputCallbacks]) callback(cliMsg) + await flushMicrotasks(30) + } + + return { + ws, + emit, + attachSecondClient: (second: ReturnType) => { + handleWebSocket.open(second) + }, + open: () => { + handleWebSocket.open(ws) + }, + admit: async (content: string) => { + handleWebSocket.message(ws, JSON.stringify({ type: 'user_message', content })) + await flushMicrotasks(30) + ws.sent.length = 0 + }, + sentBy: (socket: ReturnType) => + socket.sent.map((payload) => JSON.parse(payload)), + settle: async () => { + resolveSend(true) + await flushMicrotasks(30) + }, + } + } + + for (const status of ['failed', 'stopped', 'killed'] as const) { + it(`forwards a background task '${status}' terminal while a foreground admission awaits send acknowledgement`, async () => { + const sessionId = `task-${status}-during-admission-${crypto.randomUUID()}` + const session = await openAdmissionWindow(sessionId) + + session.open() + await session.emit({ + type: 'system', + subtype: 'task_started', + task_id: `admission-${status}-task`, + tool_use_id: `admission-${status}-tool`, + description: 'bun test', + task_type: 'bash', + }) + await session.admit('Ask something while the background command is running') + + await session.emit({ + type: 'system', + subtype: 'task_notification', + task_id: `admission-${status}-task`, + tool_use_id: `admission-${status}-tool`, + status, + summary: `Background command "bun test" ${status}`, + task_type: 'bash', + }) + + expect(session.sentBy(session.ws)).toContainEqual({ + type: 'system_notification', + subtype: 'task_notification', + data: expect.objectContaining({ + task_id: `admission-${status}-task`, + status, + }), + }) + + await session.settle() + }) + } + + it('forwards a background task heartbeat notification during a foreground admission', async () => { + const sessionId = `task-running-during-admission-${crypto.randomUUID()}` + const session = await openAdmissionWindow(sessionId) + + session.open() + await session.emit({ + type: 'system', + subtype: 'task_started', + task_id: 'admission-running-task', + tool_use_id: 'admission-running-tool', + description: 'bun test', + task_type: 'bash', + }) + await session.admit('Ask something while the background command is running') + + await session.emit({ + type: 'system', + subtype: 'task_notification', + task_id: 'admission-running-task', + tool_use_id: 'admission-running-tool', + status: 'running', + summary: 'Background work continues independently', + task_type: 'bash', + }) + + expect(session.sentBy(session.ws)).toContainEqual({ + type: 'system_notification', + subtype: 'task_notification', + data: expect.objectContaining({ + task_id: 'admission-running-task', + status: 'running', + }), + }) + + await session.settle() + }) + + it('forwards a background task that starts inside the foreground admission window', async () => { + const sessionId = `task-started-inside-admission-${crypto.randomUUID()}` + const session = await openAdmissionWindow(sessionId) + + session.open() + await session.admit('Ask something before any background command exists') + + await session.emit({ + type: 'system', + subtype: 'task_started', + task_id: 'inside-admission-task', + tool_use_id: 'inside-admission-tool', + description: 'bun test', + task_type: 'bash', + }) + + const sent = session.sentBy(session.ws) + expect(sent).toContainEqual(expect.objectContaining({ + type: 'system_notification', + subtype: 'task_started', + data: expect.objectContaining({ task_id: 'inside-admission-task' }), + })) + // A task starting inside the admission window now also drives the activity + // verb, exactly as one starting outside it already did. + expect(sent).toContainEqual({ + type: 'status', + state: 'tool_executing', + verb: 'bun test', + }) + + await session.settle() + }) + + it('forwards a background task terminal to every client bound to the session during one admission', async () => { + const sessionId = `task-notification-two-clients-${crypto.randomUUID()}` + const session = await openAdmissionWindow(sessionId) + const observer = makeClientSocket(sessionId) + + session.open() + session.attachSecondClient(observer) + await session.emit({ + type: 'system', + subtype: 'task_started', + task_id: 'two-client-task', + tool_use_id: 'two-client-tool', + description: 'bun test', + task_type: 'bash', + }) + await session.admit('Ask something while both renderers watch') + observer.sent.length = 0 + + await session.emit({ + type: 'system', + subtype: 'task_notification', + task_id: 'two-client-task', + tool_use_id: 'two-client-tool', + status: 'completed', + summary: 'Background command "bun test" completed', + task_type: 'bash', + }) + + const expected = { + type: 'system_notification', + subtype: 'task_notification', + data: expect.objectContaining({ task_id: 'two-client-task', status: 'completed' }), + } + expect(session.sentBy(session.ws)).toContainEqual(expected) + expect(session.sentBy(observer)).toContainEqual(expected) + + await session.settle() + }) + + it('keeps a stopped Agent unrevived by a late terminal during a foreground admission (control)', async () => { + const sessionId = `stopped-agent-during-admission-${crypto.randomUUID()}` + const session = await openAdmissionWindow(sessionId) + + session.open() + markTaskAuthoritativelyStopped(sessionId, 'authoritatively-stopped-task') + await session.admit('Ask something after the Agent was stopped') + + await session.emit({ + type: 'system', + subtype: 'task_notification', + task_id: 'authoritatively-stopped-task', + tool_use_id: 'authoritatively-stopped-tool', + status: 'completed', + summary: 'Late terminal for an Agent already bookended as stopped', + task_type: 'local_agent', + }) + + expect(session.sentBy(session.ws)).not.toContainEqual(expect.objectContaining({ + type: 'system_notification', + subtype: 'task_notification', + })) + + await session.settle() + }) + + it('keeps non-lifecycle task chatter muted during a foreground admission (control)', async () => { + const sessionId = `task-chatter-during-admission-${crypto.randomUUID()}` + const session = await openAdmissionWindow(sessionId) + + session.open() + await session.emit({ + type: 'system', + subtype: 'task_started', + task_id: 'muted-chatter-task', + tool_use_id: 'muted-chatter-tool', + description: 'bun test', + task_type: 'bash', + }) + await session.admit('Ask something while the background command is running') + + const mutedDuringAdmission = [ + { subtype: 'task_progress', task_id: 'muted-chatter-task', summary: 'still running' }, + { subtype: 'task_notification', task_id: 'muted-chatter-task', status: 'paused' }, + { subtype: 'task_notification', task_id: '', status: 'completed' }, + { subtype: 'task_notification', task_id: ' ', status: 'completed' }, + ] + for (const event of mutedDuringAdmission) { + await session.emit({ + type: 'system', + tool_use_id: 'muted-chatter-tool', + task_type: 'bash', + ...event, + }) + } + + expect(session.sentBy(session.ws)).toEqual([]) + + await session.settle() + }) + it('lets directed Agent terminals through the stop fence after suppressing late content', () => { const sessionId = `agent-run-terminal-after-stop-${crypto.randomUUID()}` const ws = makeClientSocket(sessionId) From 3a0586a16db7f00695fc92a407d044e8e3233693 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:27:05 +0000 Subject: [PATCH 3/8] fix(ws): drop the explanatory comment from the pre-turn mute gate --- src/server/ws/handler.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index bb77822d3b..73c65dfaf8 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -4275,12 +4275,6 @@ function bindClientSessionOutput( options?.shouldForward && !options.shouldForward(cliMsg) ) { - // The pre-turn mute gate exists to keep pre-turn SDK chatter out of the - // new turn's history. Background task lifecycle is not chatter: a task - // started before this turn still owes the renderer its terminal - // notification, and dropping it here strands the task card as running - // forever. The stop fence above already exempts lifecycle for the same - // reason. return } From 05917ba138842b7fc0b54325b477da67dd7653cf Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:27:05 +0000 Subject: [PATCH 4/8] test(ws): share one admission-window setup across the lifecycle cases Fold the completed/failed/stopped/killed/running notification cases into one parameterized test over a shared setup, and name the two muting cases by the behaviour they pin. --- .../__tests__/websocket-handler.test.ts | 260 ++++-------------- 1 file changed, 50 insertions(+), 210 deletions(-) diff --git a/src/server/__tests__/websocket-handler.test.ts b/src/server/__tests__/websocket-handler.test.ts index 5c3c4d13c4..8f95537b8a 100644 --- a/src/server/__tests__/websocket-handler.test.ts +++ b/src/server/__tests__/websocket-handler.test.ts @@ -835,8 +835,7 @@ describe('WebSocket handler session isolation', () => { await flushMicrotasks(30) }) - it('forwards background task completion while a foreground admission awaits send acknowledgement', async () => { - const sessionId = `task-notification-during-admission-${crypto.randomUUID()}` + function openAdmissionWindow(sessionId: string) { const ws = makeClientSocket(sessionId) const outputCallbacks = new Set<(cliMsg: any) => void>() let resolveSend!: (sent: boolean) => void @@ -855,93 +854,19 @@ describe('WebSocket handler session isolation', () => { resolveSend = resolve }), ) - handleWebSocket.open(ws) - for (const callback of [...outputCallbacks]) { - callback({ - type: 'system', - subtype: 'task_started', - task_id: 'admission-bash-task', - tool_use_id: 'admission-bash-tool', - description: 'bun test', - task_type: 'bash', - }) - } - - handleWebSocket.message(ws, JSON.stringify({ - type: 'user_message', - content: 'Ask something while the background command is running', - })) - await flushMicrotasks(30) - ws.sent.length = 0 - - for (const callback of [...outputCallbacks]) { - callback({ - type: 'system', - subtype: 'task_notification', - task_id: 'admission-bash-task', - tool_use_id: 'admission-bash-tool', - status: 'completed', - summary: 'Background command "bun test" completed', - task_type: 'bash', - }) - } - await flushMicrotasks(30) - - expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({ - type: 'system_notification', - subtype: 'task_notification', - data: expect.objectContaining({ - task_id: 'admission-bash-task', - status: 'completed', - }), - }) - - resolveSend(true) - await flushMicrotasks(30) - }) - - async function openAdmissionWindow(sessionId: string) { - const ws = makeClientSocket(sessionId) - const outputCallbacks = new Set<(cliMsg: any) => void>() - let resolveSend!: (sent: boolean) => void - spyOn(conversationService, 'hasSession').mockReturnValue(true) - spyOn(conversationService, 'getPendingPermissionRequests').mockReturnValue([]) - spyOn(conversationService, 'onOutput').mockImplementation((_sid, callback) => { - outputCallbacks.add(callback) - }) - spyOn(conversationService, 'removeOutputCallback').mockImplementation((_sid, callback) => { - outputCallbacks.delete(callback) - }) - spyOn(sessionService, 'getCustomTitle').mockResolvedValue('Existing title') - spyOn(sessionService, 'appendSessionTaskNotification').mockResolvedValue() - spyOn(conversationService, 'sendMessage').mockImplementation( - () => new Promise((resolve) => { - resolveSend = resolve - }), - ) - - const emit = async (cliMsg: any) => { - for (const callback of [...outputCallbacks]) callback(cliMsg) - await flushMicrotasks(30) - } return { ws, - emit, - attachSecondClient: (second: ReturnType) => { - handleWebSocket.open(second) - }, - open: () => { - handleWebSocket.open(ws) + emit: async (cliMsg: any) => { + for (const callback of [...outputCallbacks]) callback(cliMsg) + await flushMicrotasks(30) }, admit: async (content: string) => { handleWebSocket.message(ws, JSON.stringify({ type: 'user_message', content })) await flushMicrotasks(30) ws.sent.length = 0 }, - sentBy: (socket: ReturnType) => - socket.sent.map((payload) => JSON.parse(payload)), settle: async () => { resolveSend(true) await flushMicrotasks(30) @@ -949,106 +874,55 @@ describe('WebSocket handler session isolation', () => { } } - for (const status of ['failed', 'stopped', 'killed'] as const) { - it(`forwards a background task '${status}' terminal while a foreground admission awaits send acknowledgement`, async () => { - const sessionId = `task-${status}-during-admission-${crypto.randomUUID()}` - const session = await openAdmissionWindow(sessionId) + const backgroundTaskStarted = (taskId: string) => ({ + type: 'system', + subtype: 'task_started', + task_id: taskId, + tool_use_id: `${taskId}-tool`, + description: 'bun test', + task_type: 'bash', + }) - session.open() - await session.emit({ - type: 'system', - subtype: 'task_started', - task_id: `admission-${status}-task`, - tool_use_id: `admission-${status}-tool`, - description: 'bun test', - task_type: 'bash', - }) - await session.admit('Ask something while the background command is running') + const backgroundTaskNotification = (taskId: string, status: string) => ({ + type: 'system', + subtype: 'task_notification', + task_id: taskId, + tool_use_id: `${taskId}-tool`, + status, + summary: `Background command "bun test" ${status}`, + task_type: 'bash', + }) - await session.emit({ - type: 'system', - subtype: 'task_notification', - task_id: `admission-${status}-task`, - tool_use_id: `admission-${status}-tool`, - status, - summary: `Background command "bun test" ${status}`, - task_type: 'bash', - }) + for (const status of ['completed', 'failed', 'stopped', 'killed', 'running'] as const) { + it(`forwards a background task '${status}' notification while a foreground admission awaits send acknowledgement`, async () => { + const session = openAdmissionWindow(`task-${status}-during-admission-${crypto.randomUUID()}`) + + await session.emit(backgroundTaskStarted(`admission-${status}-task`)) + await session.admit('Ask something while the background command is running') + await session.emit(backgroundTaskNotification(`admission-${status}-task`, status)) - expect(session.sentBy(session.ws)).toContainEqual({ + expect(session.ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({ type: 'system_notification', subtype: 'task_notification', - data: expect.objectContaining({ - task_id: `admission-${status}-task`, - status, - }), + data: expect.objectContaining({ task_id: `admission-${status}-task`, status }), }) await session.settle() }) } - it('forwards a background task heartbeat notification during a foreground admission', async () => { - const sessionId = `task-running-during-admission-${crypto.randomUUID()}` - const session = await openAdmissionWindow(sessionId) - - session.open() - await session.emit({ - type: 'system', - subtype: 'task_started', - task_id: 'admission-running-task', - tool_use_id: 'admission-running-tool', - description: 'bun test', - task_type: 'bash', - }) - await session.admit('Ask something while the background command is running') - - await session.emit({ - type: 'system', - subtype: 'task_notification', - task_id: 'admission-running-task', - tool_use_id: 'admission-running-tool', - status: 'running', - summary: 'Background work continues independently', - task_type: 'bash', - }) - - expect(session.sentBy(session.ws)).toContainEqual({ - type: 'system_notification', - subtype: 'task_notification', - data: expect.objectContaining({ - task_id: 'admission-running-task', - status: 'running', - }), - }) - - await session.settle() - }) - it('forwards a background task that starts inside the foreground admission window', async () => { - const sessionId = `task-started-inside-admission-${crypto.randomUUID()}` - const session = await openAdmissionWindow(sessionId) + const session = openAdmissionWindow(`task-started-inside-admission-${crypto.randomUUID()}`) - session.open() await session.admit('Ask something before any background command exists') + await session.emit(backgroundTaskStarted('inside-admission-task')) - await session.emit({ - type: 'system', - subtype: 'task_started', - task_id: 'inside-admission-task', - tool_use_id: 'inside-admission-tool', - description: 'bun test', - task_type: 'bash', - }) - - const sent = session.sentBy(session.ws) + const sent = session.ws.sent.map((payload) => JSON.parse(payload)) expect(sent).toContainEqual(expect.objectContaining({ type: 'system_notification', subtype: 'task_started', data: expect.objectContaining({ task_id: 'inside-admission-task' }), })) - // A task starting inside the admission window now also drives the activity - // verb, exactly as one starting outside it already did. expect(sent).toContainEqual({ type: 'status', state: 'tool_executing', @@ -1060,62 +934,38 @@ describe('WebSocket handler session isolation', () => { it('forwards a background task terminal to every client bound to the session during one admission', async () => { const sessionId = `task-notification-two-clients-${crypto.randomUUID()}` - const session = await openAdmissionWindow(sessionId) + const session = openAdmissionWindow(sessionId) const observer = makeClientSocket(sessionId) + handleWebSocket.open(observer) - session.open() - session.attachSecondClient(observer) - await session.emit({ - type: 'system', - subtype: 'task_started', - task_id: 'two-client-task', - tool_use_id: 'two-client-tool', - description: 'bun test', - task_type: 'bash', - }) + await session.emit(backgroundTaskStarted('two-client-task')) await session.admit('Ask something while both renderers watch') observer.sent.length = 0 - - await session.emit({ - type: 'system', - subtype: 'task_notification', - task_id: 'two-client-task', - tool_use_id: 'two-client-tool', - status: 'completed', - summary: 'Background command "bun test" completed', - task_type: 'bash', - }) + await session.emit(backgroundTaskNotification('two-client-task', 'completed')) const expected = { type: 'system_notification', subtype: 'task_notification', data: expect.objectContaining({ task_id: 'two-client-task', status: 'completed' }), } - expect(session.sentBy(session.ws)).toContainEqual(expected) - expect(session.sentBy(observer)).toContainEqual(expected) + expect(session.ws.sent.map((payload) => JSON.parse(payload))).toContainEqual(expected) + expect(observer.sent.map((payload) => JSON.parse(payload))).toContainEqual(expected) await session.settle() }) - it('keeps a stopped Agent unrevived by a late terminal during a foreground admission (control)', async () => { + it('keeps a stopped Agent unrevived by a late terminal during a foreground admission', async () => { const sessionId = `stopped-agent-during-admission-${crypto.randomUUID()}` - const session = await openAdmissionWindow(sessionId) + const session = openAdmissionWindow(sessionId) - session.open() markTaskAuthoritativelyStopped(sessionId, 'authoritatively-stopped-task') await session.admit('Ask something after the Agent was stopped') - await session.emit({ - type: 'system', - subtype: 'task_notification', - task_id: 'authoritatively-stopped-task', - tool_use_id: 'authoritatively-stopped-tool', - status: 'completed', - summary: 'Late terminal for an Agent already bookended as stopped', + ...backgroundTaskNotification('authoritatively-stopped-task', 'completed'), task_type: 'local_agent', }) - expect(session.sentBy(session.ws)).not.toContainEqual(expect.objectContaining({ + expect(session.ws.sent.map((payload) => JSON.parse(payload))).not.toContainEqual(expect.objectContaining({ type: 'system_notification', subtype: 'task_notification', })) @@ -1123,37 +973,27 @@ describe('WebSocket handler session isolation', () => { await session.settle() }) - it('keeps non-lifecycle task chatter muted during a foreground admission (control)', async () => { - const sessionId = `task-chatter-during-admission-${crypto.randomUUID()}` - const session = await openAdmissionWindow(sessionId) + it('keeps non-lifecycle task chatter muted during a foreground admission', async () => { + const session = openAdmissionWindow(`task-chatter-during-admission-${crypto.randomUUID()}`) - session.open() - await session.emit({ - type: 'system', - subtype: 'task_started', - task_id: 'muted-chatter-task', - tool_use_id: 'muted-chatter-tool', - description: 'bun test', - task_type: 'bash', - }) + await session.emit(backgroundTaskStarted('muted-chatter-task')) await session.admit('Ask something while the background command is running') - const mutedDuringAdmission = [ + for (const event of [ { subtype: 'task_progress', task_id: 'muted-chatter-task', summary: 'still running' }, { subtype: 'task_notification', task_id: 'muted-chatter-task', status: 'paused' }, { subtype: 'task_notification', task_id: '', status: 'completed' }, { subtype: 'task_notification', task_id: ' ', status: 'completed' }, - ] - for (const event of mutedDuringAdmission) { + ]) { await session.emit({ type: 'system', - tool_use_id: 'muted-chatter-tool', + tool_use_id: 'muted-chatter-task-tool', task_type: 'bash', ...event, }) } - expect(session.sentBy(session.ws)).toEqual([]) + expect(session.ws.sent).toEqual([]) await session.settle() }) From 7721f8bcc045b2d4f413ce8c971d57d5917f9804 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:50:25 +0000 Subject: [PATCH 5/8] test(ws): pin the stop fence and persistence across the admission window A terminal emitted after Stop is requested mid-admission still reaches the renderer, and a terminal delivered to two bound clients is persisted once. --- .../__tests__/websocket-handler.test.ts | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/server/__tests__/websocket-handler.test.ts b/src/server/__tests__/websocket-handler.test.ts index 8f95537b8a..d0ac835786 100644 --- a/src/server/__tests__/websocket-handler.test.ts +++ b/src/server/__tests__/websocket-handler.test.ts @@ -848,7 +848,7 @@ describe('WebSocket handler session isolation', () => { outputCallbacks.delete(callback) }) spyOn(sessionService, 'getCustomTitle').mockResolvedValue('Existing title') - spyOn(sessionService, 'appendSessionTaskNotification').mockResolvedValue() + const append = spyOn(sessionService, 'appendSessionTaskNotification').mockResolvedValue() spyOn(conversationService, 'sendMessage').mockImplementation( () => new Promise((resolve) => { resolveSend = resolve @@ -858,6 +858,7 @@ describe('WebSocket handler session isolation', () => { return { ws, + append, emit: async (cliMsg: any) => { for (const callback of [...outputCallbacks]) callback(cliMsg) await flushMicrotasks(30) @@ -950,6 +951,35 @@ describe('WebSocket handler session isolation', () => { } expect(session.ws.sent.map((payload) => JSON.parse(payload))).toContainEqual(expected) expect(observer.sent.map((payload) => JSON.parse(payload))).toContainEqual(expected) + expect(session.append).toHaveBeenCalledTimes(1) + expect(session.append).toHaveBeenCalledWith( + sessionId, + expect.objectContaining({ taskId: 'two-client-task', status: 'completed' }), + ) + + await session.settle() + }) + + it('forwards a background task terminal past the stop fence during a foreground admission', async () => { + const session = openAdmissionWindow(`task-terminal-stop-during-admission-${crypto.randomUUID()}`) + spyOn(conversationService, 'sendInterrupt').mockReturnValue(true) + + await session.emit(backgroundTaskStarted('stop-fenced-task')) + await session.admit('Ask something while the background command is running') + handleWebSocket.message(session.ws, JSON.stringify({ type: 'stop_generation' })) + await flushMicrotasks(30) + session.ws.sent.length = 0 + await session.emit(backgroundTaskNotification('stop-fenced-task', 'completed')) + await session.emit({ + type: 'assistant', + message: { content: [{ type: 'text', text: 'late text from the stopped turn' }] }, + }) + + expect(session.ws.sent.map((payload) => JSON.parse(payload))).toEqual([{ + type: 'system_notification', + subtype: 'task_notification', + data: expect.objectContaining({ task_id: 'stop-fenced-task', status: 'completed' }), + }]) await session.settle() }) From 5dacfa0fe62ef725ecabd2cc43afbdfa6977b561 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:14:34 +0000 Subject: [PATCH 6/8] test(ws): fold the admission-window lifecycle cases into one sequence --- .../__tests__/websocket-handler.test.ts | 224 +++++------------- 1 file changed, 63 insertions(+), 161 deletions(-) diff --git a/src/server/__tests__/websocket-handler.test.ts b/src/server/__tests__/websocket-handler.test.ts index d0ac835786..df99fe9ac1 100644 --- a/src/server/__tests__/websocket-handler.test.ts +++ b/src/server/__tests__/websocket-handler.test.ts @@ -835,8 +835,10 @@ describe('WebSocket handler session isolation', () => { await flushMicrotasks(30) }) - function openAdmissionWindow(sessionId: string) { + it('forwards background task lifecycle while a foreground admission awaits send acknowledgement', async () => { + const sessionId = `task-lifecycle-during-admission-${crypto.randomUUID()}` const ws = makeClientSocket(sessionId) + const observer = makeClientSocket(sessionId) const outputCallbacks = new Set<(cliMsg: any) => void>() let resolveSend!: (sent: boolean) => void spyOn(conversationService, 'hasSession').mockReturnValue(true) @@ -847,6 +849,7 @@ describe('WebSocket handler session isolation', () => { spyOn(conversationService, 'removeOutputCallback').mockImplementation((_sid, callback) => { outputCallbacks.delete(callback) }) + spyOn(conversationService, 'sendInterrupt').mockReturnValue(true) spyOn(sessionService, 'getCustomTitle').mockResolvedValue('Existing title') const append = spyOn(sessionService, 'appendSessionTaskNotification').mockResolvedValue() spyOn(conversationService, 'sendMessage').mockImplementation( @@ -854,178 +857,77 @@ describe('WebSocket handler session isolation', () => { resolveSend = resolve }), ) - handleWebSocket.open(ws) - - return { - ws, - append, - emit: async (cliMsg: any) => { - for (const callback of [...outputCallbacks]) callback(cliMsg) - await flushMicrotasks(30) - }, - admit: async (content: string) => { - handleWebSocket.message(ws, JSON.stringify({ type: 'user_message', content })) - await flushMicrotasks(30) - ws.sent.length = 0 - }, - settle: async () => { - resolveSend(true) - await flushMicrotasks(30) - }, - } - } - - const backgroundTaskStarted = (taskId: string) => ({ - type: 'system', - subtype: 'task_started', - task_id: taskId, - tool_use_id: `${taskId}-tool`, - description: 'bun test', - task_type: 'bash', - }) - - const backgroundTaskNotification = (taskId: string, status: string) => ({ - type: 'system', - subtype: 'task_notification', - task_id: taskId, - tool_use_id: `${taskId}-tool`, - status, - summary: `Background command "bun test" ${status}`, - task_type: 'bash', - }) - - for (const status of ['completed', 'failed', 'stopped', 'killed', 'running'] as const) { - it(`forwards a background task '${status}' notification while a foreground admission awaits send acknowledgement`, async () => { - const session = openAdmissionWindow(`task-${status}-during-admission-${crypto.randomUUID()}`) - - await session.emit(backgroundTaskStarted(`admission-${status}-task`)) - await session.admit('Ask something while the background command is running') - await session.emit(backgroundTaskNotification(`admission-${status}-task`, status)) - - expect(session.ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({ - type: 'system_notification', - subtype: 'task_notification', - data: expect.objectContaining({ task_id: `admission-${status}-task`, status }), - }) - - await session.settle() - }) - } - - it('forwards a background task that starts inside the foreground admission window', async () => { - const session = openAdmissionWindow(`task-started-inside-admission-${crypto.randomUUID()}`) - - await session.admit('Ask something before any background command exists') - await session.emit(backgroundTaskStarted('inside-admission-task')) - - const sent = session.ws.sent.map((payload) => JSON.parse(payload)) - expect(sent).toContainEqual(expect.objectContaining({ - type: 'system_notification', - subtype: 'task_started', - data: expect.objectContaining({ task_id: 'inside-admission-task' }), - })) - expect(sent).toContainEqual({ - type: 'status', - state: 'tool_executing', - verb: 'bun test', + const task = (subtype: string, taskId: string, fields: Record) => ({ + type: 'system', + subtype, + uuid: `${taskId}-${subtype}-${fields.status ?? ''}`, + task_id: taskId, + tool_use_id: `${taskId}-tool`, + task_type: 'bash', + ...fields, }) - - await session.settle() - }) - - it('forwards a background task terminal to every client bound to the session during one admission', async () => { - const sessionId = `task-notification-two-clients-${crypto.randomUUID()}` - const session = openAdmissionWindow(sessionId) - const observer = makeClientSocket(sessionId) - handleWebSocket.open(observer) - - await session.emit(backgroundTaskStarted('two-client-task')) - await session.admit('Ask something while both renderers watch') - observer.sent.length = 0 - await session.emit(backgroundTaskNotification('two-client-task', 'completed')) - - const expected = { - type: 'system_notification', - subtype: 'task_notification', - data: expect.objectContaining({ task_id: 'two-client-task', status: 'completed' }), + const emit = async (cliMsg: any) => { + ws.sent.length = 0 + observer.sent.length = 0 + for (const callback of [...outputCallbacks]) callback(cliMsg) + await flushMicrotasks(30) + return [ws, observer].map((client) => client.sent.map((payload) => JSON.parse(payload))) } - expect(session.ws.sent.map((payload) => JSON.parse(payload))).toContainEqual(expected) - expect(observer.sent.map((payload) => JSON.parse(payload))).toContainEqual(expected) - expect(session.append).toHaveBeenCalledTimes(1) - expect(session.append).toHaveBeenCalledWith( - sessionId, - expect.objectContaining({ taskId: 'two-client-task', status: 'completed' }), - ) - - await session.settle() - }) + const statuses = ['completed', 'failed', 'stopped', 'killed', 'running'] - it('forwards a background task terminal past the stop fence during a foreground admission', async () => { - const session = openAdmissionWindow(`task-terminal-stop-during-admission-${crypto.randomUUID()}`) - spyOn(conversationService, 'sendInterrupt').mockReturnValue(true) - - await session.emit(backgroundTaskStarted('stop-fenced-task')) - await session.admit('Ask something while the background command is running') - handleWebSocket.message(session.ws, JSON.stringify({ type: 'stop_generation' })) + handleWebSocket.open(ws) + handleWebSocket.open(observer) + for (const status of statuses) await emit(task('task_started', `${status}-task`, { description: 'bun test' })) + markTaskAuthoritativelyStopped(sessionId, 'stopped-agent') + handleWebSocket.message(ws, JSON.stringify({ type: 'user_message', content: 'Ask while commands run' })) await flushMicrotasks(30) - session.ws.sent.length = 0 - await session.emit(backgroundTaskNotification('stop-fenced-task', 'completed')) - await session.emit({ - type: 'assistant', - message: { content: [{ type: 'text', text: 'late text from the stopped turn' }] }, - }) - - expect(session.ws.sent.map((payload) => JSON.parse(payload))).toEqual([{ - type: 'system_notification', - subtype: 'task_notification', - data: expect.objectContaining({ task_id: 'stop-fenced-task', status: 'completed' }), - }]) - - await session.settle() - }) - - it('keeps a stopped Agent unrevived by a late terminal during a foreground admission', async () => { - const sessionId = `stopped-agent-during-admission-${crypto.randomUUID()}` - const session = openAdmissionWindow(sessionId) - - markTaskAuthoritativelyStopped(sessionId, 'authoritatively-stopped-task') - await session.admit('Ask something after the Agent was stopped') - await session.emit({ - ...backgroundTaskNotification('authoritatively-stopped-task', 'completed'), - task_type: 'local_agent', - }) - - expect(session.ws.sent.map((payload) => JSON.parse(payload))).not.toContainEqual(expect.objectContaining({ - type: 'system_notification', - subtype: 'task_notification', - })) - await session.settle() - }) - - it('keeps non-lifecycle task chatter muted during a foreground admission', async () => { - const session = openAdmissionWindow(`task-chatter-during-admission-${crypto.randomUUID()}`) + for (const status of statuses) { + for (const sent of await emit(task('task_notification', `${status}-task`, { status }))) { + expect(sent).toContainEqual({ + type: 'system_notification', + subtype: 'task_notification', + data: expect.objectContaining({ task_id: `${status}-task`, status }), + }) + } + } + expect(append).toHaveBeenCalledTimes(4) - await session.emit(backgroundTaskStarted('muted-chatter-task')) - await session.admit('Ask something while the background command is running') + for (const sent of await emit(task('task_started', 'inside-task', { description: 'bun test' }))) { + expect(sent).toContainEqual(expect.objectContaining({ + type: 'system_notification', + subtype: 'task_started', + data: expect.objectContaining({ task_id: 'inside-task' }), + })) + expect(sent).toContainEqual({ type: 'status', state: 'tool_executing', verb: 'bun test' }) + } for (const event of [ - { subtype: 'task_progress', task_id: 'muted-chatter-task', summary: 'still running' }, - { subtype: 'task_notification', task_id: 'muted-chatter-task', status: 'paused' }, - { subtype: 'task_notification', task_id: '', status: 'completed' }, - { subtype: 'task_notification', task_id: ' ', status: 'completed' }, + task('task_progress', 'inside-task', { summary: 'still running' }), + task('task_notification', 'inside-task', { status: 'paused' }), + task('task_notification', '', { status: 'completed' }), + task('task_notification', ' ', { status: 'completed' }), + task('task_notification', 'stopped-agent', { status: 'completed', task_type: 'local_agent' }), ]) { - await session.emit({ - type: 'system', - tool_use_id: 'muted-chatter-task-tool', - task_type: 'bash', - ...event, - }) + expect(await emit(event)).toEqual([[], []]) } - expect(session.ws.sent).toEqual([]) + handleWebSocket.message(ws, JSON.stringify({ type: 'stop_generation' })) + await flushMicrotasks(30) + for (const sent of await emit(task('task_notification', 'inside-task', { status: 'completed' }))) { + expect(sent).toEqual([{ + type: 'system_notification', + subtype: 'task_notification', + data: expect.objectContaining({ task_id: 'inside-task', status: 'completed' }), + }]) + } + expect(await emit({ + type: 'assistant', + message: { content: [{ type: 'text', text: 'late text from the stopped turn' }] }, + })).toEqual([[], []]) - await session.settle() + resolveSend(true) + await flushMicrotasks(30) }) it('lets directed Agent terminals through the stop fence after suppressing late content', () => { From 707f3119fc6e8c0c56bd51665ae80f6c0a9459ff Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:10:18 +0000 Subject: [PATCH 7/8] test(ws): vary task type and owner across the admission-window lifecycle events --- src/server/__tests__/websocket-handler.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/server/__tests__/websocket-handler.test.ts b/src/server/__tests__/websocket-handler.test.ts index df99fe9ac1..d8e79c6582 100644 --- a/src/server/__tests__/websocket-handler.test.ts +++ b/src/server/__tests__/websocket-handler.test.ts @@ -873,17 +873,23 @@ describe('WebSocket handler session isolation', () => { await flushMicrotasks(30) return [ws, observer].map((client) => client.sent.map((payload) => JSON.parse(payload))) } - const statuses = ['completed', 'failed', 'stopped', 'killed', 'running'] + const tasks = Object.entries({ + completed: {}, + failed: { task_type: 'local_agent' }, + stopped: { task_type: 'remote_agent' }, + killed: { owner_agent_id: 'lead' }, + running: {}, + }) handleWebSocket.open(ws) handleWebSocket.open(observer) - for (const status of statuses) await emit(task('task_started', `${status}-task`, { description: 'bun test' })) + for (const [status, fields] of tasks) await emit(task('task_started', `${status}-task`, { description: 'bun test', ...fields })) markTaskAuthoritativelyStopped(sessionId, 'stopped-agent') handleWebSocket.message(ws, JSON.stringify({ type: 'user_message', content: 'Ask while commands run' })) await flushMicrotasks(30) - for (const status of statuses) { - for (const sent of await emit(task('task_notification', `${status}-task`, { status }))) { + for (const [status, fields] of tasks) { + for (const sent of await emit(task('task_notification', `${status}-task`, { status, ...fields }))) { expect(sent).toContainEqual({ type: 'system_notification', subtype: 'task_notification', From c0053ad856ffd065b42a339d29756b5d85d76f44 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:45:04 +0000 Subject: [PATCH 8/8] test(ws): trim the admission-window lifecycle test to the gate's own cases --- .../__tests__/websocket-handler.test.ts | 62 ++++--------------- 1 file changed, 11 insertions(+), 51 deletions(-) diff --git a/src/server/__tests__/websocket-handler.test.ts b/src/server/__tests__/websocket-handler.test.ts index d8e79c6582..4857f177a4 100644 --- a/src/server/__tests__/websocket-handler.test.ts +++ b/src/server/__tests__/websocket-handler.test.ts @@ -25,10 +25,7 @@ import { computerUseApprovalService } from '../services/computerUseApprovalServi import { sessionService } from '../services/sessionService.js' import * as titleService from '../services/titleService.js' import { SettingsService } from '../services/settingsService.js' -import { - activeBackgroundTaskIds, - markTaskAuthoritativelyStopped, -} from '../ws/agentTaskState.js' +import { activeBackgroundTaskIds } from '../ws/agentTaskState.js' import * as teleportApi from '../../utils/teleport/api.js' import { resetSettingsCache, setSessionSettingsCache } from '../../utils/settings/settingsCache.js' @@ -849,7 +846,6 @@ describe('WebSocket handler session isolation', () => { spyOn(conversationService, 'removeOutputCallback').mockImplementation((_sid, callback) => { outputCallbacks.delete(callback) }) - spyOn(conversationService, 'sendInterrupt').mockReturnValue(true) spyOn(sessionService, 'getCustomTitle').mockResolvedValue('Existing title') const append = spyOn(sessionService, 'appendSessionTaskNotification').mockResolvedValue() spyOn(conversationService, 'sendMessage').mockImplementation( @@ -857,7 +853,7 @@ describe('WebSocket handler session isolation', () => { resolveSend = resolve }), ) - const task = (subtype: string, taskId: string, fields: Record) => ({ + const task = (subtype: string, taskId: string, fields: Record = {}) => ({ type: 'system', subtype, uuid: `${taskId}-${subtype}-${fields.status ?? ''}`, @@ -873,64 +869,28 @@ describe('WebSocket handler session isolation', () => { await flushMicrotasks(30) return [ws, observer].map((client) => client.sent.map((payload) => JSON.parse(payload))) } - const tasks = Object.entries({ - completed: {}, - failed: { task_type: 'local_agent' }, - stopped: { task_type: 'remote_agent' }, - killed: { owner_agent_id: 'lead' }, - running: {}, - }) handleWebSocket.open(ws) handleWebSocket.open(observer) - for (const [status, fields] of tasks) await emit(task('task_started', `${status}-task`, { description: 'bun test', ...fields })) - markTaskAuthoritativelyStopped(sessionId, 'stopped-agent') + await emit(task('task_started', 'shell', { description: 'bun test' })) + await emit(task('task_started', 'agent', { description: 'review', task_type: 'local_agent' })) handleWebSocket.message(ws, JSON.stringify({ type: 'user_message', content: 'Ask while commands run' })) await flushMicrotasks(30) - for (const [status, fields] of tasks) { - for (const sent of await emit(task('task_notification', `${status}-task`, { status, ...fields }))) { + for (const [taskId, status, taskType] of [['shell', 'completed', 'bash'], ['agent', 'failed', 'local_agent']]) { + for (const sent of await emit(task('task_notification', taskId, { status, task_type: taskType }))) { expect(sent).toContainEqual({ type: 'system_notification', subtype: 'task_notification', - data: expect.objectContaining({ task_id: `${status}-task`, status }), + data: expect.objectContaining({ task_id: taskId, status }), }) } } - expect(append).toHaveBeenCalledTimes(4) - - for (const sent of await emit(task('task_started', 'inside-task', { description: 'bun test' }))) { - expect(sent).toContainEqual(expect.objectContaining({ - type: 'system_notification', - subtype: 'task_started', - data: expect.objectContaining({ task_id: 'inside-task' }), - })) - expect(sent).toContainEqual({ type: 'status', state: 'tool_executing', verb: 'bun test' }) - } - - for (const event of [ - task('task_progress', 'inside-task', { summary: 'still running' }), - task('task_notification', 'inside-task', { status: 'paused' }), - task('task_notification', '', { status: 'completed' }), - task('task_notification', ' ', { status: 'completed' }), - task('task_notification', 'stopped-agent', { status: 'completed', task_type: 'local_agent' }), - ]) { - expect(await emit(event)).toEqual([[], []]) + expect(append).toHaveBeenCalledTimes(2) + for (const sent of await emit(task('task_started', 'inside', { description: 'bun test' }))) { + expect(sent).toContainEqual(expect.objectContaining({ subtype: 'task_started' })) } - - handleWebSocket.message(ws, JSON.stringify({ type: 'stop_generation' })) - await flushMicrotasks(30) - for (const sent of await emit(task('task_notification', 'inside-task', { status: 'completed' }))) { - expect(sent).toEqual([{ - type: 'system_notification', - subtype: 'task_notification', - data: expect.objectContaining({ task_id: 'inside-task', status: 'completed' }), - }]) - } - expect(await emit({ - type: 'assistant', - message: { content: [{ type: 'text', text: 'late text from the stopped turn' }] }, - })).toEqual([[], []]) + expect(await emit(task('task_progress', 'inside', { summary: 'still running' }))).toEqual([[], []]) resolveSend(true) await flushMicrotasks(30)