From a69ff3cc9930688528853e29c7d2dfd641d6788a Mon Sep 17 00:00:00 2001 From: lyon Date: Thu, 18 Jun 2026 13:17:28 +0800 Subject: [PATCH] fix: record terminal turn effects once --- internal/cloud/server-agent-chat.test.ts | 92 ++++++++++++++++++++++++ internal/cloud/server-code-agent-http.ts | 40 +++++++++-- 2 files changed, 126 insertions(+), 6 deletions(-) diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 4331bf53..1be70293 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -2396,6 +2396,98 @@ test("cloud api turn status skips AgentRun refresh for complete terminal evidenc } }); +test("cloud api turn status records terminal side effects once (#1422)", async () => { + const ownerCalls = []; + const factCalls = []; + const traceId = "trc_issue1422_terminal_effects_once"; + const runId = "run_issue1422_terminal_effects_once"; + const commandId = "cmd_issue1422_terminal_effects_once"; + const finalText = "终态副作用只需要收敛一次。"; + const codeAgentChatResults = new Map([[traceId, { + accepted: true, + status: "completed", + shortConnection: true, + traceId, + conversationId: "cnv_issue1422_terminal_effects_once", + sessionId: "ses_issue1422_terminal_effects_once", + threadId: "thread-issue1422-terminal-effects-once", + 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", + finalAssistantRow: { role: "assistant", status: "completed", textChars: finalText.length, textPreview: finalText, messageId: `msg_${traceId.slice(4)}`, valuesPrinted: false }, + agentRun: { runId, commandId, lastSeq: 3, valuesPrinted: false }, + valuesPrinted: false + }, + agentRun: { + adapter: "agentrun-v01", + managerUrl: "http://127.0.0.1:1", + 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: createCodeAgentTraceStore(), + codeAgentChatResults, + env: { + HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", + HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "D601", + HWLAB_ENVIRONMENT: "v02", + HWLAB_GITOPS_PROFILE: "v02" + }, + sessionRegistry: { + recordFact(conversationId, fact) { + factCalls.push({ conversationId, fact }); + return { ok: true }; + } + }, + accessController: { + required: false, + async authenticate() { + return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION }; + }, + async recordAgentSessionOwner(input) { + ownerCalls.push(input); + return { ok: true, sessionId: input.sessionId }; + } + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + for (let i = 0; i < 2; i += 1) { + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/turns/${traceId}`, { + 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.finalResponse.text, finalText); + } + assert.equal(ownerCalls.length, 1); + assert.equal(factCalls.length, 1); + assert.equal(codeAgentChatResults.get(traceId).turnStatusTerminalEffects.recorded, true); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api repairs historical same-session AgentRun trace after lastTraceId advances (#955)", async () => { const calls = []; const ownerSessions = new Map(); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 5e2178e5..31e6b1cf 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -1222,9 +1222,7 @@ async function resolveCodeAgentTurnStatusSnapshot(traceId, options) { turnRefreshSatisfiedByResultSync = synced.eventsRefreshed === true || synced.resultSynced === true || synced.terminalRefreshSkipped === true; if (result && !canAccessOwnedResult(result, options.actor)) return forbiddenTurnSnapshot(traceId); if (result && isTraceCommandTerminalStatus(result.status)) { - await finalizeCodeAgentBillingUsage({ payload: result, params: result, options }); - recordCodeAgentConversationFact(result, options); - await recordCodeAgentSessionOwner({ payload: result, params: result, options, status: codeAgentOwnerStatusForResult(result) }); + await recordCodeAgentTerminalTurnStatusEffects({ payload: result, params: result, options }); } } catch (error) { resultPollError = error; @@ -1265,9 +1263,7 @@ async function resolveCodeAgentTurnStatusSnapshot(traceId, options) { agentRunResult = synced.result ?? agentRunResult; } if (isTraceCommandTerminalStatus(agentRunResult?.status)) { - await finalizeCodeAgentBillingUsage({ payload: agentRunResult, params: agentRunResult, options }); - recordCodeAgentConversationFact(agentRunResult, options); - await recordCodeAgentSessionOwner({ payload: agentRunResult, params: agentRunResult, options, status: codeAgentOwnerStatusForResult(agentRunResult), preserveLastTraceId: true }); + await recordCodeAgentTerminalTurnStatusEffects({ payload: agentRunResult, params: agentRunResult, options, preserveLastTraceId: true }); } } catch (error) { refreshError = error; @@ -1781,6 +1777,38 @@ function recordCodeAgentConversationFact(payload = {}, options = {}) { } } +async function recordCodeAgentTerminalTurnStatusEffects({ payload = {}, params = {}, options = {}, preserveLastTraceId = false } = {}) { + if (!payload || typeof payload !== "object" || !isTraceCommandTerminalStatus(payload.status)) return null; + if (payload.turnStatusTerminalEffects?.recorded === true) return payload.turnStatusTerminalEffects; + const billing = await finalizeCodeAgentBillingUsage({ payload, params, options }); + recordCodeAgentConversationFact(payload, options); + const owner = await recordCodeAgentSessionOwner({ payload, params, options, status: codeAgentOwnerStatusForResult(payload), preserveLastTraceId }); + const billingSettled = codeAgentTerminalBillingSettled({ payload, params, options, billing }); + const ownerRequired = Boolean(options.actor?.id && options.accessController?.recordAgentSessionOwner); + const ownerSettled = !ownerRequired || Boolean(owner); + const effects = { + recorded: billingSettled && ownerSettled, + billingSettled, + ownerSettled, + preserveLastTraceId: Boolean(preserveLastTraceId), + recordedAt: new Date().toISOString(), + valuesPrinted: false + }; + if (effects.recorded) { + payload.turnStatusTerminalEffects = effects; + const traceId = safeTraceId(payload.traceId ?? params.traceId); + if (traceId) options.codeAgentChatResults?.set?.(traceId, payload); + } + return effects; +} + +function codeAgentTerminalBillingSettled({ payload = {}, params = {}, options = {}, billing = null } = {}) { + const reservation = params.userBillingReservation ?? payload.userBillingReservation; + const reservationId = typeof reservation?.reservationId === "string" ? reservation.reservationId : ""; + if (!reservationId || !options.userBillingClient?.configured) return true; + return billing?.recorded === true || billing?.released === true || payload.billing?.recorded === true || payload.billing?.released === true; +} + export async function handleCodeAgentSteerHttp(request, response, options) { const body = await readBody(request, options.bodyLimitBytes); let params = {};