fix: use activity timeout for code agent turns

This commit is contained in:
Code Queue Review
2026-05-24 10:10:34 +00:00
parent 637593a5e0
commit 8c5f25d0e5
10 changed files with 563 additions and 54 deletions
+6 -1
View File
@@ -8,6 +8,10 @@ const codeAgentTimeoutMs = parseTimeout(process.env.HWLAB_CODE_AGENT_TIMEOUT_MS,
min: 1000,
max: 300000
});
const codeAgentHardTimeoutMs = parseTimeout(process.env.HWLAB_CODE_AGENT_HARD_TIMEOUT_MS, Math.max(600000, codeAgentTimeoutMs * 4), {
min: codeAgentTimeoutMs,
max: 1800000
});
if (!process.env.HWLAB_IMAGE && commitId) {
process.env.HWLAB_IMAGE = `ghcr.io/pikastech/hwlab-cloud-api:${commitId.slice(0, 7)}`;
@@ -18,7 +22,8 @@ if (!process.env.HWLAB_IMAGE_TAG && commitId) {
}
const server = createCloudApiServer({
codeAgentTimeoutMs
codeAgentTimeoutMs,
codeAgentHardTimeoutMs
});
function parseTimeout(value, fallback, { min, max }) {
+60 -4
View File
@@ -231,6 +231,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
now: options.now,
workspace: options.workspace,
timeoutMs: options.timeoutMs,
hardTimeoutMs: options.hardTimeoutMs,
model: providerPlan.model,
codexStdioManager: options.codexStdioManager,
traceRecorder,
@@ -246,6 +247,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
now: options.now,
workspace: options.workspace,
timeoutMs: options.timeoutMs,
hardTimeoutMs: options.hardTimeoutMs,
model: providerPlan.model,
codexStdioManager: options.codexStdioManager,
traceRecorder,
@@ -1316,7 +1318,7 @@ function m3IoSkillBlockedBeforeRequestResult({
};
}
async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, model, codexStdioManager, traceRecorder, conversationFacts, externalNetworkIntent = null }) {
async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, hardTimeoutMs, model, codexStdioManager, traceRecorder, conversationFacts, externalNetworkIntent = null }) {
const manager = resolveCodexStdioSessionManager({ codexStdioManager });
try {
return await manager.chat({
@@ -1328,6 +1330,7 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI
now,
workspace,
timeoutMs,
hardTimeoutMs,
model,
traceRecorder,
conversationFacts,
@@ -1392,6 +1395,10 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI
conversationId,
targetUrl: error.targetUrl,
timeoutMs: error.timeoutMs,
hardTimeoutMs: error.hardTimeoutMs,
idleMs: error.idleMs,
lastActivityAt: error.lastActivityAt,
waitingFor: error.waitingFor,
stage: error.stage,
missingTools: error.missingTools,
availability: await describeCodeAgentAvailability(env, { codexStdioManager: manager, workspace })
@@ -1409,6 +1416,7 @@ async function callCodexStdioWithM3IoSkillRunner({
now,
workspace,
timeoutMs,
hardTimeoutMs,
model,
codexStdioManager,
traceRecorder,
@@ -1424,6 +1432,7 @@ async function callCodexStdioWithM3IoSkillRunner({
now,
workspace,
timeoutMs,
hardTimeoutMs,
model,
codexStdioManager,
traceRecorder,
@@ -2683,6 +2692,11 @@ function normalizeChatError(error, context = {}) {
userMessage: taxonomy.userMessage,
retryable: taxonomy.retryable
});
const timeoutMs = numberErrorField(error.timeoutMs ?? context.timeoutMs);
const hardTimeoutMs = numberErrorField(error.hardTimeoutMs ?? context.hardTimeoutMs);
const idleMs = numberErrorField(error.idleMs ?? context.idleMs);
const lastActivityAt = safeIsoLike(error.lastActivityAt ?? context.lastActivityAt);
const waitingFor = redactText(error.waitingFor ?? context.waitingFor ?? "");
const blocker = structuredBlocker({
code,
layer: taxonomy.layer,
@@ -2702,7 +2716,12 @@ function normalizeChatError(error, context = {}) {
conversationId,
stage,
lastEvent,
elapsedMs
elapsedMs,
timeoutMs,
hardTimeoutMs,
idleMs,
lastActivityAt,
waitingFor
});
const normalized = {
code,
@@ -2721,6 +2740,11 @@ function normalizeChatError(error, context = {}) {
capabilityLevel,
route,
toolName,
timeoutMs,
hardTimeoutMs,
idleMs,
lastActivityAt,
waitingFor,
missingConfig: safeMissingConfig(error),
blockers
};
@@ -2896,6 +2920,18 @@ function errorTaxonomy(code, error = {}) {
retryable: true,
userMessage: "Codex stdio runner 响应超时,输入、sessionId 和 traceId 已保留,可重试。"
},
codex_stdio_idle_timeout: {
layer: "runner",
category: "timeout",
retryable: true,
userMessage: "Codex stdio runner 超过配置时间无新事件,输入、sessionId 和 traceId 已保留,可重试。"
},
codex_stdio_hard_timeout: {
layer: "runner",
category: "timeout",
retryable: true,
userMessage: "Codex stdio runner 达到硬性总时长上限,输入、sessionId 和 traceId 已保留,可重试。"
},
codex_stdio_canceled: {
layer: "runner",
category: "canceled",
@@ -2941,7 +2977,7 @@ function errorTaxonomy(code, error = {}) {
};
}
function structuredBlocker({ code, layer, message, userMessage, retryable, traceId, provider, backend, runner, capabilityLevel, route, toolName, category, blockers, sessionId = null, conversationId = null, stage = null, lastEvent = null, elapsedMs = null }) {
function structuredBlocker({ code, layer, message, userMessage, retryable, traceId, provider, backend, runner, capabilityLevel, route, toolName, category, blockers, sessionId = null, conversationId = null, stage = null, lastEvent = null, elapsedMs = null, timeoutMs = null, hardTimeoutMs = null, idleMs = null, lastActivityAt = null, waitingFor = null }) {
return {
code,
layer,
@@ -2961,6 +2997,11 @@ function structuredBlocker({ code, layer, message, userMessage, retryable, trace
stage,
lastEvent,
elapsedMs,
timeoutMs,
hardTimeoutMs,
idleMs,
lastActivityAt,
waitingFor,
blockerCodes: Array.isArray(blockers) ? blockers.map((blocker) => blocker.code).filter(Boolean) : []
};
}
@@ -2983,7 +3024,12 @@ function normalizeBlockers(blockers, fallback) {
conversationId: blocker.conversationId,
stage: blocker.stage,
lastEvent: blocker.lastEvent,
elapsedMs: blocker.elapsedMs
elapsedMs: blocker.elapsedMs,
timeoutMs: numberErrorField(blocker.timeoutMs),
hardTimeoutMs: numberErrorField(blocker.hardTimeoutMs),
idleMs: numberErrorField(blocker.idleMs),
lastActivityAt: safeIsoLike(blocker.lastActivityAt),
waitingFor: blocker.waitingFor ? redactText(blocker.waitingFor) : null
}));
if (normalized.length > 0) return normalized;
return [{
@@ -3004,6 +3050,16 @@ function safeMissingConfig(error) {
].filter((item) => typeof item === "string" && /^[A-Za-z0-9_./:-]+$/u.test(item));
}
function numberErrorField(value) {
return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : null;
}
function safeIsoLike(value) {
const text = typeof value === "string" ? value.trim() : "";
if (!text || text.length > 80) return null;
return /^[0-9T:Z.+-]+$/u.test(text) ? text : null;
}
function sanitizeErrorField(key, value) {
if (key === "missingEnv" || key === "missingCommands" || key === "missingTools") {
return Array.isArray(value) ? value.filter((item) => typeof item === "string").map((item) => redactText(item)) : [];
@@ -1582,6 +1582,64 @@ function createFakeAppServerClient({ calls, responses = ["first stdio reply", "s
};
}
function createTimedFakeAppServerClient({ calls, text = "timed stdio reply", eventEveryMs = 20, eventCount = 0, completeAfterMs = null } = {}) {
let notificationHandler = null;
let turn = 0;
const timers = new Set();
const later = (ms, callback) => {
const timer = setTimeout(() => {
timers.delete(timer);
callback();
}, ms);
timers.add(timer);
};
return {
async initialize() {
calls?.push({ method: "initialize" });
return { initialized: true };
},
setNotificationHandler(handler) {
notificationHandler = typeof handler === "function" ? handler : null;
},
async startThread(args) {
calls?.push({ method: "thread/start", args });
notificationHandler?.({ method: "thread/started", params: { thread: { id: "thread_stdio_timed" } } });
return { threadId: "thread_stdio_timed" };
},
async resumeThread(args) {
calls?.push({ method: "thread/resume", args });
notificationHandler?.({ method: "thread/started", params: { thread: { id: args.threadId } } });
return { threadId: args.threadId };
},
async startTurn(args) {
calls?.push({ method: "turn/start", args });
turn += 1;
const turnId = `turn_stdio_timed_${turn}`;
notificationHandler?.({ method: "turn/started", params: { turn: { id: turnId } } });
for (let index = 1; index <= eventCount; index += 1) {
later(eventEveryMs * index, () => {
notificationHandler?.({
method: "item/commandExecution/outputDelta",
params: { delta: `progress-${index}` }
});
});
}
if (Number.isInteger(completeAfterMs)) {
later(completeAfterMs, () => {
notificationHandler?.({ method: "item/agentMessage/delta", params: { itemId: `item_${turn}`, delta: text } });
notificationHandler?.({ method: "item/completed", params: { item: { id: `item_${turn}`, type: "agentMessage", text } } });
notificationHandler?.({ method: "turn/completed", params: { turn: { id: turnId, status: "completed" } } });
});
}
return { turnId };
},
close() {
for (const timer of timers) clearTimeout(timer);
timers.clear();
}
};
}
test("Code Agent M3 Skill CLI blocks missing service-local HWLAB API base URL before loopback fallback", async () => {
const payload = await handleCodeAgentChat(
{
@@ -1955,6 +2013,127 @@ test("Codex app-server retrying error notification waits for completed turn", as
await rm(codexHome, { recursive: true, force: true });
});
test("Codex app-server activity timeout does not fail an active turn", async () => {
const calls = [];
const fakeCodex = await createFakeCodexCommand();
const codexHome = await prepareFakeCodexHome();
const manager = createCodexStdioSessionManager({
idFactory: () => "ses_stdio_active_turn",
createRpcClient: async () => createTimedFakeAppServerClient({
calls,
text: "active turn completed",
eventEveryMs: 20,
eventCount: 5,
completeAfterMs: 130
})
});
const env = {
PATH: process.env.PATH,
OPENAI_API_KEY: "test-openai-key-material",
CODEX_HOME: codexHome,
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
HWLAB_CODE_AGENT_MODEL: "gpt-test",
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses"
};
try {
const started = Date.now();
const payload = await handleCodeAgentChat(
{
conversationId: "cnv_stdio_active_turn",
traceId: "trc_stdio_active_turn",
message: "需要超过 activity timeout 但持续输出事件"
},
{
now: () => "2026-05-24T00:09:00.000Z",
codexStdioManager: manager,
env,
timeoutMs: 35,
hardTimeoutMs: 500
}
);
validateCodeAgentChatSchema(payload);
assert.equal(payload.status, "completed");
assert.equal(payload.reply.content, "active turn completed");
assert.ok(Date.now() - started >= 100);
assert.ok(payload.runnerTrace.events.some((event) => event.label === "item/commandExecution/outputDelta"));
assert.equal(payload.runnerTrace.events.some((event) => event.label === "timeout:no_activity"), false);
assert.equal(payload.error, undefined);
} finally {
await rm(fakeCodex.root, { recursive: true, force: true });
await rm(codexHome, { recursive: true, force: true });
}
});
test("Codex app-server activity timeout fails only after no new events", async () => {
const calls = [];
const fakeCodex = await createFakeCodexCommand();
const codexHome = await prepareFakeCodexHome();
const manager = createCodexStdioSessionManager({
idFactory: () => "ses_stdio_idle_timeout",
createRpcClient: async () => createTimedFakeAppServerClient({
calls,
eventCount: 0,
completeAfterMs: null
})
});
const env = {
PATH: process.env.PATH,
OPENAI_API_KEY: "test-openai-key-material",
CODEX_HOME: codexHome,
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
HWLAB_CODE_AGENT_MODEL: "gpt-test",
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses"
};
try {
const started = Date.now();
const payload = await handleCodeAgentChat(
{
conversationId: "cnv_stdio_idle_timeout",
traceId: "trc_stdio_idle_timeout",
message: "停止输出事件后触发 idle timeout"
},
{
now: () => "2026-05-24T00:09:30.000Z",
codexStdioManager: manager,
env,
timeoutMs: 30,
hardTimeoutMs: 500
}
);
validateCodeAgentChatSchema(payload);
assert.equal(payload.status, "timeout");
assert.equal(payload.error.code, "codex_stdio_idle_timeout");
assert.match(payload.error.message, /no new events/u);
assert.equal(payload.error.timeoutMs, 30);
assert.ok(payload.error.idleMs >= 30);
assert.match(payload.error.lastActivityAt, /^\d{4}-\d{2}-\d{2}T/u);
assert.equal(payload.error.waitingFor, "turn/completed");
assert.ok(Date.now() - started >= 30);
const timeoutEvent = payload.runnerTrace.events.find((event) => event.label === "timeout:no_activity");
assert.ok(timeoutEvent);
assert.equal(timeoutEvent.timeoutMs, 30);
assert.ok(timeoutEvent.idleMs >= 30);
assert.match(timeoutEvent.lastActivityAt, /^\d{4}-\d{2}-\d{2}T/u);
assert.equal(timeoutEvent.waitingFor, "turn/completed");
assert.equal(payload.runnerTrace.events.some((event) => event.label === "timeout:hard_cap"), false);
} finally {
await rm(fakeCodex.root, { recursive: true, force: true });
await rm(codexHome, { recursive: true, force: true });
}
});
test("Codex app-server failed turn is not wrapped as completed assistant output", async () => {
const calls = [];
const fakeCodex = await createFakeCodexCommand();
@@ -263,6 +263,9 @@ function normalizeTraceEvent(event, { traceId, seq, now, fallbackRunnerKind } =
errorCode: safeText(event.errorCode, 120),
waitingFor: safeText(event.waitingFor, 220),
timeoutMs: typeof event.timeoutMs === "number" ? Math.trunc(event.timeoutMs) : undefined,
hardTimeoutMs: typeof event.hardTimeoutMs === "number" ? Math.trunc(event.hardTimeoutMs) : undefined,
lastActivityAt: safeText(event.lastActivityAt, 80),
idleMs: typeof event.idleMs === "number" ? Math.max(0, Math.trunc(event.idleMs)) : undefined,
outputTruncated: event.outputTruncated === true,
terminal: event.terminal === true,
valuesPrinted: false
+202 -28
View File
@@ -25,6 +25,8 @@ export const DEFAULT_CODEX_STDIO_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
export const DEFAULT_CODEX_STDIO_MAX_SESSIONS = 64;
export const DEFAULT_CODEX_STDIO_COMMAND = "codex";
export const DEFAULT_CODEX_STDIO_PROBE_TTL_MS = 30 * 1000;
const DEFAULT_CODEX_STDIO_TURN_ACTIVITY_TIMEOUT_MS = 150000;
const DEFAULT_CODEX_STDIO_TURN_HARD_TIMEOUT_MS = 10 * 60 * 1000;
export const CODEX_STDIO_SESSION_STATUSES = Object.freeze([
"creating",
"ready",
@@ -456,7 +458,8 @@ export function createCodexStdioSessionManager(options = {}) {
sandbox,
model: firstNonEmpty(params.model, env.HWLAB_CODE_AGENT_MODEL, env.OPENAI_MODEL, "codex-default"),
traceRecorder,
timeoutMs: effectiveTimeout(params.timeoutMs)
timeoutMs: effectiveActivityTimeout(params.timeoutMs),
hardTimeoutMs: effectiveHardTimeout(params.hardTimeoutMs, params.timeoutMs)
});
traceRecorder.append({
type: "tool_call",
@@ -608,11 +611,14 @@ export function createCodexStdioSessionManager(options = {}) {
label: canceled ? "cancel:canceled" : timeout ? "timeout" : `error:${error.code ?? "codex_stdio_failed"}`,
errorCode: canceled ? "codex_stdio_canceled" : error.code ?? (timeout ? "codex_stdio_timeout" : "codex_stdio_failed"),
message: canceled ? "Codex stdio request was canceled by the user." : error.message || "Codex stdio session failed.",
timeoutMs: timeout ? effectiveTimeout(params.timeoutMs) : undefined,
timeoutMs: timeout ? error.timeoutMs ?? effectiveActivityTimeout(params.timeoutMs) : undefined,
idleMs: timeout ? error.idleMs : undefined,
lastActivityAt: timeout ? error.lastActivityAt : undefined,
hardTimeoutMs: timeout ? error.hardTimeoutMs : undefined,
sessionId: session.sessionId,
sessionStatus: session.status,
turn: session.turn,
waitingFor: canceled ? "user-retry" : timeout ? "codex-stdio-tool-response" : null,
waitingFor: canceled ? "user-retry" : timeout ? error.waitingFor ?? "codex-stdio-tool-response" : null,
terminal: true
});
if (canceled) {
@@ -1345,17 +1351,19 @@ async function runAppServerTurn({
sandbox,
model,
traceRecorder,
timeoutMs
timeoutMs,
hardTimeoutMs
} = {}) {
const state = createAppServerTurnState({ traceRecorder, session });
const effectiveMs = effectiveTimeout(timeoutMs);
const effectiveMs = effectiveActivityTimeout(timeoutMs);
const effectiveHardMs = effectiveHardTimeout(hardTimeoutMs, effectiveMs);
if (typeof client.setNotificationHandler === "function") {
client.setNotificationHandler((message) => state.handle(message));
}
try {
let threadId = optionalId(session?.threadId);
if (threadId) {
traceRecorder.append({
state.appendTrace({
type: "thread",
status: "started",
label: "thread:resume:started",
@@ -1374,7 +1382,7 @@ async function runAppServerTurn({
}, effectiveMs);
threadId = optionalId(resumed?.threadId) ?? threadId;
state.setThreadId(threadId);
traceRecorder.append({
state.appendTrace({
type: "thread",
status: "completed",
label: "thread:resume:completed",
@@ -1385,7 +1393,7 @@ async function runAppServerTurn({
waitingFor: "turn/start"
});
} else {
traceRecorder.append({
state.appendTrace({
type: "thread",
status: "started",
label: "thread:start:started",
@@ -1404,7 +1412,7 @@ async function runAppServerTurn({
threadId = optionalId(started?.threadId);
if (!threadId) throw new Error("thread/start response did not include thread.id");
state.setThreadId(threadId);
traceRecorder.append({
state.appendTrace({
type: "thread",
status: "completed",
label: "thread:start:completed",
@@ -1416,7 +1424,7 @@ async function runAppServerTurn({
});
}
traceRecorder.append({
state.appendTrace({
type: "turn",
status: "started",
label: "turn:start:started",
@@ -1437,7 +1445,7 @@ async function runAppServerTurn({
const turnId = optionalId(turnStart?.turnId);
if (!turnId) throw new Error("turn/start response did not include turn.id");
state.setTurnId(turnId);
traceRecorder.append({
state.appendTrace({
type: "turn",
status: "started",
label: "turn:start:completed",
@@ -1449,9 +1457,7 @@ async function runAppServerTurn({
waitingFor: "turn/completed"
});
const rawResult = typeof client.waitForTurn === "function"
? await client.waitForTurn(turnId, effectiveMs)
: await state.wait(effectiveMs, client.closedPromise);
const rawResult = await state.wait(effectiveMs, client.closedPromise, { hardTimeoutMs: effectiveHardMs });
return normalizeAppServerTurnResult(rawResult, state.snapshot(), { threadId, turnId });
} finally {
if (typeof client.setNotificationHandler === "function") {
@@ -1470,6 +1476,9 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
const terminalPromise = new Promise((resolve) => {
terminalResolve = resolve;
});
let lastActivityAt = Date.now();
let lastActivityLabel = "turn-state:created";
let lastWaitingFor = "app-server-turn";
function setThreadId(value) {
threadId = optionalId(value) ?? threadId;
@@ -1479,6 +1488,31 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
turnId = optionalId(value) ?? turnId;
}
function activitySnapshot(referenceNow = Date.now()) {
return {
lastActivityAt: new Date(lastActivityAt).toISOString(),
lastActivityLabel,
idleMs: Math.max(0, referenceNow - lastActivityAt),
waitingFor: lastWaitingFor
};
}
function observeActivity({ label = "app-server:activity", waitingFor = null } = {}) {
lastActivityAt = Date.now();
lastActivityLabel = label;
if (waitingFor) lastWaitingFor = waitingFor;
return activitySnapshot(lastActivityAt);
}
function appendTrace(event = {}) {
const appended = traceRecorder?.append(event);
observeActivity({
label: appended?.label ?? event.label ?? event.type ?? "trace:event",
waitingFor: appended?.waitingFor ?? event.waitingFor ?? null
});
return appended;
}
function finish(status, error = null, extra = {}) {
if (terminal) return;
terminal = {
@@ -1486,6 +1520,7 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
terminalError: error ? redactText(error) : null,
threadId,
turnId,
...activitySnapshot(),
...extra
};
terminalResolve(terminal);
@@ -1493,12 +1528,16 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
function handle(message) {
const method = typeof message?.method === "string" ? message.method : "unknown";
observeActivity({
label: `app-server:${method}`,
waitingFor: appServerWaitingForMethod(method)
});
const params = extractAppServerRecord(message?.params);
const item = extractAppServerRecord(params?.item);
const turn = extractAppServerRecord(params?.turn);
if (method === "thread/started") {
setThreadId(extractAppServerString(extractAppServerRecord(params?.thread), "id"));
traceRecorder?.append({
appendTrace({
type: "thread",
status: "completed",
label: "thread:started",
@@ -1512,7 +1551,7 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
}
if (method === "turn/started") {
setTurnId(extractAppServerString(turn, "id"));
traceRecorder?.append({
appendTrace({
type: "turn",
status: "started",
label: "turn:started",
@@ -1529,7 +1568,7 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
const delta = String(params?.delta ?? "");
assistantText += delta;
if (delta) {
traceRecorder?.append({
appendTrace({
type: "assistant_message",
status: "chunk",
label: "assistant:chunk",
@@ -1546,7 +1585,7 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
}
if (method === "item/completed" && item?.type === "agentMessage") {
if (typeof item.text === "string") finalResponse = item.text;
traceRecorder?.append({
appendTrace({
type: "assistant_message",
status: "completed",
label: "assistant:item_completed",
@@ -1560,7 +1599,7 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
return;
}
if (method === "item/commandExecution/outputDelta" || method === "item/reasoning/summaryTextDelta" || method === "item/reasoning/textDelta") {
traceRecorder?.append({
appendTrace({
type: method.startsWith("item/reasoning/") ? "reasoning" : "tool_call",
status: "output_chunk",
label: method,
@@ -1580,7 +1619,7 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
const willRetry = params?.willRetry === true;
setThreadId(extractAppServerString(params, "threadId"));
setTurnId(extractAppServerString(params, "turnId"));
traceRecorder?.append({
appendTrace({
type: willRetry ? "provider_retry" : "error",
status: willRetry ? "retrying" : "failed",
label: willRetry ? "app-server:retrying" : "app-server:error",
@@ -1601,7 +1640,7 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
const status = appServerTerminalStatus(turn?.status);
const error = extractAppServerRecord(turn?.error);
const message = typeof error?.message === "string" ? error.message : null;
traceRecorder?.append({
appendTrace({
type: "turn",
status,
label: `turn:completed:${status ?? "unknown"}`,
@@ -1618,11 +1657,81 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
}
}
async function wait(timeoutMs, closedPromise) {
let timer;
async function wait(timeoutMs, closedPromise, { hardTimeoutMs = null } = {}) {
let activityTimer;
let hardTimer;
const timeoutPromise = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`Codex app-server turn timed out after ${timeoutMs}ms`)), timeoutMs);
const checkActivity = () => {
const now = Date.now();
const activity = activitySnapshot(now);
if (activity.idleMs >= timeoutMs) {
const error = appServerActivityTimeoutError({
timeoutMs,
idleMs: activity.idleMs,
lastActivityAt: activity.lastActivityAt,
waitingFor: activity.waitingFor,
threadId,
turnId
});
appendTrace({
type: "timeout",
status: "failed",
label: "timeout:no_activity",
errorCode: error.code,
message: error.message,
timeoutMs,
idleMs: activity.idleMs,
lastActivityAt: activity.lastActivityAt,
sessionId: session?.sessionId,
sessionStatus: session?.status,
turn: session?.turn,
threadId,
turnId,
waitingFor: activity.waitingFor,
terminal: true
});
reject(error);
return;
}
activityTimer = setTimeout(checkActivity, Math.max(1, timeoutMs - activity.idleMs));
};
activityTimer = setTimeout(checkActivity, timeoutMs);
});
const hardTimeoutPromise = hardTimeoutMs
? new Promise((_, reject) => {
hardTimer = setTimeout(() => {
const activity = activitySnapshot();
const error = appServerHardTimeoutError({
hardTimeoutMs,
timeoutMs,
idleMs: activity.idleMs,
lastActivityAt: activity.lastActivityAt,
waitingFor: activity.waitingFor,
threadId,
turnId
});
appendTrace({
type: "timeout",
status: "failed",
label: "timeout:hard_cap",
errorCode: error.code,
message: error.message,
timeoutMs,
hardTimeoutMs,
idleMs: activity.idleMs,
lastActivityAt: activity.lastActivityAt,
sessionId: session?.sessionId,
sessionStatus: session?.status,
turn: session?.turn,
threadId,
turnId,
waitingFor: activity.waitingFor,
terminal: true
});
reject(error);
}, hardTimeoutMs);
})
: null;
const candidates = [terminalPromise];
if (closedPromise && typeof closedPromise.then === "function") {
candidates.push(closedPromise.then((exit) => {
@@ -1632,29 +1741,36 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
terminalError: "Codex app-server transport closed before turn/completed.",
threadId,
turnId,
...activitySnapshot(),
appServerExit: exit
};
}));
}
try {
return await Promise.race([...candidates, timeoutPromise]);
return await Promise.race([...candidates, timeoutPromise, hardTimeoutPromise].filter(Boolean));
} finally {
clearTimeout(timer);
clearTimeout(activityTimer);
clearTimeout(hardTimer);
}
}
function snapshot() {
const activity = activitySnapshot();
return {
threadId,
turnId,
assistantText: redactText(assistantText),
finalResponse: redactText(firstNonEmpty(finalResponse, assistantText)),
terminalStatus: terminal?.terminalStatus ?? null,
terminalError: terminal?.terminalError ?? null
terminalError: terminal?.terminalError ?? null,
lastActivityAt: terminal?.lastActivityAt ?? activity.lastActivityAt,
idleMs: terminal?.idleMs ?? activity.idleMs,
waitingFor: terminal?.waitingFor ?? activity.waitingFor
};
}
return {
appendTrace,
handle,
setThreadId,
setTurnId,
@@ -1663,6 +1779,49 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
};
}
function appServerWaitingForMethod(method) {
if (method === "thread/started") return "turn/start";
if (method === "turn/started") return "assistant-message";
if (method === "item/agentMessage/delta") return "turn/completed";
if (method === "item/completed") return "turn/completed";
if (method === "item/commandExecution/outputDelta") return "turn/completed";
if (method === "item/reasoning/summaryTextDelta" || method === "item/reasoning/textDelta") return "turn/completed";
if (method === "error") return "turn/completed";
if (method === "turn/completed") return "turn/completed";
return "app-server-notification";
}
function appServerActivityTimeoutError({ timeoutMs, idleMs, lastActivityAt, waitingFor, threadId, turnId }) {
const error = new Error(`Codex app-server idle timeout: no new events for ${idleMs}ms, threshold ${timeoutMs}ms; waitingFor=${waitingFor ?? "unknown"}.`);
Object.assign(error, {
code: "codex_stdio_idle_timeout",
timeoutMs,
idleMs,
lastActivityAt,
waitingFor,
threadId,
turnId,
stage: "app-server-turn"
});
return error;
}
function appServerHardTimeoutError({ hardTimeoutMs, timeoutMs, idleMs, lastActivityAt, waitingFor, threadId, turnId }) {
const error = new Error(`Codex app-server hard timeout reached after ${hardTimeoutMs}ms; last activity was ${idleMs}ms ago; waitingFor=${waitingFor ?? "unknown"}.`);
Object.assign(error, {
code: "codex_stdio_hard_timeout",
timeoutMs,
hardTimeoutMs,
idleMs,
lastActivityAt,
waitingFor,
threadId,
turnId,
stage: "app-server-turn"
});
return error;
}
function normalizeAppServerTurnResult(rawResult, state, fallback) {
const raw = extractAppServerRecord(rawResult) ?? {};
return {
@@ -1672,6 +1831,9 @@ function normalizeAppServerTurnResult(rawResult, state, fallback) {
finalResponse: redactText(firstNonEmpty(raw.finalResponse, raw.content, raw.text, state.finalResponse, state.assistantText)),
terminalStatus: appServerTerminalStatus(raw.terminalStatus ?? raw.status ?? state.terminalStatus),
terminalError: raw.terminalError ? redactText(raw.terminalError) : state.terminalError ?? null,
lastActivityAt: raw.lastActivityAt ?? state.lastActivityAt ?? null,
idleMs: raw.idleMs ?? state.idleMs ?? null,
waitingFor: raw.waitingFor ?? state.waitingFor ?? null,
appServerExit: raw.appServerExit ?? null
};
}
@@ -3828,7 +3990,19 @@ function hasEnvValue(env, name) {
}
function effectiveTimeout(timeoutMs) {
return Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : 150000;
return effectiveActivityTimeout(timeoutMs);
}
function effectiveActivityTimeout(timeoutMs) {
return Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_CODEX_STDIO_TURN_ACTIVITY_TIMEOUT_MS;
}
function effectiveHardTimeout(hardTimeoutMs, activityTimeoutMs = null) {
const activityMs = effectiveActivityTimeout(activityTimeoutMs);
const configured = Number.isInteger(hardTimeoutMs) && hardTimeoutMs > 0
? hardTimeoutMs
: DEFAULT_CODEX_STDIO_TURN_HARD_TIMEOUT_MS;
return Math.max(configured, activityMs);
}
function dropEmpty(value) {
+1
View File
@@ -1246,6 +1246,7 @@ async function handleCodeAgentChatHttp(request, response, options) {
{
callProvider: options.callCodeAgentProvider,
timeoutMs: options.codeAgentTimeoutMs,
hardTimeoutMs: options.codeAgentHardTimeoutMs,
env: options.env,
workspace: options.workspace,
skillsDirs: options.skillsDirs,
+2 -2
View File
@@ -18,7 +18,7 @@ const blockedCodeAgentUiLabels = new Set([
"仍是 fallback",
"BLOCKED 凭证缺口"
]);
const timeoutPattern = /\b(?:timeout|timed out|abort|aborted|超时|请求超过)\b/iu;
const timeoutPattern = /\b(?:timeout|timed out|abort|aborted|超时|请求超过|无新事件)\b/iu;
export function classifyCodeAgentChatReadiness(payload, { realDevLive = false, httpStatus = null } = {}) {
const providerBlock = classifyCodeAgentProviderBlock(payload, { httpStatus });
@@ -547,7 +547,7 @@ export function classifyCodeAgentBrowserFailure(error, { traceId = null } = {})
status: "blocked",
blocker: "timeout",
category: "timeout",
chineseSummary: `超时:Code Agent 请求在配置的时间边界内没有完成${observedTraceId ? `traceId=${observedTraceId}` : ""}`,
chineseSummary: `超时:Code Agent 在配置的活动时间边界内没有新事件${observedTraceId ? `traceId=${observedTraceId}` : ""}`,
reason: message
};
}
@@ -2675,7 +2675,7 @@ function hasCodeAgentConversationUxStates({ app, styles }) {
/旧 4500ms/u.test(app) &&
/结构化 blocker/u.test(app) &&
/sourceKind:\s*"PENDING"/u.test(app) &&
/Code Agent 请求超过/u.test(app) &&
/Code Agent 超过.*无新事件/u.test(app) &&
/Code Agent SOURCE 回复/u.test(app) &&
/SOURCE fixture 只可显示为 SOURCE 回复,不能冒充 DEV-LIVE/u.test(app) &&
/sourceKind:\s*completion\.sourceKind/u.test(submitBody) &&
@@ -4302,7 +4302,7 @@ async function inspectJourneyUi(page) {
sourceMessageVisible: Boolean(document.querySelector(".message-card.status-source")),
failedMessageVisible: Boolean(document.querySelector(".message-card.status-failed")),
failedMessageHasTrace: /trace=trc_/u.test(failedText),
failedMessageHasChineseTimeout: /请求超过|请求超时|保留输入|重试/u.test(failedText),
failedMessageHasChineseTimeout: /无新事件|请求超时|保留输入|重试/u.test(failedText),
runningMessageVisible: Boolean(document.querySelector(".message-card.status-running")),
pendingMessageText: pendingText.replace(/\s+/gu, " ").trim(),
pendingMessageChinese: /正在处理|后端处理中|不会把旧 4500ms|结构化 blocker|真实超时/u.test(pendingText),
@@ -4417,7 +4417,7 @@ async function inspectLegacyFailureWindow(page, { beforeMessageCount }) {
function backendTimeoutMsFromPayload(payload) {
const message = String(payload?.error?.message ?? "");
const match = message.match(/(?:timed out after|timeout after|请求超时|请求超过)\s*(\d+)\s*ms/iu);
const match = message.match(/(?:timed out after|timeout after|请求超时|请求超过|超过)\s*(\d+)\s*ms/iu);
if (!match) return null;
const value = Number.parseInt(match[1], 10);
return Number.isInteger(value) && value > 0 ? value : null;
+103 -14
View File
@@ -710,7 +710,12 @@ async function submitAgentMessage(value, options = {}) {
conversationId: activeConversationId,
sessionId: requestedSessionId,
messageId: pendingMessage.id,
input: value
input: value,
lastActivityAt: Date.now(),
lastActivityIso: new Date().toISOString(),
traceEventCount: 0,
waitingFor: "agent/chat",
lastEventLabel: "request:submitted"
};
el.commandInput.value = "";
renderAgentChatStatus("running");
@@ -813,6 +818,10 @@ async function submitAgentMessage(value, options = {}) {
userMessage: error.userMessage,
message: error.message,
timeoutMs: error.timeoutMs,
hardTimeoutMs: error.hardTimeoutMs,
idleMs: error.idleMs,
lastActivityAt: error.lastActivityAt,
waitingFor: error.waitingFor,
providerStatus: error.providerStatus,
missingConfig: error.missingConfig,
route: error.route,
@@ -917,6 +926,7 @@ function updateMessageTrace(messageId, snapshot, options = {}) {
const current = state.chatMessages[index];
const runnerTrace = runnerTraceFromSnapshot(snapshot, current.runnerTrace);
const sessionId = snapshot.sessionId ?? runnerTrace.sessionId ?? current.sessionId;
noteCurrentRequestTraceActivity(snapshot);
state.chatMessages[index] = {
...current,
runnerTrace,
@@ -936,6 +946,22 @@ function updateMessageTrace(messageId, snapshot, options = {}) {
}
}
function noteCurrentRequestTraceActivity(snapshot) {
const request = state.currentRequest;
if (!request || request.traceId !== snapshot.traceId) return;
const eventCount = Number.isInteger(snapshot.eventCount) ? snapshot.eventCount : snapshot.events.length;
if (eventCount <= (request.traceEventCount ?? 0)) {
if (snapshot.waitingFor) request.waitingFor = snapshot.waitingFor;
return;
}
request.traceEventCount = eventCount;
request.lastActivityAt = Date.now();
request.lastActivityIso = new Date(request.lastActivityAt).toISOString();
request.lastTraceUpdatedAt = snapshot.updatedAt ?? request.lastTraceUpdatedAt;
request.waitingFor = snapshot.waitingFor ?? request.waitingFor;
request.lastEventLabel = snapshot.lastEvent?.label ?? snapshot.events.at(-1)?.label ?? request.lastEventLabel;
}
function latestTraceSnapshot(traceId) {
for (const message of [...state.chatMessages].reverse()) {
if (message.traceId === traceId && message.runnerTrace) return message.runnerTrace;
@@ -1017,6 +1043,7 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI
},
timeoutMs: CODE_AGENT_TIMEOUT_MS,
timeoutName: "Code Agent",
activityRef: () => state.currentRequest?.traceId === traceId ? state.currentRequest : null,
body: JSON.stringify({
message,
conversationId,
@@ -1094,10 +1121,26 @@ async function fetchJson(path, options = {}) {
const {
timeoutMs = API_TIMEOUT_MS,
timeoutName = path,
activityRef = null,
...fetchOptions
} = options;
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
let timer = null;
let timeoutDetail = null;
const startedAt = Date.now();
const activityTimeout = Boolean(activityRef);
const scheduleTimeout = () => {
const activity = readActivityRef(activityRef, startedAt);
const idleMs = Math.max(0, Date.now() - activity.lastActivityAt);
const remainingMs = timeoutMs - idleMs;
if (remainingMs <= 0) {
timeoutDetail = { ...activity, idleMs };
controller.abort();
return;
}
timer = window.setTimeout(scheduleTimeout, Math.max(1, remainingMs));
};
scheduleTimeout();
try {
const response = await fetch(path, {
...fetchOptions,
@@ -1133,18 +1176,39 @@ async function fetchJson(path, options = {}) {
return { ok: true, path, status: response.status, data };
} catch (error) {
const timedOut = error.name === "AbortError";
const timeoutState = timeoutDetail ?? readActivityRef(activityRef, startedAt);
const idleMs = timedOut ? Math.max(0, Date.now() - timeoutState.lastActivityAt) : undefined;
return {
ok: false,
path,
timeout: timedOut,
timeoutMs,
error: timedOut ? `${timeoutName} 请求超时 ${timeoutMs}ms` : `请求失败:${error.message}`
idleMs,
lastActivityAt: timedOut ? timeoutState.lastActivityIso : undefined,
waitingFor: timedOut ? timeoutState.waitingFor : undefined,
error: timedOut
? activityTimeout
? `${timeoutName} 超过 ${timeoutMs}ms 无新事件`
: `${timeoutName} 请求超时 ${timeoutMs}ms`
: `请求失败:${error.message}`
};
} finally {
window.clearTimeout(timer);
if (timer) window.clearTimeout(timer);
}
}
function readActivityRef(activityRef, fallbackMs = Date.now()) {
const ref = typeof activityRef === "function" ? activityRef() : activityRef;
const lastActivityAt = Number(ref?.lastActivityAt);
const safeLastActivityAt = Number.isFinite(lastActivityAt) && lastActivityAt > 0 ? lastActivityAt : fallbackMs;
return {
lastActivityAt: safeLastActivityAt,
lastActivityIso: typeof ref?.lastActivityIso === "string" ? ref.lastActivityIso : new Date(safeLastActivityAt).toISOString(),
waitingFor: typeof ref?.waitingFor === "string" ? ref.waitingFor : null,
lastEventLabel: typeof ref?.lastEventLabel === "string" ? ref.lastEventLabel : null
};
}
async function callRpc(method, params = {}) {
const id = nextProtocolId("req");
const traceId = nextProtocolId("trc");
@@ -2747,7 +2811,7 @@ function classifyCodeAgentCompletion(result) {
status: "timeout",
replied: false,
sourceKind: "BLOCKED",
title: "Code Agent 等待超时"
title: "Code Agent 无新事件超时"
};
}
if (result?.status === "error") {
@@ -2896,7 +2960,7 @@ function agentErrorFromHttpResponse(response, traceId) {
"request_failed"
);
const error = new Error(response.timeout
? `Code Agent 请求超过 ${response.timeoutMs}ms 未完成;已保留输入,可稍后重试。`
? `Code Agent 超过 ${response.timeoutMs}ms 无新事件;已保留输入,可稍后重试。`
: payloadError?.userMessage || response.error || "Code Agent 请求失败");
error.traceId = response.data?.traceId || traceId;
error.code = code;
@@ -2908,6 +2972,10 @@ function agentErrorFromHttpResponse(response, traceId) {
error.route = payloadError && typeof payloadError === "object" ? payloadError.route : undefined;
error.toolName = payloadError && typeof payloadError === "object" ? payloadError.toolName : undefined;
error.timeoutMs = response.timeoutMs ?? (payloadError && typeof payloadError === "object" ? payloadError.timeoutMs : undefined);
error.hardTimeoutMs = payloadError && typeof payloadError === "object" ? payloadError.hardTimeoutMs : undefined;
error.idleMs = response.idleMs ?? (payloadError && typeof payloadError === "object" ? payloadError.idleMs : undefined);
error.lastActivityAt = response.lastActivityAt ?? (payloadError && typeof payloadError === "object" ? payloadError.lastActivityAt : undefined);
error.waitingFor = response.waitingFor ?? (payloadError && typeof payloadError === "object" ? payloadError.waitingFor : undefined);
error.providerStatus = payloadError && typeof payloadError === "object" ? payloadError.providerStatus : response.status;
error.category = payloadError && typeof payloadError === "object" ? payloadError.category : undefined;
return error;
@@ -2921,13 +2989,30 @@ function agentFailurePresentation(error, { result = null, traceId = null } = {})
const observedTraceId = result?.traceId || error?.traceId || traceId || null;
const traceSuffix = observedTraceId ? ` trace=${observedTraceId}` : "";
const timeoutMs = error?.timeoutMs ?? result?.error?.timeoutMs ?? timeoutMsFromText(message);
const hardTimeoutMs = error?.hardTimeoutMs ?? result?.error?.hardTimeoutMs;
const idleMs = error?.idleMs ?? result?.error?.idleMs;
const lastActivityAt = error?.lastActivityAt ?? result?.error?.lastActivityAt;
const waitingFor = error?.waitingFor ?? result?.error?.waitingFor;
const providerStatus = error?.providerStatus ?? result?.error?.providerStatus;
if (isTimeoutFailure(code, message)) {
if (code === "codex_stdio_hard_timeout") {
return {
category: "timeout",
title: "Code Agent 等待超时",
text: `Code Agent 请求${timeoutMs ? `超过 ${timeoutMs}ms ` : ""}未完成;输入已保留,可稍后重试或缩小问题范围。${traceSuffix}`
title: "Code Agent 达到硬性上限",
text: `Code Agent 达到硬性总时长上限${hardTimeoutMs ? ` ${hardTimeoutMs}ms` : ""};输入已保留,可稍后重试或缩小问题范围。${traceSuffix}`
};
}
if (isTimeoutFailure(code, message)) {
const detail = [
typeof idleMs === "number" ? `idleMs=${idleMs}` : null,
lastActivityAt ? `lastActivityAt=${lastActivityAt}` : null,
waitingFor ? `waitingFor=${waitingFor}` : null
].filter(Boolean).join("");
return {
category: "timeout",
title: "Code Agent 无新事件超时",
text: `Code Agent ${timeoutMs ? `超过 ${timeoutMs}ms ` : ""}无新事件;输入已保留,可稍后重试或缩小问题范围。${detail ? ` ${detail}` : ""}${traceSuffix}`
};
}
@@ -3015,7 +3100,7 @@ function titleForBlockerCategory(category, code) {
if (value === "needs_config") return "Code Agent 需要配置";
if (value === "security_blocked" || code === "security_blocked") return "Code Agent 安全阻断";
if (value === "fallback" || code === "text_chat_only_fallback") return "Code Agent 仍是 fallback";
if (value === "timeout") return "Code Agent 等待超时";
if (value === "timeout") return "Code Agent 无新事件超时";
if (value === "canceled" || code === "codex_stdio_canceled") return "Code Agent 已取消";
if (value === "runner_busy") return "Code Agent Runner 忙碌";
if (value === "session_blocked") return "Code Agent Session 受阻";
@@ -3032,12 +3117,12 @@ function normalizeErrorCode(code) {
}
function isTimeoutFailure(code, message) {
return ["client_timeout", "proxy_timeout", "cloud_api_proxy_timeout"].includes(code) ||
/\b(?:timeout|timed out|abort|aborted|超时|请求超过)\b/iu.test(message);
return ["client_timeout", "proxy_timeout", "cloud_api_proxy_timeout", "codex_stdio_timeout", "codex_stdio_idle_timeout", "codex_stdio_hard_timeout"].includes(code) ||
/\b(?:timeout|timed out|abort|aborted|超时|请求超过|无新事件)\b/iu.test(message);
}
function timeoutMsFromText(message) {
const match = String(message ?? "").match(/(?:timed out after|timeout after|请求超时|请求超过)\s*(\d+)\s*ms/iu);
const match = String(message ?? "").match(/(?:timed out after|timeout after|请求超时|请求超过|超过)\s*(\d+)\s*ms/iu);
if (!match) return null;
const value = Number.parseInt(match[1], 10);
return Number.isInteger(value) && value > 0 ? value : null;
@@ -3269,7 +3354,7 @@ function messagePendingContextPanel(message) {
["conversation", message.conversationId],
["session", message.sessionId ?? message.runnerTrace?.sessionId ?? "等待后端分配"],
["sessionStatus", message.runnerTrace?.sessionStatus],
["timeout", `${CODE_AGENT_TIMEOUT_MS}ms`]
["activityTimeout", `${CODE_AGENT_TIMEOUT_MS}ms 无新事件`]
];
const section = document.createElement("section");
section.className = "message-pending-context tone-border-pending";
@@ -3401,6 +3486,10 @@ function traceEventMeta(event) {
return [
event.toolName ? `tool=${event.toolName}` : null,
event.waitingFor ? `waiting=${event.waitingFor}` : null,
typeof event.timeoutMs === "number" ? `timeoutMs=${event.timeoutMs}` : null,
typeof event.hardTimeoutMs === "number" ? `hardTimeoutMs=${event.hardTimeoutMs}` : null,
typeof event.idleMs === "number" ? `idleMs=${event.idleMs}` : null,
event.lastActivityAt ? `lastActivityAt=${event.lastActivityAt}` : null,
typeof event.elapsedMs === "number" ? `${event.elapsedMs}ms` : null,
event.errorCode ? `error=${event.errorCode}` : null,
event.outputSummary,
+4 -2
View File
@@ -401,7 +401,9 @@ assert.match(app, /error\.traceId = (?:traceId|response\.data\?\.traceId \|\| tr
assert.match(app, /DEFAULT_API_TIMEOUT_MS\s*=\s*4500/);
assert.match(app, /DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*180000/);
assert.match(app, /async function sendAgentMessage[\s\S]*?timeoutMs:\s*CODE_AGENT_TIMEOUT_MS/);
assert.match(app, /activityRef:\s*\(\)\s*=>\s*state\.currentRequest/);
assert.match(functionBody(app, "fetchJson"), /timeoutMs\s*=\s*API_TIMEOUT_MS/);
assert.match(functionBody(app, "fetchJson"), /无新事件/);
for (const hardwareTerm of [
"Gateway 在线",
"BOX 在线",
@@ -912,10 +914,10 @@ assert.match(app, /function\s+restartRunningAgentMessage/);
assert.match(app, /先取消当前 trace,再用同一输入重新发送/);
assert.match(app, /canRetryTerminalAgentMessage/);
assert.match(app, /结构化 blocker/);
assert.match(app, /Code Agent 请求超过/);
assert.match(app, /Code Agent 超过 \$\{response\.timeoutMs\}ms 无新事件/);
assert.match(app, /agentFailurePresentation/);
assert.match(app, /agentStatusLabel/);
assert.match(app, /Code Agent 等待超时/);
assert.match(app, /Code Agent 无新事件超时/);
assert.match(app, /Code Agent Runner 忙碌/);
assert.match(app, /Code Agent Session 受阻/);
assert.match(app, /Code Agent API 错误/);