diff --git a/internal/cloud/server-workbench-http.test.ts b/internal/cloud/server-workbench-http.test.ts index e0ebb998..bb3a6784 100644 --- a/internal/cloud/server-workbench-http.test.ts +++ b/internal/cloud/server-workbench-http.test.ts @@ -456,6 +456,85 @@ test("workbench read model projects completed current turn for idle session summ } }); +test("workbench read model lets terminal result override stale running session state", async () => { + const traceStore = createCodeAgentTraceStore(); + const results = createCodeAgentChatResultStore(); + const traceId = "trc_workbench_stale_running_result_completed"; + const finalText = "OK"; + const session = { + id: "ses_workbench_stale_running_result_completed", + projectId: "prj_hwpod_workbench", + agentId: "hwlab-code-agent", + status: "running", + ownerUserId: ACTOR.id, + conversationId: "cnv_workbench_stale_running_result_completed", + threadId: "thread-workbench-stale-running-result-completed", + lastTraceId: traceId, + updatedAt: "2026-06-18T16:31:04.000Z", + session: { + sessionStatus: "running", + lastTraceId: traceId, + messages: [ + { role: "user", text: "reply OK", traceId, status: "sent" }, + { role: "agent", text: "", traceId, status: "running" } + ] + } + }; + traceStore.append(traceId, { type: "backend", status: "running", label: "runner:created", terminal: false }); + results.set(traceId, { + status: "completed", + traceId, + ownerUserId: ACTOR.id, + conversationId: session.conversationId, + sessionId: session.id, + threadId: session.threadId, + finalResponse: finalText, + agentRun: { runId: "run_workbench_stale_running_completed", commandId: "cmd_workbench_stale_running_completed", status: "completed" } + }); + 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 server = createCloudApiServer({ accessController, traceStore, 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, "completed"); + assert.equal(sessions.body.sessions[0].running, false); + assert.equal(sessions.body.sessions[0].terminal, true); + assert.equal(sessions.body.sessions[0].turnSummary.status, "completed"); + + const detail = await getJson(port, `/v1/workbench/sessions/${encodeURIComponent(session.id)}`); + assert.equal(detail.status, 200); + assert.equal(detail.body.session.status, "completed"); + assert.equal(detail.body.session.running, false); + assert.equal(detail.body.session.terminal, true); + + 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, "completed"); + assert.equal(messages.body.messages[1].text, finalText); + + const turn = await getJson(port, `/v1/workbench/turns/${encodeURIComponent(traceId)}`); + assert.equal(turn.status, 200); + assert.equal(turn.body.status, "completed"); + assert.equal(turn.body.turn.status, "completed"); + assert.equal(turn.body.turn.running, false); + assert.equal(turn.body.turn.terminal, true); + assert.equal(turn.body.turn.assistantText, finalText); + } 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(); diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index 14ccee13..a129dce8 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -277,7 +277,7 @@ 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 = workbenchTurnProjectionStatus(trace); + const status = workbenchTurnStatusFromFacts({ trace, result }); 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 }); @@ -629,7 +629,7 @@ 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 = workbenchTurnProjectionStatus(trace); + const status = workbenchTurnStatusFromFacts({ trace, result }); return { visible: true, turnId: traceId, traceId, status, result, session, trace }; } @@ -666,7 +666,8 @@ function currentWorkbenchTurnProjection(session, options = {}) { const traceId = safeTraceId(session?.lastTraceId) ?? null; if (!traceId) return null; const trace = options.trace?.traceId === traceId ? options.trace : traceSnapshotSync(options, traceId); - const status = workbenchTurnProjectionStatus(trace); + const result = traceScopedResult(options, traceId); + const status = workbenchTurnStatusFromFacts({ trace, result }); return { turnId: traceId, traceId, @@ -677,6 +678,20 @@ function currentWorkbenchTurnProjection(session, options = {}) { }; } +function traceScopedResult(options, traceId) { + const result = options.result?.traceId === traceId || (!options.result?.traceId && options.result) ? options.result : options.codeAgentChatResults?.get?.(traceId) ?? null; + 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";