From f7debb0b4052e87de99d5f8803748c20d3f6794c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 4 Aug 2026 01:20:32 +0800 Subject: [PATCH] refactor(server): move CLI message parsing out of the ws handler --- src/server/ws/cliMessageParsing.ts | 389 +++++++++++++++++++++++++++++ src/server/ws/handler.ts | 360 ++------------------------ 2 files changed, 411 insertions(+), 338 deletions(-) create mode 100644 src/server/ws/cliMessageParsing.ts diff --git a/src/server/ws/cliMessageParsing.ts b/src/server/ws/cliMessageParsing.ts new file mode 100644 index 00000000..1d59e2f6 --- /dev/null +++ b/src/server/ws/cliMessageParsing.ts @@ -0,0 +1,389 @@ +/** + * Pure parsing of raw CLI payloads into the shapes the session handler works with. + * + * Second cut of `handler.ts`, moved verbatim. Every function here is stateless: none + * of them touches a module-level container, a service, or the socket registry, which + * is what makes relocating them a pure move rather than a refactor. They are also the + * part of the handler most worth testing directly, since a wrong extraction here + * shows up as a missing or malformed message in the desktop, H5, and IM clients + * alike. + */ + +import type { ServerMessage, StreamingFallbackCause } from './events.js' +import type { SessionTaskNotification } from '../services/sessionService.js' +import { + COMMAND_NAME_TAG, + LOCAL_COMMAND_STDERR_TAG, + LOCAL_COMMAND_STDOUT_TAG, +} from '../../constants/xml.js' +import { + getCommandMetadataDisplayText, + shouldHideCommandMetadataContent, +} from '../../utils/commandMetadata.js' + +export function extractAssistantStreamTextForTitle(cliMsg: any): string | null { + const event = cliMsg?.event + if ( + cliMsg?.type !== 'stream_event' || + event?.type !== 'content_block_delta' || + event.delta?.type !== 'text_delta' || + typeof event.delta.text !== 'string' + ) { + return null + } + return event.delta.text +} + +export function extractAssistantMessageTextForTitle(cliMsg: any): string | null { + if (cliMsg?.type !== 'assistant') return null + const content = cliMsg.message?.content + if (typeof content === 'string') return content + if (!Array.isArray(content)) return null + const text = content + .flatMap((block) => { + if (!block || typeof block !== 'object') return [] + const typedBlock = block as { type?: unknown; text?: unknown } + return typedBlock.type === 'text' && typeof typedBlock.text === 'string' + ? [typedBlock.text] + : [] + }) + .join('\n') + .trim() + return text || null +} + +export const ROOT_STREAM_SCOPE = '\u0000root' + +export function cliParentToolUseId(cliMsg: any): string | undefined { + return typeof cliMsg.parent_tool_use_id === 'string' && cliMsg.parent_tool_use_id.length > 0 + ? cliMsg.parent_tool_use_id + : undefined +} + +export function cliStreamScope(cliMsg: any): string { + return cliParentToolUseId(cliMsg) ?? ROOT_STREAM_SCOPE +} + +export function scopedToolUseId( + parentToolUseId: string | undefined, + toolUseId: string, +): string { + if (!parentToolUseId || toolUseId.startsWith(`${parentToolUseId}/`)) { + return toolUseId + } + return `${parentToolUseId}/${toolUseId}` +} + +export function extractAssistantText(cliMsg: any): string { + const content = cliMsg?.message?.content + if (!Array.isArray(content)) return '' + const textBlock = content.find( + (block: unknown): block is { type: string; text: string } => + !!block && + typeof block === 'object' && + (block as { type?: unknown }).type === 'text' && + typeof (block as { text?: unknown }).text === 'string', + ) + return textBlock?.text || '' +} + +function readObject(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + return value as Record +} + +export function normalizeAskUserQuestionToolResult(content: unknown, toolUseResult: unknown): unknown { + const result = readObject(toolUseResult) + const answers = readObject(result?.answers) + if (!result || !answers || !Array.isArray(result.questions)) return content + return { + questions: result.questions, + answers, + } +} + +export function classifyRuntimeErrorCode(message: string, fallbackCode: string): string { + if (/Stream max duration exceeded/i.test(message)) { + return 'STREAM_MAX_DURATION' + } + if ( + /Provider stream stalled after partial response/i.test(message) || + /Stream idle timeout/i.test(message) + ) { + return 'STREAM_IDLE_TIMEOUT' + } + return fallbackCode +} + +function finiteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function normalizeRetryCount(value: unknown): number | null { + const numeric = finiteNumber(value) + if (numeric === null) return null + return Math.max(0, Math.trunc(numeric)) +} + +function readRetryErrorRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + return value as Record +} + +function readRetryErrorString(value: unknown, keys: string[]): string | undefined { + const record = readRetryErrorRecord(value) + if (!record) return undefined + for (const key of keys) { + const candidate = record[key] + if (typeof candidate === 'string' && candidate.trim()) return candidate.trim() + } + return undefined +} + +export function toApiRetryServerMessage(cliMsg: any): ServerMessage | null { + const attempt = normalizeRetryCount(cliMsg.attempt) + const maxRetries = normalizeRetryCount(cliMsg.max_retries) + const retryDelayMs = normalizeRetryCount(cliMsg.retry_delay_ms) + if (attempt === null || maxRetries === null || retryDelayMs === null) return null + + const embeddedError = readRetryErrorRecord(cliMsg.error) + const embeddedStatus = embeddedError ? finiteNumber(embeddedError.status) : null + const rawStatus = cliMsg.error_status === null + ? null + : finiteNumber(cliMsg.error_status) ?? embeddedStatus + const errorType = typeof cliMsg.error === 'string' && cliMsg.error.trim() + ? cliMsg.error.trim() + : readRetryErrorString(cliMsg.error, ['type', 'code', 'name']) + const errorMessage = readRetryErrorString(cliMsg.error, ['message', 'error']) + + return { + type: 'api_retry', + attempt, + maxRetries, + retryDelayMs, + errorStatus: rawStatus === null ? null : Math.trunc(rawStatus), + ...(errorType ? { errorType } : {}), + ...(errorMessage ? { errorMessage } : {}), + } +} + +const STREAMING_FALLBACK_CAUSES: ReadonlySet = new Set([ + 'watchdog', + 'stream_error', + '404_stream_creation', + 'stream_retry', +]) + +export function toStreamingFallbackServerMessage(cliMsg: any): ServerMessage { + // 未识别的 cause 兜底为 unknown 而不是丢消息:提示本身比成因重要。 + const cause: StreamingFallbackCause = + typeof cliMsg.cause === 'string' && STREAMING_FALLBACK_CAUSES.has(cliMsg.cause as StreamingFallbackCause) + ? (cliMsg.cause as StreamingFallbackCause) + : 'unknown' + return { type: 'streaming_fallback', cause } +} + +export function extractLocalCommandOutput( + content: unknown, + options: { allowUntagged?: boolean } = {}, +): string | null { + const raw = typeof content === 'string' + ? content + : Array.isArray(content) + ? content + .flatMap((block) => { + if (!block || typeof block !== 'object') return [] + const text = (block as { text?: unknown }).text + return typeof text === 'string' ? [text] : [] + }) + .join('\n') + : '' + + if (!raw) return null + + const stdout = extractTaggedContent(raw, LOCAL_COMMAND_STDOUT_TAG) + if (stdout !== null) return stdout + + const stderr = extractTaggedContent(raw, LOCAL_COMMAND_STDERR_TAG) + if (stderr !== null) return stderr + + if (options.allowUntagged) { + const normalized = raw.trim() + return normalized || null + } + + return null +} + +export function isCompactLocalCommandOutput(output: string): boolean { + return output.trim() === 'Compacted' +} + +function extractTaggedContent(raw: string, tag: string): string | null { + const match = raw.match(new RegExp(`<${tag}>([\\s\\S]*?)`)) + return match?.[1]?.trim() ?? null +} + +export function extractLocalCommand(content: unknown): { name: string; args: string } | null { + const raw = typeof content === 'string' + ? content + : Array.isArray(content) + ? content + .flatMap((block) => { + if (!block || typeof block !== 'object') return [] + const text = (block as { text?: unknown }).text + return typeof text === 'string' ? [text] : [] + }) + .join('\n') + : '' + + const name = extractTaggedContent(raw, COMMAND_NAME_TAG) + if (!name) return null + return { + name: name.replace(/^\//, ''), + args: extractTaggedContent(raw, 'command-args') ?? '', + } +} + +type GoalEventData = { + action: 'created' | 'replaced' | 'status' | 'paused' | 'resumed' | 'completed' | 'cleared' | 'message' + status?: string + objective?: string + budget?: string + elapsed?: string + continuations?: string + message?: string +} + +export function extractGoalEvent( + output: string, + command?: { name: string; args: string }, +): GoalEventData | null { + if (command && command.name !== 'goal') return null + + const trimmed = output.trim() + if (!trimmed) return null + + if (trimmed === 'Goal cleared.' || trimmed.startsWith('Goal cleared:')) { + return { action: 'cleared', message: trimmed } + } + if (trimmed === 'Goal marked complete.') { + return { action: 'completed', message: trimmed } + } + if (trimmed === 'No active goal.') { + return { action: 'message', message: trimmed } + } + if (trimmed.startsWith('Goal continuing:')) { + return { + action: 'status', + status: 'continuing', + message: trimmed, + } + } + + if (trimmed.startsWith('Goal set:')) { + const objective = trimmed.slice('Goal set:'.length).trim() + return { + action: 'created', + status: 'active', + objective: objective || undefined, + message: trimmed, + } + } + + return command?.name === 'goal' ? { action: 'message', message: trimmed } : null +} + +export function looksLikeGoalCommandOutput(output: string): boolean { + const trimmed = output.trim() + return ( + trimmed.startsWith('Goal set:') || + trimmed.startsWith('Goal continuing:') || + trimmed.startsWith('Goal cleared:') || + trimmed === 'Goal cleared.' || + trimmed === 'Goal marked complete.' || + trimmed === 'No active goal.' + ) +} + +export function getCompactBoundaryMessage(cliMsg: any): string { + const message = typeof cliMsg?.message === 'string' ? cliMsg.message.trim() : '' + if (message) return message + + const content = typeof cliMsg?.content === 'string' ? cliMsg.content.trim() : '' + if (content) return content + + return 'Context compacted' +} + +export function isCompactSummaryMessageContent(content: unknown): content is string { + return ( + typeof content === 'string' && + content.trim().startsWith( + 'This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.', + ) + ) +} + +function hasToolResultBlock(content: unknown): boolean { + return Array.isArray(content) && + content.some((block) => + Boolean(block) && + typeof block === 'object' && + (block as { type?: unknown }).type === 'tool_result') +} + +export function extractReplayUserText(cliMsg: any): string | null { + if (cliMsg?.isReplay !== true) return null + const content = cliMsg.message?.content + const commandDisplayText = getCommandMetadataDisplayText(content) + if (commandDisplayText) return commandDisplayText + if (shouldHideCommandMetadataContent(content)) return null + if (isCompactSummaryMessageContent(content)) return null + if (hasToolResultBlock(content)) return null + if (extractLocalCommandOutput(content)) return null + + const text = typeof content === 'string' + ? content + : Array.isArray(content) + ? content + .flatMap((block) => { + if (!block || typeof block !== 'object') return [] + const typedBlock = block as { type?: unknown; text?: unknown } + return typedBlock.type === 'text' && typeof typedBlock.text === 'string' + ? [typedBlock.text] + : [] + }) + .join('\n') + : '' + + const trimmed = text.trim() + return trimmed || null +} + +export 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(), + } +} diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index e59bf019..489f3ae4 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -92,6 +92,28 @@ import type { ActiveAgentTaskState, CliBackgroundTaskLifecycle, } from './agentTaskState.js' +import { + ROOT_STREAM_SCOPE, + extractAssistantStreamTextForTitle, + extractAssistantMessageTextForTitle, + cliParentToolUseId, + cliStreamScope, + scopedToolUseId, + extractAssistantText, + normalizeAskUserQuestionToolResult, + classifyRuntimeErrorCode, + toApiRetryServerMessage, + toStreamingFallbackServerMessage, + extractLocalCommandOutput, + isCompactLocalCommandOutput, + extractLocalCommand, + extractGoalEvent, + looksLikeGoalCommandOutput, + getCompactBoundaryMessage, + isCompactSummaryMessageContent, + extractReplayUserText, + normalizeCliTaskNotification, +} from './cliMessageParsing.js' const settingsService = new SettingsService() const providerService = new ProviderService() @@ -2427,36 +2449,7 @@ function appendAssistantTextForTitle(sessionId: string, cliMsg: any): void { } } -function extractAssistantStreamTextForTitle(cliMsg: any): string | null { - const event = cliMsg?.event - if ( - cliMsg?.type !== 'stream_event' || - event?.type !== 'content_block_delta' || - event.delta?.type !== 'text_delta' || - typeof event.delta.text !== 'string' - ) { - return null - } - return event.delta.text -} -function extractAssistantMessageTextForTitle(cliMsg: any): string | null { - if (cliMsg?.type !== 'assistant') return null - const content = cliMsg.message?.content - if (typeof content === 'string') return content - if (!Array.isArray(content)) return null - const text = content - .flatMap((block) => { - if (!block || typeof block !== 'object') return [] - const typedBlock = block as { type?: unknown; text?: unknown } - return typedBlock.type === 'text' && typeof typedBlock.text === 'string' - ? [typedBlock.text] - : [] - }) - .join('\n') - .trim() - return text || null -} function completeActiveTitleTurn(sessionId: string): number | null { const state = sessionTitleState.get(sessionId) @@ -2483,7 +2476,6 @@ function discardActiveTitleTurn(sessionId: string, count: number | null): void { // CLI message translation // ============================================================================ -const ROOT_STREAM_SCOPE = '\u0000root' /** Per-session state for correlating raw stream events with buffered messages. */ type SessionStreamState = { @@ -2537,15 +2529,7 @@ function resetCurrentStreamAttempt(state: SessionStreamState): void { state.toolParentUseIds.clear() } -function cliParentToolUseId(cliMsg: any): string | undefined { - return typeof cliMsg.parent_tool_use_id === 'string' && cliMsg.parent_tool_use_id.length > 0 - ? cliMsg.parent_tool_use_id - : undefined -} -function cliStreamScope(cliMsg: any): string { - return cliParentToolUseId(cliMsg) ?? ROOT_STREAM_SCOPE -} function streamBlockKey(scope: string, index: number): string { return JSON.stringify([scope, index]) @@ -2599,15 +2583,6 @@ function pendingToolBlockKey( return JSON.stringify([parentToolUseId ?? null, toolUseId]) } -function scopedToolUseId( - parentToolUseId: string | undefined, - toolUseId: string, -): string { - if (!parentToolUseId || toolUseId.startsWith(`${parentToolUseId}/`)) { - return toolUseId - } - return `${parentToolUseId}/${toolUseId}` -} function rememberToolParentUseId( streamState: SessionStreamState, @@ -2740,33 +2715,8 @@ function cacheSessionInitMetadata(sessionId: string, cliMsg: any) { } } -function extractAssistantText(cliMsg: any): string { - const content = cliMsg?.message?.content - if (!Array.isArray(content)) return '' - const textBlock = content.find( - (block: unknown): block is { type: string; text: string } => - !!block && - typeof block === 'object' && - (block as { type?: unknown }).type === 'text' && - typeof (block as { text?: unknown }).text === 'string', - ) - return textBlock?.text || '' -} -function readObject(value: unknown): Record | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null - return value as Record -} -function normalizeAskUserQuestionToolResult(content: unknown, toolUseResult: unknown): unknown { - const result = readObject(toolUseResult) - const answers = readObject(result?.answers) - if (!result || !answers || !Array.isArray(result.questions)) return content - return { - questions: result.questions, - answers, - } -} function isDuplicateOfLastApiError( lastApiError: SessionStreamState['lastApiError'], @@ -2780,18 +2730,6 @@ function isDuplicateOfLastApiError( ) } -function classifyRuntimeErrorCode(message: string, fallbackCode: string): string { - if (/Stream max duration exceeded/i.test(message)) { - return 'STREAM_MAX_DURATION' - } - if ( - /Provider stream stalled after partial response/i.test(message) || - /Stream idle timeout/i.test(message) - ) { - return 'STREAM_IDLE_TIMEOUT' - } - return fallbackCode -} function bindPrewarmMetadataCapture(sessionId: string) { for (const msg of conversationService.getRecentSdkMessages(sessionId)) { @@ -3484,73 +3422,12 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa // Helpers // ============================================================================ -function finiteNumber(value: unknown): number | null { - return typeof value === 'number' && Number.isFinite(value) ? value : null -} -function normalizeRetryCount(value: unknown): number | null { - const numeric = finiteNumber(value) - if (numeric === null) return null - return Math.max(0, Math.trunc(numeric)) -} -function readRetryErrorRecord(value: unknown): Record | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null - return value as Record -} -function readRetryErrorString(value: unknown, keys: string[]): string | undefined { - const record = readRetryErrorRecord(value) - if (!record) return undefined - for (const key of keys) { - const candidate = record[key] - if (typeof candidate === 'string' && candidate.trim()) return candidate.trim() - } - return undefined -} -function toApiRetryServerMessage(cliMsg: any): ServerMessage | null { - const attempt = normalizeRetryCount(cliMsg.attempt) - const maxRetries = normalizeRetryCount(cliMsg.max_retries) - const retryDelayMs = normalizeRetryCount(cliMsg.retry_delay_ms) - if (attempt === null || maxRetries === null || retryDelayMs === null) return null - const embeddedError = readRetryErrorRecord(cliMsg.error) - const embeddedStatus = embeddedError ? finiteNumber(embeddedError.status) : null - const rawStatus = cliMsg.error_status === null - ? null - : finiteNumber(cliMsg.error_status) ?? embeddedStatus - const errorType = typeof cliMsg.error === 'string' && cliMsg.error.trim() - ? cliMsg.error.trim() - : readRetryErrorString(cliMsg.error, ['type', 'code', 'name']) - const errorMessage = readRetryErrorString(cliMsg.error, ['message', 'error']) - return { - type: 'api_retry', - attempt, - maxRetries, - retryDelayMs, - errorStatus: rawStatus === null ? null : Math.trunc(rawStatus), - ...(errorType ? { errorType } : {}), - ...(errorMessage ? { errorMessage } : {}), - } -} - -const STREAMING_FALLBACK_CAUSES: ReadonlySet = new Set([ - 'watchdog', - 'stream_error', - '404_stream_creation', - 'stream_retry', -]) - -function toStreamingFallbackServerMessage(cliMsg: any): ServerMessage { - // 未识别的 cause 兜底为 unknown 而不是丢消息:提示本身比成因重要。 - const cause: StreamingFallbackCause = - typeof cliMsg.cause === 'string' && STREAMING_FALLBACK_CAUSES.has(cliMsg.cause as StreamingFallbackCause) - ? (cliMsg.cause as StreamingFallbackCause) - : 'unknown' - return { type: 'streaming_fallback', cause } -} function sendMessage(ws: ServerWebSocket, message: ServerMessage) { const outgoing = ws.data.clientKind === 'pet' @@ -3845,183 +3722,16 @@ function isLocalCommandOutputMessage(cliMsg: any): boolean { ) !== null } -function extractLocalCommandOutput( - content: unknown, - options: { allowUntagged?: boolean } = {}, -): string | null { - const raw = typeof content === 'string' - ? content - : Array.isArray(content) - ? content - .flatMap((block) => { - if (!block || typeof block !== 'object') return [] - const text = (block as { text?: unknown }).text - return typeof text === 'string' ? [text] : [] - }) - .join('\n') - : '' - if (!raw) return null - const stdout = extractTaggedContent(raw, LOCAL_COMMAND_STDOUT_TAG) - if (stdout !== null) return stdout - const stderr = extractTaggedContent(raw, LOCAL_COMMAND_STDERR_TAG) - if (stderr !== null) return stderr - if (options.allowUntagged) { - const normalized = raw.trim() - return normalized || null - } - return null -} -function isCompactLocalCommandOutput(output: string): boolean { - return output.trim() === 'Compacted' -} -function extractTaggedContent(raw: string, tag: string): string | null { - const match = raw.match(new RegExp(`<${tag}>([\\s\\S]*?)`)) - return match?.[1]?.trim() ?? null -} -function extractLocalCommand(content: unknown): { name: string; args: string } | null { - const raw = typeof content === 'string' - ? content - : Array.isArray(content) - ? content - .flatMap((block) => { - if (!block || typeof block !== 'object') return [] - const text = (block as { text?: unknown }).text - return typeof text === 'string' ? [text] : [] - }) - .join('\n') - : '' - const name = extractTaggedContent(raw, COMMAND_NAME_TAG) - if (!name) return null - return { - name: name.replace(/^\//, ''), - args: extractTaggedContent(raw, 'command-args') ?? '', - } -} -type GoalEventData = { - action: 'created' | 'replaced' | 'status' | 'paused' | 'resumed' | 'completed' | 'cleared' | 'message' - status?: string - objective?: string - budget?: string - elapsed?: string - continuations?: string - message?: string -} - -function extractGoalEvent( - output: string, - command?: { name: string; args: string }, -): GoalEventData | null { - if (command && command.name !== 'goal') return null - - const trimmed = output.trim() - if (!trimmed) return null - - if (trimmed === 'Goal cleared.' || trimmed.startsWith('Goal cleared:')) { - return { action: 'cleared', message: trimmed } - } - if (trimmed === 'Goal marked complete.') { - return { action: 'completed', message: trimmed } - } - if (trimmed === 'No active goal.') { - return { action: 'message', message: trimmed } - } - if (trimmed.startsWith('Goal continuing:')) { - return { - action: 'status', - status: 'continuing', - message: trimmed, - } - } - - if (trimmed.startsWith('Goal set:')) { - const objective = trimmed.slice('Goal set:'.length).trim() - return { - action: 'created', - status: 'active', - objective: objective || undefined, - message: trimmed, - } - } - - return command?.name === 'goal' ? { action: 'message', message: trimmed } : null -} - -function looksLikeGoalCommandOutput(output: string): boolean { - const trimmed = output.trim() - return ( - trimmed.startsWith('Goal set:') || - trimmed.startsWith('Goal continuing:') || - trimmed.startsWith('Goal cleared:') || - trimmed === 'Goal cleared.' || - trimmed === 'Goal marked complete.' || - trimmed === 'No active goal.' - ) -} - -function getCompactBoundaryMessage(cliMsg: any): string { - const message = typeof cliMsg?.message === 'string' ? cliMsg.message.trim() : '' - if (message) return message - - const content = typeof cliMsg?.content === 'string' ? cliMsg.content.trim() : '' - if (content) return content - - return 'Context compacted' -} - -function isCompactSummaryMessageContent(content: unknown): content is string { - return ( - typeof content === 'string' && - content.trim().startsWith( - 'This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.', - ) - ) -} - -function hasToolResultBlock(content: unknown): boolean { - return Array.isArray(content) && - content.some((block) => - Boolean(block) && - typeof block === 'object' && - (block as { type?: unknown }).type === 'tool_result') -} - -function extractReplayUserText(cliMsg: any): string | null { - if (cliMsg?.isReplay !== true) return null - const content = cliMsg.message?.content - const commandDisplayText = getCommandMetadataDisplayText(content) - if (commandDisplayText) return commandDisplayText - if (shouldHideCommandMetadataContent(content)) return null - if (isCompactSummaryMessageContent(content)) return null - if (hasToolResultBlock(content)) return null - if (extractLocalCommandOutput(content)) return null - - const text = typeof content === 'string' - ? content - : Array.isArray(content) - ? content - .flatMap((block) => { - if (!block || typeof block !== 'object') return [] - const typedBlock = block as { type?: unknown; text?: unknown } - return typedBlock.type === 'text' && typeof typedBlock.text === 'string' - ? [typedBlock.text] - : [] - }) - .join('\n') - : '' - - const trimmed = text.trim() - return trimmed || null -} function addActiveClient( sessionId: string, @@ -4059,32 +3769,6 @@ function removeClientOutputCallback(ws: ServerWebSocket): 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 boundTaskNotificationPersistence( persistence: Promise,