Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ describe('Claude provider task lifecycle', () => {
})).toEqual({ type: 'started', sessionId: 's1', taskId: 't1' });
expect(readClaudeProviderTaskActivity({
type: 'system', subtype: 'task_progress', session_id: 's1', task_id: 't1',
})).toEqual({ type: 'progress', sessionId: 's1', taskId: 't1' });
})).toEqual({ type: 'progress', sessionId: 's1', taskId: 't1', source: 'system-task-progress' });
for (const status of ['completed', 'failed', 'stopped']) {
expect(readClaudeProviderTaskActivity({
type: 'system', subtype: 'task_notification', session_id: 's1', task_id: 't1', status,
Expand Down Expand Up @@ -356,7 +356,10 @@ describe('readClaudeSessionHookProviderTaskActivity', () => {
expect(taskOutput({
retrieval_status: 'success',
task: { task_id: 'task-1', status },
})).toEqual({ type: 'terminal', sessionId, taskId: 'task-1' });
})).toEqual({
type: 'terminal', sessionId, taskId: 'task-1',
terminalStatus: status === 'killed' ? 'stopped' : status, rememberIfUnknown: false,
});
}

expect(readClaudeSessionHookProviderTaskActivity({
Expand All @@ -372,7 +375,7 @@ describe('readClaudeSessionHookProviderTaskActivity', () => {
tool_name: 'TaskStop',
tool_input: { task_id: 'task-1' },
tool_response: { task: { task_id: 'task-1', status: 'stopped' } },
})).toEqual({ type: 'terminal', sessionId, taskId: 'task-1' });
})).toEqual({ type: 'terminal', terminalStatus: 'stopped', sessionId, taskId: 'task-1', rememberIfUnknown: false });
});

it('treats a vetoable SubagentStop as progress and re-admits a known resumed invocation', () => {
Expand Down Expand Up @@ -486,6 +489,14 @@ describe('readClaudeSessionHookProviderTaskActivity', () => {
});
});

/** Applies typed Claude SDK system rows from session `s1` to the ledger. */
function sdkRowApplier(ledger: ReturnType<typeof createClaudeProviderActivityLedger>) {
return (row: Record<string, unknown>) => {
const activity = readClaudeProviderTaskActivity({ type: 'system', session_id: 's1', ...row });
if (activity) ledger.apply(activity);
};
}

describe('createClaudeProviderActivityLedger', () => {
it('abandons only active tasks and keeps early terminal evidence', () => {
const ledger = createClaudeProviderActivityLedger();
Expand Down Expand Up @@ -578,6 +589,213 @@ describe('createClaudeProviderActivityLedger', () => {
}
});

it('keeps an agent that stopped with live background children resumable by its own progress', () => {
// Row shapes observed from Claude Code 2.1.302 stream-json: an Agent that ends its turn
// while its own backgrounded Bash runs reports `completed`, then wakes when the child settles.
const ledger = createClaudeProviderActivityLedger();
const apply = sdkRowApplier(ledger);
apply({ subtype: 'task_started', task_id: 'agent-1', task_type: 'local_agent', is_backgrounded: true });
apply({
subtype: 'task_started',
task_id: 'bash-1',
task_type: 'local_bash',
is_backgrounded: true,
owned_by_subagent: true,
parent_task_id: 'agent-1',
});
expect(ledger.getActiveProviderTaskCount()).toBe(2);

apply({ subtype: 'task_updated', task_id: 'agent-1', patch: { status: 'completed' } });
apply({ subtype: 'task_notification', task_id: 'agent-1', status: 'completed' });
expect(ledger.getActiveProviderTaskCount()).toBe(1);

const subagentStop = readClaudeSessionHookProviderTaskActivity({
hook_event_name: 'SubagentStop',
session_id: 's1',
agent_id: 'agent-1',
});
expect(subagentStop).not.toBeNull();
if (subagentStop) ledger.apply(subagentStop);
expect(ledger.getActiveProviderTaskCount()).toBe(1);

apply({ subtype: 'task_notification', task_id: 'bash-1', status: 'completed' });
// An Agent that never wakes holds nothing open once its children settle.
expect(ledger.getActiveProviderTaskCount()).toBe(0);
if (subagentStop) ledger.apply(subagentStop);
expect(ledger.getActiveProviderTaskCount()).toBe(0);

apply({ subtype: 'task_progress', task_id: 'agent-1' });
expect(ledger.hasActiveProviderTask({ sessionId: 's1', taskId: 'agent-1' })).toBe(true);
Comment thread
danljungstrom marked this conversation as resolved.

apply({ subtype: 'task_notification', task_id: 'agent-1', status: 'completed' });
expect(ledger.getActiveProviderTaskCount()).toBe(0);
apply({ subtype: 'task_progress', task_id: 'agent-1' });
expect(ledger.getActiveProviderTaskCount()).toBe(0);
});

it.each([
['failed', { subtype: 'task_notification', task_id: 'agent-1', status: 'failed' }],
['killed', { subtype: 'task_updated', task_id: 'agent-1', patch: { status: 'killed' } }],
])('does not let a %s Agent with live children wake on later progress', (_status, terminalRow) => {
const ledger = createClaudeProviderActivityLedger();
const apply = sdkRowApplier(ledger);
apply({ subtype: 'task_started', task_id: 'agent-1', task_type: 'local_agent', is_backgrounded: true });
apply({
subtype: 'task_started',
task_id: 'bash-1',
task_type: 'local_bash',
is_backgrounded: true,
owned_by_subagent: true,
parent_task_id: 'agent-1',
});
apply(terminalRow);
apply({ subtype: 'task_progress', task_id: 'agent-1' });
expect(ledger.hasActiveProviderTask({ sessionId: 's1', taskId: 'agent-1' })).toBe(false);
});

it.each([
['failed', { type: 'system', subtype: 'task_notification', task_id: 'agent-1', status: 'failed' }],
['stopped', { type: 'system', subtype: 'task_updated', task_id: 'agent-1', patch: { status: 'killed' } }],
['TaskStop', {
hook_event_name: 'PostToolUse', tool_name: 'TaskStop',
tool_input: { task_id: 'agent-1' }, tool_response: { task_id: 'agent-1', status: 'stopped' },
}],
['completed TaskStop', {
hook_event_name: 'PostToolUse', tool_name: 'TaskStop',
tool_input: { task_id: 'agent-1' }, tool_response: { task_id: 'agent-1', status: 'completed' },
}],
['failed TaskOutput', {
hook_event_name: 'PostToolUse', tool_name: 'TaskOutput',
tool_input: { task_id: 'agent-1' },
tool_response: { retrieval_status: 'success', task: { task_id: 'agent-1', status: 'failed' } },
}],
['StopFailure', { hook_event_name: 'StopFailure', agent_id: 'agent-1' }],
])('retires an awaiting Agent after %s evidence', (_label, terminalRow) => {
const ledger = createClaudeProviderActivityLedger();
const apply = sdkRowApplier(ledger);
apply({ subtype: 'task_started', task_id: 'agent-1', task_type: 'local_agent', is_backgrounded: true });
apply({
subtype: 'task_started', task_id: 'bash-1', task_type: 'local_bash',
is_backgrounded: true, parent_task_id: 'agent-1',
});
apply({ subtype: 'task_notification', task_id: 'agent-1', status: 'completed' });
const row = { session_id: 's1', ...terminalRow };
const terminal = readClaudeProviderTaskActivity(row) ?? readClaudeSessionHookProviderTaskActivity(row);
if (!terminal) throw new Error('expected terminal evidence');
ledger.apply(terminal);
apply({ subtype: 'task_notification', task_id: 'bash-1', status: 'completed' });
apply({ subtype: 'task_progress', task_id: 'agent-1' });
expect(ledger.getActiveProviderTaskCount()).toBe(0);
});

it.each(['typed', 'TaskOutput'])('keeps an Agent resumable after %s completion and completed TaskOutput aliases', (firstCompletion) => {
const ledger = createClaudeProviderActivityLedger();
const apply = sdkRowApplier(ledger);
apply({ subtype: 'task_started', task_id: 'agent-1', task_type: 'local_agent', is_backgrounded: true });
apply({
subtype: 'task_started', task_id: 'bash-1', task_type: 'local_bash',
is_backgrounded: true, parent_task_id: 'agent-1',
});
const output = readClaudeSessionHookProviderTaskActivity({
hook_event_name: 'PostToolUse', session_id: 's1', tool_name: 'TaskOutput',
tool_input: { task_id: 'agent-1' },
tool_response: { retrieval_status: 'success', task: { task_id: 'agent-1', status: 'completed' } },
});
if (!output) throw new Error('expected completed TaskOutput evidence');
if (firstCompletion === 'TaskOutput') ledger.apply(output);
else apply({ subtype: 'task_notification', task_id: 'agent-1', status: 'completed' });
ledger.apply(output);
apply({ subtype: 'task_notification', task_id: 'agent-1', status: 'completed' });
apply({ subtype: 'task_notification', task_id: 'bash-1', status: 'completed' });
apply({ subtype: 'task_progress', task_id: 'agent-1' });
expect(ledger.hasActiveProviderTask({ sessionId: 's1', taskId: 'agent-1' })).toBe(true);
});

it.each(['TaskOutput', 'TaskStop'])('keeps an unknown %s terminal from suppressing a later launch', (toolName) => {
const ledger = createClaudeProviderActivityLedger();
const terminal = readClaudeSessionHookProviderTaskActivity({
hook_event_name: 'PostToolUse', session_id: 's1', tool_name: toolName,
tool_input: { task_id: 'agent-1' },
tool_response: { retrieval_status: 'success', task: { task_id: 'agent-1', status: 'completed' } },
});
if (!terminal) throw new Error('expected exact terminal hook evidence');
ledger.apply(terminal);
const apply = sdkRowApplier(ledger);
apply({ subtype: 'task_started', task_id: 'agent-1', task_type: 'local_agent', is_backgrounded: true });
expect(ledger.hasActiveProviderTask({ sessionId: 's1', taskId: 'agent-1' })).toBe(true);
});

it('keeps an agent resumable while a nested agent it owns still waits on or resumes its own work', () => {
const createNested = () => {
const ledger = createClaudeProviderActivityLedger();
const apply = sdkRowApplier(ledger);
apply({ subtype: 'task_started', task_id: 'agent-a', task_type: 'local_agent', is_backgrounded: true });
apply({
subtype: 'task_started',
task_id: 'agent-b',
task_type: 'local_agent',
is_backgrounded: true,
owned_by_subagent: true,
parent_task_id: 'agent-a',
});
apply({
subtype: 'task_started',
task_id: 'bash-b',
task_type: 'local_bash',
is_backgrounded: true,
owned_by_subagent: true,
parent_task_id: 'agent-b',
});
return { ledger, apply };
};
const stop = (apply: (row: Record<string, unknown>) => void, taskId: string) => {
apply({ subtype: 'task_updated', task_id: taskId, patch: { status: 'completed' } });
apply({ subtype: 'task_notification', task_id: taskId, status: 'completed' });
};
const isActive = (ledger: ReturnType<typeof createClaudeProviderActivityLedger>, taskId: string) =>
ledger.hasActiveProviderTask({ sessionId: 's1', taskId });

// A stops while B is itself stopped, waiting on its own shell.
const waiting = createNested();
stop(waiting.apply, 'agent-b');
stop(waiting.apply, 'agent-a');
waiting.apply({ subtype: 'task_notification', task_id: 'bash-b', status: 'completed' });
waiting.apply({ subtype: 'task_progress', task_id: 'agent-b' });
stop(waiting.apply, 'agent-b');
waiting.apply({ subtype: 'task_progress', task_id: 'agent-a' });
expect(isActive(waiting.ledger, 'agent-a')).toBe(true);

// A stops after B has woken from its own wait and is working again.
const resumed = createNested();
stop(resumed.apply, 'agent-b');
resumed.apply({ subtype: 'task_notification', task_id: 'bash-b', status: 'completed' });
resumed.apply({ subtype: 'task_progress', task_id: 'agent-b' });
stop(resumed.apply, 'agent-a');
stop(resumed.apply, 'agent-b');
resumed.apply({ subtype: 'task_progress', task_id: 'agent-a' });
expect(isActive(resumed.ledger, 'agent-a')).toBe(true);
});

it('keeps the parent link when the typed child launch follows an earlier hook launch', () => {
const ledger = createClaudeProviderActivityLedger();
const apply = sdkRowApplier(ledger);
apply({ subtype: 'task_started', task_id: 'agent-a', task_type: 'local_agent', is_backgrounded: true });
ledger.apply({ type: 'started', sessionId: 's1', taskId: 'agent-b', source: 'hook-agent-launch' });
apply({
subtype: 'task_started',
task_id: 'agent-b',
task_type: 'local_agent',
is_backgrounded: true,
owned_by_subagent: true,
parent_task_id: 'agent-a',
});

apply({ subtype: 'task_notification', task_id: 'agent-a', status: 'completed' });
apply({ subtype: 'task_notification', task_id: 'agent-b', status: 'completed' });
apply({ subtype: 'task_progress', task_id: 'agent-a' });
expect(ledger.hasActiveProviderTask({ sessionId: 's1', taskId: 'agent-a' })).toBe(true);
});

it('does not let progress admit an unknown task', () => {
const ledger = createClaudeProviderActivityLedger();
expect(ledger.apply({ type: 'progress', sessionId: 's1', taskId: 'unknown' })).toBe(false);
Expand Down
Loading
Loading