mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-20 13:53:32 +08:00
parent
0cb612cb5d
commit
c60d3780b8
@ -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')
|
||||
|
||||
128
src/server/__tests__/task-notification-persistence.test.ts
Normal file
128
src/server/__tests__/task-notification-persistence.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()]
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
23
src/server/services/transcriptToolResults.ts
Normal file
23
src/server/services/transcriptToolResults.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user