From f5f358ab9df32223a8e68524eb7ec9752b82bb19 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 19 May 2026 11:59:48 +0900 Subject: [PATCH] fix(prompt-gate): ignore internal user tails in tool waits --- src/hooks/shared/prompt-async-gate.test.ts | 135 +++++++++++++++++++++ src/shared/prompt-async-gate.ts | 50 ++++++++ 2 files changed, 185 insertions(+) diff --git a/src/hooks/shared/prompt-async-gate.test.ts b/src/hooks/shared/prompt-async-gate.test.ts index 05aefbbaa..ce65a2d41 100644 --- a/src/hooks/shared/prompt-async-gate.test.ts +++ b/src/hooks/shared/prompt-async-gate.test.ts @@ -356,6 +356,141 @@ describe("dispatchInternalPrompt shared gate behavior", () => { expect(promptCalls).toBe(0) }) + test("#given internal user tail follows an assistant waiting on tools #when an internal promptAsync is requested #then no prompt is sent", async () => { + // given + let promptCalls = 0 + const client = { + session: { + status: async () => ({ data: { ses_internal_tail_tools: { type: "idle" } } }), + messages: async () => ({ + data: [ + { + info: { id: "msg_user", role: "user" }, + parts: [{ type: "text", text: "run work" }], + }, + { + info: { id: "msg_assistant", role: "assistant", finish: "tool-calls" }, + parts: [{ type: "tool_use", id: "toolu_pending", state: { status: "running" } }], + }, + { + info: { id: "msg_internal_user", role: "user" }, + parts: [{ type: "text", text: "wake\n" }], + }, + ], + }), + promptAsync: async () => { + promptCalls += 1 + }, + }, + } + + // when + const result = await dispatchInternalPrompt({ + mode: "async", + client, + sessionID: "ses_internal_tail_tools", + input: { path: { id: "ses_internal_tail_tools" }, body: { parts: [] } }, + source: "test:internal-tail-tools", + settleMs: 0, + postDispatchHoldMs: 0, + }) + + // then + expect(result.status).toBe("active") + expect(promptCalls).toBe(0) + }) + + test("#given synthetic user tail follows an assistant waiting on tools #when an internal promptAsync is requested #then no prompt is sent", async () => { + // given + let promptCalls = 0 + const client = { + session: { + status: async () => ({ data: { ses_synthetic_tail_tools: { type: "idle" } } }), + messages: async () => ({ + data: [ + { + info: { id: "msg_user", role: "user" }, + parts: [{ type: "text", text: "run work" }], + }, + { + info: { id: "msg_assistant", role: "assistant", finish: "tool-calls" }, + parts: [{ type: "tool_use", id: "toolu_pending", state: { status: "running" } }], + }, + { + info: { id: "msg_synthetic_user", role: "user" }, + parts: [{ type: "text", text: "continue", synthetic: true }], + }, + ], + }), + promptAsync: async () => { + promptCalls += 1 + }, + }, + } + + // when + const result = await dispatchInternalPrompt({ + mode: "async", + client, + sessionID: "ses_synthetic_tail_tools", + input: { path: { id: "ses_synthetic_tail_tools" }, body: { parts: [] } }, + source: "test:synthetic-tail-tools", + settleMs: 0, + postDispatchHoldMs: 0, + }) + + // then + expect(result.status).toBe("active") + expect(promptCalls).toBe(0) + }) + + test("#given mixed real user tail follows an assistant waiting on tools #when an internal promptAsync is requested #then promptAsync is sent", async () => { + // given + let promptCalls = 0 + const client = { + session: { + status: async () => ({ data: { ses_mixed_tail_tools: { type: "idle" } } }), + messages: async () => ({ + data: [ + { + info: { id: "msg_user", role: "user" }, + parts: [{ type: "text", text: "run work" }], + }, + { + info: { id: "msg_assistant", role: "assistant", finish: "tool-calls" }, + parts: [{ type: "tool_use", id: "toolu_pending", state: { status: "running" } }], + }, + { + info: { id: "msg_mixed_user", role: "user" }, + parts: [ + { type: "text", text: "wake\n" }, + { type: "text", text: "real user follow-up" }, + ], + }, + ], + }), + promptAsync: async () => { + promptCalls += 1 + }, + }, + } + + // when + const result = await dispatchInternalPrompt({ + mode: "async", + client, + sessionID: "ses_mixed_tail_tools", + input: { path: { id: "ses_mixed_tail_tools" }, body: { parts: [] } }, + source: "test:mixed-tail-tools", + settleMs: 0, + postDispatchHoldMs: 0, + }) + + // then + expect(result.status).toBe("dispatched") + expect(promptCalls).toBe(1) + }) + test("#given latest assistant turn is waiting on tools #when tool-state check is disabled #then promptAsync is sent", async () => { // given let promptCalls = 0 diff --git a/src/shared/prompt-async-gate.ts b/src/shared/prompt-async-gate.ts index db317e8ce..e23d6c849 100644 --- a/src/shared/prompt-async-gate.ts +++ b/src/shared/prompt-async-gate.ts @@ -1,4 +1,9 @@ import { log } from "./logger" +import { + isSyntheticOrInternalUserMessage, + type InternalInitiatorMessageLike, + type InternalInitiatorTextPartLike, +} from "./internal-initiator-marker" import { DEFAULT_SESSION_IDLE_SETTLE_MS, isSessionActive, @@ -198,6 +203,48 @@ function messageRole(message: unknown): string | undefined { return typeof message.role === "string" ? message.role : undefined } +function toInternalInitiatorTextPartLike(part: unknown): InternalInitiatorTextPartLike { + const result: InternalInitiatorTextPartLike = {} + if (!isRecord(part)) { + return result + } + + if (typeof part.type === "string") { + result.type = part.type + } + if (typeof part.text === "string") { + result.text = part.text + } + if (typeof part.synthetic === "boolean") { + result.synthetic = part.synthetic + } + return result +} + +function toInternalInitiatorMessageLike(message: unknown): InternalInitiatorMessageLike | undefined { + if (!isRecord(message)) { + return undefined + } + + const result: InternalInitiatorMessageLike = {} + const info = message.info + if (isRecord(info) && typeof info.role === "string") { + result.info = { role: info.role } + } + if (typeof message.role === "string") { + result.role = message.role + } + if (Array.isArray(message.parts)) { + result.parts = message.parts.map(toInternalInitiatorTextPartLike) + } + return result +} + +function messageIsSyntheticOrInternalUser(message: unknown): boolean { + const initiatorMessage = toInternalInitiatorMessageLike(message) + return initiatorMessage !== undefined && isSyntheticOrInternalUserMessage(initiatorMessage) +} + function partIsWaitingOnTool(part: unknown): boolean { if (!isRecord(part)) { return false @@ -224,6 +271,9 @@ function latestAssistantTurnIsWaitingOnTools(messages: unknown[]): boolean { return message.parts.some(partIsWaitingOnTool) } if (role === "user") { + if (messageIsSyntheticOrInternalUser(message)) { + continue + } return false } }