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
2 changes: 2 additions & 0 deletions changelog.d/3437.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<!-- ### Fixed -->
- **fix(agent-health): Stop の直後に送信なしで届いた pre_tool_use で新しいターンを開かない** (#3437): 送信(`user_prompt_submit`)を報告するソース(Claude)では、エージェント自身の `Stop` でターンが閉じた後、送信の無いまま届いた `pre_tool_use` を新しいターンの始まりとして扱わない。2026-10-07 の本番で `Stop` の 2.5 秒後に届いた `PreToolUse(AskUserQuestion)` が Stop の来ないターンを開き、`wait` がその終了を待ち続けていた。通常のターン、`post_tool_use` で始まるターン、Command Code・antigravity の自己再開(#2614)は従来どおり
4 changes: 2 additions & 2 deletions docs/module-reference.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/lib/hooks/agent-event-intake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,10 @@ export function applyAgentEventToState(
// turn fires `UserPromptSubmit` as well. The source says which prompts
// those are; the state decides whether there is a turn to join.
joinsOpenTurn,
// Issue #3437: read off the declaration, never the tool id. A source that
// reports its prompts opens its turns on them, so a `pre_tool_use` after
// its own `Stop` with no prompt since does not begin one.
promptOpensTurns: source.capabilities.supportedEvents.includes('user_prompt_submit'),
},
{
// Issue #1903: the declared value, read off the source this route already
Expand Down
12 changes: 12 additions & 0 deletions src/lib/session/agent-event-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,16 @@ export interface AgentEventRecord {
* says nothing opens a new turn, as every prompt did before this Issue.
*/
joinsOpenTurn?: boolean;
/**
* Whether the delivering source reports the prompt that begins each of its
* turns — it declares `user_prompt_submit` in `supportedEvents` (Issue #3437).
*
* The caller computes it from the source's capabilities. Read only for
* `pre_tool_use`: from such a source, a `pre_tool_use` arriving after the
* agent's own `Stop` with no prompt in between is not the start of a turn,
* so it does not open one — see {@link applyTurnTransition}. Absent is
* `false`: a source whose turns begin with a tool event (Command Code) opens
* them on `pre_tool_use`, as every source did before this Issue.
*/
promptOpensTurns?: boolean;
}
36 changes: 36 additions & 0 deletions src/lib/session/agent-event-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ export function fenceTurnForNewGeneration(key: string, at: number): void {
* | `user_prompt_submit` | **opens a new one** | release | yes |
* | `user_prompt_submit` (joins, #3330)| continues, else opens | release | yes |
* | `pre_tool_use` | continues, else opens | unchanged| yes |
* | `pre_tool_use` (after its stop) | unchanged (#3437) | unchanged| no |
* | `post_tool_use` | continues, else opens | release | yes |
* | `stop` (this session) | **closes** `stop` | release | yes |
* | `stop` (another session) | unchanged | unchanged| no |
Expand Down Expand Up @@ -440,6 +441,22 @@ export function fenceTurnForNewGeneration(key: string, at: number): void {
* still dropped by the window in front of it ({@link isDuplicateAgentEvent}),
* and dropped or applied, none of them moves the turn.
*
* The `after its stop` row is the other exception (Issue #3437), and it is
* narrower than it reads: it applies only to a source that reports its
* prompts ({@link AgentEventRecord.promptOpensTurns}), and only when the
* instance's last turn was closed by the agent's own `stop`. In production on
* 2026-10-07 a Claude `PreToolUse(AskUserQuestion)` landed 2.5 s after the
* `Stop`, with no `UserPromptSubmit`, no picker on the pane (the scraper read
* `ready` throughout), and nothing in the session's transcript after the
* `Stop` — something other than the conversation the `Stop` had just ended.
* It opened a turn whose `stop` never came, and a `wait` adopted that turn.
* Every turn of such a source begins with its prompt, and a tool call that
* continues past a `Stop` (a Stop hook that blocks) still reports its
* `post_tool_use`, which opens the turn as before. Sources whose turns begin
* with a tool event are untouched: Command Code's `pre_tool_use` and
* antigravity's self-resume `post_tool_use` (#2614) open turns because
* neither sends `user_prompt_submit`.
*
* `idle_prompt` is the one row that publishes `ready` **without** closing the
* turn, and that is a measurement rather than an oversight: #1839 caught Claude
* emitting it 62 s into a turn that ran nothing, so it cannot be a turn
Expand Down Expand Up @@ -472,6 +489,11 @@ export function applyTurnTransition(key: string, record: AgentEventRecord): void
// Deliberately leaves the dialog alone (Issue #1726). It is the
// `AskUserQuestion` invocation, and a picker being *about to be drawn* is
// not a fact this state can carry.
//
// Issue #3437: and from a source that reports its prompts, it is not the
// start of a turn when the agent's own `Stop` is the last thing that
// ended one. See the `after its stop` row above.
if (record.promptOpensTurns === true && endedByOwnStop(key)) return;
openTurn(key, record, generation, { continueOpen: true });
return;
case 'stop':
Expand All @@ -496,6 +518,20 @@ export function applyTurnTransition(key: string, record: AgentEventRecord): void
}
}

/**
* Whether this instance's turn was last ended by the agent's own `stop`, with
* nothing opening a turn since (Issue #3437).
*
* Only `stop`: every other close is this server's own inference (`stale`,
* `scraper_evidence`, `resync_idle`) or a session that went away
* (`session_end`, `generation`), and an agent that keeps working after one of
* those must be allowed to open the turn it is in.
*/
function endedByOwnStop(key: string): boolean {
const turn = fencedTurn(key);
return turn !== null && turn.closedAt !== null && turn.closedBy === 'stop';
}

/** The three fields the turn keeps from a record. */
function displayOf(record: AgentEventRecord): TurnRecord['displayEvent'] {
return { event: record.event, at: record.at, detail: record.detail };
Expand Down
242 changes: 242 additions & 0 deletions tests/unit/lib/session/agent-event-turn-after-stop-3437.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/**
* A `pre_tool_use` after the agent's own `Stop`, with no prompt in between,
* does not open a turn — for a source that reports its prompts (Issue #3437).
*
* The measured sequence (production `server.log.1`, worktree
* `commandmate-issue-3420`, 2026-10-07): `UserPromptSubmit` 23:40:24, `Stop`
* 23:41:32.759, then `PreToolUse(AskUserQuestion)` 23:41:35.219 with no
* `UserPromptSubmit`, and no `Stop` after it. The turn it opened was closed by
* `scraper_evidence`, and a `wait` adopted it and waited on its `stop`.
*
* Every case is a positive or a negative control, and the mutation is the
* declaration (`promptOpensTurns`), never the tool id: a source whose turns
* begin with a tool event (Command Code's `pre_tool_use`, antigravity's
* self-resume `post_tool_use`, #2614) keeps opening them.
*
* @vitest-environment node
*/

import { beforeEach, describe, expect, it } from 'vitest';
import {
clearAgentStopEvents,
closeAgentTurn,
getAgentTurn,
getStructuredSessionState,
recordAgentEvent,
type AgentEventRecord,
} from '@/lib/session/agent-event-state';
import { applyAgentEventToState, type ResolvedAgentEvent } from '@/lib/hooks/agent-event-intake';
import { getAgentEventSource } from '@/lib/hooks/sources';
import { SELF_RESUME_PENDING_DETAIL } from '@/lib/hooks/agent-event-types';
import type { CLIToolType } from '@/lib/cli-tools/types';
import type { Worktree } from '@/types/models';

const WT = 'wt-3437';
const SESSION = '07a5ddd9-46df-42c1-be1c-a6e35209c4a3';
/** The prompt of the measured turn, 2026-10-07T23:40:24.052Z. */
const PROMPT_AT = 1_791_416_424_052;
/** Its `Stop`, 23:41:32.759Z. */
const STOP_AT = 1_791_416_492_759;
/** The `PreToolUse(AskUserQuestion)` that opened the turn, 23:41:35.219Z. */
const PRE_AT = 1_791_416_495_219;

beforeEach(() => {
clearAgentStopEvents();
});

function post(
tool: CLIToolType,
record: Partial<AgentEventRecord> & Pick<AgentEventRecord, 'event' | 'at'>,
): void {
recordAgentEvent(WT, tool, tool, { detail: null, sessionId: SESSION, ...record });
}

const turn = (tool: CLIToolType, now: number) => getAgentTurn(WT, tool, tool, now);
const status = (tool: CLIToolType, now: number) => getStructuredSessionState(WT, tool, tool, now);

describe('[#3437] a pre_tool_use after the agent\'s own stop', () => {
it('does not open a turn when the source reports its prompts (the measured sequence)', () => {
post('claude', { event: 'user_prompt_submit', at: PROMPT_AT, promptOpensTurns: true });
post('claude', { event: 'stop', at: STOP_AT, promptOpensTurns: true });
const stopped = turn('claude', STOP_AT + 1);
expect(stopped).toMatchObject({ openedAt: PROMPT_AT, closedAt: STOP_AT, closedBy: 'stop' });

post('claude', {
event: 'pre_tool_use',
at: PRE_AT,
detail: 'AskUserQuestion',
promptOpensTurns: true,
});

const after = turn('claude', PRE_AT + 1);
expect(after?.turnId).toBe(stopped?.turnId);
expect(after).toMatchObject({ openedAt: PROMPT_AT, closedAt: STOP_AT, closedBy: 'stop' });
expect(after?.displayEvent.event).toBe('stop');
expect(status('claude', PRE_AT + 1)?.status).toBe('ready');
});

it('opens a turn when the source does not say so (negative control: the pre-#3437 reading)', () => {
post('claude', { event: 'user_prompt_submit', at: PROMPT_AT });
post('claude', { event: 'stop', at: STOP_AT });
const stopped = turn('claude', STOP_AT + 1);

post('claude', { event: 'pre_tool_use', at: PRE_AT, detail: 'AskUserQuestion' });

const after = turn('claude', PRE_AT + 1);
expect(after?.turnId).not.toBe(stopped?.turnId);
expect(after).toMatchObject({ openedAt: PRE_AT, closedAt: null });
expect(status('claude', PRE_AT + 1)?.status).toBe('running');
});

it('still opens a turn for Command Code, whose turns begin with pre_tool_use', () => {
post('command-code', { event: 'pre_tool_use', at: PROMPT_AT, detail: 'shell_command' });
post('command-code', { event: 'stop', at: STOP_AT });

post('command-code', { event: 'pre_tool_use', at: PRE_AT, detail: 'read_file' });

expect(turn('command-code', PRE_AT + 1)).toMatchObject({ openedAt: PRE_AT, closedAt: null });
});

it('still opens a turn after a close that was not the agent\'s own stop', () => {
post('claude', { event: 'user_prompt_submit', at: PROMPT_AT, promptOpensTurns: true });
closeAgentTurn(WT, 'claude', 'claude', 'scraper_evidence', STOP_AT);
expect(turn('claude', STOP_AT + 1)).toMatchObject({ closedBy: 'scraper_evidence' });

post('claude', {
event: 'pre_tool_use',
at: PRE_AT,
detail: 'AskUserQuestion',
promptOpensTurns: true,
});

expect(turn('claude', PRE_AT + 1)).toMatchObject({ openedAt: PRE_AT, closedAt: null });
});

it('still opens a turn when nothing has ended one (server started mid-turn)', () => {
post('claude', {
event: 'pre_tool_use',
at: PRE_AT,
detail: 'AskUserQuestion',
promptOpensTurns: true,
});

expect(turn('claude', PRE_AT + 1)).toMatchObject({ openedAt: PRE_AT, closedAt: null });
});
});

describe('[#3437] turns that must not change', () => {
it('the ordinary turn: prompt, tool events, stop', () => {
post('claude', { event: 'user_prompt_submit', at: PROMPT_AT, promptOpensTurns: true });
const opened = turn('claude', PROMPT_AT + 1);
post('claude', {
event: 'pre_tool_use',
at: PROMPT_AT + 1_000,
detail: 'AskUserQuestion',
promptOpensTurns: true,
});
post('claude', {
event: 'post_tool_use',
at: PROMPT_AT + 2_000,
detail: 'AskUserQuestion',
promptOpensTurns: true,
});
expect(turn('claude', PROMPT_AT + 2_001)?.turnId).toBe(opened?.turnId);

post('claude', { event: 'stop', at: STOP_AT, promptOpensTurns: true });
expect(turn('claude', STOP_AT + 1)).toMatchObject({
turnId: opened?.turnId,
openedAt: PROMPT_AT,
closedAt: STOP_AT,
closedBy: 'stop',
});
});

it('the next prompt after a stop opens a new turn, and its pre_tool_use continues it', () => {
post('claude', { event: 'user_prompt_submit', at: PROMPT_AT, promptOpensTurns: true });
post('claude', { event: 'stop', at: STOP_AT, promptOpensTurns: true });
const first = turn('claude', STOP_AT + 1);

post('claude', { event: 'user_prompt_submit', at: PRE_AT, promptOpensTurns: true });
const second = turn('claude', PRE_AT + 1);
expect(second?.turnId).not.toBe(first?.turnId);
expect(second).toMatchObject({ openedAt: PRE_AT, closedAt: null });

post('claude', {
event: 'pre_tool_use',
at: PRE_AT + 1_000,
detail: 'AskUserQuestion',
promptOpensTurns: true,
});
expect(turn('claude', PRE_AT + 1_001)).toMatchObject({
turnId: second?.turnId,
openedAt: PRE_AT,
closedAt: null,
});
});

it('a post_tool_use after a stop still opens a turn (a Stop hook that blocks continues the agent)', () => {
post('claude', { event: 'user_prompt_submit', at: PROMPT_AT, promptOpensTurns: true });
post('claude', { event: 'stop', at: STOP_AT, promptOpensTurns: true });

post('claude', {
event: 'post_tool_use',
at: PRE_AT,
detail: 'AskUserQuestion',
promptOpensTurns: true,
});

expect(turn('claude', PRE_AT + 1)).toMatchObject({ openedAt: PRE_AT, closedAt: null });
});

it('the self-resume wake (#2614): antigravity\'s post_tool_use after its stop opens a turn', () => {
post('antigravity', { event: 'post_tool_use', at: PROMPT_AT, detail: 'run_command' });
post('antigravity', { event: 'stop', at: STOP_AT, detail: SELF_RESUME_PENDING_DETAIL });
const stopped = turn('antigravity', STOP_AT + 1);

post('antigravity', { event: 'post_tool_use', at: PRE_AT, detail: 'schedule' });

const woke = turn('antigravity', PRE_AT + 1);
expect(woke?.turnId).not.toBe(stopped?.turnId);
expect(woke).toMatchObject({ openedAt: PRE_AT, closedAt: null });
});
});

describe('[#3437] the intake reads the declaration off the source', () => {
function deliver(tool: CLIToolType, event: AgentEventRecord['event'], at: number, detail: string | null): void {
const ctx = {
worktree: { id: WT } as Worktree,
tool,
source: getAgentEventSource(tool),
instanceParam: tool,
event,
sessionId: SESSION,
receivedAt: at,
detail,
normalized: { model: null },
} as unknown as ResolvedAgentEvent;
applyAgentEventToState(ctx, {}, false, { info: () => {}, warn: () => {} } as never);
}

it('claude: the measured sequence does not open a turn', () => {
deliver('claude', 'user_prompt_submit', PROMPT_AT, null);
deliver('claude', 'stop', STOP_AT, null);
deliver('claude', 'pre_tool_use', PRE_AT, 'AskUserQuestion');

expect(turn('claude', PRE_AT + 1)).toMatchObject({
openedAt: PROMPT_AT,
closedAt: STOP_AT,
closedBy: 'stop',
});
});

it('command-code: declares no user_prompt_submit, so its pre_tool_use opens a turn', () => {
expect(getAgentEventSource('command-code').capabilities.supportedEvents).not.toContain(
'user_prompt_submit',
);
deliver('command-code', 'pre_tool_use', PROMPT_AT, 'shell_command');
deliver('command-code', 'stop', STOP_AT, null);
deliver('command-code', 'pre_tool_use', PRE_AT, 'read_file');

expect(turn('command-code', PRE_AT + 1)).toMatchObject({ openedAt: PRE_AT, closedAt: null });
});
});
Loading