feat(run): print agent/model/duration on assistant completion

This commit is contained in:
YeonGyu-Kim 2026-02-18 18:12:12 +09:00
parent 6bf365595f
commit b0e8f5ec7b
3 changed files with 105 additions and 1 deletions

View File

@ -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", () => {

View File

@ -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

View File

@ -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<string, number>
/** Prevent duplicate completion metadata lines per message */
completionMetaPrintedByMessageId: Record<string, boolean>
}
export function createEventState(): EventState {
@ -66,5 +70,7 @@ export function createEventState(): EventState {
textAtLineStart: true,
thinkingAtLineStart: false,
currentMessageId: null,
messageStartedAtById: {},
completionMetaPrintedByMessageId: {},
}
}