From b0e8f5ec7bd5c202a2f4c86e5aa7250730735281 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 18 Feb 2026 18:12:12 +0900 Subject: [PATCH] feat(run): print agent/model/duration on assistant completion --- src/cli/run/event-handlers.test.ts | 76 +++++++++++++++++++++++++++++- src/cli/run/event-handlers.ts | 24 ++++++++++ src/cli/run/event-state.ts | 6 +++ 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/cli/run/event-handlers.test.ts b/src/cli/run/event-handlers.test.ts index a30fd27f..267b394c 100644 --- a/src/cli/run/event-handlers.test.ts +++ b/src/cli/run/event-handlers.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, spyOn } from "bun:test" import type { RunContext } from "./types" import { createEventState } from "./events" -import { handleSessionStatus, handleMessagePartUpdated, handleTuiToast } from "./event-handlers" +import { handleSessionStatus, handleMessagePartUpdated, handleMessageUpdated, handleTuiToast } from "./event-handlers" const createMockContext = (sessionID: string = "test-session"): RunContext => ({ sessionID, @@ -232,6 +232,80 @@ describe("handleMessagePartUpdated", () => { expect(state.lastPartText).toBe("Legacy text") stdoutSpy.mockRestore() }) + + it("prints completion metadata once when assistant text part is completed", () => { + // given + const nowSpy = spyOn(Date, "now") + nowSpy.mockReturnValueOnce(1000) + nowSpy.mockReturnValueOnce(3400) + + const ctx = createMockContext("ses_main") + const state = createEventState() + const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true) + + handleMessageUpdated( + ctx, + { + type: "message.updated", + properties: { + info: { + id: "msg_1", + sessionID: "ses_main", + role: "assistant", + agent: "Sisyphus", + modelID: "claude-sonnet-4-6", + }, + }, + } as any, + state, + ) + + // when + handleMessagePartUpdated( + ctx, + { + type: "message.part.updated", + properties: { + part: { + id: "part_1", + sessionID: "ses_main", + messageID: "msg_1", + type: "text", + text: "done", + time: { end: 1 }, + }, + }, + } as any, + state, + ) + + handleMessagePartUpdated( + ctx, + { + type: "message.part.updated", + properties: { + part: { + id: "part_1", + sessionID: "ses_main", + messageID: "msg_1", + type: "text", + text: "done", + time: { end: 2 }, + }, + }, + } as any, + state, + ) + + // then + const output = stdoutSpy.mock.calls.map(call => String(call[0])).join("") + const metaCount = output.split("Sisyphus · claude-sonnet-4-6 · 2.4s").length - 1 + expect(metaCount).toBe(1) + expect(state.completionMetaPrintedByMessageId["msg_1"]).toBe(true) + + stdoutSpy.mockRestore() + nowSpy.mockRestore() + }) }) describe("handleTuiToast", () => { diff --git a/src/cli/run/event-handlers.ts b/src/cli/run/event-handlers.ts index 1ee2f358..ba6559cd 100644 --- a/src/cli/run/event-handlers.ts +++ b/src/cli/run/event-handlers.ts @@ -51,6 +51,19 @@ function getDeltaMessageId(props?: { return props?.messageID } +function renderCompletionMetaLine(state: EventState, messageID: string): void { + if (state.completionMetaPrintedByMessageId[messageID]) return + + const startedAt = state.messageStartedAtById[messageID] + const elapsedSec = startedAt ? ((Date.now() - startedAt) / 1000).toFixed(1) : "0.0" + const agent = state.currentAgent ?? "assistant" + const model = state.currentModel ?? "unknown-model" + const variant = state.currentVariant ? ` (${state.currentVariant})` : "" + + process.stdout.write(pc.dim(`\n ${displayChars.treeEnd} ${agent} · ${model}${variant} · ${elapsedSec}s \n`)) + state.completionMetaPrintedByMessageId[messageID] = true +} + export function handleSessionIdle(ctx: RunContext, payload: EventPayload, state: EventState): void { if (payload.type !== "session.idle") return @@ -133,6 +146,13 @@ export function handleMessagePartUpdated(ctx: RunContext, payload: EventPayload, state.hasReceivedMeaningfulWork = true } state.lastPartText = part.text + + if (part.time?.end) { + const messageID = part.messageID ?? state.currentMessageId + if (messageID) { + renderCompletionMetaLine(state, messageID) + } + } } if (part.type === "tool") { @@ -238,6 +258,10 @@ export function handleMessageUpdated(ctx: RunContext, payload: EventPayload, sta state.textAtLineStart = true state.thinkingAtLineStart = false closeThinkBlockIfNeeded(state) + if (messageID) { + state.messageStartedAtById[messageID] = Date.now() + state.completionMetaPrintedByMessageId[messageID] = false + } } const agent = props?.info?.agent ?? null diff --git a/src/cli/run/event-state.ts b/src/cli/run/event-state.ts index 9caac122..4d05f7da 100644 --- a/src/cli/run/event-state.ts +++ b/src/cli/run/event-state.ts @@ -39,6 +39,10 @@ export interface EventState { thinkingAtLineStart: boolean /** Current assistant message ID — prevents counter resets on repeated message.updated for same message */ currentMessageId: string | null + /** Assistant message start timestamp by message ID */ + messageStartedAtById: Record + /** Prevent duplicate completion metadata lines per message */ + completionMetaPrintedByMessageId: Record } export function createEventState(): EventState { @@ -66,5 +70,7 @@ export function createEventState(): EventState { textAtLineStart: true, thinkingAtLineStart: false, currentMessageId: null, + messageStartedAtById: {}, + completionMetaPrintedByMessageId: {}, } }