import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { buildToolStatusContractFixture } from "../../server/src/tool-status-events"; import { cancelActiveToolStatus, createToolStatusState, flushToolStatusUpdates, reduceToolStatusEvent, reduceToolStatusEvents, } from "../src/lib/tool-status-model"; import type { ToolStatusContractEvent, ToolStatusEndEvent, ToolStatusStartEvent, ToolStatusSummaryEvent, ToolStatusUpdateEvent, } from "../src/lib/api"; const runId = "run-1"; const messageId = "message-1"; describe("tool status model", () => { it("reuses the backend contract fixture and rejects contract drift", () => { const fixture = buildToolStatusContractFixture(); const expectedKeys: Record = { assistant_text_delta: ["delta", "messageId", "runId", "schemaVersion", "seq", "type"], tool_status_start: [ "action", "args", "messageId", "otherRunningCount", "runId", "runningToolCount", "schemaVersion", "seq", "status", "target", "toolCallId", "toolName", "type", ], tool_status_update: [ "action", "args", "detail", "messageId", "otherRunningCount", "runId", "runningToolCount", "schemaVersion", "seq", "status", "target", "toolCallId", "toolName", "type", ], tool_status_end: [ "action", "durationMs", "error", "messageId", "otherRunningCount", "result", "runId", "runningToolCount", "schemaVersion", "seq", "status", "summary", "target", "toolCallId", "toolName", "type", ], tool_status_summary: ["items", "messageId", "remainingRunningCount", "runId", "schemaVersion", "seq", "type"], assistant_done: ["messageId", "runId", "schemaVersion", "seq", "type"], assistant_cancelled: ["messageId", "reason", "runId", "schemaVersion", "seq", "type"], assistant_error: ["error", "messageId", "runId", "schemaVersion", "seq", "type"], }; assert.deepEqual( fixture.map((event) => ({ type: event.type, keys: Object.keys(event).sort() })), fixture.map((event) => ({ type: event.type, keys: expectedKeys[event.type] })), ); const state = reduceToolStatusEvents(createToolStatusState("fixture-run", "fixture-message"), fixture, { nowMs: 1_000, }); assert.equal(state.completed[0]?.toolCallId, "read-1"); assert.equal(state.completed[0]?.status, "done"); assert.equal(state.summary.items[0]?.toolCallId, "read-1"); assert.equal(state.active.length, 0); assert.equal(state.isDone, true); }); it("tracks sequential, missing-args, failed, cancelled, and parallel tools", () => { const state = reduceToolStatusEvents( createToolStatusState(runId, messageId), [ start("read-1", "read", 1, { path: "a.md" }), end("read-1", "read", 2, "done", "read a.md"), start("unknown-args", "custom_tool", 3), update("unknown-args", "custom_tool", 4), end("unknown-args", "custom_tool", 5, "failed", "missing args", "bad input"), start("write-1", "write", 6, { path: "out.md" }), start("bash-1", "bash", 7, { command: "npm test" }), end("write-1", "write", 8, "cancelled", "cancelled"), end("bash-1", "bash", 9, "done", "tests passed"), ], { nowMs: 1_000 }, ); assert.equal(state.active.length, 0); assert.deepEqual( state.completed.map((item) => [item.toolCallId, item.status]), [ ["read-1", "done"], ["unknown-args", "failed"], ["write-1", "cancelled"], ["bash-1", "done"], ], ); assert.deepEqual( state.failures.map((item) => [item.toolCallId, item.error]), [["unknown-args", "bad input"]], ); assert.deepEqual( state.completed.map((item) => item.args), [{ path: "a.md" }, {}, { path: "out.md" }, { command: "npm test" }], ); }); it("keys events by runId/messageId and ignores late, duplicate, and unknown-run events", () => { let state = createToolStatusState(runId, messageId); state = reduceToolStatusEvent(state, start("read-1", "read", 1), { nowMs: 1_000 }); state = reduceToolStatusEvent(state, start("foreign", "read", 2, {}, "other-run", messageId), { nowMs: 1_000 }); state = reduceToolStatusEvent(state, start("wrong-message", "read", 3, {}, runId, "other-message"), { nowMs: 1_000, }); state = reduceToolStatusEvent(state, update("read-1", "read", 1), { nowMs: 1_000 }); state = reduceToolStatusEvent(state, end("read-1", "read", 4, "done"), { nowMs: 1_000 }); state = reduceToolStatusEvent(state, update("read-1", "read", 3), { nowMs: 1_000 }); state = reduceToolStatusEvent(state, end("read-1", "read", 4, "done"), { nowMs: 1_000 }); assert.deepEqual( state.completed.map((item) => item.toolCallId), ["read-1"], ); assert.deepEqual( state.ignored.map((item) => item.reason), ["foreign-run", "foreign-message", "duplicate-or-late", "duplicate-or-late", "duplicate-or-late"], ); }); it("coalesces rapid update events and flushes visible updates on a bounded cadence", () => { let state = createToolStatusState(runId, messageId, { coalesceMs: 100 }); state = reduceToolStatusEvent(state, start("search-1", "search", 1, { query: "alpha" }), { nowMs: 0 }); state = reduceToolStatusEvent(state, update("search-1", "search", 2, { phase: "one" }), { nowMs: 10 }); state = reduceToolStatusEvent(state, update("search-1", "search", 3, { phase: "two" }), { nowMs: 40 }); state = reduceToolStatusEvent(state, update("search-1", "search", 4, { phase: "three" }), { nowMs: 80 }); assert.equal(state.active[0]?.detail, undefined); assert.equal(state.pendingUpdateCount, 3); assert.equal(state.coalesceMs, 100); state = flushToolStatusUpdates(state, 120); assert.deepEqual(state.active[0]?.detail, { phase: "three" }); assert.equal(state.pendingUpdateCount, 0); assert.equal(state.nextUpdateFlushAt, 220); }); it("caps completed history and summary items with remaining counts", () => { const events: ToolStatusContractEvent[] = []; for (let index = 1; index <= 5; index += 1) { events.push(start(`tool-${index}`, "read", index * 2 - 1)); events.push(end(`tool-${index}`, "read", index * 2, "done", `summary ${index}`)); } events.push(summary(11, 5)); const state = reduceToolStatusEvents(createToolStatusState(runId, messageId, { maxCompletedItems: 3, maxSummaryItems: 2 }), events, { nowMs: 1_000, }); assert.deepEqual( state.completed.map((item) => item.toolCallId), ["tool-3", "tool-4", "tool-5"], ); assert.equal(state.completedOverflowCount, 2); assert.equal(state.completedOverflowLabel, "还有 2 项"); assert.deepEqual( state.summary.items.map((item) => item.toolCallId), ["tool-1", "tool-2"], ); assert.equal(state.summary.overflowCount, 3); assert.equal(state.summary.overflowLabel, "还有 3 项"); }); it("locally cancels active tools without waiting for backend abort confirmation", () => { const running = reduceToolStatusEvents( createToolStatusState(runId, messageId), [ start("read-1", "read", 1, { path: "a.md" }), start("bash-1", "bash", 2, { command: "npm test" }), ], { nowMs: 1_000 }, ); const cancelled = cancelActiveToolStatus(running, "用户已停止"); assert.equal(cancelled.active.length, 0); assert.equal(cancelled.isDone, true); assert.equal(cancelled.cancelReason, "用户已停止"); assert.deepEqual( cancelled.completed.map((item) => [item.toolCallId, item.status, item.summary]), [ ["read-1", "cancelled", "用户已停止"], ["bash-1", "cancelled", "用户已停止"], ], ); assert.equal(cancelled.summary.items.length, 2); }); }); function start( toolCallId: string, toolName: string, seq: number, args: Record = {}, eventRunId = runId, eventMessageId = messageId, ): ToolStatusStartEvent { return { schemaVersion: 1, type: "tool_status_start", runId: eventRunId, messageId: eventMessageId, seq, toolCallId, toolName, action: toolName, target: "target", status: "running", args, runningToolCount: 1, otherRunningCount: 0, }; } function update( toolCallId: string, toolName: string, seq: number, detail: unknown = null, eventRunId = runId, eventMessageId = messageId, ): ToolStatusUpdateEvent { return { schemaVersion: 1, type: "tool_status_update", runId: eventRunId, messageId: eventMessageId, seq, toolCallId, toolName, action: toolName, target: "target", status: "running", args: {}, detail, runningToolCount: 1, otherRunningCount: 0, }; } function end( toolCallId: string, toolName: string, seq: number, status: ToolStatusEndEvent["status"], summaryText: string | null = null, error: string | null = null, ): ToolStatusEndEvent { return { schemaVersion: 1, type: "tool_status_end", runId, messageId, seq, toolCallId, toolName, action: toolName, target: "target", status, result: null, summary: summaryText, error, durationMs: 10, runningToolCount: 0, otherRunningCount: 0, }; } function summary(seq: number, count: number): ToolStatusSummaryEvent { return { schemaVersion: 1, type: "tool_status_summary", runId, messageId, seq, items: Array.from({ length: count }, (_, index) => ({ toolCallId: `tool-${index + 1}`, toolName: "read", action: "read", target: `target ${index + 1}`, status: "done", summary: `summary ${index + 1}`, })), remainingRunningCount: 0, }; }