diff --git a/src/cli/run/event-handlers.ts b/src/cli/run/event-handlers.ts index 885cd4c5..1ee2f358 100644 --- a/src/cli/run/event-handlers.ts +++ b/src/cli/run/event-handlers.ts @@ -187,6 +187,7 @@ function handleToolPart( const status = part.state?.status if (status === "running") { + if (state.currentTool !== null) return state.currentTool = toolName const header = formatToolHeader(toolName, part.state?.input ?? {}) const suffix = header.description ? ` ${pc.dim(header.description)}` : "" @@ -195,6 +196,7 @@ function handleToolPart( } if (status === "completed" || status === "error") { + if (state.currentTool === null) return const output = part.state?.output || "" if (output.trim()) { process.stdout.write(pc.dim(` ${displayChars.treeEnd} output \n`)) @@ -216,7 +218,7 @@ export function handleMessageUpdated(ctx: RunContext, payload: EventPayload, sta state.currentMessageRole = props?.info?.role ?? null - const messageID = props?.info?.id + const messageID = props?.info?.id ?? null const role = props?.info?.role if (messageID && role) { state.messageRoleById[messageID] = role @@ -224,15 +226,19 @@ export function handleMessageUpdated(ctx: RunContext, payload: EventPayload, sta if (props?.info?.role !== "assistant") return - state.hasReceivedMeaningfulWork = true - state.messageCount++ - state.lastPartText = "" - state.lastReasoningText = "" - state.hasPrintedThinkingLine = false - state.lastThinkingSummary = "" - state.textAtLineStart = true - state.thinkingAtLineStart = false - closeThinkBlockIfNeeded(state) + const isNewMessage = !messageID || messageID !== state.currentMessageId + if (isNewMessage) { + state.currentMessageId = messageID + state.hasReceivedMeaningfulWork = true + state.messageCount++ + state.lastPartText = "" + state.lastReasoningText = "" + state.hasPrintedThinkingLine = false + state.lastThinkingSummary = "" + state.textAtLineStart = true + state.thinkingAtLineStart = false + closeThinkBlockIfNeeded(state) + } const agent = props?.info?.agent ?? null const model = props?.info?.modelID ?? null @@ -253,6 +259,8 @@ export function handleToolExecute(ctx: RunContext, payload: EventPayload, state: closeThinkBlockIfNeeded(state) + if (state.currentTool !== null) return + const toolName = props?.name || "unknown" state.currentTool = toolName const header = formatToolHeader(toolName, props?.input ?? {}) @@ -270,6 +278,8 @@ export function handleToolResult(ctx: RunContext, payload: EventPayload, state: closeThinkBlockIfNeeded(state) + if (state.currentTool === null) return + const output = props?.output || "" if (output.trim()) { process.stdout.write(pc.dim(` ${displayChars.treeEnd} output \n`)) diff --git a/src/cli/run/event-state.ts b/src/cli/run/event-state.ts index db485707..9caac122 100644 --- a/src/cli/run/event-state.ts +++ b/src/cli/run/event-state.ts @@ -37,6 +37,8 @@ export interface EventState { textAtLineStart: boolean /** Whether reasoning stream is currently at line start (for padding) */ thinkingAtLineStart: boolean + /** Current assistant message ID — prevents counter resets on repeated message.updated for same message */ + currentMessageId: string | null } export function createEventState(): EventState { @@ -63,5 +65,6 @@ export function createEventState(): EventState { lastThinkingSummary: "", textAtLineStart: true, thinkingAtLineStart: false, + currentMessageId: null, } } diff --git a/src/cli/run/message-part-delta.test.ts b/src/cli/run/message-part-delta.test.ts index 379572c0..c5bba429 100644 --- a/src/cli/run/message-part-delta.test.ts +++ b/src/cli/run/message-part-delta.test.ts @@ -462,4 +462,187 @@ describe("message.part.delta handling", () => { expect(rendered).toContain("END-OF-TOOL-OUTPUT-MARKER") stdoutSpy.mockRestore() }) + + it("renders tool header only once when message.part.updated fires multiple times for same running tool", async () => { + //#given + const ctx = createMockContext("ses_main") + const state = createEventState() + const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true) + const events: EventPayload[] = [ + { + type: "message.part.updated", + properties: { + part: { + id: "tool-1", + sessionID: "ses_main", + type: "tool", + tool: "bash", + state: { status: "running", input: { command: "bun test" } }, + }, + }, + }, + { + type: "message.part.updated", + properties: { + part: { + id: "tool-1", + sessionID: "ses_main", + type: "tool", + tool: "bash", + state: { status: "running", input: { command: "bun test" } }, + }, + }, + }, + { + type: "message.part.updated", + properties: { + part: { + id: "tool-1", + sessionID: "ses_main", + type: "tool", + tool: "bash", + state: { status: "running", input: { command: "bun test" } }, + }, + }, + }, + ] + + //#when + await processEvents(ctx, toAsyncIterable(events), state) + + //#then + const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("") + const headerCount = rendered.split("bun test").length - 1 + expect(headerCount).toBe(1) + stdoutSpy.mockRestore() + }) + + it("renders tool header only once when both tool.execute and message.part.updated fire", async () => { + //#given + const ctx = createMockContext("ses_main") + const state = createEventState() + const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true) + const events: EventPayload[] = [ + { + type: "tool.execute", + properties: { + sessionID: "ses_main", + name: "bash", + input: { command: "bun test" }, + }, + }, + { + type: "message.part.updated", + properties: { + part: { + id: "tool-1", + sessionID: "ses_main", + type: "tool", + tool: "bash", + state: { status: "running", input: { command: "bun test" } }, + }, + }, + }, + ] + + //#when + await processEvents(ctx, toAsyncIterable(events), state) + + //#then + const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("") + const headerCount = rendered.split("bun test").length - 1 + expect(headerCount).toBe(1) + stdoutSpy.mockRestore() + }) + + it("renders tool output only once when both tool.result and message.part.updated(completed) fire", async () => { + //#given + const ctx = createMockContext("ses_main") + const state = createEventState() + const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true) + const events: EventPayload[] = [ + { + type: "tool.execute", + properties: { + sessionID: "ses_main", + name: "bash", + input: { command: "bun test" }, + }, + }, + { + type: "tool.result", + properties: { + sessionID: "ses_main", + name: "bash", + output: "UNIQUE-OUTPUT-MARKER", + }, + }, + { + type: "message.part.updated", + properties: { + part: { + id: "tool-1", + sessionID: "ses_main", + type: "tool", + tool: "bash", + state: { status: "completed", input: { command: "bun test" }, output: "UNIQUE-OUTPUT-MARKER" }, + }, + }, + }, + ] + + //#when + await processEvents(ctx, toAsyncIterable(events), state) + + //#then + const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("") + const outputCount = rendered.split("UNIQUE-OUTPUT-MARKER").length - 1 + expect(outputCount).toBe(1) + stdoutSpy.mockRestore() + }) + + it("does not re-render text when message.updated fires multiple times for same message", async () => { + //#given + const ctx = createMockContext("ses_main") + const state = createEventState() + const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true) + const events: EventPayload[] = [ + { + type: "message.updated", + properties: { + info: { id: "msg_1", sessionID: "ses_main", role: "assistant", agent: "Sisyphus", modelID: "claude-opus-4-6" }, + }, + }, + { + type: "message.part.delta", + properties: { + sessionID: "ses_main", + messageID: "msg_1", + field: "text", + delta: "Hello world", + }, + }, + { + type: "message.updated", + properties: { + info: { id: "msg_1", sessionID: "ses_main", role: "assistant", agent: "Sisyphus", modelID: "claude-opus-4-6" }, + }, + }, + { + type: "message.part.updated", + properties: { + part: { id: "text-1", sessionID: "ses_main", type: "text", text: "Hello world" }, + }, + }, + ] + + //#when + await processEvents(ctx, toAsyncIterable(events), state) + + //#then + const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("") + const textCount = rendered.split("Hello world").length - 1 + expect(textCount).toBe(1) + stdoutSpy.mockRestore() + }) })