From 621b34c42498dbde8e95c633fbec8ff26d598057 Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:22:18 -0700 Subject: [PATCH] fix(agent-status): require owned Claude completion evidence --- .../server-hook-http-ingest.test.ts | 73 +++++++++++++++ src/main/agent-hooks/server.ts | 53 ++++++++++- .../session-scanner-claude-subagents.test.ts | 21 +++-- src/relay/agent-hook-server.test.ts | 10 ++- src/shared/agent-hook-listener.ts | 34 +++---- .../claude-background-task-status.test.ts | 88 ++++++++++++++++--- src/shared/claude-task-notification.test.ts | 38 ++++++-- src/shared/claude-task-notification.ts | 17 +++- 8 files changed, 281 insertions(+), 53 deletions(-) diff --git a/src/main/agent-hooks/server-hook-http-ingest.test.ts b/src/main/agent-hooks/server-hook-http-ingest.test.ts index 622fc7d428b..b7ae5981d6b 100644 --- a/src/main/agent-hooks/server-hook-http-ingest.test.ts +++ b/src/main/agent-hooks/server-hook-http-ingest.test.ts @@ -1,4 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { AgentHookServer, _internals } from './server' import { AGENT_STATUS_MAX_FIELD_LENGTH } from '../../shared/agent-status-types' import { makePaneKey } from '../../shared/stable-pane-id' @@ -130,6 +133,76 @@ describe('AgentHookServer listener replay', () => { } }) + it('does not retire Claude subagents from a rejected idle status', async () => { + const dir = mkdtempSync(join(tmpdir(), 'claude-rejected-idle-')) + const transcriptPath = join(dir, 'session.jsonl') + writeFileSync(transcriptPath, '') + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postHook = async ( + source: 'codex' | 'claude', + payload: Record + ): Promise => { + const response = await fetch( + `http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/${source}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + } + ) + expect(response.status).toBe(204) + } + + await postHook('codex', { + hook_event_name: 'UserPromptSubmit', + prompt: 'parent codex' + }) + const parent = server.getStatusSnapshot()[0] + const state = server._getStateForTests() + state.claudeSubagentRosterByPaneKey.set( + PANE, + new Map([['agent-1', { state: 'working', startedAt: 1 }]]) + ) + state.claudeBackgroundTaskObservationsByPaneKey.set( + PANE, + new Map([['agent-1', { transcriptPath, transcriptByteOffset: 0, nonAgent: false }]]) + ) + appendFileSync( + transcriptPath, + `${JSON.stringify({ + type: 'user', + promptSource: 'system', + origin: { kind: 'task-notification' }, + message: { + content: + '\nagent-1\ncompleted\n' + } + })}\n` + ) + + await postHook('claude', { + hook_event_name: 'Notification', + notification_type: 'idle_prompt', + transcript_path: transcriptPath + }) + + expect(server.getStatusSnapshot()[0]).toEqual(parent) + expect( + server._getStateForTests().claudeSubagentRosterByPaneKey.get(PANE)?.has('agent-1') + ).toBe(true) + expect(server._getStateForTests().claudeLeadStateByPaneKey.has(PANE)).toBe(false) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) + it('maps registered legacy numeric HTTP pane keys to stable pane keys', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index cfbbcbcd276..1fc36a1d2ed 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -39,6 +39,7 @@ import { warnOnHookEnvOrVersionMismatch, writeEndpointFile, type AgentHookEventPayload, + type ClaudeLeadTurnState, type HookListenerState } from '../../shared/agent-hook-listener' import { @@ -51,7 +52,8 @@ import { claudeTeammateIdMatchesName, claudeRosterHasRestoredSnapshotSubagent, claudeRosterHasWorkingSubagent, - claudeRosterToSnapshots + claudeRosterToSnapshots, + type ClaudeSubagentRoster } from '../../shared/claude-subagent-roster' import { isAgentHookSource, @@ -120,6 +122,12 @@ type NormalizedLocalHook = { onAccepted?: () => void } +function cloneClaudeSubagentRoster( + roster: ClaudeSubagentRoster | undefined +): ClaudeSubagentRoster | undefined { + return roster ? new Map(Array.from(roster, ([id, tracked]) => [id, { ...tracked }])) : undefined +} + type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, | 'claudeRunningNonAgentTask' @@ -2043,6 +2051,10 @@ export class AgentHookServer { const previousUnidentifiedTask = this.state.claudeUnidentifiedRunningNonAgentTaskPaneKeys.has(paneKey) const previousActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) + const previousSubagentRoster = cloneClaudeSubagentRoster( + this.state.claudeSubagentRosterByPaneKey.get(paneKey) + ) + const previousLeadState = this.cloneClaudeLeadState(paneKey) const event = normalizeHookPayload(this.state, source, body, this.env) const nextRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) const nextTaskObservations = new Map( @@ -2051,6 +2063,10 @@ export class AgentHookServer { const nextUnidentifiedTask = this.state.claudeUnidentifiedRunningNonAgentTaskPaneKeys.has(paneKey) const nextActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) + const nextSubagentRoster = cloneClaudeSubagentRoster( + this.state.claudeSubagentRosterByPaneKey.get(paneKey) + ) + const nextLeadState = this.cloneClaudeLeadState(paneKey) this.setClaudeBackgroundEvidence( paneKey, previousRunningTask, @@ -2058,13 +2074,15 @@ export class AgentHookServer { previousUnidentifiedTask, previousActiveCron ) + this.setClaudeSubagentRoster(paneKey, previousSubagentRoster) + this.setClaudeLeadState(paneKey, previousLeadState) if (!event || event.paneKey !== paneKey) { return { event } } - // Why: nested CLIs may inherit the pane key; only accepted statuses may mutate its background-work gate. + // Why: nested CLIs may inherit the pane key; only accepted statuses may mutate Claude lifecycle evidence. return { event, - onAccepted: () => + onAccepted: () => { this.setClaudeBackgroundEvidence( paneKey, nextRunningTask, @@ -2072,6 +2090,35 @@ export class AgentHookServer { nextUnidentifiedTask, nextActiveCron ) + this.setClaudeSubagentRoster(paneKey, nextSubagentRoster) + this.setClaudeLeadState(paneKey, nextLeadState) + } + } + } + + private setClaudeSubagentRoster(paneKey: string, roster: ClaudeSubagentRoster | undefined): void { + if (roster) { + this.state.claudeSubagentRosterByPaneKey.set(paneKey, roster) + } else { + this.state.claudeSubagentRosterByPaneKey.delete(paneKey) + } + } + + private cloneClaudeLeadState(paneKey: string): ClaudeLeadTurnState | undefined { + const lead = this.state.claudeLeadStateByPaneKey.get(paneKey) + return lead + ? { + ...lead, + ...(lead.stateBeforeWait ? { stateBeforeWait: { ...lead.stateBeforeWait } } : {}) + } + : undefined + } + + private setClaudeLeadState(paneKey: string, lead: ClaudeLeadTurnState | undefined): void { + if (lead) { + this.state.claudeLeadStateByPaneKey.set(paneKey, lead) + } else { + this.state.claudeLeadStateByPaneKey.delete(paneKey) } } diff --git a/src/main/ai-vault/session-scanner-claude-subagents.test.ts b/src/main/ai-vault/session-scanner-claude-subagents.test.ts index eb0f90d9bc9..80feb091fcc 100644 --- a/src/main/ai-vault/session-scanner-claude-subagents.test.ts +++ b/src/main/ai-vault/session-scanner-claude-subagents.test.ts @@ -144,6 +144,8 @@ describe('listClaudeSubagentSessions', () => { { // Interim notification: superseded by the terminal one below. type: 'user', + promptSource: 'system', + origin: { kind: 'task-notification' }, sessionId: 'parent-session', timestamp: '2026-07-05T10:00:30.000Z', message: { @@ -154,6 +156,8 @@ describe('listClaudeSubagentSessions', () => { }, { type: 'user', + promptSource: 'system', + origin: { kind: 'task-notification' }, sessionId: 'parent-session', timestamp: '2026-07-05T10:01:00.000Z', message: { @@ -163,13 +167,16 @@ describe('listClaudeSubagentSessions', () => { } }, { - // queue-operation records carry the notification as a plain string. - type: 'queue-operation', - operation: 'enqueue', + type: 'user', + promptSource: 'system', + origin: { kind: 'task-notification' }, sessionId: 'parent-session', timestamp: '2026-07-05T10:02:00.000Z', - content: - '\ncrashed\nfailed\n' + message: { + role: 'user', + content: + '\ncrashed\nfailed\n' + } }, { // Synchronous Tasks finish with a toolUseResult record, no notification. @@ -240,6 +247,8 @@ describe('listClaudeSubagentSessions', () => { }, { type: 'user', + promptSource: 'system', + origin: { kind: 'task-notification' }, sessionId: 'parent-session', timestamp: '2026-07-05T10:01:00.000Z', message: { role: 'user', content: notification } @@ -269,6 +278,8 @@ describe('listClaudeSubagentSessions', () => { await writeJsonlFile(parentFilePath, [ { type: 'user', + promptSource: 'system', + origin: { kind: 'task-notification' }, sessionId: 'parent-session', timestamp: '2026-07-05T10:00:00.000Z', message: { diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index d27d780dc41..d3cfb8caa9f 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -133,9 +133,13 @@ describe('RelayAgentHookServer', () => { appendFileSync( transcriptPath, `${JSON.stringify({ - type: 'queue-operation', - content: - '\nshell-1\ncompleted\n' + type: 'user', + promptSource: 'system', + origin: { kind: 'task-notification' }, + message: { + content: + '\nshell-1\ncompleted\n' + } })}\n` ) await post({ hook_event_name: 'PreToolUse', tool_name: 'Bash' }) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 37747c57927..f4468f7e86d 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -2670,7 +2670,6 @@ function updateClaudeBackgroundTaskEvidence( transcriptByteOffset = null } } - const previous = state.claudeBackgroundTaskObservationsByPaneKey.get(paneKey) const observations = new Map< string, { transcriptPath: string; transcriptByteOffset: number; nonAgent: boolean } @@ -2684,14 +2683,7 @@ function updateClaudeBackgroundTaskEvidence( .map((task) => ({ id: task.id, nonAgent: false })) ] for (const task of runningTasks) { - const existing = previous?.get(task.id) - if ( - existing && - existing.transcriptPath === transcriptPath && - existing.nonAgent === task.nonAgent - ) { - observations.set(task.id, existing) - } else if (transcriptPath && transcriptByteOffset !== null) { + if (transcriptPath && transcriptByteOffset !== null) { observations.set(task.id, { transcriptPath, transcriptByteOffset, nonAgent: task.nonAgent }) } else if (task.nonAgent) { hasUnidentified = true @@ -2720,11 +2712,11 @@ function reconcileClaudeIdlePromptTasks( state: HookListenerState, paneKey: string, hookPayload: Record -): void { +): boolean { const transcriptPath = readString(hookPayload, 'transcript_path') const observations = state.claudeBackgroundTaskObservationsByPaneKey.get(paneKey) if (!transcriptPath || !observations) { - return + return false } const matchingObservations = Array.from(observations.entries()).filter( ([, observation]) => observation.transcriptPath === transcriptPath @@ -2733,7 +2725,7 @@ function reconcileClaudeIdlePromptTasks( ...matchingObservations.map(([, observation]) => observation.transcriptByteOffset) ) if (!Number.isFinite(minimumByteOffset)) { - return + return false } const notifications = readClaudeTerminalTaskNotifications(transcriptPath, minimumByteOffset) const terminalTaskIds = new Set() @@ -2750,7 +2742,7 @@ function reconcileClaudeIdlePromptTasks( } } if (terminalTaskIds.size === 0) { - return + return false } if (observations.size === 0) { state.claudeBackgroundTaskObservationsByPaneKey.delete(paneKey) @@ -2766,7 +2758,7 @@ function reconcileClaudeIdlePromptTasks( } const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) if (!roster) { - return + return true } for (const taskId of terminalTaskIds) { stopClaudeSubagent(roster, taskId) @@ -2774,6 +2766,7 @@ function reconcileClaudeIdlePromptTasks( if (roster.size === 0) { state.claudeSubagentRosterByPaneKey.delete(paneKey) } + return true } function resolveClaudePaneState( @@ -3097,6 +3090,10 @@ function normalizeClaudeEvent( const previousLead = state.claudeLeadStateByPaneKey.get(paneKey) const isIdlePromptNotification = eventName === 'Notification' && hookPayload['notification_type'] === 'idle_prompt' + const reconciledIdlePrompt = + isIdlePromptNotification && eventAgentId === undefined + ? reconcileClaudeIdlePromptTasks(state, paneKey, hookPayload) + : false // Why: only a turn boundary may declare an interrupt or carry a prior one forward; any other event starts a fresh turn and drops it. const isTurnBoundary = eventName === 'Stop' || eventName === 'StopFailure' const interrupted = @@ -3134,7 +3131,7 @@ function normalizeClaudeEvent( : eventName === 'PermissionRequest' || isAskUserQuestion ? 'waiting' : isTurnBoundary || - isIdlePromptNotification || + reconciledIdlePrompt || (eventName === 'PostCompact' && hookPayload.trigger === 'manual') ? 'done' : null @@ -3142,9 +3139,6 @@ function normalizeClaudeEvent( if (!reportedStateName) { return null } - if (isIdlePromptNotification && eventAgentId === undefined) { - reconcileClaudeIdlePromptTasks(state, paneKey, hookPayload) - } if (backgroundTasks.present && eventAgentId === undefined) { updateClaudeBackgroundTaskEvidence( state, @@ -3278,12 +3272,12 @@ function normalizeClaudeEvent( // Why: the lead already ended — the pane stays `working` only because background inventory is still registered. `stateStartedAt` is pinned for that whole run, so this end time is the per-turn identity and the later all-clear's pair key. const turnCompletedAt = eventAgentId === undefined && - (isTurnBoundary || isIdlePromptNotification) && + (isTurnBoundary || reconciledIdlePrompt) && reportedStateName === 'done' && interrupted !== true ? effectiveState === 'working' ? (previousLead?.turnCompletedAt ?? Date.now()) - : isIdlePromptNotification + : reconciledIdlePrompt ? previousLead?.turnCompletedAt : undefined : undefined diff --git a/src/shared/claude-background-task-status.test.ts b/src/shared/claude-background-task-status.test.ts index 91812e96e3b..69a43da3563 100644 --- a/src/shared/claude-background-task-status.test.ts +++ b/src/shared/claude-background-task-status.test.ts @@ -32,10 +32,14 @@ function claudeEvent(state: HookListenerState, paneKey: string, payload: Record< function taskNotificationLine(taskId: string, status = 'completed'): string { return `${JSON.stringify({ - type: 'queue-operation', - content: - `\n${taskId}\n` + - `${status}\n` + type: 'user', + promptSource: 'system', + origin: { kind: 'task-notification' }, + message: { + content: + `\n${taskId}\n` + + `${status}\n` + } })}\n` } @@ -186,8 +190,9 @@ describe('Claude background task status', () => { hook_event_name: 'Notification', notification_type: 'idle_prompt', transcript_path: transcriptPath - })?.state - ).toBe('working') + }) + ).toBeUndefined() + expect(state.claudeLeadStateByPaneKey.get(SOURCE_PANE)?.state).toBe('done') appendFileSync(transcriptPath, taskNotificationLine(RUNNING_SHELL.id)) expect( @@ -195,8 +200,9 @@ describe('Claude background task status', () => { hook_event_name: 'Notification', notification_type: 'idle_prompt', transcript_path: transcriptPath - })?.state - ).toBe('working') + }) + ).toMatchObject({ state: 'working' }) + expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true) appendFileSync(transcriptPath, taskNotificationLine('shell-2', 'failed')) expect( @@ -228,8 +234,9 @@ describe('Claude background task status', () => { hook_event_name: 'Notification', notification_type: 'idle_prompt', transcript_path: transcriptPath - })?.state - ).toBe('working') + }) + ).toBeUndefined() + expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true) } finally { rmSync(dir, { recursive: true, force: true }) } @@ -251,8 +258,9 @@ describe('Claude background task status', () => { hook_event_name: 'Notification', notification_type: 'idle_prompt', transcript_path: transcriptPath - })?.state - ).toBe('working') + }) + ).toBeUndefined() + expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true) appendFileSync(transcriptPath, taskNotificationLine(RUNNING_SHELL.id)) expect( @@ -267,6 +275,46 @@ describe('Claude background task status', () => { } }) + it('refreshes ownership when Claude reports a reused task id as running', () => { + const dir = mkdtempSync(join(tmpdir(), 'claude-idle-reused-task-')) + const transcriptPath = join(dir, 'session.jsonl') + writeFileSync(transcriptPath, '') + try { + const state = createHookListenerState() + claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Stop', + transcript_path: transcriptPath, + background_tasks: [RUNNING_SHELL] + }) + appendFileSync(transcriptPath, taskNotificationLine(RUNNING_SHELL.id)) + claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Stop', + transcript_path: transcriptPath, + background_tasks: [RUNNING_SHELL] + }) + + expect( + claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Notification', + notification_type: 'idle_prompt', + transcript_path: transcriptPath + }) + ).toBeUndefined() + expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true) + + appendFileSync(transcriptPath, taskNotificationLine(RUNNING_SHELL.id)) + expect( + claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Notification', + notification_type: 'idle_prompt', + transcript_path: transcriptPath + }) + ).toMatchObject({ state: 'done' }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + it('retires a background subagent whose SubagentStop was missed', () => { const dir = mkdtempSync(join(tmpdir(), 'claude-idle-subagent-')) const transcriptPath = join(dir, 'session.jsonl') @@ -306,6 +354,7 @@ describe('Claude background task status', () => { background_tasks: [RUNNING_SHELL], session_crons: [{ id: 'cron-1' }] }) + appendFileSync(transcriptPath, taskNotificationLine(RUNNING_SHELL.id)) expect( claudeEvent(state, SOURCE_PANE, { @@ -333,6 +382,21 @@ describe('Claude background task status', () => { expect(state.claudeLeadStateByPaneKey.get(SOURCE_PANE)?.state).toBe('working') }) + it('does not treat idle_prompt alone as task completion evidence', () => { + const state = createHookListenerState() + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'run it' }) + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'PreToolUse', tool_name: 'Bash' }) + + expect( + claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Notification', + notification_type: 'idle_prompt', + transcript_path: 'unowned-session.jsonl' + }) + ).toBeUndefined() + expect(state.claudeLeadStateByPaneKey.get(SOURCE_PANE)?.state).toBe('working') + }) + it('keeps pending task state when pane authority moves', () => { const state = createHookListenerState() claudeEvent(state, SOURCE_PANE, { diff --git a/src/shared/claude-task-notification.test.ts b/src/shared/claude-task-notification.test.ts index 4cefdfc9e84..7943321258f 100644 --- a/src/shared/claude-task-notification.test.ts +++ b/src/shared/claude-task-notification.test.ts @@ -10,15 +10,20 @@ import { function notificationRecord(taskId: string, status: string): string { return JSON.stringify({ - type: 'queue-operation', - content: - `\n${taskId}\n` + - `${status}\n` + type: 'user', + promptSource: 'system', + origin: { kind: 'task-notification' }, + message: { + role: 'user', + content: + `\n${taskId}\n` + + `${status}\n` + } }) } describe('Claude task notifications', () => { - it('parses queue and user delivery shapes but not quoted prose', () => { + it('parses provider task delivery but not queue or user-authored shapes', () => { expect(parseClaudeTaskNotificationLine(notificationRecord('task-1', 'completed'))).toEqual({ taskId: 'task-1', status: 'completed' @@ -27,6 +32,8 @@ describe('Claude task notifications', () => { parseClaudeTaskNotificationLine( JSON.stringify({ type: 'user', + promptSource: 'system', + origin: { kind: 'task-notification' }, message: { content: [ { @@ -38,13 +45,29 @@ describe('Claude task notifications', () => { }) ) ).toEqual({ taskId: 'task-2', status: 'failed' }) + expect( + parseClaudeTaskNotificationLine( + JSON.stringify({ + type: 'queue-operation', + operation: 'enqueue', + content: + 'task-2failed' + }) + ) + ).toBeNull() + expect( + parseClaudeTaskNotificationLine( + notificationRecord('task-4', 'completed').replace('', '') + ) + ).toBeNull() expect( parseClaudeTaskNotificationLine( JSON.stringify({ type: 'user', + promptSource: 'user', message: { content: - 'Explain task-3completed' + 'task-3completed' } }) ) @@ -74,7 +97,8 @@ describe('Claude task notifications', () => { it('skips a partial JSONL record at the ownership offset', () => { const dir = mkdtempSync(join(tmpdir(), 'claude-task-offset-')) const transcriptPath = join(dir, 'session.jsonl') - const partialRecord = '{"type":"user","message":{"content":"' + const partialRecord = + '{"type":"user","promptSource":"system","origin":{"kind":"task-notification"},"message":{"content":"' writeFileSync(transcriptPath, partialRecord) try { appendFileSync( diff --git a/src/shared/claude-task-notification.ts b/src/shared/claude-task-notification.ts index 6a26fe1c29e..e2759f94cec 100644 --- a/src/shared/claude-task-notification.ts +++ b/src/shared/claude-task-notification.ts @@ -86,7 +86,10 @@ export function parseClaudeTaskNotificationLine( line: string ): { taskId: string; status: string } | null { const notification = taskNotificationText(line) - if (!notification.startsWith(CLAUDE_TASK_NOTIFICATION_MARKER)) { + if ( + !notification.startsWith(CLAUDE_TASK_NOTIFICATION_MARKER) || + !notification.endsWith('') + ) { return null } const taskId = TASK_ID_PATTERN.exec(notification)?.[1]?.trim() @@ -101,8 +104,16 @@ function taskNotificationText(line: string): string { return '' } const record = parsed as Record - if (typeof record.content === 'string') { - return record.content.trim() + const origin = + record.origin && typeof record.origin === 'object' + ? (record.origin as Record) + : undefined + if ( + record.type !== 'user' || + record.promptSource !== 'system' || + origin?.kind !== 'task-notification' + ) { + return '' } if (!record.message || typeof record.message !== 'object') { return ''