fix(server): close release QA regressions (#1019, #1047, #1051)

This commit is contained in:
程序员阿江(Relakkes) 2026-07-18 15:38:32 +08:00
parent 0cb612cb5d
commit c60d3780b8
9 changed files with 508 additions and 29 deletions

View File

@ -5523,6 +5523,59 @@ describe('Sessions API', () => {
expect(body.checkpoints[0]!.code.deletions).toBe(0)
})
it('GET /api/sessions/:id/turn-checkpoints should ignore rejected transcript tool changes', async () => {
const sessionId = '99999999-bbbb-cccc-dddd-000000000004'
const workDir = path.join(tmpDir, 'transcript-rejected-session')
const userId = crypto.randomUUID()
const toolUseId = 'Write:rejected'
await fs.mkdir(workDir, { recursive: true })
await writeSessionFile('-tmp-transcript-rejected-session', sessionId, [
makeSessionMetaEntry(workDir),
{
...makeUserEntry('write a denied file', userId),
cwd: workDir,
sessionId,
},
makeAssistantToolUseEntry([{
id: toolUseId,
name: 'Write',
input: {
file_path: path.join(workDir, 'permission-denial-test.txt'),
content: 'must not be written\n',
},
}], userId),
{
...makeToolResultUserEntry(
toolUseId,
'The user rejected this tool use.',
undefined,
undefined,
sessionId,
),
message: {
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: toolUseId,
content: 'The user rejected this tool use.',
is_error: true,
}],
},
cwd: workDir,
},
makeAssistantEntry('The requested write was not completed.', userId),
])
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
expect(res.status).toBe(200)
const body = await res.json() as { checkpoints: unknown[] }
expect(body.checkpoints).toEqual([])
await expect(fs.stat(path.join(workDir, 'permission-denial-test.txt')))
.rejects.toMatchObject({ code: 'ENOENT' })
})
it('GET /api/sessions/:id/turn-checkpoints/diff should return transcript tool diffs when file snapshots are missing', async () => {
const sessionId = '99999999-bbbb-cccc-dddd-000000000002'
const workDir = path.join(tmpDir, 'transcript-only-diff-session')

View File

@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import {
__persistCliTaskNotificationForTests,
__resetWebSocketHandlerStateForTests,
} from '../ws/handler.js'
import { SessionService, sessionService } from '../services/sessionService.js'
describe('background task notification persistence', () => {
let configDir = ''
let previousConfigDir: string | undefined
beforeEach(async () => {
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
configDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-task-notification-'))
process.env.CLAUDE_CONFIG_DIR = configDir
await fs.mkdir(path.join(configDir, 'projects'), { recursive: true })
})
afterEach(async () => {
__resetWebSocketHandlerStateForTests()
mock.restore()
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir
await fs.rm(configDir, { recursive: true, force: true })
})
it('restores a terminal Agent notification that was not forwarded into transcript history', async () => {
const sessionId = crypto.randomUUID()
const projectDir = path.join(configDir, 'projects', '-tmp-background-agent')
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`)
await fs.mkdir(projectDir, { recursive: true })
await fs.writeFile(transcriptPath, `${JSON.stringify({
type: 'assistant',
uuid: crypto.randomUUID(),
timestamp: '2026-07-18T00:00:00.000Z',
message: {
role: 'assistant',
content: [{
type: 'tool_use',
id: 'agent-tool-1',
name: 'Agent',
input: { description: 'Verify background restore', run_in_background: true },
}],
},
})}\n`, 'utf8')
const service = new SessionService()
await service.appendSessionTaskNotification(sessionId, {
taskId: 'agent-task-1',
toolUseId: 'agent-tool-1',
status: 'completed',
summary: 'Agent completed after the foreground Skill output',
result: 'Background verification passed',
timestamp: '2026-07-18T00:01:00.000Z',
})
expect(await service.getSessionTaskNotifications(sessionId)).toEqual([{
taskId: 'agent-task-1',
toolUseId: 'agent-tool-1',
status: 'completed',
summary: 'Agent completed after the foreground Skill output',
result: 'Background verification passed',
timestamp: '2026-07-18T00:01:00.000Z',
}])
expect(await fs.readFile(transcriptPath, 'utf8')).toContain('"type":"cc-haha-task-notification"')
})
it('keeps restoring legacy task-notification transcript turns', async () => {
const sessionId = crypto.randomUUID()
const projectDir = path.join(configDir, 'projects', '-tmp-legacy-notification')
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`)
await fs.mkdir(projectDir, { recursive: true })
await fs.writeFile(transcriptPath, `${JSON.stringify({
type: 'user',
uuid: crypto.randomUUID(),
timestamp: '2026-07-17T00:00:00.000Z',
message: {
role: 'user',
content: '<task-notification>\n<task-id>legacy-task</task-id>\n<tool-use-id>legacy-tool</tool-use-id>\n<status>completed</status>\n<summary>Legacy task completed</summary>\n</task-notification>',
},
})}\n`, 'utf8')
const service = new SessionService()
expect(await service.getSessionTaskNotifications(sessionId)).toEqual([{
taskId: 'legacy-task',
toolUseId: 'legacy-tool',
status: 'completed',
summary: 'Legacy task completed',
timestamp: '2026-07-17T00:00:00.000Z',
}])
})
it('normalizes and persists one terminal SDK event for multiple observers', async () => {
const append = spyOn(sessionService, 'appendSessionTaskNotification').mockResolvedValue()
const sdkEvent = {
type: 'system',
subtype: 'task_notification',
uuid: 'terminal-event-1',
task_id: 'agent-task-1',
tool_use_id: 'agent-tool-1',
status: 'completed',
summary: 'Agent completed',
result: 'All checks passed',
output_file: '/tmp/agent-task-1.output',
timestamp: '2026-07-18T00:01:00.000Z',
}
const first = __persistCliTaskNotificationForTests('session-1', sdkEvent)
const second = __persistCliTaskNotificationForTests('session-1', sdkEvent)
expect(first).not.toBeNull()
expect(second).toBe(first)
await Promise.all([first, second])
expect(append).toHaveBeenCalledTimes(1)
expect(append).toHaveBeenCalledWith('session-1', {
taskId: 'agent-task-1',
toolUseId: 'agent-tool-1',
status: 'completed',
summary: 'Agent completed',
result: 'All checks passed',
outputFile: '/tmp/agent-task-1.output',
timestamp: '2026-07-18T00:01:00.000Z',
})
})
})

View File

@ -3,6 +3,7 @@ import type { ServerWebSocket } from 'bun'
import {
__markPrewarmPendingForTests,
__markActiveTurnForTests,
__refreshDisconnectedTurnCleanupWatcherForTests,
__registerPendingUserTurnForTests,
__markPrewarmedForTests,
__resetWebSocketHandlerStateForTests,
@ -322,6 +323,57 @@ describe('WebSocket handler session isolation', () => {
})
})
it('persists terminal task notifications before forwarding them to the client', async () => {
const sessionId = `task-notification-${crypto.randomUUID()}`
const ws = makeClientSocket(sessionId)
let outputCallback: ((cliMsg: any) => void) | null = null
spyOn(conversationService, 'hasSession').mockReturnValue(true)
spyOn(conversationService, 'onOutput').mockImplementation((_sid, callback) => {
outputCallback = callback
})
const append = spyOn(sessionService, 'appendSessionTaskNotification').mockResolvedValue()
handleWebSocket.open(ws)
ws.sent.length = 0
const completed = {
type: 'system',
subtype: 'task_notification',
uuid: 'terminal-task-event-1',
task_id: 'agent-task-1',
tool_use_id: 'agent-tool-1',
status: 'completed',
summary: 'Background task completed',
timestamp: '2026-07-18T00:01:00.000Z',
}
outputCallback?.(completed)
await Promise.resolve()
await Promise.resolve()
expect(append).toHaveBeenCalledTimes(1)
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
type: 'system_notification',
subtype: 'task_notification',
data: completed,
})
// A running notification is UI activity, not a terminal state that should
// be restored after restart. It must forward without another persistence.
const running = {
...completed,
uuid: 'running-task-event-1',
status: 'running',
}
outputCallback?.(running)
expect(append).toHaveBeenCalledTimes(1)
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
type: 'system_notification',
subtype: 'task_notification',
data: running,
})
})
it('broadcasts tool and Computer Use permission resolutions to every client', () => {
const sessionId = `permission-resolution-${crypto.randomUUID()}`
const first = makeClientSocket(sessionId)
@ -430,24 +482,37 @@ describe('WebSocket handler session isolation', () => {
expect(stopSession).toHaveBeenCalledWith(sessionId)
})
it('starts the permission cleanup bound when the prompt arrives after disconnect', () => {
it('starts the permission cleanup bound when disconnect happens before the turn is sent', () => {
const sessionId = `late-permission-disconnect-${crypto.randomUUID()}`
const ws = makeClientSocket(sessionId)
const setTimeoutSpy = spyOn(globalThis, 'setTimeout').mockImplementation(() => 0 as any)
const pendingRequests = spyOn(conversationService, 'getPendingPermissionRequests')
.mockReturnValue([])
let turnOutputCallback: ((cliMsg: any) => void) | null = null
let cliSessionReady = false
spyOn(conversationService, 'onOutput').mockImplementation((_sid, callback) => {
turnOutputCallback = callback
// ConversationService.onOutput is a no-op until startSession has inserted
// the session. This was the gap hidden by the previous regression test.
if (cliSessionReady) turnOutputCallback = callback
})
spyOn(conversationService, 'removeOutputCallback').mockImplementation(() => {})
handleWebSocket.open(ws)
__markActiveTurnForTests(sessionId)
// Mirrors the real H5 race: user_message has synchronously claimed the
// turn, but CLI startup has not completed and messageSent is still false.
__registerPendingUserTurnForTests(sessionId)
setTimeoutSpy.mockClear()
handleWebSocket.close(ws, 1006, 'renderer closed before permission prompt')
expect(setTimeoutSpy).not.toHaveBeenCalled()
expect(turnOutputCallback).toBeNull()
// CLI startup finishes while the H5 tab remains closed. handleUserMessage
// refreshes the watcher immediately before sending the queued turn.
cliSessionReady = true
__refreshDisconnectedTurnCleanupWatcherForTests(sessionId)
expect(turnOutputCallback).not.toBeNull()
pendingRequests.mockReturnValue([{
requestId: 'late-request-1',
toolName: 'Bash',

View File

@ -286,6 +286,50 @@ describe('WorkspaceService', () => {
expect(diff.diff).toContain('+export default function App() { return <main>New</main> }')
})
it('does not report a rejected session tool edit as a changed file', async () => {
const nonGitDir = await makeTempDir('workspace-service-rejected-change-')
const toolUseId = 'Write:rejected'
const service = new WorkspaceService(
async () => nonGitDir,
async () => [
{
id: 'assistant-1',
type: 'tool_use',
timestamp: new Date().toISOString(),
content: [{
type: 'tool_use',
id: toolUseId,
name: 'Write',
input: {
file_path: 'permission-denial-test.txt',
content: 'must not be written\n',
},
}],
},
{
id: 'tool-result-1',
type: 'tool_result',
timestamp: new Date().toISOString(),
content: [{
type: 'tool_result',
tool_use_id: toolUseId,
content: 'The user rejected this tool use.',
is_error: true,
}],
},
],
)
const status = await service.getStatus('session-1')
expect(status).toMatchObject({
state: 'ok',
workDir: nonGitDir,
isGitRepo: false,
changedFiles: [],
})
})
it('reports file-history changes without requiring a git repository', async () => {
const nonGitDir = await makeTempDir('workspace-service-file-history-')
const generatedFile = path.join(nonGitDir, 'aacc', 'src', 'App.tsx')

View File

@ -9,6 +9,7 @@ import {
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { conversationService } from './conversationService.js'
import { sessionService, type MessageEntry } from './sessionService.js'
import { collectErroredToolUseIds } from './transcriptToolResults.js'
type RewindTarget = {
targetUserMessageId: string
@ -618,6 +619,7 @@ function collectTranscriptTurnFileChanges(
if (turnMessages.length === 0) return []
const changes = new Map<string, TranscriptFileChange>()
const erroredToolUseIds = collectErroredToolUseIds(turnMessages)
for (const message of turnMessages) {
if (message.type !== 'tool_use' || !Array.isArray(message.content)) continue
@ -625,6 +627,7 @@ function collectTranscriptTurnFileChanges(
if (!block || typeof block !== 'object') continue
const record = block as Record<string, unknown>
if (record.type !== 'tool_use' || typeof record.name !== 'string') continue
if (typeof record.id === 'string' && erroredToolUseIds.has(record.id)) continue
const input = record.input
if (!input || typeof input !== 'object') continue

View File

@ -375,6 +375,7 @@ const USER_INTERRUPTION_TEXTS = new Set([
const NO_RESPONSE_REQUESTED_TEXT = 'No response requested.'
const TASK_NOTIFICATION_RE = /^<task-notification>\s*[\s\S]*<\/task-notification>$/i
const TASK_NOTIFICATION_BLOCK_RE = /<task-notification>\s*[\s\S]*?<\/task-notification>/i
const PERSISTED_TASK_NOTIFICATION_ENTRY_TYPE = 'cc-haha-task-notification'
const PROVIDER_MODEL_ALIAS_SEPARATORS = ['-', '_', ':', '/', '.', ' ']
function normalizeProviderModelAlias(model: string): string {
@ -1600,6 +1601,38 @@ export class SessionService {
}
}
private parsePersistedTaskNotification(
value: unknown,
timestamp?: string,
): SessionTaskNotification | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const notification = value as Record<string, unknown>
const toolUseId = typeof notification.toolUseId === 'string'
? notification.toolUseId
: null
const status = notification.status
if (
!toolUseId ||
(status !== 'completed' && status !== 'failed' && status !== 'stopped')
) {
return null
}
const optionalString = (key: string) =>
typeof notification[key] === 'string' && notification[key]
? notification[key] as string
: undefined
return {
taskId: optionalString('taskId') ?? toolUseId,
toolUseId,
status,
...(optionalString('summary') ? { summary: optionalString('summary') } : {}),
...(optionalString('result') ? { result: optionalString('result') } : {}),
...(optionalString('outputFile') ? { outputFile: optionalString('outputFile') } : {}),
...(timestamp ? { timestamp } : {}),
}
}
private shouldHideTranscriptEntry(entry: RawEntry): boolean {
const role = entry.message?.role
const content = entry.message?.content
@ -3940,6 +3973,28 @@ export class SessionService {
return [...snapshotsByMessageId.values()]
}
async appendSessionTaskNotification(
sessionId: string,
notification: SessionTaskNotification,
): Promise<void> {
const normalized = this.parsePersistedTaskNotification(
notification,
notification.timestamp ?? new Date(this.now()).toISOString(),
)
if (!normalized) return
const found = await this.findSessionFile(sessionId)
if (!found) return
await this.appendJsonlEntry(found.filePath, {
type: PERSISTED_TASK_NOTIFICATION_ENTRY_TYPE,
isMeta: true,
taskNotification: normalized,
timestamp: normalized.timestamp,
})
this.invalidateSessionListCache()
}
async getSessionTaskNotifications(
sessionId: string,
): Promise<SessionTaskNotification[]> {
@ -3950,18 +4005,18 @@ export class SessionService {
const entries = await this.readTargetedJsonlEntries(
found,
['user'],
['user', PERSISTED_TASK_NOTIFICATION_ENTRY_TYPE],
) ?? await this.readJsonlFile(found.filePath)
const notifications: SessionTaskNotification[] = []
const notifications = new Map<string, SessionTaskNotification>()
for (const entry of entries) {
if (entry.message?.role !== 'user') continue
const notification = this.parseTaskNotificationContent(
entry.message.content,
entry.timestamp,
)
if (notification) notifications.push(notification)
const notification = entry.type === PERSISTED_TASK_NOTIFICATION_ENTRY_TYPE
? this.parsePersistedTaskNotification(entry.taskNotification, entry.timestamp)
: entry.message?.role === 'user'
? this.parseTaskNotificationContent(entry.message.content, entry.timestamp)
: null
if (notification) notifications.set(notification.toolUseId, notification)
}
return notifications
return [...notifications.values()]
}
// --------------------------------------------------------------------------

View File

@ -0,0 +1,23 @@
import type { MessageEntry } from './sessionService.js'
export function collectErroredToolUseIds(messages: MessageEntry[]): Set<string> {
const erroredToolUseIds = new Set<string>()
for (const message of messages) {
if (message.type !== 'tool_result' || !Array.isArray(message.content)) continue
for (const block of message.content) {
if (!block || typeof block !== 'object') continue
const record = block as Record<string, unknown>
if (
record.type === 'tool_result' &&
record.is_error === true &&
typeof record.tool_use_id === 'string'
) {
erroredToolUseIds.add(record.tool_use_id)
}
}
}
return erroredToolUseIds
}

View File

@ -7,6 +7,7 @@ import type { MessageEntry } from './sessionService.js'
import type { FileHistorySnapshot } from '../../utils/fileHistory.js'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { isWithinRegisteredFilesystemRoot } from './filesystemAccessRoots.js'
import { collectErroredToolUseIds } from './transcriptToolResults.js'
import {
isSameOrInsidePathForPlatform,
normalizeDriveRootPathForPlatform,
@ -712,6 +713,7 @@ export class WorkspaceService {
}
const changes = new Map<string, SessionFileChange>()
const erroredToolUseIds = collectErroredToolUseIds(messages)
for (const message of messages) {
if (message.type !== 'tool_use' || !Array.isArray(message.content)) continue
@ -720,6 +722,7 @@ export class WorkspaceService {
if (!block || typeof block !== 'object') continue
const record = block as Record<string, unknown>
if (record.type !== 'tool_use' || typeof record.name !== 'string') continue
if (typeof record.id === 'string' && erroredToolUseIds.has(record.id)) continue
const input = record.input
if (!input || typeof input !== 'object') continue

View File

@ -20,7 +20,10 @@ import {
conversationService,
} from '../services/conversationService.js'
import { computerUseApprovalService } from '../services/computerUseApprovalService.js'
import { sessionService } from '../services/sessionService.js'
import {
sessionService,
type SessionTaskNotification,
} from '../services/sessionService.js'
import { SettingsService } from '../services/settingsService.js'
import { ProviderService } from '../services/providerService.js'
import { isOpenAIOfficialProviderId } from '../services/openaiOfficialProvider.js'
@ -208,6 +211,7 @@ const clientOutputCallbacks = new Map<
callback: (cliMsg: any) => void
}
>()
const taskNotificationPersistence = new Map<string, Map<string, Promise<void>>>()
export const handleWebSocket = {
open(ws: ServerWebSocket<WebSocketData>) {
@ -374,7 +378,7 @@ export const handleWebSocket = {
// background (issue #764) — never kill it just because a phone locked its
// screen. Defer cleanup until the turn completes, then apply the idle
// grace period. Sessions that are already idle go straight to the timer.
if (isSessionTurnActive(sessionId)) {
if (hasPendingOrActiveUserTurn(sessionId)) {
// A turn blocked on permission cannot finish without user input. Keep the
// completion watcher for early cleanup, but also enforce the existing
// pending-permission maximum so an abandoned prompt cannot pin the CLI.
@ -518,6 +522,13 @@ async function handleUserMessage(
})
const removeActiveTurnOutputCallback = bindActiveUserTurnCompletion(ws, sessionId, activeTurn)
// The renderer may have left while the CLI was still starting, before this
// turn could flip messageSent=true. The disconnect handler cannot attach an
// effective output watcher until the ConversationService session exists, so
// refresh it here, immediately before sending the turn, to observe a
// permission request that arrives after the disconnect.
refreshDisconnectedTurnCleanupWatcher(sessionId)
const sent = await conversationService.sendMessage(
sessionId,
message.content,
@ -1412,6 +1423,7 @@ function cleanupSessionRuntimeState(sessionId: string) {
runtimeTransitionPromises.delete(sessionId)
sessionStartupPromises.delete(sessionId)
lastResolvedStartupWorkDirs.delete(sessionId)
taskNotificationPersistence.delete(sessionId)
clearPrewarmState(sessionId)
}
@ -2246,20 +2258,12 @@ function getDisconnectCleanupDelayMs(sessionId: string): number {
: getDisconnectGraceMs()
}
/**
* Whether the session is mid-turn (a user message was sent and no result has
* arrived yet). Such a turn must not be killed on disconnect.
*/
function isSessionTurnActive(sessionId: string): boolean {
return activeUserTurns.get(sessionId)?.messageSent === true
}
/**
* Whether a user turn has been registered for this session and not yet settled,
* INCLUDING the CLI-startup window before messageSent flips true. handleUserMessage
* registers the turn in its synchronous prefix (activeUserTurns.set), well before
* the message is actually sent. Unlike isSessionTurnActive, this is not blind to
* that window, so the prewarm idle timer can neither arm on nor fire against a
* the message is actually sent. Checking the registration is not blind to that
* window, so the prewarm idle timer can neither arm on nor fire against a
* session a user turn has already claimed even when a concurrent
* prewarm_session/user_message flush inverts their ordering.
*/
@ -2323,6 +2327,23 @@ function watchTurnCompletionForCleanup(sessionId: string): void {
})
}
/**
* Re-arm the disconnect watcher once CLI startup has completed. A client can
* leave during the startup window, when the user turn is registered but the
* ConversationService session (and therefore its output callback list) does
* not exist yet.
*/
function refreshDisconnectedTurnCleanupWatcher(sessionId: string): void {
if (hasActiveClients(sessionId) || !hasPendingOrActiveUserTurn(sessionId)) return
const pendingTimer = sessionCleanupTimers.get(sessionId)
if (pendingTimer) {
clearTimeout(pendingTimer)
sessionCleanupTimers.delete(sessionId)
}
watchTurnCompletionForCleanup(sessionId)
}
/** Remove any pending turn-completion watcher for a session. */
function cancelSessionDisconnectWatcher(sessionId: string): void {
const remove = sessionDisconnectWatchers.get(sessionId)
@ -2656,6 +2677,66 @@ function removeClientOutputCallback(ws: ServerWebSocket<WebSocketData>): void {
clientOutputCallbacks.delete(ws)
}
function normalizeCliTaskNotification(cliMsg: any): SessionTaskNotification | null {
if (cliMsg?.type !== 'system' || cliMsg.subtype !== 'task_notification') return null
const toolUseId = typeof cliMsg.tool_use_id === 'string' && cliMsg.tool_use_id
? cliMsg.tool_use_id
: null
const rawStatus = cliMsg.status
const status = rawStatus === 'killed' ? 'stopped' : rawStatus
if (
!toolUseId ||
(status !== 'completed' && status !== 'failed' && status !== 'stopped')
) {
return null
}
const optionalString = (value: unknown) =>
typeof value === 'string' && value ? value : undefined
return {
taskId: optionalString(cliMsg.task_id) ?? toolUseId,
toolUseId,
status,
...(optionalString(cliMsg.summary) ? { summary: optionalString(cliMsg.summary) } : {}),
...(optionalString(cliMsg.result) ? { result: optionalString(cliMsg.result) } : {}),
...(optionalString(cliMsg.output_file) ? { outputFile: optionalString(cliMsg.output_file) } : {}),
timestamp: optionalString(cliMsg.timestamp) ?? new Date().toISOString(),
}
}
function persistCliTaskNotification(
sessionId: string,
cliMsg: any,
): Promise<void> | null {
const notification = normalizeCliTaskNotification(cliMsg)
if (!notification) return null
let sessionWrites = taskNotificationPersistence.get(sessionId)
if (!sessionWrites) {
sessionWrites = new Map()
taskNotificationPersistence.set(sessionId, sessionWrites)
}
const eventKey = typeof cliMsg.uuid === 'string' && cliMsg.uuid
? cliMsg.uuid
: JSON.stringify(notification)
const existing = sessionWrites.get(eventKey)
if (existing) return existing
const write = sessionService.appendSessionTaskNotification(sessionId, notification)
.catch((error) => {
sessionWrites?.delete(eventKey)
console.warn(
`[WS] Failed to persist task notification for ${sessionId}: ${
error instanceof Error ? error.message : String(error)
}`,
)
})
sessionWrites.set(eventKey, write)
return write
}
export const __persistCliTaskNotificationForTests = persistCliTaskNotification
function bindAllClientSessionOutputs(
sessionId: string,
options?: {
@ -2693,12 +2774,30 @@ function bindClientSessionOutput(
return
}
handleCliPermissionModeBroadcast(sessionId, cliMsg)
const serverMsgs = translateCliMessage(cliMsg, sessionId)
for (const msg of serverMsgs) {
sendMessage(ws, msg)
const forward = () => {
handleCliPermissionModeBroadcast(sessionId, cliMsg)
const serverMsgs = translateCliMessage(cliMsg, sessionId)
for (const msg of serverMsgs) {
sendMessage(ws, msg)
}
}
const persistence = persistCliTaskNotification(sessionId, cliMsg)
if (persistence) {
void persistence
.then(() => {
if (activeSessions.get(sessionId)?.has(ws)) forward()
})
.catch((error) => {
console.warn(
`[WS] Failed to forward persisted task notification for ${sessionId}: ${
error instanceof Error ? error.message : String(error)
}`,
)
})
return
}
forward()
}
clientOutputCallbacks.set(ws, { sessionId, callback })
@ -3135,6 +3234,7 @@ export function __resetWebSocketHandlerStateForTests(): void {
for (const remove of sessionDisconnectWatchers.values()) remove()
activeSessions.clear()
clientOutputCallbacks.clear()
taskNotificationPersistence.clear()
sessionCleanupTimers.clear()
sessionDisconnectWatchers.clear()
prewarmPendingSessions.clear()
@ -3153,12 +3253,17 @@ export function __markActiveTurnForTests(sessionId: string): void {
/**
* Test hook: register a user turn still in the pre-send (messageSent:false)
* window i.e. the CLI-startup window that isSessionTurnActive is blind to.
* window i.e. the CLI-startup window before messageSent becomes true.
*/
export function __registerPendingUserTurnForTests(sessionId: string): void {
activeUserTurns.set(sessionId, { messageSent: false })
}
/** Test hook: simulate CLI startup completing after the last client left. */
export function __refreshDisconnectedTurnCleanupWatcherForTests(sessionId: string): void {
refreshDisconnectedTurnCleanupWatcher(sessionId)
}
/** Test hook: arm the prewarm idle timer for a session, as markPrewarmed does. */
export function __markPrewarmedForTests(sessionId: string): void {
markPrewarmed(sessionId)