fix(workbench): use single turn projection authority

This commit is contained in:
lyon
2026-06-19 04:17:28 +08:00
parent 6f13f5df2e
commit e5994d8af1
9 changed files with 433 additions and 115 deletions
@@ -611,6 +611,97 @@ test("workbench read model recovers terminal session status from durable trace",
}
});
test("workbench read model keeps non-terminal durable tool completed events out of turn status", async () => {
const traceStore = createCodeAgentTraceStore();
const results = createCodeAgentChatResultStore();
const traceId = "trc_workbench_durable_tool_completed_nonterminal";
const session = {
id: "ses_workbench_durable_tool_completed_nonterminal",
projectId: "prj_hwpod_workbench",
agentId: "hwlab-code-agent",
status: "running",
ownerUserId: ACTOR.id,
conversationId: "cnv_workbench_durable_tool_completed_nonterminal",
threadId: "thread-workbench-durable-tool-completed-nonterminal",
lastTraceId: traceId,
updatedAt: "2026-06-18T19:35:51.787Z",
session: {
sessionStatus: "running",
lastTraceId: traceId,
messages: [
{ role: "user", text: "write benchmark", traceId, status: "sent" },
{ role: "agent", text: "", traceId, status: "running" }
],
valuesRedacted: true,
secretMaterialStored: false
}
};
traceStore.append(traceId, { seq: 1, type: "backend_status", status: "running", label: "runner:created", terminal: false });
const durableEvents = [
{ traceId, seq: 1, type: "assistant_message", status: "running", label: "agentrun:assistant:message", terminal: false, message: "正在安装 Python。", createdAt: "2026-06-18T19:35:40.000Z", valuesPrinted: false },
{ traceId, seq: 2, type: "commandExecution", status: "completed", label: "item/commandExecution:completed", terminal: false, command: "ls -ld .", stdout: "OK\n", createdAt: "2026-06-18T19:35:51.787Z", valuesPrinted: false }
];
results.set(traceId, {
status: "running",
traceId,
ownerUserId: ACTOR.id,
conversationId: session.conversationId,
sessionId: session.id,
threadId: session.threadId,
finalResponse: null,
assistantText: null,
agentRun: { runId: "run_workbench_nonterminal_tool_completed", commandId: "cmd_workbench_nonterminal_tool_completed", status: "runner-job-created", runStatus: "pending", commandState: "pending" }
});
const accessController = {
store: {
async listAgentSessionsForUser() { return [session]; },
async getAgentSession(sessionId) { return sessionId === session.id ? session : null; },
async getAgentSessionByTraceId(requestTraceId) { return requestTraceId === traceId ? session : null; }
},
async ensureBootstrap() {},
async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; }
};
const runtimeStore = {
async queryAgentTraceEvents(params = {}) {
assert.equal(params.traceId, traceId);
return { events: durableEvents, count: durableEvents.length };
}
};
const server = createCloudApiServer({ accessController, traceStore, runtimeStore, codeAgentChatResults: results });
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const sessions = await getJson(port, `/v1/workbench/sessions?includeSessionId=${encodeURIComponent(session.id)}`);
assert.equal(sessions.status, 200);
assert.equal(sessions.body.sessions[0].status, "running");
assert.equal(sessions.body.sessions[0].running, true);
assert.equal(sessions.body.sessions[0].terminal, false);
const messages = await getJson(port, `/v1/workbench/sessions/${encodeURIComponent(session.id)}/messages?limit=10`);
assert.equal(messages.status, 200);
assert.equal(messages.body.messages[1].status, "running");
assert.equal(messages.body.messages[1].text, "");
const turn = await getJson(port, `/v1/workbench/turns/${encodeURIComponent(traceId)}`);
assert.equal(turn.status, 200);
assert.equal(turn.body.status, "running");
assert.equal(turn.body.turn.status, "running");
assert.equal(turn.body.turn.running, true);
assert.equal(turn.body.turn.terminal, false);
assert.equal(turn.body.turn.finalResponse, null);
assert.equal(turn.body.projectionStatus, "projecting");
const trace = await getJson(port, `/v1/workbench/traces/${encodeURIComponent(traceId)}/events?limit=10`);
assert.equal(trace.status, 200);
assert.equal(trace.body.events.at(-1).status, "completed");
assert.equal(trace.body.events.at(-1).terminal, false);
assert.equal(trace.body.projectionStatus, "projecting");
} finally {
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
test("workbench realtime stream accepts session authority without project or workspace", async () => {
const traceStore = createCodeAgentTraceStore();
const results = createCodeAgentChatResultStore();
+19 -39
View File
@@ -14,13 +14,11 @@ import {
sendJson
} from "./server-http-utils.ts";
import { createWorkbenchReadModel } from "./workbench-read-model.ts";
import { createWorkbenchTurnProjection, RUNNING_STATUSES, TERMINAL_STATUSES } from "./workbench-turn-projection.ts";
const DEFAULT_PAGE_LIMIT = 50;
const MAX_PAGE_LIMIT = 100;
const DEFAULT_WORKBENCH_SSE_HEARTBEAT_MS = 15000;
const TERMINAL_STATUSES = new Set(["completed", "failed", "blocked", "timeout", "cancelled", "canceled", "idle"]);
const RUNNING_STATUSES = new Set(["running", "pending", "queued", "accepted", "dispatching", "streaming", "active"]);
export async function handleWorkbenchReadModelHttp(request, response, url, options = {}) {
const perf = options.backendPerformance;
const auth = perf ? await perf.measure("workbench_auth", () => authenticateWorkbenchRead(request, response, options)) : await authenticateWorkbenchRead(request, response, options);
@@ -296,15 +294,16 @@ async function handleWorkbenchTurnSnapshot(response, url, options, actor, rawTur
}
const resultVisible = result && (session || actor.role === "admin" || Boolean(result.ownerUserId));
const trace = await readModel.traceSnapshot(traceId);
const status = workbenchTurnStatusFromFacts({ trace, result });
const turnProjection = createWorkbenchTurnProjection({ turnId, traceId, result, session, trace });
const status = turnProjection.status;
const found = Boolean(resultVisible || session);
if (!found) return sendJson(response, 404, workbenchError("workbench_turn_not_found", "Workbench turn is not visible to the current actor.", { turnId, traceId }));
const projection = readModel.projectionDiagnostics({ traceId, result, trace });
const projection = readModel.projectionDiagnostics({ traceId, result, trace, projection: turnProjection });
sendJson(response, 200, {
ok: true,
status,
contractVersion: "workbench-turn-snapshot-v1",
turn: turnSnapshot({ turnId, traceId, status, result, session, trace }),
turn: turnSnapshot({ projection: turnProjection, result, session, trace }),
projection,
projectionStatus: projection.projectionStatus,
lastProjectedSeq: projection.lastProjectedSeq,
@@ -331,7 +330,8 @@ async function handleWorkbenchTraceEventPage(response, url, options, actor, rawT
}
const trace = await readModel.traceSnapshot(traceId);
const page = traceEventPage(trace, tracePageOptions(url));
const projection = readModel.projectionDiagnostics({ traceId, result, trace });
const turnProjection = createWorkbenchTurnProjection({ traceId, result, session, trace });
const projection = readModel.projectionDiagnostics({ traceId, result, trace, projection: turnProjection });
sendJson(response, 200, {
ok: true,
status: "succeeded",
@@ -384,7 +384,8 @@ async function sessionProjectionOptions(readModel, session, options) {
if (!traceId) return options;
const trace = await readModel.traceSnapshot(traceId);
const result = readModel.resultForTrace(traceId);
return { ...options, trace, result };
const projection = createWorkbenchTurnProjection({ traceId, result, session, trace });
return { ...options, trace, result, projection };
}
function sessionSummary(session, options) {
@@ -459,7 +460,7 @@ function terminalMessageProjection(snapshot, options, currentTurn) {
const trace = currentTurn.trace;
const status = currentTurn.status;
if (!TERMINAL_STATUSES.has(status) || RUNNING_STATUSES.has(status)) return null;
const text = projectionText(result?.finalResponse, result?.assistantText, result?.reply, result?.text, result?.summary, trace?.finalResponse, trace?.terminalEvidence?.finalResponse, snapshot.finalResponse);
const text = projectionText(currentTurn.finalResponse, result?.finalResponse, result?.assistantText, result?.reply, result?.text, result?.summary, trace?.finalResponse, trace?.terminalEvidence?.finalResponse, snapshot.finalResponse);
return { traceId, status, text, source: "turn-projection" };
}
@@ -544,11 +545,14 @@ function partFact(part, index, messageId, traceId) {
};
}
function turnSnapshot({ turnId, traceId, status, result, session, trace }) {
function turnSnapshot({ projection, result, session, trace }) {
const turnId = projection.turnId;
const traceId = projection.traceId;
const status = projection.status;
const messages = session ? sessionMessages(session, { result, trace }) : [];
const userMessage = messages.find((message) => message.role === "user") ?? null;
const assistantMessage = [...messages].reverse().find((message) => isAssistantLikeRole(message.role)) ?? null;
const finalText = projectionText(result?.finalResponse, result?.assistantText, result?.reply, result?.text, trace?.finalResponse, trace?.terminalEvidence?.finalResponse, assistantMessage?.text);
const finalText = projectionText(projection.finalResponse);
return {
turnId,
traceId,
@@ -560,7 +564,7 @@ function turnSnapshot({ turnId, traceId, status, result, session, trace }) {
userMessageId: userMessage?.messageId ?? null,
assistantMessageId: assistantMessage?.messageId ?? null,
assistantText: finalText ?? null,
finalResponse: finalText ? { text: finalText, status, traceId, valuesPrinted: false } : null,
finalResponse: projection.finalResponse ?? null,
agentRun: result?.agentRun ?? null,
trace: {
traceId,
@@ -657,8 +661,8 @@ async function visibleTraceContext(options, actor, traceId) {
const resultVisible = result && (session || actor.role === "admin" || Boolean(result.ownerUserId));
if (!resultVisible && !session) return { visible: false };
const trace = await readModel.traceSnapshot(traceId);
const status = workbenchTurnStatusFromFacts({ trace, result });
return { visible: true, turnId: traceId, traceId, status, result, session, trace };
const projection = createWorkbenchTurnProjection({ turnId: traceId, traceId, result, session, trace });
return { visible: true, turnId: projection.turnId, traceId, status: projection.status, projection, result, session, trace };
}
function traceSnapshotForRealtime(snapshot) {
@@ -695,15 +699,7 @@ function currentWorkbenchTurnProjection(session, options = {}) {
if (!traceId) return null;
const trace = options.trace?.traceId === traceId ? options.trace : traceSnapshotSync(options, traceId);
const result = traceScopedResult(options, traceId);
const status = workbenchTurnStatusFromFacts({ trace, result });
return {
turnId: traceId,
traceId,
status,
trace,
eventCount: Number.isFinite(Number(trace?.eventCount)) ? Number(trace.eventCount) : null,
updatedAt: trace?.updatedAt ?? session?.updatedAt ?? null
};
return createWorkbenchTurnProjection({ turnId: traceId, traceId, result, session, trace });
}
function traceScopedResult(options, traceId) {
@@ -711,22 +707,6 @@ function traceScopedResult(options, traceId) {
return result && typeof result === "object" ? result : null;
}
function workbenchTurnStatusFromFacts({ trace = null, result = null } = {}) {
const resultStatus = normalizeStatus(result?.status ?? result?.terminalStatus);
if (TERMINAL_STATUSES.has(resultStatus) && !RUNNING_STATUSES.has(resultStatus)) return resultStatus;
const traceStatus = workbenchTurnProjectionStatus(trace);
if (traceStatus !== "unknown") return traceStatus;
if (RUNNING_STATUSES.has(resultStatus)) return resultStatus;
return "unknown";
}
function workbenchTurnProjectionStatus(trace) {
const status = normalizeStatus(trace?.status);
if (status === "missing") return "unknown";
if (RUNNING_STATUSES.has(status) || TERMINAL_STATUSES.has(status)) return status;
return "unknown";
}
function sessionLifecycleProjectionStatus(session) {
return normalizeStatus(session?.status);
}
+5 -17
View File
@@ -4,6 +4,7 @@
*/
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts";
import { safeConversationId, safeSessionId, safeTraceId } from "./server-http-utils.ts";
import { durableTraceStatus, normalizeWorkbenchStatus, TERMINAL_STATUSES, traceTerminalEvidence } from "./workbench-turn-projection.ts";
export function createWorkbenchFactsStore(options = {}, actor = null) {
const accessStore = options.accessController?.store ?? options.accessController ?? null;
@@ -111,6 +112,7 @@ async function durableTraceSnapshot(runtimeStore, traceId) {
const firstEvent = normalizedEvents[0] ?? null;
const lastEvent = normalizedEvents.at(-1) ?? null;
const status = durableTraceStatus(normalizedEvents);
const terminalEvidence = traceTerminalEvidence({ events: normalizedEvents, status });
return {
traceId,
status,
@@ -123,14 +125,12 @@ async function durableTraceSnapshot(runtimeStore, traceId) {
assistantStreams: [],
eventLabels: normalizedEvents.map((event) => event.label).filter(Boolean),
lastEvent,
terminalEvidence,
outputTruncated: normalizedEvents.some((event) => event.outputTruncated === true),
valuesPrinted: false
};
}
const TERMINAL_STATUSES = new Set(["completed", "failed", "blocked", "timeout", "cancelled", "canceled", "idle"]);
const RUNNING_STATUSES = new Set(["running", "pending", "queued", "accepted", "dispatching", "streaming", "active"]);
function hasTraceProjection(snapshot) {
return Boolean(snapshot?.status !== "missing" && (Number(snapshot?.eventCount ?? 0) > 0 || snapshot?.lastEvent));
}
@@ -138,8 +138,8 @@ function hasTraceProjection(snapshot) {
function shouldPreferDurableTrace(memory, durable) {
if (!durable) return false;
if (!hasTraceProjection(memory)) return true;
const memoryStatus = normalizeStatus(memory?.status);
const durableStatus = normalizeStatus(durable?.status);
const memoryStatus = normalizeWorkbenchStatus(memory?.status);
const durableStatus = normalizeWorkbenchStatus(durable?.status);
if (TERMINAL_STATUSES.has(durableStatus) && !TERMINAL_STATUSES.has(memoryStatus)) return true;
return traceLastSeq(durable) > traceLastSeq(memory);
}
@@ -151,18 +151,6 @@ function traceLastSeq(snapshot) {
return Math.max(indexedMax, lastEvent ? eventSeq(lastEvent, Math.max(0, events.length - 1)) : 0);
}
function durableTraceStatus(events) {
const lastEvent = events.at(-1) ?? null;
const normalized = normalizeStatus(lastEvent?.status ?? lastEvent?.type);
if (lastEvent?.terminal === true) {
if (normalized === "observed" || normalized === "event" || normalized === "unknown") return "completed";
return normalized;
}
if (TERMINAL_STATUSES.has(normalized) || RUNNING_STATUSES.has(normalized)) return normalized;
if (["failed", "error", "timeout"].includes(normalized)) return normalized;
return "running";
}
function canActorReadSession(session, actor) {
if (!session || !actor) return false;
if (normalizeStatus(session.status) === "archived") return false;
+2 -44
View File
@@ -3,6 +3,7 @@
* 职责: WorkbenchReadModel 组件入口。只通过 WorkbenchFactsStore 读取 durable projection facts,不执行 AgentRun sync/finalize/repair。
*/
import { createWorkbenchFactsStore } from "./workbench-facts-store.ts";
import { projectionDiagnostics as workbenchProjectionDiagnostics } from "./workbench-turn-projection.ts";
export function createWorkbenchReadModel(options = {}, actor = null) {
const facts = createWorkbenchFactsStore(options, actor);
@@ -17,49 +18,6 @@ export function createWorkbenchReadModel(options = {}, actor = null) {
traceSnapshotSync: (traceId) => facts.traceSnapshotSync(traceId),
subscribeTrace: (traceId, listener) => facts.subscribeTrace(traceId, listener),
canReadOwner: (ownerUserId) => facts.canReadOwner(ownerUserId),
projectionDiagnostics: ({ traceId, result = null, trace = null } = {}) => projectionDiagnostics({ traceId, result, trace })
projectionDiagnostics: ({ traceId, result = null, trace = null, projection = null } = {}) => workbenchProjectionDiagnostics({ traceId, result, trace, projection })
};
}
export function projectionDiagnostics({ traceId, result = null, trace = null } = {}) {
const agentRun = result?.agentRun && typeof result.agentRun === "object" ? result.agentRun : null;
const events = Array.isArray(trace?.events) ? trace.events : [];
const lastEvent = trace?.lastEvent ?? events.at(-1) ?? null;
const lastProjectedSeq = Number(lastEvent?.seq ?? trace?.lastProjectedSeq ?? 0);
const hasTraceProjection = Boolean(trace && trace.status !== "missing" && (events.length > 0 || lastEvent));
const sourceRunId = agentRun?.runId ?? lastEvent?.runId ?? null;
const sourceCommandId = agentRun?.commandId ?? lastEvent?.commandId ?? null;
const blocker = result?.blocker ?? result?.error ?? null;
const status = blocker
? "blocked"
: !hasTraceProjection && (result || agentRun)
? "projecting"
: isTerminalStatus(result?.status ?? trace?.status)
? "caught-up"
: result || trace?.status !== "missing"
? "projecting"
: "unknown";
return {
projectionStatus: status,
lastProjectedSeq: Number.isFinite(lastProjectedSeq) && lastProjectedSeq > 0 ? Math.trunc(lastProjectedSeq) : null,
sourceRunId,
sourceCommandId,
blocker: blocker ? diagnosticBlocker(blocker) : null,
updatedAt: trace?.updatedAt ?? result?.updatedAt ?? null,
valuesRedacted: true
};
}
function diagnosticBlocker(value = {}) {
if (!value || typeof value !== "object") return { code: "projection_blocked", summary: String(value), valuesRedacted: true };
return {
code: String(value.code ?? value.errorCode ?? "projection_blocked"),
summary: String(value.summary ?? value.userMessage ?? value.message ?? value.code ?? "Projection is blocked."),
retryable: typeof value.retryable === "boolean" ? value.retryable : null,
valuesRedacted: true
};
}
function isTerminalStatus(value) {
return ["completed", "failed", "blocked", "timeout", "cancelled", "canceled", "idle"].includes(String(value ?? "").trim().toLowerCase());
}
+203
View File
@@ -0,0 +1,203 @@
/*
* SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-010403 API契约 draft-2026-06-18-r1.
* 职责: 生成唯一 Workbench turn projectiontrace event 状态只作为输入证据,不直接成为 turn lifecycle。
*/
export const TERMINAL_STATUSES = new Set(["completed", "failed", "blocked", "timeout", "cancelled", "canceled", "idle"]);
export const RUNNING_STATUSES = new Set(["running", "pending", "queued", "accepted", "dispatching", "streaming", "active", "processing", "busy", "creating"]);
export function createWorkbenchTurnProjection({ turnId = null, traceId = null, result = null, session = null, trace = null } = {}) {
const projectionTraceId = textValue(traceId ?? trace?.traceId ?? result?.traceId ?? session?.lastTraceId) || null;
const projectionTurnId = textValue(turnId) || projectionTraceId;
const terminalEvidence = terminalTurnEvidence({ result, trace });
const activeEvidence = activeTurnEvidence({ result, session, trace });
const status = terminalEvidence?.status ?? activeEvidence?.status ?? "unknown";
const running = RUNNING_STATUSES.has(status);
const terminal = Boolean(terminalEvidence && TERMINAL_STATUSES.has(status) && !running);
const finalText = terminal ? projectionText(result?.finalResponse, result?.assistantText, result?.reply, result?.text, result?.summary, trace?.finalResponse, trace?.terminalEvidence?.finalResponse) : null;
const agentRun = objectValue(result?.agentRun ?? trace?.agentRun);
const lastEvent = traceLastEvent(trace);
return {
turnId: projectionTurnId,
traceId: projectionTraceId,
status,
running,
terminal,
source: terminalEvidence?.source ?? activeEvidence?.source ?? null,
terminalEvidence,
finalResponse: finalText ? { text: finalText, status, traceId: projectionTraceId, valuesPrinted: false } : null,
assistantText: finalText,
lastProjectedSeq: traceLastSeq(trace),
sourceRunId: agentRun?.runId ?? lastEvent?.runId ?? lastEvent?.payload?.runId ?? null,
sourceCommandId: agentRun?.commandId ?? lastEvent?.commandId ?? lastEvent?.payload?.commandId ?? null,
eventCount: normalizedEventCount(trace),
updatedAt: trace?.updatedAt ?? result?.updatedAt ?? session?.updatedAt ?? null,
valuesRedacted: true
};
}
export function projectionDiagnostics({ traceId = null, projection = null, result = null, trace = null } = {}) {
const turn = projection ?? createWorkbenchTurnProjection({ traceId, result, trace });
const blocker = result?.blocker ?? result?.error ?? null;
const hasProjectionInput = hasTraceProjection(trace) || Boolean(result || result?.agentRun);
const status = blocker ? "blocked" : turn.terminal ? "caught-up" : hasProjectionInput ? "projecting" : "unknown";
return {
projectionStatus: status,
lastProjectedSeq: turn.lastProjectedSeq ?? null,
sourceRunId: turn.sourceRunId ?? null,
sourceCommandId: turn.sourceCommandId ?? null,
blocker: blocker ? diagnosticBlocker(blocker) : null,
updatedAt: turn.updatedAt ?? null,
valuesRedacted: true
};
}
export function durableTraceStatus(events = []) {
const terminal = terminalTraceEventEvidence(events);
if (terminal) return terminal.status;
const activeStatus = activeTraceEventStatus(events);
return activeStatus ?? (events.length > 0 ? "running" : "missing");
}
export function traceTerminalEvidence(trace = null) {
const direct = objectValue(trace?.terminalEvidence);
if (direct) {
const status = terminalStatusFromValue(direct.status ?? direct.terminalStatus ?? trace?.status) ?? "completed";
return { source: "trace-terminal-evidence", status, evidence: direct, valuesRedacted: true };
}
const finalResponse = objectValue(trace?.finalResponse);
if (finalResponse) {
const status = terminalStatusFromValue(finalResponse.status ?? trace?.status) ?? "completed";
return { source: "trace-final-response", status, evidence: { textPresent: Boolean(projectionText(finalResponse)), valuesRedacted: true }, valuesRedacted: true };
}
const events = Array.isArray(trace?.events) ? trace.events : [];
return terminalTraceEventEvidence(events);
}
export function normalizeWorkbenchStatus(value) {
const text = String(value ?? "").trim().toLowerCase().replace(/_/gu, "-");
if (text === "cancelled") return "canceled";
return text || "unknown";
}
function terminalTurnEvidence({ result = null, trace = null } = {}) {
const resultStatus = terminalStatusFromValue(result?.status ?? result?.terminalStatus ?? result?.agentRun?.terminalStatus);
if (resultStatus) return { source: "result", status: resultStatus, valuesRedacted: true };
return traceTerminalEvidence(trace);
}
function activeTurnEvidence({ result = null, session = null, trace = null } = {}) {
const resultStatus = firstStatus([
result?.status,
result?.agentRun?.status,
result?.agentRun?.commandState,
result?.agentRun?.runStatus
], RUNNING_STATUSES);
if (resultStatus) return { source: "result", status: normalizeActiveStatus(resultStatus), valuesRedacted: true };
const traceStatus = firstStatus([trace?.status, trace?.traceStatus], RUNNING_STATUSES);
if (traceStatus) return { source: "trace", status: normalizeActiveStatus(traceStatus), valuesRedacted: true };
if (hasTraceProjection(trace)) return { source: "trace-events", status: "running", valuesRedacted: true };
const sessionStatus = firstStatus([session?.status, session?.session?.sessionStatus], RUNNING_STATUSES);
if (sessionStatus) return { source: "session", status: normalizeActiveStatus(sessionStatus), valuesRedacted: true };
return null;
}
function firstStatus(values, accepted) {
for (const value of values) {
const status = normalizeWorkbenchStatus(value);
if (accepted.has(status)) return status;
}
return null;
}
function terminalStatusFromValue(value) {
const status = normalizeWorkbenchStatus(value);
return TERMINAL_STATUSES.has(status) && !RUNNING_STATUSES.has(status) ? status : null;
}
function normalizeActiveStatus(status) {
return status === "active" || status === "busy" || status === "processing" || status === "creating" ? "running" : status;
}
function terminalTraceEventEvidence(events = []) {
for (const event of [...events].reverse()) {
if (!event || typeof event !== "object") continue;
const terminal = event.terminal === true || event.final === true || event.replyAuthority === true;
if (!terminal) continue;
const status = terminalStatusFromValue(event.status ?? event.terminalStatus) ?? (event.error || event.errorCode ? "failed" : "completed");
return {
source: "trace-terminal-event",
status,
seq: eventSeq(event, events.indexOf(event)),
eventType: textValue(event.type ?? event.label) || null,
valuesRedacted: true
};
}
return null;
}
function activeTraceEventStatus(events = []) {
for (const event of [...events].reverse()) {
const status = normalizeWorkbenchStatus(event?.status ?? event?.type);
if (RUNNING_STATUSES.has(status)) return normalizeActiveStatus(status);
}
return null;
}
function hasTraceProjection(trace) {
return Boolean(trace && trace.status !== "missing" && (normalizedEventCount(trace) > 0 || traceLastEvent(trace)));
}
function normalizedEventCount(trace) {
const events = Array.isArray(trace?.events) ? trace.events : [];
const count = Number(trace?.eventCount ?? events.length);
return Number.isFinite(count) && count >= 0 ? Math.trunc(count) : events.length;
}
function traceLastSeq(trace) {
const events = Array.isArray(trace?.events) ? trace.events : [];
const lastEvent = traceLastEvent(trace);
const indexedMax = events.reduce((max, event, index) => Math.max(max, eventSeq(event, index)), 0);
return Math.max(indexedMax, lastEvent ? eventSeq(lastEvent, Math.max(0, events.length - 1)) : 0) || null;
}
function traceLastEvent(trace) {
const events = Array.isArray(trace?.events) ? trace.events : [];
return trace?.lastEvent ?? events.at(-1) ?? null;
}
function eventSeq(event, index) {
const seq = Number(event?.seq);
return Number.isFinite(seq) && seq > 0 ? Math.trunc(seq) : index + 1;
}
function projectionText(...values) {
for (const value of values) {
if (value && typeof value === "object") {
const nested = textValue(value.text ?? value.content ?? value.message ?? value.summary ?? value.preview ?? value.title);
if (nested) return nested;
continue;
}
const text = textValue(value);
if (text) return text;
}
return null;
}
function objectValue(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
}
function textValue(value) {
return String(value ?? "").trim();
}
function diagnosticBlocker(value = {}) {
if (!value || typeof value !== "object") return { code: "projection_blocked", summary: String(value), valuesRedacted: true };
return {
code: String(value.code ?? value.errorCode ?? "projection_blocked"),
summary: String(value.summary ?? value.userMessage ?? value.message ?? value.code ?? "Projection is blocked."),
retryable: typeof value.retryable === "boolean" ? value.retryable : null,
valuesRedacted: true
};
}
@@ -280,6 +280,7 @@ function createScenarioState(scenarioId: string): ScenarioState {
}
if (id === "progress-only-final-response") markRunningProgressOnly(sessions, traces);
if (id === "terminal-completed-no-final-response") markRunningNoFinalResponse(sessions, traces);
if (id === "tool-completed-projection-running") markToolCompletedProjectionRunning(sessions, traces);
if (id === "scroll-follow-long-trace") {
const session = scrollFollowSession();
sessions.unshift(session);
@@ -302,7 +303,7 @@ function createScenarioState(scenarioId: string): ScenarioState {
? "ses_completed"
: id === "terminal-empty-trace"
? "ses_terminal_empty"
: id === "progress-only-final-response" || id === "terminal-completed-no-final-response"
: id === "progress-only-final-response" || id === "terminal-completed-no-final-response" || id === "tool-completed-projection-running"
? "ses_running"
: base.selectedSessionId;
const staleTraceId = id === "stale-nested-trace" || id === "stale-submit-restore" ? "trc_stale_502" : null;
@@ -370,6 +371,44 @@ function markRunningNoFinalResponse(sessions: SessionRecord[], traces: Record<st
});
}
function markToolCompletedProjectionRunning(sessions: SessionRecord[], traces: Record<string, JsonRecord>): void {
const trace = toolCompletedProjectionRunningTrace();
traces.trc_running = trace;
const session = sessions.find((item) => item.sessionId === "ses_running");
if (!session) return;
session.status = "running";
session.lastTraceId = "trc_running";
session.firstUserMessagePreview = "tool completed but turn still running";
session.messages = (session.messages ?? []).map((message) => {
if (message.role === "user") return { ...message, text: "tool completed but turn still running" };
if (message.role !== "agent" || message.traceId !== "trc_running") return message;
return { ...message, text: "", status: "running", runnerTrace: trace };
});
}
function toolCompletedProjectionRunningTrace(): JsonRecord {
const createdAt = new Date().toISOString();
const events = [
{ seq: 1, createdAt, label: "agentrun:assistant:message", type: "assistant_message", status: "running", terminal: false, message: "正在安装 Python。" },
{ seq: 2, createdAt, label: "item/commandExecution:completed", type: "commandExecution", status: "completed", terminal: false, command: "ls -ld .", stdout: "OK\n" }
];
return {
traceId: "trc_running",
status: "completed",
turnStatus: "running",
projectionStatus: "projecting",
sessionId: "ses_running",
threadId: "thr_running",
turnId: "turn_running",
events,
eventCount: events.length,
fullTraceLoaded: true,
hasMore: false,
finalResponse: null,
assistantText: null
};
}
function noFinalRunningTrace(): JsonRecord {
const createdAt = new Date().toISOString();
return {
@@ -862,6 +901,7 @@ function turnPayload(traceId: string): JsonRecord {
return state.traces[traceId] ?? liveBackfillEarlyTrace(sessionId, threadId, traceId);
}
const trace = state.traces[traceId] ?? { traceId, status: "unknown", events: [] };
if (state.scenarioId === "tool-completed-projection-running" && traceId === "trc_running") return { ...trace, traceId, status: "running", running: true, terminal: false, projectionStatus: "projecting", trace: { status: trace.status, eventCount: trace.eventCount ?? 0 } };
if (state.scenarioId === "terminal-turn-stale-session-active" && traceId === "trc_completed") return { ...trace, traceId, status: "active", running: true, terminal: false, trace: { status: "completed", eventCount: trace.eventCount ?? 0 } };
const status = String(trace.status ?? "unknown");
return { ...trace, traceId, status, running: ["running", "pending", "accepted"].includes(status), terminal: ["completed", "failed", "blocked", "timeout", "canceled"].includes(status) };
@@ -882,6 +922,9 @@ function tracePayload(traceId: string, url: URL): JsonRecord {
function workbenchTracePayload(traceId: string, url: URL): JsonRecord {
const payload = tracePayload(traceId, url);
if (state.scenarioId === "tool-completed-projection-running" && traceId === "trc_running") {
return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, sessionId: payload.sessionId ?? null, threadId: payload.threadId ?? null, traceStatus: "completed", events: payload.events, eventCount: payload.eventCount, hasMore: payload.hasMore, nextSeq: payload.nextSinceSeq, range: payload.range, fullTraceLoaded: payload.fullTraceLoaded, projectionStatus: "projecting", terminalEvidence: null, finalResponse: null, traceSummary: payload.traceSummary, retention: payload.retention };
}
return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, sessionId: payload.sessionId ?? null, threadId: payload.threadId ?? null, traceStatus: payload.status, events: payload.events, eventCount: payload.eventCount, hasMore: payload.hasMore, nextSeq: payload.nextSinceSeq, range: payload.range, fullTraceLoaded: payload.fullTraceLoaded, terminalEvidence: payload.terminalEvidence, finalResponse: payload.finalResponse, traceSummary: payload.traceSummary, retention: payload.retention };
}
+2 -8
View File
@@ -91,7 +91,8 @@ function normalizeWorkbenchTurnResult(result: ApiResult<Record<string, unknown>>
if (!result.ok || !result.data) return result as ApiResult<AgentChatResultResponse>;
const turn = recordValue(result.data.turn) ?? result.data;
const trace = recordValue(turn.trace);
const status = canonicalWorkbenchStatus(textValue(turn.status) ?? textValue(result.data.status) ?? "unknown", textValue(trace?.status, result.data.traceStatus));
const envelopeStatus = textValue(result.data.status);
const status = normalizeStatus(textValue(turn.status) ?? (envelopeStatus && !["ok", "succeeded", "found"].includes(envelopeStatus) ? envelopeStatus : null)) ?? "unknown";
const terminal = turn.terminal === true || isTerminalStatus(status);
return {
...result,
@@ -148,13 +149,6 @@ function normalizeWorkbenchTraceResult(result: ApiResult<Record<string, unknown>
};
}
function canonicalWorkbenchStatus(primary: string | null, traceStatus: string | null): string {
const primaryStatus = normalizeStatus(primary);
const trace = normalizeStatus(traceStatus);
if (trace && isTerminalStatus(trace) && (!primaryStatus || isActiveStatus(primaryStatus) || !isTerminalStatus(primaryStatus))) return trace;
return primaryStatus ?? trace ?? "unknown";
}
function normalizeStatus(value: unknown): string | null {
const text = textValue(value);
if (!text) return null;
+23 -6
View File
@@ -729,7 +729,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (!id || !snapshot) return;
if (!shouldApplyActiveTraceAuthority(id, traceResultSessionId(snapshot))) return;
applyTraceSnapshot(id, realtimeSnapshotToTraceSnapshot(id, snapshot));
if (isTerminalMessageStatus(snapshot.status)) void refreshTerminalTraceFromRest(id, "realtime-trace-snapshot");
if (traceSnapshotHasTerminalEvidence(snapshot)) void refreshTerminalTraceFromRest(id, "realtime-trace-snapshot");
}
function applyRealtimeTraceEvent(traceId: string | null | undefined, event: WorkbenchRealtimeEvent["event"], snapshot: WorkbenchRealtimeEvent["snapshot"], realtimeEvent?: WorkbenchRealtimeEvent | null): void {
@@ -740,7 +740,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const events = event ? [event] : Array.isArray(snapshot?.events) ? snapshot.events : [];
markWorkbenchTraceEventsReceived({ traceId: id, events, transport: "sse", serverSentAt: realtimeEvent?.serverSentAt, eventCreatedAt: realtimeEvent?.eventCreatedAt, traceSeq: realtimeEvent?.traceSeq ?? realtimeEvent?.cursor?.traceSeq });
applyTraceSnapshot(id, realtimeSnapshotToTraceSnapshot(id, snapshot ?? { traceId: id, status: event?.status, events }, events));
if (event?.terminal === true || isTerminalMessageStatus(event?.status) || isTerminalMessageStatus(snapshot?.status)) void refreshTerminalTraceFromRest(id, "realtime-trace-event");
if (traceEventHasTerminalEvidence(event) || traceSnapshotHasTerminalEvidence(snapshot)) void refreshTerminalTraceFromRest(id, "realtime-trace-event");
}
function applyRealtimeTurnSnapshot(turn: Record<string, unknown>): void {
@@ -836,16 +836,20 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
function applyTraceSnapshot(traceId: string, snapshot: TraceSnapshot): void {
const trace = snapshotToRunnerTrace(snapshot);
const turn = turnStatusAuthority.value[traceId];
const trace = snapshotToRunnerTrace({
...snapshot,
status: firstNonEmptyString(turn?.status, snapshot.status) ?? snapshot.status,
traceStatus: firstNonEmptyString(snapshot.traceStatus, snapshot.status) ?? undefined
});
const authoritySessionId = traceResultSessionId(trace) ?? traceResultSessionId(snapshot);
if (!shouldApplyActiveTraceAuthority(traceId, authoritySessionId)) return;
rememberTurnStatus(traceId, snapshot);
const status = statusFromResult(snapshot.status);
const terminal = isTerminalMessageStatus(status);
updateActiveMessages((source) => source.map((message) => {
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
const runnerTrace = mergeRunnerTrace(message.runnerTrace, trace);
rememberTraceAuthority(runnerTrace);
const status = statusFromResult(firstNonEmptyString(turn?.status, message.status) ?? message.status);
const terminal = turn?.terminal === true || isTerminalMessageStatus(turn?.status);
const traceAssistantText = message.role === "agent" ? assistantTextFromTraceEvents(Array.isArray(runnerTrace.events) ? runnerTrace.events : []) : null;
const error = message.role === "agent" ? normalizeAgentError(runnerTrace.error ?? message.error) : normalizeAgentError(message.error);
const errorText = message.role === "agent" ? agentErrorDisplayText(error) : null;
@@ -1257,6 +1261,19 @@ function isTerminalMessageStatus(value: unknown): boolean {
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(value ?? "").trim().toLowerCase().replace(/_/gu, "-"));
}
function traceEventHasTerminalEvidence(event: unknown): boolean {
const record = recordValue(event);
return record?.terminal === true || record?.final === true || record?.replyAuthority === true;
}
function traceSnapshotHasTerminalEvidence(snapshot: unknown): boolean {
const record = recordValue(snapshot);
if (!record) return false;
if (record.terminal === true || record.terminalEvidence || record.finalResponse) return true;
const events = Array.isArray(record.events) ? record.events : [];
return events.some((event) => traceEventHasTerminalEvidence(event));
}
function recordValue(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? value as Record<string, unknown> : null;
}
@@ -43,6 +43,50 @@ test.describe("terminal completed without final response", () => {
});
});
test.describe("non-terminal tool completed event", () => {
test.use({ scenarioId: "tool-completed-projection-running" });
test("keeps turn, session card, and trace header running", async ({ page }, testInfo) => {
await gotoWorkbench(page, "/workbench/sessions/ses_running");
const turn = await page.request.get("/v1/workbench/turns/trc_running");
expect(turn.status()).toBe(200);
const turnBody = await turn.json();
expect(turnBody.turn.status).toBe("running");
expect(turnBody.turn.trace.status).toBe("completed");
const trace = await page.request.get("/v1/workbench/traces/trc_running/events");
expect(trace.status()).toBe(200);
const traceBody = await trace.json();
expect(traceBody.traceStatus).toBe("completed");
expect(traceBody.events.at(-1).status).toBe("completed");
expect(traceBody.events.at(-1).terminal).toBe(false);
const tab = page.locator(sessionTab("ses_running"));
await expect(tab).toHaveAttribute("data-running", "true");
await expect(tab).toHaveAttribute("data-status", "running");
const agentCard = page.locator(`${selectors.messageCard}[data-role="agent"]`).last();
await expect(agentCard).toHaveAttribute("data-status", "running");
const timeline = agentCard.locator(`${selectors.traceTimeline}[data-status="running"]`);
await expect(timeline).toBeVisible();
await timeline.locator("summary.trace-disclosure-summary").click();
const toolRow = timeline.locator(`${selectors.traceRow}[data-row-kind="tool"]`).filter({ hasText: "commandExecution" });
await expect(toolRow).toHaveAttribute("data-terminal", "false");
await expect(toolRow).toContainText("ls -ld .");
const snapshot = await agentCard.evaluate((element) => ({
status: element.getAttribute("data-status"),
messageTexts: Array.from(element.querySelectorAll(".message-markdown.message-text")).map((item) => (item.textContent ?? "").trim()).filter(Boolean),
bodyText: document.body.textContent ?? ""
}));
expect(snapshot.status).toBe("running");
expect(snapshot.messageTexts).toEqual([]);
expect(snapshot.bodyText).not.toContain("Code Agent 已完成,但没有返回可展示的 final response。");
await saveScreenshot(page, testInfo, "tool-completed-projection-running");
});
});
test("fresh deep link replays completed turn from the session authority", async ({ page }, testInfo) => {
const detail = await page.request.get("/v1/workbench/sessions/ses_completed");
expect(detail.status()).toBe(200);