diff --git a/internal/cloud/server-workbench-http.test.ts b/internal/cloud/server-workbench-http.test.ts index fd9a2a2a..4e9950fc 100644 --- a/internal/cloud/server-workbench-http.test.ts +++ b/internal/cloud/server-workbench-http.test.ts @@ -574,6 +574,99 @@ test("workbench read model exposes session, messages, turn, and trace without wr } }); +test("workbench terminal timing ignores late projection updatedAt after sealed result (#2132)", async () => { + const traceId = "trc_workbench_terminal_late_projection_update"; + const session = { + id: "ses_workbench_terminal_late_projection_update", + projectId: "prj_hwpod_workbench", + agentId: "hwlab-code-agent", + status: "completed", + startedAt: "2026-06-25T23:07:10.153Z", + ownerUserId: ACTOR.id, + conversationId: "cnv_workbench_terminal_late_projection_update", + threadId: "thread-workbench-terminal-late-projection-update", + lastTraceId: traceId, + updatedAt: "2026-06-25T23:08:27.097Z", + session: { + sessionStatus: "completed", + lastTraceId: traceId, + finalResponse: "P5 durable facts smoke OK", + messages: [ + { role: "user", text: "请直接回答:P5 durable facts smoke OK", traceId, status: "sent", createdAt: "2026-06-25T23:07:10.153Z" }, + { role: "agent", text: "P5 durable facts smoke OK", traceId, status: "completed", createdAt: "2026-06-25T23:07:10.976Z", updatedAt: "2026-06-25T23:08:27.097Z" } + ], + valuesRedacted: true, + secretMaterialStored: false + } + }; + const facts = emptyFacts(); + mergeFacts(facts, buildDurableFactsForSession({ + session, + status: "completed", + finalText: "P5 durable facts smoke OK", + events: [ + { projectedSeq: 1, sourceSeq: 1, type: "backend", status: "running", label: "agentrun:backend:admitted", createdAt: "2026-06-25T23:07:10.153Z", updatedAt: "2026-06-25T23:07:10.153Z" }, + { projectedSeq: 31, sourceSeq: 31, type: "terminal", eventType: "terminal", status: "completed", label: "agentrun:result:completed", terminal: true, createdAt: "2026-06-25T23:07:32.418Z", occurredAt: "2026-06-25T23:07:32.418Z", updatedAt: "2026-06-25T23:08:27.127Z" } + ], + lastProjectedSeq: 31 + })); + const checkpointTiming = { + startedAt: "2026-06-25T23:07:10.153Z", + lastEventAt: "2026-06-25T23:07:32.418Z", + finishedAt: "2026-06-25T23:07:32.418Z", + durationMs: 22265, + valuesRedacted: true + }; + const turnTiming = { + startedAt: "2026-06-25T23:07:10.976Z", + lastEventAt: "2026-06-25T23:08:27.097Z", + finishedAt: "2026-06-25T23:07:32.418Z", + durationMs: 21442, + valuesRedacted: true + }; + for (const record of [...facts.sessions, ...facts.messages.filter((message) => message.role !== "user"), ...facts.turns]) { + Object.assign(record, turnTiming, { timing: turnTiming, updatedAt: "2026-06-25T23:08:27.097Z" }); + } + Object.assign(facts.checkpoints[0], checkpointTiming, { timing: checkpointTiming, updatedAt: "2026-06-25T23:08:27.127Z" }); + Object.assign(facts.traceEvents.at(-1), checkpointTiming, { timing: checkpointTiming, updatedAt: "2026-06-25T23:08:27.127Z" }); + const runtimeStore = { + async queryWorkbenchFacts(params = {}) { + const filtered = filterFacts(facts, params); + return { + facts: filtered, + count: Object.values(filtered).reduce((sum, rows) => sum + rows.length, 0), + persistence: { adapter: "test-durable-workbench-facts", durable: true } + }; + } + }; + const accessController = { + async ensureBootstrap() {}, + async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; } + }; + const server = createCloudApiServer({ accessController, workbenchRuntime: runtimeStore, codeAgentChatResults: createCodeAgentChatResultStore() }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const turn = await getJson(port, `/v1/workbench/turns/${encodeURIComponent(traceId)}`); + assert.equal(turn.status, 200); + assert.equal(turn.body.turn.startedAt, "2026-06-25T23:07:10.153Z"); + assert.equal(turn.body.turn.lastEventAt, "2026-06-25T23:07:32.418Z"); + assert.equal(turn.body.turn.finishedAt, "2026-06-25T23:07:32.418Z"); + assert.equal(turn.body.turn.durationMs, 22265); + assert.equal(turn.body.turn.trace.updatedAt, "2026-06-25T23:08:27.127Z"); + + const trace = await getJson(port, `/v1/workbench/traces/${encodeURIComponent(traceId)}/events?limit=80`); + assert.equal(trace.status, 200); + assert.equal(trace.body.lastEventAt, "2026-06-25T23:07:32.418Z"); + assert.equal(trace.body.finishedAt, "2026-06-25T23:07:32.418Z"); + assert.equal(trace.body.durationMs, 22265); + assert.equal(trace.body.events.at(-1).updatedAt, "2026-06-25T23:08:27.127Z"); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +}); + test("workbench read model recovers trace events from durable projection without duplicating final response", async () => { const traceStore = createCodeAgentTraceStore(); const traceId = "trc_workbench_durable_reload"; diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index 7b25f59c..8795bc01 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -1276,9 +1276,12 @@ function factTraceTimingProjection(events = [], checkpoint = null, status = null const observedAt = new Date().toISOString(); const eventStartTimes = factArray(events).flatMap((event) => [event?.createdAt, event?.occurredAt]); const eventActivityTimes = factArray(events).flatMap((event) => [event?.createdAt, event?.occurredAt, event?.updatedAt]); + const terminalEventTimes = factArray(events) + .filter(factTraceEventIsTerminalAuthority) + .flatMap(factTraceEventTerminalTimes); const startedAt = firstTimestampIso(checkpointTiming.startedAt, ...eventStartTimes); - const lastEventAt = latestTimestampIso(checkpointTiming.lastEventAt, ...eventActivityTimes); - const finishedAt = terminal ? latestTimestampIso(checkpointTiming.finishedAt, lastEventAt) : null; + const finishedAt = terminal ? latestTimestampIso(checkpointTiming.finishedAt, ...terminalEventTimes) : null; + const lastEventAt = terminal && finishedAt ? finishedAt : latestTimestampIso(checkpointTiming.lastEventAt, ...eventActivityTimes); const durationMs = elapsedFactMs(startedAt, terminal ? finishedAt : observedAt); const lastEventAgeMs = terminal ? null : elapsedFactMs(lastEventAt, observedAt); return { @@ -1293,6 +1296,16 @@ function factTraceTimingProjection(events = [], checkpoint = null, status = null }; } +function factTraceEventIsTerminalAuthority(event = null) { + const eventType = textValue(event?.eventType ?? event?.type); + return event?.terminal === true || event?.sealed === true || eventType === "terminal"; +} + +function factTraceEventTerminalTimes(event = null) { + const source = objectValue(event?.timing); + return [source?.finishedAt, event?.finishedAt, source?.lastEventAt, event?.lastEventAt, event?.occurredAt, event?.createdAt]; +} + function factTraceEventDto(event, index) { const seq = factProjectedSeq(event); if (!seq) return null; @@ -1368,8 +1381,8 @@ function factCombinedTimingProjection(status = null, ...records) { const observedAt = new Date().toISOString(); const timings = records.map((record) => factTimingSource(record)).filter(Boolean); const startedAt = firstTimestampIso(...timings.map((timing) => timing.startedAt)); - const lastEventAt = latestTimestampIso(...timings.map((timing) => timing.lastEventAt)); const finishedAt = terminal ? latestTimestampIso(...timings.map((timing) => timing.finishedAt)) : null; + const lastEventAt = terminal && finishedAt ? finishedAt : latestTimestampIso(...timings.map((timing) => timing.lastEventAt)); const durationMs = elapsedFactMs(startedAt, terminal ? finishedAt : observedAt); const lastEventAgeMs = terminal ? null : elapsedFactMs(lastEventAt, observedAt); return { startedAt, lastEventAt, finishedAt, durationMs, observedAt: terminal ? null : observedAt, lastEventAgeMs, valuesRedacted: true };