fix(v02): persist terminal trace before refresh (#861)

Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
Lyon
2026-06-04 22:05:59 +08:00
committed by GitHub
parent c4ff24f60a
commit 03f604d3fe
2 changed files with 86 additions and 5 deletions
@@ -121,11 +121,70 @@ test("subscribeToTrace keeps polling result when trace terminal lacks final resp
}).catch(reject);
});
assert.equal(completed.assistantText, "最终回复");
assert.equal(completed.runnerTrace?.events?.length, 2);
assert.equal(resultPolls, 2);
assert.deepEqual(fetches, [
"/v1/agent/chat/result/trc_terminal_first",
"/v1/agent/chat/trace/trc_terminal_first",
"/v1/agent/chat/result/trc_terminal_first"
"/v1/agent/chat/result/trc_terminal_first",
"/v1/agent/chat/trace/trc_terminal_first"
]);
} finally {
globalThis.fetch = originalFetch;
if (globalThis.window) {
globalThis.window.setTimeout = originalSetTimeout ?? globalThis.window.setTimeout;
globalThis.window.clearTimeout = originalClearTimeout ?? globalThis.window.clearTimeout;
}
}
});
test("subscribeToTrace preserves trace when terminal result with text arrives before trace poll", async () => {
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.window?.setTimeout;
const originalClearTimeout = globalThis.window?.clearTimeout;
const fetches: string[] = [];
const events: TraceEvent[] = [
{ seq: 1, label: "agentrun:request:accepted", status: "accepted" },
{ seq: 2, label: "agentrun:terminal:failed", type: "terminal", status: "failed", message: "provider failed" },
{ seq: 3, label: "agentrun:result:failed", type: "result", status: "failed", message: "provider failed" }
];
globalThis.window = {
...(globalThis.window ?? {}),
setTimeout: ((callback: () => void) => { callback(); return 1; }) as typeof window.setTimeout,
clearTimeout: (() => undefined) as typeof window.clearTimeout
} as typeof window;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
fetches.push(url);
if (url.includes("/result/")) {
return jsonResponse({ status: "failed", traceId: "trc_failed_fast", assistantText: "provider failed" });
}
if (url.includes("/trace/")) {
return jsonResponse({ status: "failed", traceId: "trc_failed_fast", events, eventCount: events.length });
}
return jsonResponse({});
}) as typeof fetch;
try {
const completed = await new Promise<AgentChatResultResponse>((resolve, reject) => {
void subscribeToTrace({
traceId: "trc_failed_fast",
initial: { status: "running", traceId: "trc_failed_fast", resultUrl: "/v1/agent/chat/result/trc_failed_fast" },
onActivity: () => undefined,
onSnapshot: () => undefined,
onComplete: resolve,
onInfrastructureError: (error) => reject(new Error(error)),
signal: new AbortController().signal,
inactivityTimeoutMs: 1000
}).catch(reject);
});
assert.equal(completed.assistantText, "provider failed");
assert.equal(completed.runnerTrace?.events?.length, 3);
assert.equal(completed.runnerTrace?.eventCount, 3);
assert.equal(completed.traceEvents?.length, 3);
assert.deepEqual(fetches, [
"/v1/agent/chat/result/trc_failed_fast",
"/v1/agent/chat/trace/trc_failed_fast"
]);
} finally {
globalThis.fetch = originalFetch;
+26 -4
View File
@@ -162,7 +162,8 @@ function firstNonEmptyString(...values: unknown[]): string | null {
* unresponsive for `inactivityTimeoutMs`). In normal operation the
* 1.5s polling cadence keeps `lastActivityAt` fresh and the inactivity
* abort never triggers.
* - Terminal status -> `onComplete` and return.
* - Terminal status -> poll trace once before `onComplete` so refresh keeps
* a replayable trace panel even when result text arrives first.
* - 5xx -> keep polling, do NOT call `onActivity` (the inactivity
* window should detect the real backend outage).
* - 4xx (non-5xx) -> `onInfrastructureError` and return.
@@ -197,6 +198,7 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise
let lastEventCount = -1;
let lastStatus: string | null = null;
let lastUpdatedAt: string | null = null;
let terminalResultWithText: AgentChatResultResponse | null = null;
let terminalResultWithoutText: AgentChatResultResponse | null = null;
for (;;) {
@@ -216,10 +218,10 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise
onActivity();
if (isTerminalStatus(resultPolled.data.status)) {
if (firstNonEmptyResultText(resultPolled.data)) {
onComplete(resultPolled.data);
return;
terminalResultWithText = resultPolled.data;
} else {
terminalResultWithoutText = resultPolled.data;
}
terminalResultWithoutText = resultPolled.data;
}
} else if (!resultPolled.ok && resultPolled.status >= 500) {
// server hiccup: keep polling, do NOT call onActivity — the
@@ -260,6 +262,18 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise
};
onSnapshot(snapshot);
}
if (terminalResultWithText) {
const last = events.at(-1);
const traceSnapshot = snapshotToTraceSnapshot({
...tracePolled.data,
traceId,
events,
eventCount,
lastEventLabel: String(last?.label ?? last?.type ?? "")
} as AgentChatResultResponse);
onComplete(mergeTraceResults(terminalResultWithText, traceSnapshot));
return;
}
if (isTerminalStatus(status)) {
const last = events.at(-1);
const traceTerminal = {
@@ -277,8 +291,16 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise
}
}
} else if (!tracePolled.ok && tracePolled.status >= 500) {
if (terminalResultWithText) {
onComplete(terminalResultWithText);
return;
}
// server hiccup: keep polling, no activity update.
} else if (!tracePolled.ok) {
if (terminalResultWithText) {
onComplete(terminalResultWithText);
return;
}
onInfrastructureError(tracePolled.error ?? "Code Agent trace poll failed (non-5xx)");
return;
}