1647 lines
70 KiB
TypeScript
1647 lines
70 KiB
TypeScript
// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-20-p2-terminal-outbox-recovery; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
|
||
// Responsibility: Cloud API AgentRun read lifecycle, Codex fallback, inspect, and cancel regression tests.
|
||
|
||
import assert from "node:assert/strict";
|
||
import { createServer as createHttpServer } from "node:http";
|
||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import { test } from "bun:test";
|
||
|
||
import { createCloudApiServer } from "./server.ts";
|
||
import { createCloudRuntimeStore } from "../db/runtime-store.ts";
|
||
import { validateCodeAgentChatSchema } from "./code-agent-chat.ts";
|
||
import { createCodexStdioSessionManager } from "./codex-stdio-session.ts";
|
||
import { createCodeAgentTraceStore } from "./code-agent-trace-store.ts";
|
||
import { submitAgentRunChatTurn } from "./code-agent-agentrun-adapter.ts";
|
||
import {
|
||
codexStdioChatFixture,
|
||
codexStdioReadyFixture,
|
||
createFakeAppServerClient,
|
||
createFakeCodexCommand,
|
||
createManualAgentSession,
|
||
delay,
|
||
ensureTestAgentAuth,
|
||
pollAgentResult,
|
||
postAgent,
|
||
postAgentRaw,
|
||
prepareFakeCodexHome
|
||
} from "./server-test-helpers.ts";
|
||
|
||
const TEST_AGENT_ACTOR = { id: "usr_agent_owner", username: "agent-owner", displayName: "Agent Owner", role: "user", status: "active" };
|
||
const TEST_AUTH_SESSION = { id: "auth_ses_agent_owner" };
|
||
|
||
function testAgentSessionRecord(input = {}) {
|
||
const sessionId = input.sessionId ?? input.id;
|
||
return {
|
||
id: sessionId,
|
||
sessionId,
|
||
projectId: input.projectId ?? "pikasTech/HWLAB",
|
||
agentId: input.agentId ?? "hwlab-code-agent",
|
||
status: input.status ?? "idle",
|
||
ownerUserId: input.ownerUserId ?? TEST_AGENT_ACTOR.id,
|
||
ownerRole: input.ownerRole ?? TEST_AGENT_ACTOR.role,
|
||
conversationId: input.conversationId ?? null,
|
||
threadId: input.threadId ?? null,
|
||
lastTraceId: input.traceId ?? input.lastTraceId ?? null,
|
||
session: input.session ?? {},
|
||
updatedAt: input.updatedAt ?? "2026-06-03T00:00:00.000Z"
|
||
};
|
||
}
|
||
|
||
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 trace does not schedule terminal side effects from GET reads (#1519)", async () => {
|
||
const ownerCalls = [];
|
||
const factCalls = [];
|
||
let releaseOwnerWrite;
|
||
let ownerWriteReleased = false;
|
||
const ownerWrite = new Promise((resolve) => {
|
||
releaseOwnerWrite = () => {
|
||
if (ownerWriteReleased) return;
|
||
ownerWriteReleased = true;
|
||
resolve();
|
||
};
|
||
});
|
||
const traceId = "trc_issue1422_trace_effects_async";
|
||
const runId = "run_issue1422_trace_effects_async";
|
||
const commandId = "cmd_issue1422_trace_effects_async";
|
||
const finalText = "trace reads should not wait for terminal side effects.";
|
||
const traceStore = createCodeAgentTraceStore();
|
||
traceStore.append(traceId, {
|
||
type: "backend",
|
||
status: "running",
|
||
label: "agentrun:backend:run-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_effects_async",
|
||
sessionId: "ses_issue1422_trace_effects_async",
|
||
threadId: "thread-issue1422-trace-effects-async",
|
||
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,
|
||
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);
|
||
await ownerWrite;
|
||
return { ok: true, sessionId: input.sessionId };
|
||
}
|
||
}
|
||
});
|
||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||
|
||
try {
|
||
const { port } = server.address();
|
||
const fetchTrace = async () => {
|
||
const response = await Promise.race([
|
||
fetch(`http://127.0.0.1:${port}/v1/agent/traces/${traceId}?limit=1`, {
|
||
headers: { cookie: "hwlab_session=test-stub-session" }
|
||
}),
|
||
delay(250).then(() => null)
|
||
]);
|
||
assert.ok(response, "trace read should not wait for terminal side effects to finish");
|
||
assert.equal(response.status, 200);
|
||
const body = await response.json();
|
||
assert.equal(body.status, "completed");
|
||
assert.equal(body.eventCount, 2);
|
||
assert.equal(body.finalResponse.text, finalText);
|
||
};
|
||
for (let i = 0; i < 2; i += 1) {
|
||
await fetchTrace();
|
||
}
|
||
for (let i = 0; i < 20 && ownerCalls.length < 1; i += 1) await delay(10);
|
||
assert.equal(ownerCalls.length, 0);
|
||
assert.equal(factCalls.length, 0);
|
||
assert.equal(codeAgentChatResults.get(traceId).turnStatusTerminalEffects, undefined);
|
||
releaseOwnerWrite();
|
||
assert.equal(codeAgentChatResults.get(traceId).turnStatusTerminalEffects, undefined);
|
||
} finally {
|
||
releaseOwnerWrite?.();
|
||
await new Promise((resolve, reject) => {
|
||
server.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";
|
||
const runId = "run_issue1422_terminal_fast_path";
|
||
const commandId = "cmd_issue1422_terminal_fast_path";
|
||
const finalText = "终态结果已经由 AgentRun command result 投影完成。";
|
||
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 codeAgentChatResults = new Map([[traceId, {
|
||
accepted: true,
|
||
status: "completed",
|
||
shortConnection: true,
|
||
traceId,
|
||
conversationId: "cnv_issue1422_terminal_fast_path",
|
||
sessionId: "ses_issue1422_terminal_fast_path",
|
||
threadId: "thread-issue1422-terminal-fast-path",
|
||
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:${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: createCodeAgentTraceStore(),
|
||
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/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.terminal, true);
|
||
assert.equal(body.finalResponse.text, finalText);
|
||
assert.equal(body.traceSummary.agentRun.commandId, commandId);
|
||
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 does not record terminal side effects from GET reads (#1519)", async () => {
|
||
const ownerCalls = [];
|
||
const factCalls = [];
|
||
let releaseOwnerWrite;
|
||
let ownerWriteReleased = false;
|
||
const ownerWrite = new Promise((resolve) => {
|
||
releaseOwnerWrite = () => {
|
||
if (ownerWriteReleased) return;
|
||
ownerWriteReleased = true;
|
||
resolve();
|
||
};
|
||
});
|
||
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);
|
||
await ownerWrite;
|
||
return { ok: true, sessionId: input.sessionId };
|
||
}
|
||
}
|
||
});
|
||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||
|
||
try {
|
||
const { port } = server.address();
|
||
const fetchTurnStatus = async () => {
|
||
const response = await Promise.race([
|
||
fetch(`http://127.0.0.1:${port}/v1/agent/turns/${traceId}`, {
|
||
headers: { cookie: "hwlab_session=test-stub-session" }
|
||
}),
|
||
delay(250).then(() => null)
|
||
]);
|
||
assert.ok(response, "turn status should not wait for terminal side effects to finish");
|
||
assert.equal(response.status, 200);
|
||
const body = await response.json();
|
||
assert.equal(body.status, "completed");
|
||
assert.equal(body.finalResponse.text, finalText);
|
||
};
|
||
for (let i = 0; i < 2; i += 1) {
|
||
await fetchTurnStatus();
|
||
}
|
||
for (let i = 0; i < 20 && ownerCalls.length < 1; i += 1) await delay(10);
|
||
assert.equal(ownerCalls.length, 0);
|
||
assert.equal(factCalls.length, 0);
|
||
assert.equal(codeAgentChatResults.get(traceId).turnStatusTerminalEffects, undefined);
|
||
releaseOwnerWrite();
|
||
assert.equal(codeAgentChatResults.get(traceId).turnStatusTerminalEffects, undefined);
|
||
} finally {
|
||
releaseOwnerWrite?.();
|
||
await new Promise((resolve, reject) => {
|
||
server.close((error) => (error ? reject(error) : resolve()));
|
||
});
|
||
}
|
||
});
|
||
|
||
test("cloud api legacy trace read does not repair historical AgentRun trace after lastTraceId advances (#1519)", async () => {
|
||
const calls = [];
|
||
const ownerSessions = new Map();
|
||
const runId = "run_issue955_historical";
|
||
const firstTraceId = "trc_issue955_historical_first";
|
||
const secondTraceId = "trc_issue955_historical_second";
|
||
const firstCommandId = "cmd_issue955_historical_first";
|
||
const secondCommandId = "cmd_issue955_historical_second";
|
||
const firstFinalText = "目前只有一个 HWPOD 可用:\n\n- d601-f103-v2 (board: D601-F103-V2 / STM32F103)\n\nAPI 返回 count=1, availableCount=1。";
|
||
const secondFinalText = "编译成功!hwpod build completed。";
|
||
const events = [
|
||
{ id: "evt_issue955_hist_first_created", runId, seq: 1, type: "command_created", payload: { commandId: firstCommandId }, createdAt: "2026-06-06T01:43:23.000Z" },
|
||
{ id: "evt_issue955_hist_first_tool", runId, seq: 25, type: "tool_call", payload: { method: "item/completed", type: "commandExecution", toolName: "commandExecution", itemId: "call_issue955_hwpod_list", command: "/bin/sh -lc 'hwpod list --available'", status: "completed", exitCode: 0, outputSummary: '{\"count\":1,\"availableCount\":1}', commandId: firstCommandId }, createdAt: "2026-06-06T01:43:46.000Z" },
|
||
{ id: "evt_issue955_hist_first_message", runId, seq: 34, type: "assistant_message", payload: { commandId: firstCommandId, itemId: "msg_issue955_first", text: firstFinalText }, createdAt: "2026-06-06T01:46:19.000Z" },
|
||
{ id: "evt_issue955_hist_first_done", runId, seq: 35, type: "terminal_status", payload: { commandId: firstCommandId, terminalStatus: "completed" }, createdAt: "2026-06-06T01:46:19.000Z" },
|
||
{ id: "evt_issue955_hist_second_created", runId, seq: 36, type: "command_created", payload: { commandId: secondCommandId }, createdAt: "2026-06-06T01:46:11.000Z" },
|
||
{ id: "evt_issue955_hist_second_message", runId, seq: 58, type: "assistant_message", payload: { commandId: secondCommandId, itemId: "msg_issue955_second", text: secondFinalText }, createdAt: "2026-06-06T01:46:28.000Z" },
|
||
{ id: "evt_issue955_hist_second_done", runId, seq: 67, type: "terminal_status", payload: { commandId: secondCommandId, terminalStatus: "completed" }, createdAt: "2026-06-06T01:46:28.000Z" }
|
||
];
|
||
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 });
|
||
const send = (data) => {
|
||
response.writeHead(200, { "content-type": "application/json" });
|
||
response.end(`${JSON.stringify({ ok: true, data, traceId: "trc_fake_issue955_historical" })}\n`);
|
||
};
|
||
if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands`) {
|
||
return send({ items: [
|
||
{ id: firstCommandId, runId, state: "completed", idempotencyKey: firstTraceId, createdAt: "2026-06-06T01:43:30.040Z", updatedAt: "2026-06-06T01:43:46.360Z", payload: { traceId: firstTraceId, conversationId: "cnv_issue955_historical", hwlabSessionId: "ses_issue955_historical", threadId: "thread-issue955-historical", providerProfile: "deepseek" } },
|
||
{ id: secondCommandId, runId, state: "completed", idempotencyKey: secondTraceId, createdAt: "2026-06-06T01:46:11.000Z", updatedAt: "2026-06-06T01:46:28.000Z", payload: { traceId: secondTraceId, conversationId: "cnv_issue955_historical", hwlabSessionId: "ses_issue955_historical", threadId: "thread-issue955-historical", providerProfile: "deepseek" } }
|
||
] });
|
||
}
|
||
if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands/${firstCommandId}/result`) {
|
||
return send({ runId, commandId: firstCommandId, status: "completed", runStatus: "completed", commandState: "completed", terminalStatus: "completed", completed: true, reply: firstFinalText, lastSeq: 35, eventCount: 35, sessionRef: { sessionId: "ses_agentrun_deepseek_issue955", conversationId: "cnv_issue955_historical", threadId: "thread-issue955-historical" } });
|
||
}
|
||
if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands/${secondCommandId}/result`) {
|
||
return send({ runId, commandId: secondCommandId, status: "completed", runStatus: "completed", commandState: "completed", terminalStatus: "completed", completed: true, reply: secondFinalText, lastSeq: 67, eventCount: 67, sessionRef: { sessionId: "ses_agentrun_deepseek_issue955", conversationId: "cnv_issue955_historical", threadId: "thread-issue955-historical" } });
|
||
}
|
||
if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/events`) {
|
||
return send({ items: events });
|
||
}
|
||
response.writeHead(404, { "content-type": "application/json" });
|
||
response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`);
|
||
});
|
||
await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve));
|
||
|
||
const agentRunPort = agentRunServer.address().port;
|
||
ownerSessions.set("ses_issue955_historical", testAgentSessionRecord({
|
||
sessionId: "ses_issue955_historical",
|
||
projectId: "prj_v02_code_agent",
|
||
conversationId: "cnv_issue955_historical",
|
||
threadId: "thread-issue955-historical",
|
||
lastTraceId: secondTraceId,
|
||
status: "active",
|
||
session: {
|
||
messages: [
|
||
{ id: "msg_issue955_user_first", role: "user", text: "看看有几个hwpod可用", traceId: firstTraceId, status: "submitted" },
|
||
{ id: "msg_issue955_user_second", role: "user", text: "试一下编译 d601-f103-v2", traceId: secondTraceId, status: "submitted" },
|
||
{ id: "msg_issue955_second", role: "agent", text: firstFinalText, traceId: secondTraceId, status: "idle" }
|
||
],
|
||
agentRun: { adapter: "agentrun-v01", managerUrl: `http://127.0.0.1:${agentRunPort}`, runId, commandId: firstCommandId, backendProfile: "deepseek", terminalStatus: "completed", lastSeq: 35, valuesPrinted: false },
|
||
finalResponse: { text: firstFinalText, textChars: firstFinalText.length, role: "assistant", status: "completed", traceId: secondTraceId, valuesPrinted: false },
|
||
traceSummary: { traceId: secondTraceId, source: "code-agent-derived-session-evidence", sourceEventCount: 36, terminalStatus: "completed", agentRun: { runId, commandId: firstCommandId, lastSeq: 35, valuesPrinted: false }, valuesPrinted: false },
|
||
traceResults: {
|
||
[secondTraceId]: {
|
||
traceId: secondTraceId,
|
||
status: "completed",
|
||
conversationId: "cnv_issue955_historical",
|
||
sessionId: "ses_issue955_historical",
|
||
threadId: "thread-issue955-historical",
|
||
finalResponse: { text: firstFinalText, textChars: firstFinalText.length, role: "assistant", status: "completed", traceId: secondTraceId, valuesPrinted: false },
|
||
traceSummary: { traceId: secondTraceId, source: "agent-session-trace-repair", sourceEventCount: 4, terminalStatus: "completed", agentRun: { runId, commandId: firstCommandId, lastSeq: 35, valuesPrinted: false }, valuesPrinted: false },
|
||
agentRun: { adapter: "agentrun-v01", managerUrl: `http://127.0.0.1:${agentRunPort}`, runId, commandId: firstCommandId, backendProfile: "deepseek", terminalStatus: "completed", lastSeq: 35, valuesPrinted: false },
|
||
valuesRedacted: true
|
||
}
|
||
},
|
||
valuesRedacted: true
|
||
}
|
||
}));
|
||
|
||
const server = createCloudApiServer({
|
||
traceStore: createCodeAgentTraceStore(),
|
||
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 };
|
||
},
|
||
async getAgentSessionByTraceId(traceId) {
|
||
return [...ownerSessions.values()].find((session) => session.lastTraceId === traceId || session.session?.messages?.some((message) => message.traceId === traceId)) ?? null;
|
||
},
|
||
async recordAgentSessionOwner(input) {
|
||
const existing = ownerSessions.get(input.sessionId) ?? testAgentSessionRecord(input);
|
||
const nextSession = { ...(existing.session ?? {}), ...(input.session ?? {}) };
|
||
nextSession.traceResults = { ...(existing.session?.traceResults ?? {}), ...(input.session?.traceResults ?? {}) };
|
||
const record = testAgentSessionRecord({ ...existing, ...input, session: nextSession });
|
||
ownerSessions.set(record.id, record);
|
||
return record;
|
||
}
|
||
}
|
||
});
|
||
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/${firstTraceId}`, {
|
||
headers: { cookie: "hwlab_session=test-stub-session" }
|
||
});
|
||
assert.equal(response.status, 200);
|
||
const body = await response.json();
|
||
assert.equal(body.traceId, firstTraceId);
|
||
assert.equal(body.status, "missing");
|
||
assert.equal(body.projectionStatus, "unknown");
|
||
assert.equal(body.eventCount, 0);
|
||
assert.deepEqual(calls, []);
|
||
assert.equal(ownerSessions.get("ses_issue955_historical").session.traceResults[firstTraceId], undefined);
|
||
|
||
const secondResultResponse = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/result/${secondTraceId}`, {
|
||
headers: { cookie: "hwlab_session=test-stub-session" }
|
||
});
|
||
assert.equal(secondResultResponse.status, 200);
|
||
const secondResult = await secondResultResponse.json();
|
||
assert.equal(secondResult.traceId, secondTraceId);
|
||
assert.equal(secondResult.projectionStatus, "caught-up");
|
||
assert.equal(secondResult.agentRun.commandId, firstCommandId);
|
||
assert.deepEqual(calls, []);
|
||
assert.equal(ownerSessions.get("ses_issue955_historical").session.traceResults[secondTraceId].agentRun.commandId, firstCommandId);
|
||
} 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 result polling does not repair polluted completed AgentRun memory cache (#1519)", async () => {
|
||
const calls = [];
|
||
const runId = "run_issue955_live_cache";
|
||
const firstTraceId = "trc_issue955_live_cache_first";
|
||
const secondTraceId = "trc_issue955_live_cache_second";
|
||
const firstCommandId = "cmd_issue955_live_cache_first";
|
||
const secondCommandId = "cmd_issue955_live_cache_second";
|
||
const firstFinalText = "目前只有一个 HWPOD 可用:d601-f103-v2。";
|
||
const secondFinalText = "编译成功!hwpod build completed,Keil MDK USART atk_f103.hex queued。";
|
||
const events = [
|
||
{ id: "evt_issue955_live_first_message", runId, seq: 10, type: "assistant_message", payload: { commandId: firstCommandId, itemId: "msg_issue955_live_first", text: firstFinalText }, createdAt: "2026-06-06T01:46:19.000Z" },
|
||
{ id: "evt_issue955_live_first_done", runId, seq: 11, type: "terminal_status", payload: { commandId: firstCommandId, terminalStatus: "completed" }, createdAt: "2026-06-06T01:46:19.000Z" },
|
||
{ id: "evt_issue955_live_second_message", runId, seq: 20, type: "assistant_message", payload: { commandId: secondCommandId, itemId: "msg_issue955_live_second", text: secondFinalText }, createdAt: "2026-06-06T01:46:28.000Z" },
|
||
{ id: "evt_issue955_live_second_done", runId, seq: 21, type: "terminal_status", payload: { commandId: secondCommandId, terminalStatus: "completed" }, createdAt: "2026-06-06T01:46:28.000Z" }
|
||
];
|
||
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 });
|
||
const send = (data) => {
|
||
response.writeHead(200, { "content-type": "application/json" });
|
||
response.end(`${JSON.stringify({ ok: true, data, traceId: "trc_fake_issue955_live_cache" })}\n`);
|
||
};
|
||
if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands`) {
|
||
return send({ items: [
|
||
{ id: firstCommandId, runId, state: "completed", idempotencyKey: firstTraceId, payload: { traceId: firstTraceId, conversationId: "cnv_issue955_live_cache", hwlabSessionId: "ses_issue955_live_cache", threadId: "thread-issue955-live-cache" } },
|
||
{ id: secondCommandId, runId, state: "completed", idempotencyKey: secondTraceId, payload: { traceId: secondTraceId, conversationId: "cnv_issue955_live_cache", hwlabSessionId: "ses_issue955_live_cache", threadId: "thread-issue955-live-cache" } }
|
||
] });
|
||
}
|
||
if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands/${firstCommandId}/result`) {
|
||
return send({ runId, commandId: firstCommandId, status: "completed", runStatus: "completed", commandState: "completed", terminalStatus: "completed", completed: true, reply: firstFinalText, lastSeq: 11, eventCount: 11 });
|
||
}
|
||
if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands/${secondCommandId}/result`) {
|
||
return send({ runId, commandId: secondCommandId, status: "completed", runStatus: "completed", commandState: "completed", terminalStatus: "completed", completed: true, reply: secondFinalText, lastSeq: 21, eventCount: 21 });
|
||
}
|
||
if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/events`) {
|
||
return send({ items: events });
|
||
}
|
||
response.writeHead(404, { "content-type": "application/json" });
|
||
response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`);
|
||
});
|
||
await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve));
|
||
|
||
const agentRunPort = agentRunServer.address().port;
|
||
const codeAgentChatResults = new Map();
|
||
codeAgentChatResults.set(secondTraceId, {
|
||
accepted: true,
|
||
status: "completed",
|
||
shortConnection: true,
|
||
traceId: secondTraceId,
|
||
conversationId: "cnv_issue955_live_cache",
|
||
sessionId: "ses_issue955_live_cache",
|
||
threadId: "thread-issue955-live-cache",
|
||
ownerUserId: TEST_AGENT_ACTOR.id,
|
||
ownerRole: TEST_AGENT_ACTOR.role,
|
||
reply: { role: "assistant", content: firstFinalText },
|
||
finalResponse: { text: firstFinalText, textChars: firstFinalText.length, role: "assistant", status: "completed", traceId: secondTraceId, valuesPrinted: false },
|
||
traceSummary: { traceId: secondTraceId, source: "code-agent-derived-session-evidence", sourceEventCount: 11, terminalStatus: "completed", agentRun: { runId, commandId: firstCommandId, lastSeq: 11, valuesPrinted: false }, valuesPrinted: false },
|
||
agentRun: {
|
||
adapter: "agentrun-v01",
|
||
managerUrl: `http://127.0.0.1:${agentRunPort}`,
|
||
runId,
|
||
commandId: firstCommandId,
|
||
traceId: secondTraceId,
|
||
lastSeq: 11,
|
||
terminalStatus: "completed",
|
||
providerTrace: { traceId: secondTraceId, runId, commandId: firstCommandId, terminalStatus: "completed", valuesPrinted: false },
|
||
valuesPrinted: false
|
||
},
|
||
providerTrace: { traceId: secondTraceId, runId, commandId: firstCommandId, terminalStatus: "completed", valuesPrinted: false },
|
||
valuesPrinted: false
|
||
});
|
||
|
||
const server = createCloudApiServer({
|
||
traceStore: createCodeAgentTraceStore(),
|
||
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/chat/result/${secondTraceId}`, {
|
||
headers: { cookie: "hwlab_session=test-stub-session" }
|
||
});
|
||
assert.equal(response.status, 200);
|
||
const body = await response.json();
|
||
const text = JSON.stringify(body);
|
||
assert.equal(body.traceId, secondTraceId);
|
||
assert.equal(body.status, "completed");
|
||
assert.equal(body.projectionStatus, "caught-up");
|
||
assert.equal(body.agentRun.commandId, firstCommandId);
|
||
assert.equal(body.agentRun.traceId, secondTraceId);
|
||
assert.equal(body.terminalEvidence.available, true);
|
||
assert.equal(body.terminalEvidence.agentRun.commandId, firstCommandId);
|
||
assert.equal(body.terminalEvidence.finalResponse.traceId, secondTraceId);
|
||
assert.equal(body.reply.content, firstFinalText);
|
||
assert.match(text, /目前只有一个 HWPOD 可用/u);
|
||
assert.doesNotMatch(text, /编译成功/u);
|
||
assert.equal(text.includes('"fallback"'), false);
|
||
assert.deepEqual(calls, []);
|
||
assert.equal(codeAgentChatResults.get(secondTraceId).agentRun.commandId, firstCommandId);
|
||
} 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 result polling does not query AgentRun registry when projection is stale (#1519)", async () => {
|
||
const calls = [];
|
||
const runId = "run_issue955_registry_missing";
|
||
const traceId = "trc_issue955_registry_missing";
|
||
const staleCommandId = "cmd_issue955_registry_stale";
|
||
const staleFinalText = "目前只有一个 HWPOD 可用:d601-f103-v2。";
|
||
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 });
|
||
if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands`) {
|
||
response.writeHead(200, { "content-type": "application/json" });
|
||
response.end(`${JSON.stringify({ ok: true, data: { items: [] }, traceId: "trc_fake_issue955_missing" })}\n`);
|
||
return;
|
||
}
|
||
response.writeHead(404, { "content-type": "application/json" });
|
||
response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`);
|
||
});
|
||
await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve));
|
||
|
||
const agentRunPort = agentRunServer.address().port;
|
||
const codeAgentChatResults = new Map();
|
||
codeAgentChatResults.set(traceId, {
|
||
accepted: true,
|
||
status: "completed",
|
||
shortConnection: true,
|
||
traceId,
|
||
conversationId: "cnv_issue955_registry_missing",
|
||
sessionId: "ses_issue955_registry_missing",
|
||
threadId: "thread-issue955-registry-missing",
|
||
ownerUserId: TEST_AGENT_ACTOR.id,
|
||
ownerRole: TEST_AGENT_ACTOR.role,
|
||
reply: { role: "assistant", content: staleFinalText },
|
||
finalResponse: { text: staleFinalText, textChars: staleFinalText.length, role: "assistant", status: "completed", traceId, valuesPrinted: false },
|
||
agentRun: { adapter: "agentrun-v01", managerUrl: `http://127.0.0.1:${agentRunPort}`, runId, commandId: staleCommandId, traceId, terminalStatus: "completed", valuesPrinted: false },
|
||
valuesPrinted: false
|
||
});
|
||
|
||
const server = createCloudApiServer({
|
||
traceStore: createCodeAgentTraceStore(),
|
||
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/chat/result/${traceId}`, {
|
||
headers: { cookie: "hwlab_session=test-stub-session" }
|
||
});
|
||
assert.equal(response.status, 200);
|
||
const body = await response.json();
|
||
const text = JSON.stringify(body);
|
||
assert.equal(body.traceId, traceId);
|
||
assert.equal(body.status, "completed");
|
||
assert.equal(body.projectionStatus, "caught-up");
|
||
assert.equal(body.agentRun.commandId, staleCommandId);
|
||
assert.match(text, /目前只有一个 HWPOD 可用/u);
|
||
assert.deepEqual(calls, []);
|
||
assert.equal(calls.some((call) => call.path.includes("/result")), false);
|
||
} 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 result polling compacts large runnerTrace while preserving providerTrace", async () => {
|
||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-"));
|
||
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-codex-home-"));
|
||
const server = createCloudApiServer({
|
||
env: {
|
||
PATH: process.env.PATH,
|
||
CODEX_HOME: codexHome,
|
||
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
||
HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access",
|
||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||
HWLAB_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT: "32",
|
||
OPENAI_API_KEY: "test-openai-key-material",
|
||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
|
||
},
|
||
codexStdioManager: {
|
||
describe() {
|
||
return {
|
||
...codexStdioReadyFixture({ workspace, codexHome }),
|
||
sandbox: "danger-full-access"
|
||
};
|
||
},
|
||
async probe() {
|
||
return {
|
||
...codexStdioReadyFixture({ workspace, codexHome }),
|
||
sandbox: "danger-full-access"
|
||
};
|
||
},
|
||
async chat(params = {}) {
|
||
const base = codexStdioChatFixture({ workspace, codexHome, params });
|
||
const events = Array.from({ length: 160 }, (_, index) => ({
|
||
seq: index + 1,
|
||
traceId: params.traceId,
|
||
type: "assistant_message",
|
||
status: "chunk",
|
||
label: index === 159 ? "assistant:completed" : "assistant:chunk",
|
||
createdAt: "2026-05-23T00:00:00.000Z",
|
||
waitingFor: index === 159 ? null : "turn/completed",
|
||
valuesPrinted: false
|
||
}));
|
||
return {
|
||
...base,
|
||
sandbox: "danger-full-access",
|
||
session: {
|
||
...base.session,
|
||
sandbox: "danger-full-access"
|
||
},
|
||
runnerTrace: {
|
||
...base.runnerTrace,
|
||
events,
|
||
eventLabels: events.map((event) => event.label),
|
||
eventCount: events.length,
|
||
lastEvent: events.at(-1)
|
||
}
|
||
};
|
||
},
|
||
cancel() {},
|
||
reapIdle() {}
|
||
}
|
||
});
|
||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||
|
||
try {
|
||
const { port } = server.address();
|
||
const traceId = "trc_server-test-result-compact";
|
||
const manualSession = await createManualAgentSession(port, {
|
||
conversationId: "cnv_server-test-result-compact"
|
||
});
|
||
const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json",
|
||
cookie: manualSession.cookie,
|
||
"x-trace-id": traceId,
|
||
"prefer": "respond-async",
|
||
"x-hwlab-short-connection": "1"
|
||
},
|
||
body: JSON.stringify({
|
||
conversationId: manualSession.conversationId,
|
||
sessionId: manualSession.sessionId,
|
||
message: "生成大量 trace 后返回"
|
||
})
|
||
});
|
||
assert.equal(submit.status, 202);
|
||
|
||
const payload = await pollAgentResult(port, traceId);
|
||
validateCodeAgentChatSchema(payload);
|
||
assert.equal(payload.status, "completed");
|
||
assert.equal(payload.traceId, traceId);
|
||
assert.equal(payload.providerTrace.protocol, "codex-app-server-jsonrpc-stdio");
|
||
assert.equal(payload.providerTrace.command, "codex app-server --listen stdio://");
|
||
assert.equal(payload.runnerTrace.eventCount, 160);
|
||
assert.equal(payload.runnerTrace.eventsCompacted, true);
|
||
assert.equal(payload.runnerTrace.events.length, 32);
|
||
assert.equal(payload.runnerTrace.events.some((event) => event.label === "trace:compacted"), true);
|
||
assert.equal(payload.runnerTrace.lastEvent.label, "assistant:completed");
|
||
assert.equal(payload.runnerTrace.eventLabels.includes("trace:compacted"), true);
|
||
} finally {
|
||
await new Promise((resolve, reject) => {
|
||
server.close((error) => (error ? reject(error) : resolve()));
|
||
});
|
||
await rm(workspace, { recursive: true, force: true });
|
||
await rm(codexHome, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("cloud api /v1/agent/chat answers skills prompt through real Codex stdio and retains trace", async () => {
|
||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-skills-"));
|
||
const codexHome = path.join(workspace, "codex-home");
|
||
const skillsDir = path.join(workspace, "skills");
|
||
const fakeCodex = await createFakeCodexCommand();
|
||
await mkdir(path.join(skillsDir, "hwlab-test-skill"), { recursive: true });
|
||
await prepareFakeCodexHome(codexHome);
|
||
await writeFile(path.join(skillsDir, "hwlab-test-skill", "SKILL.md"), [
|
||
"---",
|
||
"name: hwlab-test-skill",
|
||
"description: Test skill mounted for Codex stdio discovery.",
|
||
"version: v-test",
|
||
"---",
|
||
"",
|
||
"# HWLAB Test Skill"
|
||
].join("\n"));
|
||
const server = createCloudApiServer({
|
||
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_CODEX_WORKSPACE: workspace,
|
||
HWLAB_CODE_AGENT_SKILLS_DIRS: skillsDir,
|
||
HWLAB_CODE_AGENT_SKILLS_STRICT: "1",
|
||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
|
||
},
|
||
codexStdioManager: createCodexStdioSessionManager({
|
||
idFactory: () => "ses_server_stdio_skills",
|
||
createRpcClient: async () => createFakeAppServerClient({
|
||
text: "模型泛化回答不应成为 skills 最终回复。"
|
||
})
|
||
})
|
||
});
|
||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||
|
||
try {
|
||
const { port } = server.address();
|
||
const payload = await postAgent(port, {
|
||
conversationId: "cnv_server_stdio_skills",
|
||
traceId: "trc_server_stdio_skills",
|
||
message: "列出你可用的skills"
|
||
});
|
||
|
||
validateCodeAgentChatSchema(payload);
|
||
assert.equal(payload.status, "completed");
|
||
assert.equal(payload.provider, "codex-stdio");
|
||
assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio");
|
||
assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session");
|
||
assert.equal(payload.providerTrace.sidecarOnly, undefined);
|
||
assert.equal(payload.providerTrace.toolName, "codex-app-server.thread/start+turn/start");
|
||
assert.equal(payload.skills.status, "ready");
|
||
const discovered = payload.skills.items.find((skill) => skill.name === "hwlab-test-skill");
|
||
assert.ok(discovered);
|
||
assert.equal(discovered.source, path.join(skillsDir, "hwlab-test-skill", "SKILL.md"));
|
||
assert.equal(discovered.manifest.path, path.join(skillsDir, "hwlab-test-skill", "SKILL.md"));
|
||
assert.equal(discovered.version, "v-test");
|
||
assert.ok(payload.toolCalls.some((toolCall) => toolCall.name === "skills.discover" && toolCall.status === "completed"));
|
||
assert.match(payload.reply.content, /hwlab-test-skill/u);
|
||
assert.match(payload.reply.content, /真实 skill manifest 清单/u);
|
||
assert.doesNotMatch(payload.reply.content, /模型泛化回答/u);
|
||
assert.ok(payload.runnerTrace.events.some((event) => event.label === "prompt:sent"));
|
||
assert.ok(payload.runnerTrace.events.some((event) => event.label === "tool:codex-app-server.thread/start+turn/start:started"));
|
||
assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false);
|
||
} finally {
|
||
await new Promise((resolve, reject) => {
|
||
server.close((error) => (error ? reject(error) : resolve()));
|
||
});
|
||
await rm(workspace, { recursive: true, force: true });
|
||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("cloud api /v1/agent/chat returns structured skills blocker without local skills manifest", async () => {
|
||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-skills-missing-"));
|
||
const codexHome = path.join(workspace, "codex-home");
|
||
const fakeCodex = await createFakeCodexCommand();
|
||
await prepareFakeCodexHome(codexHome);
|
||
const server = createCloudApiServer({
|
||
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_CODEX_WORKSPACE: workspace,
|
||
HWLAB_CODE_AGENT_SKILLS_DIRS: path.join(workspace, "missing-skills"),
|
||
HWLAB_CODE_AGENT_SKILLS_STRICT: "1",
|
||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
|
||
},
|
||
codexStdioManager: createCodexStdioSessionManager({
|
||
idFactory: () => "ses_server_stdio_skills_missing",
|
||
createRpcClient: async () => createFakeAppServerClient({
|
||
text: "模型泛化回答:alpha-skill"
|
||
})
|
||
})
|
||
});
|
||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||
|
||
try {
|
||
const { port } = server.address();
|
||
const payload = await postAgent(port, {
|
||
conversationId: "cnv_server_stdio_skills_missing",
|
||
traceId: "trc_server_stdio_skills_missing",
|
||
message: "列出你可用的skills"
|
||
});
|
||
|
||
validateCodeAgentChatSchema(payload);
|
||
assert.equal(payload.status, "failed");
|
||
assert.equal(payload.provider, "codex-stdio");
|
||
assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio");
|
||
assert.equal(payload.capabilityLevel, "blocked");
|
||
assert.equal(payload.error.code, "skills_unavailable");
|
||
assert.equal(payload.skills.status, "blocked");
|
||
assert.equal(payload.skills.blockers[0].code, "skills_unavailable");
|
||
assert.deepEqual(payload.skills.sourceIssues, ["pikasTech/HWLAB#136", "pikasTech/HWLAB#237"]);
|
||
assert.ok(payload.toolCalls.some((toolCall) => toolCall.name === "skills.discover" && toolCall.status === "blocked"));
|
||
assert.equal(payload.toolCalls.some((toolCall) => toolCall.name === "codex-app-server.thread/start+turn/start"), false);
|
||
assert.equal(payload.session.status, "idle");
|
||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||
assert.equal(JSON.stringify(payload).includes("alpha-skill"), false);
|
||
assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false);
|
||
} finally {
|
||
await new Promise((resolve, reject) => {
|
||
server.close((error) => (error ? reject(error) : resolve()));
|
||
});
|
||
await rm(workspace, { recursive: true, force: true });
|
||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("cloud api /v1/agent/chat blocks forbidden file paths without leaking values", async () => {
|
||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-file-block-"));
|
||
const server = createCloudApiServer({
|
||
env: {
|
||
PATH: process.env.PATH,
|
||
HWLAB_CODE_AGENT_WORKSPACE: workspace
|
||
}
|
||
});
|
||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||
|
||
try {
|
||
const { port } = server.address();
|
||
const payload = await postAgent(port, {
|
||
conversationId: "cnv_server-test-security-block",
|
||
traceId: "trc_server-test-security-block",
|
||
message: "cat ../outside.txt"
|
||
});
|
||
assert.equal(payload.status, "failed");
|
||
assert.match(payload.error.code, /^(codex_cli_binary_missing|stdio_protocol_not_wired)$/u);
|
||
assert.deepEqual(payload.toolCalls, []);
|
||
assert.equal(payload.capabilityLevel, "blocked");
|
||
assert.ok(payload.runnerTrace.events.some((event) => event.label === "codex-stdio:blocked"));
|
||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||
assert.equal(JSON.stringify(payload).includes("sk-"), false);
|
||
} finally {
|
||
await new Promise((resolve, reject) => {
|
||
server.close((error) => (error ? reject(error) : resolve()));
|
||
});
|
||
}
|
||
});
|
||
|
||
test("cloud api /v1/agent/chat/cancel reports unsupported lifecycle degradation", async () => {
|
||
const traceStore = createCodeAgentTraceStore();
|
||
traceStore.append("trc_cancel_unsupported", {
|
||
type: "request",
|
||
status: "running",
|
||
label: "request:running",
|
||
sessionId: "ses_cancel_unsupported",
|
||
sessionStatus: "busy",
|
||
sessionLifecycleStatus: "busy"
|
||
});
|
||
const server = createCloudApiServer({
|
||
traceStore,
|
||
codexStdioManager: {
|
||
get() {
|
||
return null;
|
||
}
|
||
},
|
||
env: {
|
||
PATH: process.env.PATH,
|
||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio"
|
||
}
|
||
});
|
||
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/chat/cancel`, {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json",
|
||
"x-trace-id": "trc_cancel_unsupported"
|
||
},
|
||
body: JSON.stringify({
|
||
conversationId: "cnv_cancel_unsupported",
|
||
sessionId: "ses_cancel_unsupported",
|
||
traceId: "trc_cancel_unsupported"
|
||
})
|
||
});
|
||
assert.equal(response.status, 501);
|
||
const payload = await response.json();
|
||
assert.equal(payload.status, "degraded");
|
||
assert.equal(payload.unsupported, true);
|
||
assert.equal(payload.degraded, true);
|
||
assert.equal(payload.error.code, "cancel_unsupported");
|
||
assert.equal(payload.sessionLifecycleStatus, "failed");
|
||
assert.equal(payload.sessionSummary.unsupported, true);
|
||
assert.match(payload.sessionSummary.userMessage, /unsupported\/degraded/u);
|
||
} finally {
|
||
await new Promise((resolve, reject) => {
|
||
server.close((error) => (error ? reject(error) : resolve()));
|
||
});
|
||
}
|
||
});
|
||
|
||
test("cloud api /v1/agent/chat/cancel rejects AgentRun trace scope mismatch", async () => {
|
||
const traceId = "trc_cancel_scope_mismatch";
|
||
const codeAgentChatResults = new Map();
|
||
const traceStore = createCodeAgentTraceStore();
|
||
codeAgentChatResults.set(traceId, {
|
||
traceId,
|
||
status: "running",
|
||
conversationId: "cnv_cancel_scope",
|
||
sessionId: "ses_cancel_scope",
|
||
threadId: "thread-cancel-scope",
|
||
agentRun: {
|
||
runId: "run_cancel_scope",
|
||
commandId: "cmd_cancel_scope",
|
||
sessionId: "ses_agentrun_cancel_scope",
|
||
conversationId: "cnv_cancel_scope",
|
||
threadId: "thread-cancel-scope",
|
||
managerUrl: "http://127.0.0.1:65535",
|
||
status: "running",
|
||
commandState: "running"
|
||
}
|
||
});
|
||
const server = createCloudApiServer({
|
||
traceStore,
|
||
codeAgentChatResults,
|
||
env: {
|
||
PATH: process.env.PATH,
|
||
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01",
|
||
HWLAB_CODE_AGENT_PROVIDER: "agentrun-v01",
|
||
HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1"
|
||
}
|
||
});
|
||
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/chat/cancel`, {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json",
|
||
"x-trace-id": traceId
|
||
},
|
||
body: JSON.stringify({
|
||
conversationId: "cnv_cancel_scope_other",
|
||
sessionId: "ses_cancel_scope_other",
|
||
threadId: "thread-cancel-scope-other",
|
||
traceId
|
||
})
|
||
});
|
||
assert.equal(response.status, 409);
|
||
const payload = await response.json();
|
||
assert.equal(payload.status, "blocked");
|
||
assert.equal(payload.canceled, false);
|
||
assert.equal(payload.error.code, "cancel_scope_mismatch");
|
||
assert.equal(codeAgentChatResults.get(traceId)?.status, "running");
|
||
assert.ok(traceStore.snapshot(traceId)?.events?.some((event) => event.label === "agentrun:cancel:scope_mismatch"));
|
||
} finally {
|
||
await new Promise((resolve, reject) => {
|
||
server.close((error) => (error ? reject(error) : resolve()));
|
||
});
|
||
}
|
||
});
|
||
|
||
test("cloud api /v1/agent/chat/cancel keeps already terminal AgentRun result instead of overwriting", async () => {
|
||
const traceId = "trc_cancel_already_terminal";
|
||
const codeAgentChatResults = new Map();
|
||
const traceStore = createCodeAgentTraceStore();
|
||
codeAgentChatResults.set(traceId, {
|
||
traceId,
|
||
status: "completed",
|
||
finalResponse: "OK",
|
||
conversationId: "cnv_cancel_already_terminal",
|
||
sessionId: "ses_cancel_already_terminal",
|
||
threadId: "thread-cancel-already-terminal",
|
||
agentRun: {
|
||
runId: "run_cancel_already_terminal",
|
||
commandId: "cmd_cancel_already_terminal",
|
||
sessionId: "ses_agentrun_cancel_already_terminal",
|
||
conversationId: "cnv_cancel_already_terminal",
|
||
threadId: "thread-cancel-already-terminal",
|
||
status: "completed",
|
||
commandState: "completed",
|
||
terminalStatus: "completed"
|
||
}
|
||
});
|
||
const accessController = {
|
||
required: false,
|
||
async authenticate() { return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION }; }
|
||
};
|
||
const server = createCloudApiServer({
|
||
traceStore,
|
||
codeAgentChatResults,
|
||
accessController,
|
||
env: {
|
||
PATH: process.env.PATH,
|
||
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01",
|
||
HWLAB_CODE_AGENT_PROVIDER: "agentrun-v01"
|
||
}
|
||
});
|
||
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/chat/cancel`, {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json",
|
||
"x-trace-id": traceId
|
||
},
|
||
body: JSON.stringify({
|
||
conversationId: "cnv_cancel_already_terminal",
|
||
sessionId: "ses_cancel_already_terminal",
|
||
threadId: "thread-cancel-already-terminal",
|
||
traceId
|
||
})
|
||
});
|
||
assert.equal(response.status, 200);
|
||
const payload = await response.json();
|
||
assert.equal(payload.status, "completed");
|
||
assert.equal(payload.finalResponse, "OK");
|
||
assert.equal(payload.cancelDisposition.status, "already_terminal");
|
||
assert.equal(payload.cancelDisposition.forwarded, false);
|
||
assert.equal(codeAgentChatResults.get(traceId).status, "completed");
|
||
assert.ok(traceStore.snapshot(traceId)?.events?.some((event) => event.label === "agentrun:cancel:already-terminal"));
|
||
} finally {
|
||
await new Promise((resolve, reject) => {
|
||
server.close((error) => (error ? reject(error) : resolve()));
|
||
});
|
||
}
|
||
});
|
||
|
||
test("cloud api /v1/agent/chat/cancel returns already-terminal when AgentRun result completed", async () => {
|
||
const calls = [];
|
||
const traceId = "trc_cancel_terminal_result_authority";
|
||
const runId = "run_cancel_terminal_result_authority";
|
||
const commandId = "cmd_cancel_terminal_result_authority";
|
||
const finalText = "Cancel did not overwrite the completed AgentRun result.";
|
||
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 });
|
||
const send = (body, status = 200) => {
|
||
response.writeHead(status, { "content-type": "application/json" });
|
||
response.end(`${JSON.stringify(body)}\n`);
|
||
};
|
||
if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/events`) return send({ items: [] });
|
||
if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands/${commandId}/result`) {
|
||
return send({ runId, commandId, status: "completed", runStatus: "completed", commandState: "completed", terminalStatus: "completed", completed: true, reply: finalText, lastSeq: 7, eventCount: 7 });
|
||
}
|
||
if (request.method === "POST" && url.pathname === `/api/v1/commands/${commandId}/cancel`) return send({ ok: true, canceled: true });
|
||
return send({ ok: false, message: `unexpected ${request.method} ${url.pathname}` }, 404);
|
||
});
|
||
await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve));
|
||
const agentRunPort = agentRunServer.address().port;
|
||
const traceStore = createCodeAgentTraceStore();
|
||
const codeAgentChatResults = new Map();
|
||
codeAgentChatResults.set(traceId, {
|
||
traceId,
|
||
status: "running",
|
||
conversationId: "cnv_cancel_terminal_result_authority",
|
||
sessionId: "ses_cancel_terminal_result_authority",
|
||
threadId: "thread-cancel-terminal-result-authority",
|
||
agentRun: {
|
||
adapter: "agentrun-v01",
|
||
managerUrl: `http://127.0.0.1:${agentRunPort}`,
|
||
runId,
|
||
commandId,
|
||
traceId,
|
||
providerTrace: { traceId, runId, commandId, valuesPrinted: false },
|
||
lastSeq: 6,
|
||
status: "running",
|
||
commandState: "running",
|
||
valuesPrinted: false
|
||
},
|
||
valuesRedacted: true
|
||
});
|
||
const server = createCloudApiServer({
|
||
traceStore,
|
||
codeAgentChatResults,
|
||
env: {
|
||
PATH: process.env.PATH,
|
||
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01",
|
||
HWLAB_CODE_AGENT_PROVIDER: "agentrun-v01",
|
||
HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1"
|
||
}
|
||
});
|
||
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/chat/cancel`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json", "x-trace-id": traceId },
|
||
body: JSON.stringify({ traceId, conversationId: "cnv_cancel_terminal_result_authority", sessionId: "ses_cancel_terminal_result_authority", threadId: "thread-cancel-terminal-result-authority" })
|
||
});
|
||
assert.equal(response.status, 200);
|
||
const payload = await response.json();
|
||
assert.equal(payload.status, "completed");
|
||
assert.equal(payload.cancelDisposition.status, "already_terminal");
|
||
assert.equal(payload.cancelDisposition.forwarded, false);
|
||
assert.equal(payload.finalResponse.text, finalText);
|
||
assert.equal(calls.some((call) => call.method === "POST" && call.path === `/api/v1/commands/${commandId}/cancel`), false);
|
||
assert.equal(codeAgentChatResults.get(traceId)?.status, "completed");
|
||
} 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 /v1/agent/chat reports Codex stdio blocker instead of structured skills fallback", async () => {
|
||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-no-skills-"));
|
||
const skillsDir = path.join(root, "missing-skills");
|
||
const server = createCloudApiServer({
|
||
env: {
|
||
PATH: process.env.PATH,
|
||
HWLAB_CODE_AGENT_WORKSPACE: root
|
||
},
|
||
skillsDirs: [skillsDir],
|
||
skillsDirsExact: true
|
||
});
|
||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||
|
||
try {
|
||
const { port } = server.address();
|
||
const manualSession = await createManualAgentSession(port, {
|
||
conversationId: "cnv_server-test-skills-missing"
|
||
});
|
||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json",
|
||
cookie: manualSession.cookie
|
||
},
|
||
body: JSON.stringify({
|
||
conversationId: manualSession.conversationId,
|
||
sessionId: manualSession.sessionId,
|
||
message: "列出你可用的skills"
|
||
})
|
||
});
|
||
assert.equal(response.status, 200);
|
||
const payload = await response.json();
|
||
assert.equal(payload.status, "failed");
|
||
assert.equal(payload.provider, "codex-stdio");
|
||
assert.notEqual(payload.error.code, "skills_unavailable");
|
||
assert.equal(payload.capabilityLevel, "blocked");
|
||
assert.equal(payload.skills.status, "not_requested");
|
||
assert.deepEqual(payload.toolCalls, []);
|
||
assert.ok(payload.runnerLimitations.includes("no-controlled-readonly-fallback"));
|
||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||
} finally {
|
||
await new Promise((resolve, reject) => {
|
||
server.close((error) => (error ? reject(error) : resolve()));
|
||
});
|
||
}
|
||
});
|
||
|
||
test("cloud api /v1/agent/chat does not run unsupported local grep fallback", async () => {
|
||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-tool-blocked-"));
|
||
const server = createCloudApiServer({
|
||
env: {
|
||
PATH: process.env.PATH,
|
||
HWLAB_CODE_AGENT_WORKSPACE: workspace
|
||
}
|
||
});
|
||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||
|
||
try {
|
||
const { port } = server.address();
|
||
const manualSession = await createManualAgentSession(port, {
|
||
conversationId: "cnv_server-test-tool-unavailable"
|
||
});
|
||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json",
|
||
cookie: manualSession.cookie
|
||
},
|
||
body: JSON.stringify({
|
||
conversationId: manualSession.conversationId,
|
||
sessionId: manualSession.sessionId,
|
||
message: "请用grep搜索 package.json"
|
||
})
|
||
});
|
||
assert.equal(response.status, 200);
|
||
const payload = await response.json();
|
||
assert.equal(payload.status, "failed");
|
||
assert.notEqual(payload.error.code, "tool_unavailable");
|
||
assert.equal(payload.provider, "codex-stdio");
|
||
assert.equal(payload.capabilityLevel, "blocked");
|
||
assert.deepEqual(payload.toolCalls, []);
|
||
assert.ok(payload.runnerLimitations.includes("no-controlled-readonly-fallback"));
|
||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||
} finally {
|
||
await new Promise((resolve, reject) => {
|
||
server.close((error) => (error ? reject(error) : resolve()));
|
||
});
|
||
}
|
||
});
|
||
|
||
test("cloud api /v1/agent/chat does not call delayed provider fallback beyond legacy 4500ms UI timeout", async () => {
|
||
let providerCalled = false;
|
||
const server = createCloudApiServer({
|
||
callCodeAgentProvider: async ({ providerPlan, message, traceId }) => {
|
||
providerCalled = true;
|
||
await delay(4700);
|
||
return {
|
||
provider: providerPlan.provider,
|
||
model: providerPlan.model,
|
||
backend: providerPlan.backend,
|
||
content: `延迟真实 provider stub: ${message} / ${traceId}`,
|
||
usage: null,
|
||
providerTrace: {
|
||
source: "delayed-test-provider"
|
||
}
|
||
};
|
||
}
|
||
});
|
||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||
|
||
try {
|
||
const { port } = server.address();
|
||
const manualSession = await createManualAgentSession(port, {
|
||
conversationId: "cnv_server-test-agent-chat-delayed"
|
||
});
|
||
const startedAt = Date.now();
|
||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json",
|
||
cookie: manualSession.cookie,
|
||
"x-trace-id": "trc_server-test-agent-chat-delayed"
|
||
},
|
||
body: JSON.stringify({
|
||
conversationId: manualSession.conversationId,
|
||
sessionId: manualSession.sessionId,
|
||
message: "请用一句话说明当前 HWLAB 工作台可以做什么,稍后回答"
|
||
})
|
||
});
|
||
assert.equal(response.status, 200);
|
||
const payload = await response.json();
|
||
assert.equal(Date.now() - startedAt < 4500, true);
|
||
assert.equal(payload.status, "failed");
|
||
assert.equal(payload.conversationId, "cnv_server-test-agent-chat-delayed");
|
||
assert.equal(payload.traceId, "trc_server-test-agent-chat-delayed");
|
||
assert.equal(payload.provider, "codex-stdio");
|
||
assert.equal(payload.capabilityLevel, "blocked");
|
||
assert.equal(providerCalled, false);
|
||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||
} finally {
|
||
await new Promise((resolve, reject) => {
|
||
server.close((error) => (error ? reject(error) : resolve()));
|
||
});
|
||
}
|
||
});
|
||
|
||
test("cloud api /v1/agent/chat does not call OpenAI provider 502/503 fallback", async () => {
|
||
for (const status of [502, 503]) {
|
||
const providerServer = createHttpServer((request, response) => {
|
||
request.resume();
|
||
response.writeHead(status, { "content-type": "application/json" });
|
||
response.end(JSON.stringify({
|
||
error: {
|
||
message: `upstream ${status}`
|
||
}
|
||
}));
|
||
});
|
||
await new Promise((resolve) => providerServer.listen(0, "127.0.0.1", resolve));
|
||
const providerPort = providerServer.address().port;
|
||
|
||
const server = createCloudApiServer({
|
||
env: {
|
||
OPENAI_API_KEY: "test-openai-key-material",
|
||
HWLAB_CODE_AGENT_PROVIDER: "openai",
|
||
HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK: "true",
|
||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: `http://127.0.0.1:${providerPort}/v1/responses`
|
||
}
|
||
});
|
||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||
|
||
try {
|
||
const { port } = server.address();
|
||
const manualSession = await createManualAgentSession(port, {
|
||
conversationId: `cnv_server-test-agent-chat-provider-${status}`
|
||
});
|
||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json",
|
||
cookie: manualSession.cookie,
|
||
"x-trace-id": `trc_server-test-agent-chat-provider-${status}`
|
||
},
|
||
body: JSON.stringify({
|
||
conversationId: manualSession.conversationId,
|
||
sessionId: manualSession.sessionId,
|
||
message: `provider ${status}`
|
||
})
|
||
});
|
||
assert.equal(response.status, 200);
|
||
const payload = await response.json();
|
||
assert.equal(payload.status, "failed");
|
||
assert.equal(payload.traceId, `trc_server-test-agent-chat-provider-${status}`);
|
||
assert.match(payload.error.code, /^(codex_cli_binary_missing|codex_cli_native_dependency_missing|codex_stdio_supervisor_disabled|stdio_protocol_not_wired)$/u);
|
||
assert.match(payload.error.layer, /^(runner|api)$/u);
|
||
assert.equal(typeof payload.error.retryable, "boolean");
|
||
assert.equal(payload.error.blocker.traceId, `trc_server-test-agent-chat-provider-${status}`);
|
||
assert.equal(typeof payload.error.blocker.retryable, "boolean");
|
||
assert.equal(payload.error.blocker.capabilityLevel, "blocked");
|
||
assert.equal(payload.provider, "codex-stdio");
|
||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||
} finally {
|
||
await new Promise((resolve, reject) => {
|
||
server.close((error) => (error ? reject(error) : resolve()));
|
||
});
|
||
await new Promise((resolve, reject) => {
|
||
providerServer.close((error) => (error ? reject(error) : resolve()));
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
test("cloud api /v1/agent/chat does not call empty provider text fallback", async () => {
|
||
let providerCalled = false;
|
||
const server = createCloudApiServer({
|
||
callCodeAgentProvider: async () => {
|
||
providerCalled = true;
|
||
throw new Error("empty provider fallback must not be used");
|
||
}
|
||
});
|
||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||
|
||
try {
|
||
const { port } = server.address();
|
||
const manualSession = await createManualAgentSession(port, {
|
||
conversationId: "cnv_empty-provider-text"
|
||
});
|
||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json",
|
||
cookie: manualSession.cookie
|
||
},
|
||
body: JSON.stringify({
|
||
conversationId: manualSession.conversationId,
|
||
sessionId: manualSession.sessionId,
|
||
message: "你好"
|
||
})
|
||
});
|
||
assert.equal(response.status, 200);
|
||
const payload = await response.json();
|
||
assert.equal(payload.conversationId, "cnv_empty-provider-text");
|
||
assert.equal(payload.status, "failed");
|
||
assert.match(payload.error.code, /^(codex_cli_binary_missing|stdio_protocol_not_wired)$/u);
|
||
assert.equal(payload.provider, "codex-stdio");
|
||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||
assert.equal(providerCalled, false);
|
||
} finally {
|
||
await new Promise((resolve, reject) => {
|
||
server.close((error) => (error ? reject(error) : resolve()));
|
||
});
|
||
}
|
||
});
|