diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 52e1c875..9e8b5752 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -2396,6 +2396,120 @@ test("cloud api turn status reuses request session lookup for project check and } }); +test("cloud api trace skips AgentRun refresh when terminal snapshot is already complete (#1422)", async () => { + const calls = []; + const traceId = "trc_issue1422_trace_terminal_snapshot"; + const runId = "run_issue1422_trace_terminal_snapshot"; + const commandId = "cmd_issue1422_trace_terminal_snapshot"; + const finalText = "trace endpoint should reuse the local terminal snapshot."; + const agentRunServer = createHttpServer(async (request, response) => { + const url = new URL(request.url || "/", "http://127.0.0.1"); + calls.push({ method: request.method, path: url.pathname, search: url.search }); + response.writeHead(500, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: false, message: `unexpected refresh ${request.method} ${url.pathname}` })}\n`); + }); + await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve)); + + const agentRunPort = agentRunServer.address().port; + const traceStore = createCodeAgentTraceStore(); + traceStore.append(traceId, { + type: "backend", + status: "running", + label: "agentrun:backend:runner-job-created", + source: "agentrun", + sourceSeq: 1, + runId, + commandId, + valuesPrinted: false + }); + traceStore.append(traceId, { + type: "result", + status: "completed", + label: "agentrun:terminal:completed", + source: "agentrun", + sourceSeq: 3, + runId, + commandId, + terminal: true, + valuesPrinted: false + }); + const codeAgentChatResults = new Map([[traceId, { + accepted: true, + status: "completed", + shortConnection: true, + traceId, + conversationId: "cnv_issue1422_trace_terminal_snapshot", + sessionId: "ses_issue1422_trace_terminal_snapshot", + threadId: "thread-issue1422-trace-terminal-snapshot", + ownerUserId: TEST_AGENT_ACTOR.id, + ownerRole: TEST_AGENT_ACTOR.role, + finalResponse: { text: finalText, textChars: finalText.length, role: "assistant", status: "completed", traceId, valuesPrinted: false }, + traceSummary: { + traceId, + source: "agentrun-command-result", + sourceEventCount: 3, + terminalStatus: "completed", + agentRun: { runId, commandId, lastSeq: 3, valuesPrinted: false }, + valuesPrinted: false + }, + agentRun: { + adapter: "agentrun-v01", + managerUrl: `http://127.0.0.1:${agentRunPort}`, + runId, + commandId, + traceId, + lastSeq: 3, + status: "completed", + commandState: "completed", + terminalStatus: "completed", + providerTrace: { traceId, runId, commandId, terminalStatus: "completed", valuesPrinted: false }, + valuesPrinted: false + }, + providerTrace: { traceId, runId, commandId, terminalStatus: "completed", valuesPrinted: false }, + valuesPrinted: false + }]]); + const server = createCloudApiServer({ + traceStore, + codeAgentChatResults, + env: { + HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", + AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`, + HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", + HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "D601", + HWLAB_ENVIRONMENT: "v02", + HWLAB_GITOPS_PROFILE: "v02" + }, + accessController: { + required: false, + async authenticate() { + return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION }; + } + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/traces/${traceId}?limit=1`, { + headers: { cookie: "hwlab_session=test-stub-session" } + }); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.status, "completed"); + assert.equal(body.eventCount, 2); + assert.equal(body.agentRun.commandId, commandId); + assert.equal(body.finalResponse.text, finalText); + assert.deepEqual(calls, []); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await new Promise((resolve, reject) => { + agentRunServer.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api turn status skips AgentRun refresh for complete terminal evidence (#1422)", async () => { const calls = []; const traceId = "trc_issue1422_terminal_fast_path"; diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 54edfa79..79bc80aa 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -2553,9 +2553,13 @@ export async function handleCodeAgentTraceHttp(request, response, url, options) } if (agentRunResult?.agentRun) { try { - const snapshot = await refreshAgentRunTrace({ traceId, result: agentRunResult, options: requestOptions, traceStore }); + const existingSnapshot = traceStore.snapshot(traceId); + const terminalSnapshotRefreshSatisfied = traceRefreshSatisfiedByTerminalSnapshot(agentRunResult, existingSnapshot); + const snapshot = terminalSnapshotRefreshSatisfied + ? existingSnapshot + : await refreshAgentRunTrace({ traceId, result: agentRunResult, options: requestOptions, traceStore }); agentRunResult = requestOptions.codeAgentChatResults?.get?.(traceId) ?? agentRunResult; - if (traceNeedsCommandResultSync(agentRunResult, snapshot)) { + if (!terminalSnapshotRefreshSatisfied && traceNeedsCommandResultSync(agentRunResult, snapshot)) { const synced = await syncAgentRunChatResult({ traceId, currentResult: agentRunResult, options: requestOptions, traceStore, appendResultEvent: false, refreshEvents: false, forceResultSync: true }); agentRunResult = synced.result ?? agentRunResult; } @@ -2644,6 +2648,27 @@ function snapshotHasTerminalEvent(snapshot) { return events.some((event) => event?.terminal === true || (event?.type === "result" && isTraceCommandTerminalStatus(event?.status))); } +function traceRefreshSatisfiedByTerminalSnapshot(result, snapshot) { + if (!result?.agentRun || snapshot?.status === "missing") return false; + const status = String(result.status ?? result.agentRun.terminalStatus ?? result.agentRun.commandState ?? result.agentRun.status ?? ""); + if (!isTraceCommandTerminalStatus(status)) return false; + const events = Array.isArray(snapshot?.events) ? snapshot.events : []; + if (events.length === 0) return false; + const commandId = textValue(result.agentRun.commandId); + const terminalEvents = events.filter((event) => { + if (!(event?.terminal === true || (event?.type === "result" && isTraceCommandTerminalStatus(event?.status)))) return false; + return !commandId || textValue(event?.commandId) === commandId; + }); + if (terminalEvents.length === 0) return false; + const expectedLastSeq = Number(result.agentRun.lastSeq ?? result.traceSummary?.agentRun?.lastSeq ?? 0); + if (!Number.isFinite(expectedLastSeq) || expectedLastSeq <= 0) return true; + const observedLastSeq = Math.max(0, ...events + .filter((event) => !commandId || textValue(event?.commandId) === commandId) + .map((event) => Number(event?.sourceSeq ?? 0)) + .filter(Number.isFinite)); + return observedLastSeq === 0 || observedLastSeq >= expectedLastSeq; +} + function traceSnapshotWithTerminalEvidence(snapshot, result, traceId, refreshError = null) { const evidence = agentRunTerminalTraceEvidence(result, traceId); if (snapshot?.status !== "missing") return traceSnapshotWithAttachedTerminalEvidence(snapshot, evidence, refreshError);