mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 16:02:42 +00:00
fix(agent-status): require owned Claude completion evidence
This commit is contained in:
@@ -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<string, unknown>
|
||||
): Promise<void> => {
|
||||
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:
|
||||
'<task-notification>\n<task-id>agent-1</task-id>\n<status>completed</status>\n</task-notification>'
|
||||
}
|
||||
})}\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' })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
'<task-notification>\n<task-id>crashed</task-id>\n<status>failed</status>\n</task-notification>'
|
||||
message: {
|
||||
role: 'user',
|
||||
content:
|
||||
'<task-notification>\n<task-id>crashed</task-id>\n<status>failed</status>\n</task-notification>'
|
||||
}
|
||||
},
|
||||
{
|
||||
// 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: {
|
||||
|
||||
@@ -133,9 +133,13 @@ describe('RelayAgentHookServer', () => {
|
||||
appendFileSync(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({
|
||||
type: 'queue-operation',
|
||||
content:
|
||||
'<task-notification>\n<task-id>shell-1</task-id>\n<status>completed</status>\n</task-notification>'
|
||||
type: 'user',
|
||||
promptSource: 'system',
|
||||
origin: { kind: 'task-notification' },
|
||||
message: {
|
||||
content:
|
||||
'<task-notification>\n<task-id>shell-1</task-id>\n<status>completed</status>\n</task-notification>'
|
||||
}
|
||||
})}\n`
|
||||
)
|
||||
await post({ hook_event_name: 'PreToolUse', tool_name: 'Bash' })
|
||||
|
||||
@@ -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<string, unknown>
|
||||
): 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<string>()
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
`<task-notification>\n<task-id>${taskId}</task-id>\n` +
|
||||
`<status>${status}</status>\n</task-notification>`
|
||||
type: 'user',
|
||||
promptSource: 'system',
|
||||
origin: { kind: 'task-notification' },
|
||||
message: {
|
||||
content:
|
||||
`<task-notification>\n<task-id>${taskId}</task-id>\n` +
|
||||
`<status>${status}</status>\n</task-notification>`
|
||||
}
|
||||
})}\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, {
|
||||
|
||||
@@ -10,15 +10,20 @@ import {
|
||||
|
||||
function notificationRecord(taskId: string, status: string): string {
|
||||
return JSON.stringify({
|
||||
type: 'queue-operation',
|
||||
content:
|
||||
`<task-notification>\n<task-id>${taskId}</task-id>\n` +
|
||||
`<status>${status}</status>\n</task-notification>`
|
||||
type: 'user',
|
||||
promptSource: 'system',
|
||||
origin: { kind: 'task-notification' },
|
||||
message: {
|
||||
role: 'user',
|
||||
content:
|
||||
`<task-notification>\n<task-id>${taskId}</task-id>\n` +
|
||||
`<status>${status}</status>\n</task-notification>`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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-notification><task-id>task-2</task-id><status>failed</status></task-notification>'
|
||||
})
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
parseClaudeTaskNotificationLine(
|
||||
notificationRecord('task-4', 'completed').replace('</task-notification>', '')
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
parseClaudeTaskNotificationLine(
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
promptSource: 'user',
|
||||
message: {
|
||||
content:
|
||||
'Explain <task-notification><task-id>task-3</task-id><status>completed</status></task-notification>'
|
||||
'<task-notification><task-id>task-3</task-id><status>completed</status></task-notification>'
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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('</task-notification>')
|
||||
) {
|
||||
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<string, unknown>
|
||||
if (typeof record.content === 'string') {
|
||||
return record.content.trim()
|
||||
const origin =
|
||||
record.origin && typeof record.origin === 'object'
|
||||
? (record.origin as Record<string, unknown>)
|
||||
: undefined
|
||||
if (
|
||||
record.type !== 'user' ||
|
||||
record.promptSource !== 'system' ||
|
||||
origin?.kind !== 'task-notification'
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
if (!record.message || typeof record.message !== 'object') {
|
||||
return ''
|
||||
|
||||
Reference in New Issue
Block a user