fix(cli-run): deduplicate tool headers and message counter resets on repeated events
Guard against duplicate tool header/output rendering when both tool.execute and message.part.updated fire for the same tool, and prevent message counter resets when message.updated fires multiple times for the same assistant message. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) assistance
This commit is contained in:
parent
3313ec3e4f
commit
d9751bd5cb
@ -187,6 +187,7 @@ function handleToolPart(
|
|||||||
const status = part.state?.status
|
const status = part.state?.status
|
||||||
|
|
||||||
if (status === "running") {
|
if (status === "running") {
|
||||||
|
if (state.currentTool !== null) return
|
||||||
state.currentTool = toolName
|
state.currentTool = toolName
|
||||||
const header = formatToolHeader(toolName, part.state?.input ?? {})
|
const header = formatToolHeader(toolName, part.state?.input ?? {})
|
||||||
const suffix = header.description ? ` ${pc.dim(header.description)}` : ""
|
const suffix = header.description ? ` ${pc.dim(header.description)}` : ""
|
||||||
@ -195,6 +196,7 @@ function handleToolPart(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === "completed" || status === "error") {
|
if (status === "completed" || status === "error") {
|
||||||
|
if (state.currentTool === null) return
|
||||||
const output = part.state?.output || ""
|
const output = part.state?.output || ""
|
||||||
if (output.trim()) {
|
if (output.trim()) {
|
||||||
process.stdout.write(pc.dim(` ${displayChars.treeEnd} output \n`))
|
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
|
state.currentMessageRole = props?.info?.role ?? null
|
||||||
|
|
||||||
const messageID = props?.info?.id
|
const messageID = props?.info?.id ?? null
|
||||||
const role = props?.info?.role
|
const role = props?.info?.role
|
||||||
if (messageID && role) {
|
if (messageID && role) {
|
||||||
state.messageRoleById[messageID] = role
|
state.messageRoleById[messageID] = role
|
||||||
@ -224,15 +226,19 @@ export function handleMessageUpdated(ctx: RunContext, payload: EventPayload, sta
|
|||||||
|
|
||||||
if (props?.info?.role !== "assistant") return
|
if (props?.info?.role !== "assistant") return
|
||||||
|
|
||||||
state.hasReceivedMeaningfulWork = true
|
const isNewMessage = !messageID || messageID !== state.currentMessageId
|
||||||
state.messageCount++
|
if (isNewMessage) {
|
||||||
state.lastPartText = ""
|
state.currentMessageId = messageID
|
||||||
state.lastReasoningText = ""
|
state.hasReceivedMeaningfulWork = true
|
||||||
state.hasPrintedThinkingLine = false
|
state.messageCount++
|
||||||
state.lastThinkingSummary = ""
|
state.lastPartText = ""
|
||||||
state.textAtLineStart = true
|
state.lastReasoningText = ""
|
||||||
state.thinkingAtLineStart = false
|
state.hasPrintedThinkingLine = false
|
||||||
closeThinkBlockIfNeeded(state)
|
state.lastThinkingSummary = ""
|
||||||
|
state.textAtLineStart = true
|
||||||
|
state.thinkingAtLineStart = false
|
||||||
|
closeThinkBlockIfNeeded(state)
|
||||||
|
}
|
||||||
|
|
||||||
const agent = props?.info?.agent ?? null
|
const agent = props?.info?.agent ?? null
|
||||||
const model = props?.info?.modelID ?? null
|
const model = props?.info?.modelID ?? null
|
||||||
@ -253,6 +259,8 @@ export function handleToolExecute(ctx: RunContext, payload: EventPayload, state:
|
|||||||
|
|
||||||
closeThinkBlockIfNeeded(state)
|
closeThinkBlockIfNeeded(state)
|
||||||
|
|
||||||
|
if (state.currentTool !== null) return
|
||||||
|
|
||||||
const toolName = props?.name || "unknown"
|
const toolName = props?.name || "unknown"
|
||||||
state.currentTool = toolName
|
state.currentTool = toolName
|
||||||
const header = formatToolHeader(toolName, props?.input ?? {})
|
const header = formatToolHeader(toolName, props?.input ?? {})
|
||||||
@ -270,6 +278,8 @@ export function handleToolResult(ctx: RunContext, payload: EventPayload, state:
|
|||||||
|
|
||||||
closeThinkBlockIfNeeded(state)
|
closeThinkBlockIfNeeded(state)
|
||||||
|
|
||||||
|
if (state.currentTool === null) return
|
||||||
|
|
||||||
const output = props?.output || ""
|
const output = props?.output || ""
|
||||||
if (output.trim()) {
|
if (output.trim()) {
|
||||||
process.stdout.write(pc.dim(` ${displayChars.treeEnd} output \n`))
|
process.stdout.write(pc.dim(` ${displayChars.treeEnd} output \n`))
|
||||||
|
|||||||
@ -37,6 +37,8 @@ export interface EventState {
|
|||||||
textAtLineStart: boolean
|
textAtLineStart: boolean
|
||||||
/** Whether reasoning stream is currently at line start (for padding) */
|
/** Whether reasoning stream is currently at line start (for padding) */
|
||||||
thinkingAtLineStart: boolean
|
thinkingAtLineStart: boolean
|
||||||
|
/** Current assistant message ID — prevents counter resets on repeated message.updated for same message */
|
||||||
|
currentMessageId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEventState(): EventState {
|
export function createEventState(): EventState {
|
||||||
@ -63,5 +65,6 @@ export function createEventState(): EventState {
|
|||||||
lastThinkingSummary: "",
|
lastThinkingSummary: "",
|
||||||
textAtLineStart: true,
|
textAtLineStart: true,
|
||||||
thinkingAtLineStart: false,
|
thinkingAtLineStart: false,
|
||||||
|
currentMessageId: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -462,4 +462,187 @@ describe("message.part.delta handling", () => {
|
|||||||
expect(rendered).toContain("END-OF-TOOL-OUTPUT-MARKER")
|
expect(rendered).toContain("END-OF-TOOL-OUTPUT-MARKER")
|
||||||
stdoutSpy.mockRestore()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user