fix: seal workbench terminal final state

This commit is contained in:
UniDesk Codex
2026-07-01 17:35:59 +08:00
parent e1caa49e24
commit 95eb6fab11
5 changed files with 305 additions and 12 deletions
@@ -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()));
@@ -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",
@@ -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, {
@@ -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<ChatMessage> {
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);
@@ -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<ChatMessage> = {
status: existing.status,
const patch: Partial<ChatMessage> = 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<ChatMessage> {
const patch: Partial<ChatMessage> = {
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<string, unknown>).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<string, unknown>).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<string, unknown> : 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<string, unknown>;
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<string, unknown> : null;
const direct = session.projection && typeof session.projection === "object" ? session.projection : null;