fix(workbench): use single turn projection authority
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 projection;trace 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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user