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/3446.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<!-- ### Fixed -->
- **fix(agent-health): エージェント自身の Stop の後に送信なしで届いた AskUserQuestion を質問として記録しない** (#3446): #3441 でターンを開かなくした会話外の `pre_tool_use(AskUserQuestion)` が、画面にピッカーが無いのに質問のレコードを残していた。直近のターンが自身の Stop で閉じたまま(プロンプトを報告するツールのみ。#3441 と同じ判定)なら記録せず `ask-user-question-outside-turn` を記録する。質問の記録・会話外の `pre_tool_use` のログには、送り主を後から特定できるよう session・agent・transcript・tool_use の識別子をハッシュの先頭 8 桁で残す(本文・パスは残さない)。ターンの中の質問の記録と応答は変わらない。
4 changes: 2 additions & 2 deletions docs/module-reference.md

Large diffs are not rendered by default.

99 changes: 88 additions & 11 deletions src/lib/hooks/agent-event-intake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { AgentEventSource, NormalizedAgentEvent } from '@/lib/hooks/sources
import {
agentEventKeyClaimedAt,
isDuplicateAgentEvent,
isPreToolUseOutsideTurn,
joinOpenTurnFromDuplicate,
recordAgentEvent,
recordAskUserQuestion,
Expand Down Expand Up @@ -141,6 +142,47 @@ function readSessionId(
return { sessionId };
}

/** An agent-type name a log line may carry as itself: short, and no path or prose. */
const LOGGABLE_AGENT_TYPE = /^[A-Za-z0-9_.:-]{1,64}$/;

/**
* Who sent a hook, in a form a log line may carry (Issue #3446).
*
* The payload's own identifiers — the agent session, the subagent (`agent_id`
* and `agent_type`, which Claude adds when a subagent's hook fires), the
* transcript and the tool call — each as {@link shortSessionTag}'s hash prefix,
* so the session and the transcript path are never written. A hash still lets
* an operator tell whether two lines came from the same sender, and check a
* line against a transcript by hashing its ids the same way. `agent_type` is a
* name, kept as itself only when it looks like one.
*
* Only for the lines that need it — a question recorded or set aside, a
* `pre_tool_use` that opened no turn — never for every delivery.
*/
export function hookSenderTags(
payload: Record<string, unknown>,
sessionId: string | undefined
): Record<string, string | null> {
const tag = (value: string | undefined): string | null =>
value === undefined ? null : shortSessionTag(value);
const agentType = readString(payload, 'agent_type');
return {
session: tag(sessionId),
agent: tag(readString(payload, 'agent_id')),
agentType: agentType !== undefined && LOGGABLE_AGENT_TYPE.test(agentType) ? agentType : null,
transcript: tag(readString(payload, 'transcript_path')),
toolUse: tag(readString(payload, 'tool_use_id')),
};
}

/**
* Whether the source opens its turns on its prompts (Issue #3437): read off the
* declaration, never the tool id.
*/
function promptOpensTurns(source: AgentEventSource): boolean {
return source.capabilities.supportedEvents.includes('user_prompt_submit');
}

/** The instance, the worktree id and `cwd` the event names, or an error string. */
function readTarget(
payload: Record<string, unknown>,
Expand Down Expand Up @@ -330,7 +372,7 @@ export function applyAgentEventToState(
// 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'),
promptOpensTurns: promptOpensTurns(source),
},
{
// Issue #1903: the declared value, read off the source this route already
Expand All @@ -351,6 +393,24 @@ export function applyAgentEventToState(
event,
reason: recordOutcome.skipped,
});
return;
}

// Issue #3446: the delivery #3437 keeps from opening a turn is the one whose
// sender nobody could name afterwards, so it is logged with the payload's
// identifiers. Asked after the event was applied: the turn is still closed by
// the agent's own `stop` exactly when the transition declined to open one.
if (
event === 'pre_tool_use' &&
isPreToolUseOutsideTurn(worktree.id, tool, instanceParam, promptOpensTurns(source))
) {
logger.info('agent-event-pre-tool-use-outside-turn', {
worktreeId: worktree.id,
tool,
instanceId: instanceParam,
detail,
...hookSenderTags(payload, sessionId),
});
}
}

Expand Down Expand Up @@ -392,7 +452,7 @@ export function recordQuestionIfAsked(
payload: Record<string, unknown>,
logger: AgentEventLogger
): void {
const { worktree, tool, source, instanceParam, event, receivedAt } = ctx;
const { worktree, tool, source, instanceParam, event, sessionId, receivedAt } = ctx;
if (event === 'pre_tool_use') {
// Issue #1726: the one event whose *body* is the point. The injected hook
// carries `matcher: "AskUserQuestion"`, but `tool_name` is re-read here
Expand All @@ -408,15 +468,32 @@ export function recordQuestionIfAsked(
// (S7). opencode's `question.asked` carries structured choices in a shape
// that shares no field name with Claude's `tool_input.questions`.
const spec = source.parseQuestion(payload);
if (spec) {
recordAskUserQuestion(worktree.id, tool, instanceParam, spec, receivedAt);
logger.info('ask-user-question-recorded', {
worktreeId: worktree.id,
tool,
instanceId: instanceParam,
questionCount: spec.questions.length,
optionCounts: spec.questions.map((q) => q.choices.length),
});
if (!spec) return;

const fields = {
worktreeId: worktree.id,
tool,
instanceId: instanceParam,
questionCount: spec.questions.length,
optionCounts: spec.questions.map((q) => q.choices.length),
...hookSenderTags(payload, sessionId),
};

// Issue #3446: a question asked outside any turn — after the agent's own
// `stop`, with no prompt since, the event #3437 keeps from opening a turn —
// is not filed. Measured on 2026-10-07: such a `PreToolUse(AskUserQuestion)`
// was nowhere in the session's transcript and the pane showed no picker,
// so a record of it is a question nobody is being asked. Not filing it,
// rather than filing it and teaching every reader to ignore it, keeps the
// episode's one meaning — "the picker on screen asks this". The `stop`
// before it has already released any earlier question, so nothing is left
// waiting. A question inside a turn is untouched: there the turn is open.
if (isPreToolUseOutsideTurn(worktree.id, tool, instanceParam, promptOpensTurns(source))) {
logger.info('ask-user-question-outside-turn', fields);
return;
}

recordAskUserQuestion(worktree.id, tool, instanceParam, spec, receivedAt);
logger.info('ask-user-question-recorded', fields);
}
}
1 change: 1 addition & 0 deletions src/lib/session/agent-event-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export {
getAgentTurn,
getPendingDecisions,
getPublishedAgentTurn,
isPreToolUseOutsideTurn,
joinOpenTurnFromDuplicate,
observeScraperCompletionEvidence,
} from '@/lib/session/agent-event-turn';
Expand Down
20 changes: 20 additions & 0 deletions src/lib/session/agent-event-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,26 @@ function endedByOwnStop(key: string): boolean {
return turn !== null && turn.closedAt !== null && turn.closedBy === 'stop';
}

/**
* Whether a `pre_tool_use` from a source that reports its prompts falls outside
* any turn: the instance's last turn was ended by the agent's own `stop` and
* nothing has opened one since (Issue #3446).
*
* The same reading {@link applyTurnTransition} uses to leave such an event's
* turn closed (#3437), exposed so the intake can decide the question that event
* carries on the same facts. Asked after the event was applied, it still holds,
* because the transition did not open a turn; for a source that does not report
* its prompts it is always false, because that event opened one.
*/
export function isPreToolUseOutsideTurn(
worktreeId: string,
cliToolId: CLIToolType,
instanceId: string | undefined,
promptOpensTurns: boolean
): boolean {
return promptOpensTurns && endedByOwnStop(buildCompositeKey(worktreeId, cliToolId, instanceId));
}

/** 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
209 changes: 209 additions & 0 deletions tests/unit/lib/hooks/agent-event-intake-question-3446.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/**
* A question asked outside any turn is not filed, and the lines that decide it
* name the sender (Issue #3446).
*
* The measured sequence is #3437's (production, 2026-10-07): `UserPromptSubmit`
* 23:40:24, `Stop` 23:41:32.759, then `PreToolUse(AskUserQuestion)`
* 23:41:35.219 with no prompt between — absent from the session's transcript,
* no picker on the pane. #3441 stopped it opening a turn; it still filed a
* question. Every case below is a positive or a negative control on the same
* condition #3437 reads, and the question inside a turn (#1726) is unchanged.
*
* @vitest-environment node
*/

import { readFileSync } from 'fs';
import { join } from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearAgentStopEvents,
clearAskUserQuestion,
getAskUserQuestion,
shortSessionTag,
} from '@/lib/session/agent-event-state';
import {
applyAgentEventToState,
hookSenderTags,
recordQuestionIfAsked,
type AgentEventLogger,
type ResolvedAgentEvent,
} from '@/lib/hooks/agent-event-intake';
import { getAgentEventSource } from '@/lib/hooks/sources';
import type { AgentEventType } from '@/lib/hooks/agent-event-types';
import type { Worktree } from '@/types/models';

const WT = 'wt-3446';
const SESSION = '07a5ddd9-46df-42c1-be1c-a6e35209c4a3';
const AGENT_ID = 'a1b2c3d4e5f6';
const TRANSCRIPT = '/Users/someone/.claude/projects/x/07a5ddd9.jsonl';
const TOOL_USE_ID = 'toolu_01ABCDEF';
const PROMPT_AT = 1_791_416_424_052;
const STOP_AT = 1_791_416_492_759;
const PRE_AT = 1_791_416_495_219;

/** The captured `PreToolUse(AskUserQuestion)` payload (#1721), with this test's ids. */
function questionPayload(extra: Record<string, unknown> = {}): Record<string, unknown> {
const captured = JSON.parse(
readFileSync(
join(process.cwd(), 'tests/fixtures/hooks/claude/pre-tool-use-ask-user-question.json'),
'utf8',
),
) as Record<string, unknown>;
return {
...captured,
session_id: SESSION,
transcript_path: TRANSCRIPT,
tool_use_id: TOOL_USE_ID,
...extra,
};
}

function makeLogger(): AgentEventLogger & { info: ReturnType<typeof vi.fn> } {
return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never;
}

function ctxOf(event: AgentEventType, at: number, detail: string | null): ResolvedAgentEvent {
return {
worktree: { id: WT } as Worktree,
tool: 'claude',
source: getAgentEventSource('claude'),
instanceParam: 'claude',
event,
sessionId: SESSION,
receivedAt: at,
detail,
normalized: { model: null },
} as unknown as ResolvedAgentEvent;
}

/** What the route does with one delivery, minus the HTTP. */
function deliver(
event: AgentEventType,
at: number,
detail: string | null,
payload: Record<string, unknown> = {},
logger: AgentEventLogger = makeLogger(),
): void {
const ctx = ctxOf(event, at, detail);
applyAgentEventToState(ctx, payload, false, logger);
recordQuestionIfAsked(ctx, payload, logger);
}

function lines(logger: ReturnType<typeof makeLogger>, name: string): Record<string, unknown>[] {
return logger.info.mock.calls.filter(([n]) => n === name).map(([, data]) => data);
}

beforeEach(() => {
clearAgentStopEvents();
clearAskUserQuestion(WT, 'claude', 'claude');
});

describe('[#3446] AskUserQuestion after the agent\'s own stop', () => {
it('is not filed as a question (the measured sequence)', () => {
deliver('user_prompt_submit', PROMPT_AT, null);
deliver('stop', STOP_AT, null);

const logger = makeLogger();
deliver('pre_tool_use', PRE_AT, 'AskUserQuestion', questionPayload(), logger);

expect(getAskUserQuestion(WT, 'claude', 'claude', PRE_AT + 1)).toBeNull();
expect(lines(logger, 'ask-user-question-recorded')).toHaveLength(0);
expect(lines(logger, 'ask-user-question-outside-turn')).toEqual([
expect.objectContaining({
worktreeId: WT,
questionCount: 2,
session: shortSessionTag(SESSION),
transcript: shortSessionTag(TRANSCRIPT),
toolUse: shortSessionTag(TOOL_USE_ID),
}),
]);
expect(lines(logger, 'agent-event-pre-tool-use-outside-turn')).toEqual([
expect.objectContaining({ detail: 'AskUserQuestion', session: shortSessionTag(SESSION) }),
]);
});

it('is filed when a post_tool_use reopened the turn after the stop (negative control)', () => {
// A Stop hook that blocks keeps the agent working, and its tool call
// reports `post_tool_use`, which opens the turn (#3437).
deliver('user_prompt_submit', PROMPT_AT, null);
deliver('stop', STOP_AT, null);
deliver('post_tool_use', STOP_AT + 500, 'Bash');

const logger = makeLogger();
deliver('pre_tool_use', PRE_AT, 'AskUserQuestion', questionPayload(), logger);

expect(getAskUserQuestion(WT, 'claude', 'claude', PRE_AT + 1)?.spec.questions).toHaveLength(2);
expect(lines(logger, 'ask-user-question-outside-turn')).toHaveLength(0);
expect(lines(logger, 'agent-event-pre-tool-use-outside-turn')).toHaveLength(0);
});

it('is filed when a new prompt opened a turn after the stop (negative control)', () => {
deliver('user_prompt_submit', PROMPT_AT, null);
deliver('stop', STOP_AT, null);
deliver('user_prompt_submit', STOP_AT + 1000, null);

deliver('pre_tool_use', PRE_AT, 'AskUserQuestion', questionPayload());

expect(getAskUserQuestion(WT, 'claude', 'claude', PRE_AT + 1)).not.toBeNull();
});
});

describe('[#3446] AskUserQuestion inside a turn (#1726, unchanged)', () => {
it('is filed and logged with the sender tags', () => {
deliver('user_prompt_submit', PROMPT_AT, null);

const logger = makeLogger();
deliver(
'pre_tool_use',
PRE_AT,
'AskUserQuestion',
questionPayload({ agent_id: AGENT_ID, agent_type: 'general-purpose' }),
logger,
);

expect(getAskUserQuestion(WT, 'claude', 'claude', PRE_AT + 1)?.at).toBe(PRE_AT);
expect(lines(logger, 'ask-user-question-recorded')).toEqual([
expect.objectContaining({
questionCount: 2,
optionCounts: [3, 2],
session: shortSessionTag(SESSION),
agent: shortSessionTag(AGENT_ID),
agentType: 'general-purpose',
}),
]);
expect(lines(logger, 'agent-event-pre-tool-use-outside-turn')).toHaveLength(0);
});

it('is released by the stop that ends the turn, as before', () => {
deliver('user_prompt_submit', PROMPT_AT, null);
deliver('pre_tool_use', PRE_AT - 10_000, 'AskUserQuestion', questionPayload());
deliver('stop', STOP_AT + 10_000, null);

expect(getAskUserQuestion(WT, 'claude', 'claude', STOP_AT + 10_001)).toBeNull();
});
});

describe('[#3446] hookSenderTags', () => {
it('never carries the session id, the transcript path or the tool call id as themselves', () => {
const tags = hookSenderTags(
{ agent_id: AGENT_ID, transcript_path: TRANSCRIPT, tool_use_id: TOOL_USE_ID },
SESSION,
);
const written = JSON.stringify(tags);

for (const secret of [SESSION, TRANSCRIPT, TOOL_USE_ID, AGENT_ID]) {
expect(written).not.toContain(secret);
}
expect(Object.values(tags).every((v) => v === null || /^[0-9a-f]{8}$/.test(v))).toBe(true);
});

it('gives null for fields the payload does not carry, and drops an agent_type that is not a name', () => {
expect(hookSenderTags({ agent_type: 'not a name / with a path' }, undefined)).toEqual({
session: null,
agent: null,
agentType: null,
transcript: null,
toolUse: null,
});
});
});
Loading