diff --git a/internal/cloud/server-workbench-http.test.ts b/internal/cloud/server-workbench-http.test.ts index fbe58075..815f0a42 100644 --- a/internal/cloud/server-workbench-http.test.ts +++ b/internal/cloud/server-workbench-http.test.ts @@ -1164,13 +1164,18 @@ test("workbench read model projects terminal result atomically across session, m assert.equal(assistant.text, finalText); assert.equal(assistant.parts[0].status, "completed"); assert.equal(assistant.parts[0].type, "final_response"); + assert.equal(assistant.parts[0].messageId, assistant.messageId); + assert.equal(assistant.parts[0].traceId, traceId); assert.equal(assistant.parts[0].text, finalText); const turn = await getJson(port, `/v1/workbench/turns/${encodeURIComponent(traceId)}`); assert.equal(turn.status, 200); assert.equal(turn.body.turn.status, "completed"); assert.equal(turn.body.turn.terminal, true); + assert.equal(turn.body.turn.assistantMessageId, assistant.messageId); assert.equal(turn.body.turn.assistantText, finalText); + assert.equal(turn.body.turn.finalResponse.source, "message-part"); + assert.equal(turn.body.turn.finalResponse.sealed, true); assert.equal(turn.body.turn.finalResponse.text, finalText); } finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); diff --git a/web/hwlab-cloud-web/scripts/workbench-message-projection-runtime.test.ts b/web/hwlab-cloud-web/scripts/workbench-message-projection-runtime.test.ts index d4089409..dc7a787b 100644 --- a/web/hwlab-cloud-web/scripts/workbench-message-projection-runtime.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-message-projection-runtime.test.ts @@ -83,6 +83,87 @@ test("message timing and terminal status patches stay independent from store sta assert.equal(terminalPatch.durationMs, 3500); }); +test("terminal turn result seals completed final response body", () => { + const restored = { + role: "agent", + status: "running", + text: "", + traceAutoLifecycle: "running", + traceId: "trc_unit_final", + runnerTrace: { traceId: "trc_unit_final", status: "running" } + } as ChatMessage; + + const terminal = terminalMessagePatchFromTurnResult(restored, { + traceId: "trc_unit_final", + status: "completed", + terminal: true, + finalResponse: { text: "final answer" }, + startedAt: "2026-01-01T00:00:00.000Z", + finishedAt: "2026-01-01T00:00:04.000Z", + durationMs: 4000 + } as AgentChatResultResponse); + + assert.equal(terminal?.status, "completed"); + assert.equal(terminal?.traceAutoLifecycle, "terminal"); + assert.equal(terminal?.text, "final answer"); + assert.equal((terminal?.finalResponse as { text?: string } | undefined)?.text, "final answer"); +}); + +test("terminal turn result accepts authoritative assistant trace final", () => { + const restored = { + role: "agent", + status: "running", + text: "", + traceId: "trc_trace_final", + runnerTrace: { traceId: "trc_trace_final", status: "running" } + } as ChatMessage; + + const terminal = terminalMessagePatchFromTurnResult(restored, { + traceId: "trc_trace_final", + status: "completed", + terminal: true, + runnerTrace: { + traceId: "trc_trace_final", + status: "completed", + events: [ + { seq: 1, type: "assistant_message", status: "running", message: "progress only" }, + { seq: 2, type: "assistant_message", status: "completed", message: "final from trace", replyAuthority: true, final: true } + ] + } + } as unknown as AgentChatResultResponse); + + assert.equal(terminal?.status, "completed"); + assert.equal(terminal?.text, "final from trace"); + assert.equal((terminal?.finalResponse as { text?: string } | undefined)?.text, "final from trace"); +}); + +test("terminal failure does not synthesize completed final response from progress text", () => { + const restored = { + role: "agent", + status: "running", + text: "", + traceId: "trc_failed_progress", + runnerTrace: { traceId: "trc_failed_progress", status: "running" } + } as ChatMessage; + + const terminal = terminalMessagePatchFromTurnResult(restored, { + traceId: "trc_failed_progress", + status: "failed", + terminal: true, + runnerTrace: { + traceId: "trc_failed_progress", + status: "failed", + events: [{ seq: 1, type: "assistant_message", status: "running", message: "progress only" }] + }, + error: { message: "provider failed" } + } as unknown as AgentChatResultResponse); + + assert.equal(terminal?.status, "failed"); + assert.equal(terminal?.text, undefined); + assert.equal(terminal?.finalResponse, undefined); + assert.equal(terminal?.error?.message, "provider failed"); +}); + test("terminal seal blocks stale running snapshots after session restore", () => { const sealed = { role: "agent", diff --git a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts index 7fc76569..cf1cb772 100644 --- a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts @@ -168,6 +168,50 @@ test("Workbench session messages keep sealed terminal timing on bulk refresh", ( assert.equal(agent?.finishedAt, "2026-06-24T00:00:12.000Z"); }); +test("Workbench sealed completed final body survives stale running session messages", () => { + let state = createWorkbenchServerState(); + state = reduceWorkbenchServerState(state, { + type: "session.status", + session: { sessionId: "ses_final_seal", status: "running", lastTraceId: "trc_final_seal" } + }); + state = reduceWorkbenchServerState(state, { + type: "session.messages", + sessionId: "ses_final_seal", + messages: [ + { id: "msg_user", messageId: "msg_user", role: "user", title: "用户", text: "ping", status: "sent", createdAt: "2026-06-24T00:00:00.000Z", sessionId: "ses_final_seal", traceId: "trc_final_seal" }, + { id: "msg_agent", messageId: "msg_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: "2026-06-24T00:00:01.000Z", sessionId: "ses_final_seal", traceId: "trc_final_seal", traceAutoLifecycle: "running", timing: { startedAt: "2026-06-24T00:00:01.000Z", lastEventAt: "2026-06-24T00:00:02.000Z", durationMs: null, valuesRedacted: true }, startedAt: "2026-06-24T00:00:01.000Z", lastEventAt: "2026-06-24T00:00:02.000Z", durationMs: null } + ] + }); + + state = reduceWorkbenchServerState(state, { + type: "message.snapshot", + sessionId: "ses_final_seal", + message: { id: "msg_agent", messageId: "msg_agent", role: "agent", title: "Code Agent", text: "final answer", finalResponse: { text: "final answer", sealed: true }, status: "completed", createdAt: "2026-06-24T00:00:01.000Z", updatedAt: "2026-06-24T00:00:06.000Z", sessionId: "ses_final_seal", traceId: "trc_final_seal", traceAutoLifecycle: "terminal", timing: { startedAt: "2026-06-24T00:00:01.000Z", finishedAt: "2026-06-24T00:00:06.000Z", durationMs: 5_000, valuesRedacted: true }, startedAt: "2026-06-24T00:00:01.000Z", finishedAt: "2026-06-24T00:00:06.000Z", durationMs: 5_000 } + }); + state = reduceWorkbenchServerState(state, { + type: "session.messages", + sessionId: "ses_final_seal", + messages: [ + { id: "msg_user", messageId: "msg_user", role: "user", title: "用户", text: "ping", status: "sent", createdAt: "2026-06-24T00:00:00.000Z", sessionId: "ses_final_seal", traceId: "trc_final_seal" }, + { id: "msg_agent", messageId: "msg_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: "2026-06-24T00:00:01.000Z", sessionId: "ses_final_seal", traceId: "trc_final_seal", traceAutoLifecycle: "running", timing: { startedAt: "2026-06-24T00:00:01.000Z", lastEventAt: "2026-06-24T00:00:04.000Z", durationMs: null, valuesRedacted: true }, startedAt: "2026-06-24T00:00:01.000Z", lastEventAt: "2026-06-24T00:00:04.000Z", durationMs: null } + ] + }); + + const agent = selectActiveMessages(state, "ses_final_seal").find((message) => message.role === "agent"); + assert.equal(agent?.status, "completed"); + assert.equal(agent?.text, "final answer"); + assert.equal((agent?.finalResponse as { text?: string } | undefined)?.text, "final answer"); + assert.equal(selectSessionStatusAuthority(state).ses_final_seal?.status, "completed"); + + state = reduceWorkbenchServerState(state, { + type: "session.list", + sessions: [{ sessionId: "ses_final_seal", threadId: "thr_final_seal", status: "running", lastTraceId: "trc_final_seal", updatedAt: "2026-06-24T00:00:07.000Z" }] + }); + + assert.equal(selectSessionStatusAuthority(state).ses_final_seal?.status, "completed"); + assert.equal(selectActiveSession(state, "ses_final_seal")?.status, "completed"); +}); + test("Workbench session messages repair terminal zero timing from running projection", () => { let state = createWorkbenchServerState(); state = reduceWorkbenchServerState(state, { diff --git a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts index e1d20e72..fb70db95 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts @@ -282,9 +282,11 @@ export function terminalMessagePatchFromTurnResult(message: ChatMessage, result: const error = resultError ?? (clearCompletedDiagnostics ? null : normalizeAgentError(runnerTrace?.error ?? message.error)); const projection = clearCompletedDiagnostics ? nonBlockingProjection(resultProjection) : resultProjection ?? runnerTrace.projection ?? message.projection ?? null; const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message); + const bodyPatch = terminalMessageBodyPatchFromTurnResult(message, result, resultStatus); return { ...messageTimingPatchForMerge(message, result), ...messageStatusPatchForTerminalMerge(message, resultStatus, terminal), + ...bodyPatch, runnerTrace, error, projection, @@ -297,6 +299,87 @@ export function terminalMessagePatchFromTurnResult(message: ChatMessage, result: }; } +function terminalMessageBodyPatchFromTurnResult(message: ChatMessage, result: AgentChatResultResponse, resultStatus: string | null): Partial { + if (normalizedStatusText(resultStatus) !== "completed") return {}; + const finalText = terminalFinalResponseTextFromTurnResult(result); + if (!finalText) return {}; + const traceId = firstNonEmptyString(result.traceId, message.traceId, message.runnerTrace?.traceId) ?? null; + return { + finalResponse: { + text: finalText, + status: "completed", + traceId, + sealed: true, + source: "turn-result", + valuesRedacted: true + }, + text: projectedAgentMessageText({ status: "completed", finalText }) + }; +} + +function terminalFinalResponseTextFromTurnResult(result: AgentChatResultResponse): string | null { + const record = recordValue(result); + const direct = firstNonEmptyString( + finalResponseText(record?.finalResponse), + messageText(record?.assistantText), + messageText(record?.finalText), + messageText(record?.reply), + messageText(record?.text), + messageText(record?.content), + messageText(record?.message) + ); + if (direct) return direct; + const resultTrace = recordValue(record?.runnerTrace); + return firstNonEmptyString( + terminalEvidenceFinalText(record?.terminalEvidence), + terminalEvidenceFinalText(resultTrace?.terminalEvidence), + terminalAssistantFinalEventText(firstArray(record?.events, record?.traceEvents, resultTrace?.events)) + ) ?? null; +} + +function terminalEvidenceFinalText(value: unknown): string | null { + const record = recordValue(value); + if (!record) return null; + return firstNonEmptyString( + finalResponseText(record.finalResponse), + messageText(record.assistantText), + messageText(record.finalText), + messageText(record.reply), + messageText(record.text), + messageText(record.content), + messageText(record.message), + terminalEvidenceFinalText(record.evidence), + terminalEvidenceFinalText(record.payload) + ) ?? null; +} + +function terminalAssistantFinalEventText(events: TraceEvent[]): string | null { + for (const event of [...events].reverse()) { + if (!traceEventHasTerminalEvidence(event)) continue; + const record = recordValue(event); + if (!record) continue; + const payload = recordValue(record.payload); + const text = firstNonEmptyString( + finalResponseText(record.finalResponse), + messageText(record.assistantText), + messageText(record.finalText), + messageText(record.reply), + messageText(record.text), + messageText(record.content), + messageText(record.message), + finalResponseText(payload?.finalResponse), + messageText(payload?.assistantText), + messageText(payload?.finalText), + messageText(payload?.reply), + messageText(payload?.text), + messageText(payload?.content), + messageText(payload?.message) + ); + if (text) return text; + } + return null; +} + export function firstPositiveFiniteNumber(...values: unknown[]): number | null { for (const value of values) { const number = firstFiniteNumber(value); diff --git a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts index bf75ef88..90abd21d 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts @@ -110,14 +110,15 @@ function reduceSessionDetail(state: WorkbenchServerState, session: WorkbenchSess const existing = state.sessionsById[sessionId]; const existingMessages = state.messagesBySessionId[sessionId] ?? existing?.messages ?? []; const messages = Array.isArray(session.messages) ? mergeMessageList(existingMessages, session.messages) : existingMessages; - const merged = mergeSessionRecord(existing, { ...session, messages }); - const sessionStatus = sessionStatusAuthorityFromDetail(session); + const messageStatus = sessionStatusAuthorityFromMessages(sessionId, messages); + const merged = mergeSessionRecord(existing, { ...session, status: messageStatus?.status ?? session.status, messages }); + const sessionStatus = messageStatus ?? sessionStatusAuthorityFromDetail(session); return { ...state, sessionOrder: state.sessionOrder.includes(sessionId) ? state.sessionOrder : [sessionId, ...state.sessionOrder], sessionsById: { ...state.sessionsById, [sessionId]: merged }, sessionStatusById: sessionStatus - ? { ...state.sessionStatusById, [sessionId]: sessionStatus } + ? { ...state.sessionStatusById, [sessionId]: mergeSessionStatusAuthority(state.sessionStatusById[sessionId], sessionStatus) } : state.sessionStatusById, messagesBySessionId: { ...state.messagesBySessionId, @@ -138,13 +139,14 @@ function reduceSessionListItem(state: WorkbenchServerState, session: WorkbenchSe const existing = state.sessionsById[sessionId]; const existingMessages = state.messagesBySessionId[sessionId] ?? existing?.messages; const messages = Array.isArray(existingMessages) ? existingMessages : Array.isArray(session.messages) ? session.messages : []; - const merged = mergeSessionRecord(existing, { ...session, messages }); - const sessionStatus = sessionStatusAuthorityFromDetail(session); + const messageStatus = sessionStatusAuthorityFromMessages(sessionId, messages); + const merged = mergeSessionRecord(existing, { ...session, status: messageStatus?.status ?? session.status, messages }); + const sessionStatus = messageStatus ?? sessionStatusAuthorityFromDetail(session); return { ...state, sessionsById: { ...state.sessionsById, [sessionId]: merged }, sessionStatusById: sessionStatus - ? { ...state.sessionStatusById, [sessionId]: sessionStatus } + ? { ...state.sessionStatusById, [sessionId]: mergeSessionStatusAuthority(state.sessionStatusById[sessionId], sessionStatus) } : state.sessionStatusById }; } @@ -154,10 +156,14 @@ function reduceSessionMessages(state: WorkbenchServerState, sessionId: string | const existing = state.sessionsById[sessionId]; const existingMessages = state.messagesBySessionId[sessionId] ?? existing?.messages ?? []; const mergedMessages = mergeMessageList(existingMessages, messages); - const session = existing ? { ...existing, messages: mergedMessages, messageCount: mergedMessages.length } : null; + const reconciledStatus = sessionStatusAuthorityFromMessages(sessionId, mergedMessages); + const session = existing ? { ...existing, status: reconciledStatus?.status ?? existing.status, messages: mergedMessages, messageCount: mergedMessages.length } : null; return { ...state, sessionsById: session ? { ...state.sessionsById, [sessionId]: session } : state.sessionsById, + sessionStatusById: reconciledStatus + ? { ...state.sessionStatusById, [sessionId]: mergeSessionStatusAuthority(state.sessionStatusById[sessionId], reconciledStatus) } + : state.sessionStatusById, messagesBySessionId: { ...state.messagesBySessionId, [sessionId]: mergedMessages } }; } @@ -224,18 +230,26 @@ function sealExistingRunningMessageTiming(existing: ChatMessage, incoming: ChatM function sealExistingTerminalMessageTiming(existing: ChatMessage, incoming: ChatMessage): ChatMessage { if (!isTerminalMessageStatus(existing.status)) return incoming; const timing = sealedTerminalTimingProjection(existing, incoming); - if (!timing || timing.durationMs == null) return incoming; - const patch: Partial = { - status: existing.status, + const patch: Partial = sealedTerminalMessagePatch(existing); + if (timing && timing.durationMs != null) Object.assign(patch, { timing, startedAt: timing.startedAt ?? null, lastEventAt: timing.lastEventAt ?? null, finishedAt: timing.finishedAt ?? null, - durationMs: timing.durationMs ?? null, + durationMs: timing.durationMs ?? null + }); + return { ...incoming, ...patch }; +} + +function sealedTerminalMessagePatch(existing: ChatMessage): Partial { + const patch: Partial = { + status: existing.status, traceAutoLifecycle: existing.traceAutoLifecycle ?? "terminal" }; if (typeof existing.text === "string" && existing.text.trim()) patch.text = existing.text; - return { ...incoming, ...patch }; + const finalResponse = (existing as Record).finalResponse; + if (finalResponse) patch.finalResponse = finalResponse; + return patch; } function sealTerminalTransitionMessageTiming(existing: ChatMessage, incoming: ChatMessage): ChatMessage { @@ -360,6 +374,72 @@ function sessionStatusAuthorityFromDetail(session: WorkbenchSessionRecord): Sess }; } +function sessionStatusAuthorityFromMessages(sessionId: string, messages: ChatMessage[]): SessionStatusAuthority | null { + const message = [...messages].reverse().find((item) => messageHasCompletedFinalResponse(item)); + if (!message) return null; + return { + sessionId, + status: "completed", + updatedAt: textValue(message.updatedAt) ?? textValue(message.finishedAt) ?? textValue(message.lastEventAt), + lastTraceId: textValue(message.traceId) ?? textValue(message.runnerTrace?.traceId), + projection: projectionFromMessageRecord(message), + loadedAt: new Date().toISOString() + }; +} + +function mergeSessionStatusAuthority(existing: SessionStatusAuthority | undefined, incoming: SessionStatusAuthority): SessionStatusAuthority { + if (existing?.status === "completed" && incoming.status !== "completed" && isSameTraceAuthority(existing, incoming) && isRunningSessionStatus(incoming.status)) return existing; + return { + ...(existing ?? {}), + ...incoming, + projection: incoming.projection ?? existing?.projection ?? null, + loadedAt: incoming.loadedAt ?? existing?.loadedAt ?? null + }; +} + +function isSameTraceAuthority(left: SessionStatusAuthority, right: SessionStatusAuthority): boolean { + const leftTrace = textValue(left.lastTraceId); + const rightTrace = textValue(right.lastTraceId); + return Boolean(leftTrace && rightTrace && leftTrace === rightTrace); +} + +function isRunningSessionStatus(value: unknown): boolean { + return ["", "pending", "running", "accepted", "queued", "dispatching", "streaming", "processing", "retrying", "busy", "creating"].includes(normalizedMessageStatus(value)); +} + +function messageHasCompletedFinalResponse(message: ChatMessage): boolean { + if (message.role !== "agent") return false; + if (normalizedMessageStatus(message.status) !== "completed") return false; + return Boolean(messageFinalResponseText(message)); +} + +function messageFinalResponseText(message: ChatMessage): string | null { + return textValue(message.text) ?? nestedTextValue((message as Record).finalResponse); +} + +function projectionFromMessageRecord(message: ChatMessage): ProjectionDiagnostic | null { + const direct = message.projection && typeof message.projection === "object" ? message.projection : null; + if (direct) return direct; + const trace = message.runnerTrace && typeof message.runnerTrace === "object" ? message.runnerTrace as Record : null; + const traceProjection = trace?.projection && typeof trace.projection === "object" ? trace.projection as ProjectionDiagnostic : null; + return traceProjection ?? null; +} + +function nestedTextValue(value: unknown): string | null { + const direct = textValue(value); + if (direct) return direct; + if (Array.isArray(value)) return value.map(nestedTextValue).filter((item): item is string => Boolean(item)).join("\n") || null; + if (!value || typeof value !== "object") return null; + const record = value as Record; + return textValue(record.text) ?? textValue(record.content) ?? textValue(record.message) ?? textValue(record.summary) ?? textValue(record.preview); +} + +function textValue(value: unknown): string | null { + if (typeof value !== "string") return null; + const text = value.trim(); + return text || null; +} + function projectionFromSessionRecord(session: WorkbenchSessionRecord): ProjectionDiagnostic | null { const turnSummary = session.turnSummary && typeof session.turnSummary === "object" ? session.turnSummary as Record : null; const direct = session.projection && typeof session.projection === "object" ? session.projection : null;