feat: 手动化 Code Agent session 管理

This commit is contained in:
Codex
2026-06-03 10:36:41 +08:00
parent 32deb72273
commit 94b9df822c
18 changed files with 963 additions and 84 deletions
-1
View File
@@ -1967,7 +1967,6 @@ function terminalWorkbenchSessionStatus(result = {}) {
const status = textOr(result.status ?? result.agentRun?.terminalStatus, "").toLowerCase();
if (status === "completed") return "idle";
if (status === "cancelled") return "canceled";
if (status === "failed" && result.agentRun && result.reuseEligible !== false && result.agentRun.reuseEligible !== false) return "idle";
return status || "idle";
}
function canActorReadWorkspaceTrace(result = {}, actor = null) {
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts";
import { decorateCodeAgentSession } from "./code-agent-session-lifecycle.ts";
import {
CODEX_APP_SERVER_PROTOCOL,
CODEX_APP_SERVER_WIRE_API,
@@ -907,14 +908,13 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId })
valuesPrinted: false
});
const partialContext = partialAgentRunContext(runnerTrace);
const recoverableFailure = !canceled;
return {
...base,
status: canceled ? "canceled" : "failed",
canceled,
updatedAt: now,
session: agentRunSessionSummary(base, canceled ? "canceled" : "idle"),
sessionReuse: agentRunSessionReuseSummary(base, base.agentRun.reused === true, { status: recoverableFailure ? "available-after-failed-command" : undefined }),
session: agentRunSessionSummary(base, canceled ? "canceled" : "failed"),
sessionReuse: agentRunSessionReuseSummary(base, base.agentRun.reused === true, { status: canceled ? undefined : "failed-requires-new-session" }),
runner: agentRunRunnerSummary(base.agentRun),
runnerTrace: partialContext ? { ...runnerTrace, partialContext } : runnerTrace,
toolCalls: agentRunToolCalls(result, canceled ? "canceled" : "failed"),
@@ -941,8 +941,8 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId })
route: "/v1/agent/chat",
toolName: "agentrun.manual-dispatch"
},
agentRun: { ...base.agentRun, terminalStatus, completed: false, reuseEligible: recoverableFailure, providerTrace, valuesPrinted: false },
reuseEligible: recoverableFailure,
agentRun: { ...base.agentRun, terminalStatus, completed: false, reuseEligible: false, providerTrace, valuesPrinted: false },
reuseEligible: false,
...(partialContext ? { partialContext } : {}),
valuesPrinted: false
};
@@ -1301,7 +1301,7 @@ function agentRunSessionId(traceId) {
}
function agentRunSessionSummary(base, status) {
return {
return decorateCodeAgentSession({
sessionId: base.sessionId ?? base.agentRun?.sessionId ?? null,
conversationId: base.conversationId ?? base.agentRun?.conversationId ?? null,
threadId: base.threadId ?? base.agentRun?.threadId ?? null,
@@ -1317,7 +1317,7 @@ function agentRunSessionSummary(base, status) {
idleTimeoutMs: parsePositiveInteger(base.agentRun?.idleTimeoutMs, 600000),
secretMaterialStored: false,
valuesRedacted: true
};
});
}
function agentRunSessionReuseSummary(base, reused, options = {}) {
@@ -18,7 +18,7 @@ export const CODE_AGENT_SESSION_STATUS_ALIASES = Object.freeze({
});
const ACTIVE_LIFECYCLE_STATUSES = new Set(["creating", "ready", "busy", "idle"]);
const TERMINAL_LIFECYCLE_STATUSES = new Set(["interrupted", "expired"]);
const TERMINAL_LIFECYCLE_STATUSES = new Set(["interrupted", "expired", "failed"]);
export function normalizeCodeAgentSessionLifecycleStatus(value, { fallback = null } = {}) {
const text = String(value ?? "").trim().toLowerCase();
@@ -75,7 +75,7 @@ export function codeAgentSessionLifecycleSummary(input = {}) {
(gateBlocked && !concreteLifecycleBlock);
if ((fallbackUsed || degraded) && !hasSession) status = "failed";
const requiresNewSession = status ? TERMINAL_LIFECYCLE_STATUSES.has(status) : degraded || fallbackUsed;
const reusable = status ? ((ACTIVE_LIFECYCLE_STATUSES.has(status) && status !== "creating") || status === "failed") && degraded !== true : false;
const reusable = status ? ACTIVE_LIFECYCLE_STATUSES.has(status) && status !== "creating" && degraded !== true : false;
const newSession = reused === false || (hasSession && !reused && !requiresNewSession);
const reasonCode = firstText(
input.degradationReasonCode,
@@ -171,8 +171,8 @@ function lifecyclePresentation({ status, rawStatus, reused, newSession, degraded
if (status === "failed") {
return {
label: "会话失败",
action: "continue",
userMessage: "当前 Code Agent turn 已失败;session/thread 已保留,可继续发送下一条消息。"
action: "new_session",
userMessage: "当前 Code Agent session 已失败;需要显式创建新 session 后再继续。"
};
}
if (status === "interrupted") {
@@ -326,7 +326,7 @@ test("code agent session registry reports busy sessions as structured blocker",
assert.equal(busy.session.currentTraceId, "trc_busy_1");
});
test("code agent session registry resumes failed sessions and still blocks interrupted sessions", () => {
test("code agent session registry blocks failed and interrupted sessions", () => {
const failedRegistry = createCodeAgentSessionRegistry({
idFactory: () => "ses_failed"
});
@@ -348,14 +348,13 @@ test("code agent session registry resumes failed sessions and still blocks inter
traceId: "trc_failed_2",
now: "2026-05-23T00:00:01.000Z"
});
assert.equal(failed.ok, true);
assert.equal(failed.reused, true);
assert.equal(failed.ok, false);
assert.equal(failed.code, "session_failed");
assert.equal(failed.session.sessionId, "ses_failed");
assert.equal(failed.session.status, "busy");
assert.equal(failed.session.lifecycleStatus, "busy");
assert.equal(failed.session.currentTraceId, "trc_failed_2");
assert.equal(failed.session.status, "failed");
assert.equal(failed.session.lifecycleStatus, "failed");
assert.equal(failed.session.lastTraceId, "trc_failed_2");
assert.equal(failed.session.turn, 2);
assert.equal(failed.session.lifecycle.requiresNewSession, true);
const interruptedRegistry = createCodeAgentSessionRegistry({
idFactory: () => "ses_interrupted"
@@ -495,7 +494,7 @@ test("stale read-only conversation facts do not mask Codex stdio blockers", asyn
assert.equal(payload.provider === "openai-responses", false);
});
test("expired and busy sessions are blockers while failed sessions can resume", async () => {
test("expired busy and failed sessions are blockers", async () => {
const expiredRegistry = createCodeAgentSessionRegistry({
idleTimeoutMs: 10,
idFactory: () => "ses_expired_chat"
@@ -648,12 +647,12 @@ test("expired and busy sessions are blockers while failed sessions can resume",
traceId: "trc_failed_request",
now: "2026-05-23T00:04:01.000Z"
});
assert.equal(failedAcquire.ok, true);
assert.equal(failedAcquire.reused, true);
assert.equal(failedAcquire.ok, false);
assert.equal(failedAcquire.code, "session_failed");
assert.equal(failedAcquire.session.sessionId, "ses_failed_chat");
assert.equal(failedAcquire.session.status, "busy");
assert.equal(failedAcquire.session.lifecycleStatus, "busy");
assert.equal(failedAcquire.session.currentTraceId, "trc_failed_request");
assert.equal(failedAcquire.session.status, "failed");
assert.equal(failedAcquire.session.lifecycleStatus, "failed");
assert.equal(failedAcquire.session.lifecycle.requiresNewSession, true);
const failedFacts = failedRegistry.getConversationFacts("cnv_failed_chat");
assert.equal(failedFacts.conversationId, "cnv_failed_chat");
assert.equal(failedFacts.sessionId, "ses_failed_chat");
@@ -84,7 +84,7 @@ export function createCodeAgentSessionRegistry(options = {}) {
});
}
if (session && ["interrupted", "canceled"].includes(session.status)) {
if (session && ["interrupted", "canceled", "failed", "error", "timeout", "expired"].includes(session.status)) {
session.updatedAt = timestamp;
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
return blockedAcquire({
+151 -39
View File
@@ -14,13 +14,36 @@ import {
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 /v1/agent/chat parse and params errors use structured blocker envelope", async () => {
const server = createCloudApiServer({
env: {
@@ -102,16 +125,21 @@ test("cloud api /v1/agent/chat supports short submit and result polling", async
try {
const { port } = server.address();
const traceId = "trc_server-test-short-submit";
const manualSession = await createManualAgentSession(port, {
conversationId: "cnv_server-test-short-submit"
});
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: "cnv_server-test-short-submit",
conversationId: manualSession.conversationId,
sessionId: manualSession.sessionId,
message: "用pwd列出你当前的工作目录"
})
});
@@ -352,14 +380,15 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
accessController: {
required: false,
async authenticate() {
return { ok: true, actor: { id: "usr_agent_owner", username: "agent-owner", displayName: "Agent Owner", role: "user", status: "active" }, session: { id: "auth_ses_agent_owner" } };
return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION };
},
async createCodeAgentDevicePodApiKey() {
return { apiKey: "test-device-pod-api-key" };
},
async recordAgentSessionOwner(input) {
ownerSessions.set(input.sessionId, input);
return input;
const record = testAgentSessionRecord(input);
ownerSessions.set(record.id, record);
return record;
},
async getAgentSession(sessionId) {
return ownerSessions.get(sessionId) ?? null;
@@ -378,9 +407,16 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
try {
const { port } = server.address();
const traceId = "trc_server-test-agentrun-adapter";
ownerSessions.set("ses_server-test-agentrun", testAgentSessionRecord({
sessionId: "ses_server-test-agentrun",
conversationId: "cnv_server-test-agentrun",
projectId: "pikasTech/HWLAB",
status: "idle",
threadId: "019e8078-db67-7750-a5d9-1a99f3abd445"
}));
const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
method: "POST",
headers: { "content-type": "application/json", "x-trace-id": traceId },
headers: { "content-type": "application/json", "x-trace-id": traceId, cookie: "hwlab_session=test-stub-session" },
body: JSON.stringify({
conversationId: "cnv_server-test-agentrun",
projectId: "pikasTech/HWLAB",
@@ -473,7 +509,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
const steer = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/steer`, {
method: "POST",
headers: { "content-type": "application/json", "x-trace-id": traceId },
headers: { "content-type": "application/json", "x-trace-id": traceId, cookie: "hwlab_session=test-stub-session" },
body: JSON.stringify({
traceId,
steerTraceId: "trc_steer_server_test",
@@ -498,7 +534,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
const secondTraceId = "trc_server-test-agentrun-adapter-second";
const second = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
method: "POST",
headers: { "content-type": "application/json", "x-trace-id": secondTraceId },
headers: { "content-type": "application/json", "x-trace-id": secondTraceId, cookie: "hwlab_session=test-stub-session" },
body: JSON.stringify({
conversationId: "cnv_server-test-agentrun",
projectId: "pikasTech/HWLAB",
@@ -603,6 +639,11 @@ test("cloud api AgentRun adapter exposes invalid tool-call attribution in result
});
await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve));
const agentRunPort = agentRunServer.address().port;
const ownerSessions = new Map([["ses_server-test-invalid-tool", testAgentSessionRecord({
sessionId: "ses_server-test-invalid-tool",
conversationId: "cnv_invalid_tool",
status: "idle"
})]]);
const server = createCloudApiServer({
env: {
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01",
@@ -616,10 +657,18 @@ test("cloud api AgentRun adapter exposes invalid tool-call attribution in result
accessController: {
required: false,
async authenticate() {
return { ok: true, actor: null, session: null };
return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION };
},
async createCodeAgentDevicePodApiKey() {
return { apiKey: "test-device-pod-api-key" };
},
async recordAgentSessionOwner(input) {
const record = testAgentSessionRecord(input);
ownerSessions.set(record.id, record);
return record;
},
async getAgentSession(sessionId) {
return ownerSessions.get(sessionId) ?? null;
}
}
});
@@ -630,8 +679,8 @@ test("cloud api AgentRun adapter exposes invalid tool-call attribution in result
const traceId = "trc_server-test-agentrun-invalid-tool";
const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
method: "POST",
headers: { "content-type": "application/json", "x-trace-id": traceId },
body: JSON.stringify({ conversationId: "cnv_invalid_tool", message: "invalid tool-call attribution" })
headers: { "content-type": "application/json", "x-trace-id": traceId, cookie: "hwlab_session=test-stub-session" },
body: JSON.stringify({ conversationId: "cnv_invalid_tool", sessionId: "ses_server-test-invalid-tool", message: "invalid tool-call attribution" })
});
assert.equal(submit.status, 202);
const payload = await pollAgentResult(port, traceId);
@@ -644,11 +693,11 @@ test("cloud api AgentRun adapter exposes invalid tool-call attribution in result
assert.match(payload.error.userMessage, / HWLAB device-pod/u);
assert.equal(payload.blocker.category, "provider_invalid_tool_call");
assert.match(payload.blocker.summary, /invalid function arguments json string/u);
assert.equal(payload.session.status, "idle");
assert.equal(payload.sessionReuse.status, "available-after-failed-command");
assert.equal(payload.session.status, "failed");
assert.equal(payload.session.lifecycle.requiresNewSession, true);
assert.equal(payload.sessionReuse.threadId, "thread_invalid_tool");
assert.equal(payload.agentRun.reuseEligible, true);
assert.equal(payload.reuseEligible, true);
assert.equal(payload.agentRun.reuseEligible, false);
assert.equal(payload.reuseEligible, 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())));
@@ -731,6 +780,11 @@ test("cloud api AgentRun adapter maps minimax-m3 provider profile to AgentRun ba
await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve));
const agentRunPort = agentRunServer.address().port;
const minimaxOwnerSessions = new Map([["ses_server-test-minimax-m3", testAgentSessionRecord({
sessionId: "ses_server-test-minimax-m3",
conversationId: "cnv_server-test-minimax-m3",
status: "idle"
})]]);
const server = createCloudApiServer({
env: {
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01",
@@ -744,7 +798,15 @@ test("cloud api AgentRun adapter maps minimax-m3 provider profile to AgentRun ba
accessController: {
required: false,
async authenticate() {
return { ok: true, actor: null, session: null };
return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION };
},
async recordAgentSessionOwner(input) {
const record = testAgentSessionRecord(input);
minimaxOwnerSessions.set(record.id, record);
return record;
},
async getAgentSession(sessionId) {
return minimaxOwnerSessions.get(sessionId) ?? null;
}
}
});
@@ -755,7 +817,7 @@ test("cloud api AgentRun adapter maps minimax-m3 provider profile to AgentRun ba
const traceId = "trc_server-test-agentrun-minimax-m3";
const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
method: "POST",
headers: { "content-type": "application/json", "x-trace-id": traceId },
headers: { "content-type": "application/json", "x-trace-id": traceId, cookie: "hwlab_session=test-stub-session" },
body: JSON.stringify({
conversationId: "cnv_server-test-minimax-m3",
sessionId: "ses_server-test-minimax-m3",
@@ -788,9 +850,7 @@ test("cloud api AgentRun adapter scopes AgentRun sessions by backend profile", a
const calls = [];
const hwlabSessionId = "ses_server-test-profile-switch";
const minimaxSessionId = "ses_agentrun_minimax_m3_server_test_profile_switch";
const ownerSessions = new Map([[hwlabSessionId, {
ownerUserId: "usr_agent_owner",
ownerRole: "user",
const ownerSessions = new Map([[hwlabSessionId, testAgentSessionRecord({
sessionId: hwlabSessionId,
conversationId: "cnv_server-test-profile-switch",
threadId: "019e8078-db67-7750-a5d9-1a99f3abd445",
@@ -810,7 +870,7 @@ test("cloud api AgentRun adapter scopes AgentRun sessions by backend profile", a
status: "runner-job-created"
}
}
}]]);
})]]);
const agentRunServer = createHttpServer(async (request, response) => {
const url = new URL(request.url || "/", "http://127.0.0.1");
@@ -899,11 +959,12 @@ test("cloud api AgentRun adapter scopes AgentRun sessions by backend profile", a
accessController: {
required: false,
async authenticate() {
return { ok: true, actor: null, session: null };
return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION };
},
async recordAgentSessionOwner(input) {
ownerSessions.set(input.sessionId, input);
return input;
const record = testAgentSessionRecord(input);
ownerSessions.set(record.id, record);
return record;
},
async getAgentSession(sessionId) {
return ownerSessions.get(sessionId) ?? null;
@@ -917,7 +978,7 @@ test("cloud api AgentRun adapter scopes AgentRun sessions by backend profile", a
const traceId = "trc_server-test-agentrun-profile-switch";
const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
method: "POST",
headers: { "content-type": "application/json", "x-trace-id": traceId },
headers: { "content-type": "application/json", "x-trace-id": traceId, cookie: "hwlab_session=test-stub-session" },
body: JSON.stringify({
conversationId: "cnv_server-test-profile-switch",
sessionId: hwlabSessionId,
@@ -961,10 +1022,13 @@ test("cloud api AgentRun adapter rejects non-internal manager URLs by default",
try {
const { port } = server.address();
const manualSession = await createManualAgentSession(port, {
conversationId: "cnv_server-test-agentrun-public-url"
});
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
method: "POST",
headers: { "content-type": "application/json", "x-trace-id": "trc_server-test-agentrun-public-url" },
body: JSON.stringify({ conversationId: "cnv_server-test-agentrun-public-url", message: "must reject public manager url" })
headers: { "content-type": "application/json", "x-trace-id": "trc_server-test-agentrun-public-url", cookie: manualSession.cookie },
body: JSON.stringify({ conversationId: manualSession.conversationId, sessionId: manualSession.sessionId, message: "must reject public manager url" })
});
assert.equal(response.status, 500);
const body = await response.json();
@@ -979,6 +1043,11 @@ test("cloud api /v1/agent/chat records authenticated owner on agent session", as
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-owner-"));
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-owner-codex-home-"));
const ownerRecords = [];
const ownerSessions = new Map([["ses_server-test-owner", testAgentSessionRecord({
sessionId: "ses_server-test-owner",
conversationId: "cnv_server-test-owner",
status: "idle"
})]]);
const server = createCloudApiServer({
env: {
PATH: process.env.PATH,
@@ -993,11 +1062,16 @@ test("cloud api /v1/agent/chat records authenticated owner on agent session", as
accessController: {
required: true,
async authenticate() {
return { ok: true, actor: { id: "usr_agent_owner", username: "agent-owner", displayName: "Agent Owner", role: "user", status: "active" } };
return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION };
},
async recordAgentSessionOwner(input) {
const record = testAgentSessionRecord(input);
ownerRecords.push(input);
return input;
ownerSessions.set(record.id, record);
return record;
},
async getAgentSession(sessionId) {
return ownerSessions.get(sessionId) ?? null;
}
},
codexStdioManager: {
@@ -1019,6 +1093,7 @@ test("cloud api /v1/agent/chat records authenticated owner on agent session", as
const { port } = server.address();
const payload = await postAgent(port, {
conversationId: "cnv_server-test-owner",
sessionId: "ses_server-test-owner",
traceId: "trc_server-test-owner",
message: "owner binding smoke"
});
@@ -1118,16 +1193,21 @@ test("cloud api /v1/agent/chat/inspect maps conversation session and thread deta
try {
const { port } = server.address();
const traceId = "trc_server-test-inspect";
const manualSession = await createManualAgentSession(port, {
conversationId: "cnv_server-test-inspect"
});
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: "cnv_server-test-inspect",
conversationId: manualSession.conversationId,
sessionId: manualSession.sessionId,
message: "inspect mapping smoke"
})
});
@@ -1135,7 +1215,7 @@ test("cloud api /v1/agent/chat/inspect maps conversation session and thread deta
const payload = await pollAgentResult(port, traceId);
assert.equal(payload.status, "completed");
const inspect = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/inspect?conversationId=cnv_server-test-inspect&sessionId=ses_server_stdio_pwd&threadId=019e6c72-2373-75e3-9856-eff286db7163`);
const inspect = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/inspect?conversationId=cnv_server-test-inspect&sessionId=${manualSession.sessionId}&threadId=019e6c72-2373-75e3-9856-eff286db7163`);
assert.equal(inspect.status, 200);
const body = await inspect.json();
assert.equal(body.ok, true);
@@ -1143,8 +1223,10 @@ test("cloud api /v1/agent/chat/inspect maps conversation session and thread deta
assert.equal(body.latestTraceId, traceId);
assert.equal(body.traceUrl, `/v1/agent/chat/trace/${traceId}`);
assert.equal(body.resultUrl, `/v1/agent/chat/result/${traceId}`);
assert.equal(body.query.sessionId, manualSession.sessionId);
assert.equal(body.query.threadId, "019e6c72-2373-75e3-9856-eff286db7163");
assert.equal(body.session.sessionId, "ses_server_stdio_pwd");
assert.equal(body.session.threadId, "019e6c72-2373-75e3-9856-eff286db7163");
assert.equal(body.conversationFacts.conversationId, "cnv_server-test-inspect");
assert.equal(body.runnerTrace.traceId, traceId);
assert.equal(body.valuesRedacted, true);
@@ -1247,16 +1329,21 @@ test("cloud api result polling compacts large runnerTrace while preserving provi
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: "cnv_server-test-result-compact",
conversationId: manualSession.conversationId,
sessionId: manualSession.sessionId,
message: "生成大量 trace 后返回"
})
});
@@ -1521,13 +1608,18 @@ test("cloud api /v1/agent/chat reports Codex stdio blocker instead of structured
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"
"content-type": "application/json",
cookie: manualSession.cookie
},
body: JSON.stringify({
conversationId: "cnv_server-test-skills-missing",
conversationId: manualSession.conversationId,
sessionId: manualSession.sessionId,
message: "列出你可用的skills"
})
});
@@ -1560,13 +1652,18 @@ test("cloud api /v1/agent/chat does not run unsupported local grep fallback", as
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"
"content-type": "application/json",
cookie: manualSession.cookie
},
body: JSON.stringify({
conversationId: "cnv_server-test-tool-unavailable",
conversationId: manualSession.conversationId,
sessionId: manualSession.sessionId,
message: "请用grep搜索 package.json"
})
});
@@ -1608,15 +1705,20 @@ test("cloud api /v1/agent/chat does not call delayed provider fallback beyond le
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: "cnv_server-test-agent-chat-delayed",
conversationId: manualSession.conversationId,
sessionId: manualSession.sessionId,
message: "请用一句话说明当前 HWLAB 工作台可以做什么,稍后回答"
})
});
@@ -1664,14 +1766,19 @@ test("cloud api /v1/agent/chat does not call OpenAI provider 502/503 fallback",
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: `cnv_server-test-agent-chat-provider-${status}`,
conversationId: manualSession.conversationId,
sessionId: manualSession.sessionId,
message: `provider ${status}`
})
});
@@ -1710,13 +1817,18 @@ test("cloud api /v1/agent/chat does not call empty provider text fallback", asyn
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"
"content-type": "application/json",
cookie: manualSession.cookie
},
body: JSON.stringify({
conversationId: "cnv_empty-provider-text",
conversationId: manualSession.conversationId,
sessionId: manualSession.sessionId,
message: "你好"
})
});
+314 -1
View File
@@ -31,6 +31,7 @@ import {
} from "./server-http-utils.ts";
const DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT = 120;
const DEFAULT_CODE_AGENT_PROJECT_ID = "prj_device_pod_workbench";
const DEFAULT_CODE_AGENT_PROVIDER_PROFILE = "deepseek";
const CODE_AGENT_PROVIDER_PROFILE_IDS = Object.freeze(["deepseek", "codex-api", "minimax-m3", "runtime-default"]);
const CODE_AGENT_PROVIDER_PROFILE_LABELS = Object.freeze({
@@ -86,6 +87,8 @@ export async function handleCodeAgentChatHttp(request, response, options) {
ownerUserId: options.actor?.id ?? params.ownerUserId,
ownerRole: options.actor?.role ?? params.ownerRole
};
const manualSession = await prepareManualCodeAgentSession({ params: chatParams, options, traceId, response });
if (manualSession?.blocked) return;
const workspaceClaim = await claimWorkbenchWorkspaceTurn({ params: chatParams, options, traceId, response });
if (workspaceClaim?.blocked) return;
const nativeSessionChatParams = stripSyntheticConversationContext(chatParams, traceId, options);
@@ -127,6 +130,317 @@ export async function handleCodeAgentChatHttp(request, response, options) {
sendJson(response, responsePayload.status === "failed" && responsePayload.error?.code === "invalid_params" ? 400 : 200, responsePayload);
}
export async function handleCodeAgentSessionsHttp(request, response, url, options) {
const match = url.pathname.match(/^\/v1\/agent\/sessions(?:\/([^/]+)(?:\/(select))?)?$/u);
const sessionId = safeSessionId(match?.[1] ? decodeURIComponent(match[1]) : "");
const action = match?.[2] ?? null;
if (!match) {
sendJson(response, 404, manualSessionErrorPayload({ code: "session_route_not_found", message: "Code Agent session route is not implemented." }));
return;
}
if (request.method === "GET" && !sessionId) {
await listManualCodeAgentSessions(request, response, url, options);
return;
}
if (request.method === "POST" && !sessionId) {
await createManualCodeAgentSession(request, response, options);
return;
}
if (request.method === "GET" && sessionId && !action) {
await getManualCodeAgentSession(response, options, sessionId);
return;
}
if (request.method === "POST" && sessionId && action === "select") {
await selectManualCodeAgentSession(request, response, options, sessionId);
return;
}
sendJson(response, 405, manualSessionErrorPayload({ code: "session_method_not_allowed", message: "Unsupported Code Agent session method." }));
}
async function listManualCodeAgentSessions(request, response, url, options) {
const projectId = textValue(url.searchParams.get("projectId")) || DEFAULT_CODE_AGENT_PROJECT_ID;
const limit = parsePositiveInteger(url.searchParams.get("limit"), 20);
const sessions = await options.accessController?.store?.listAgentSessionsForUser?.({
ownerUserId: options.actor?.id,
actorRole: options.actor?.role,
ownerScoped: true,
projectId,
limit
}) ?? [];
sendJson(response, 200, {
ok: true,
status: "succeeded",
contractVersion: "code-agent-manual-session-v1",
projectId,
sessions: sessions.map(publicManualAgentSession),
count: sessions.length,
valuesRedacted: true,
secretMaterialStored: false
});
}
async function createManualCodeAgentSession(request, response, options) {
const body = await readJsonObjectBody(request, options.bodyLimitBytes);
if (!body.ok) {
sendJson(response, 400, manualSessionErrorPayload({ code: body.code, message: body.message, reason: body.reason }));
return;
}
const params = body.value;
const projectId = textValue(params.projectId) || DEFAULT_CODE_AGENT_PROJECT_ID;
const conversationId = safeConversationId(params.conversationId) || `cnv_${randomUUID()}`;
const sessionId = safeSessionId(params.sessionId) || `ses_${randomUUID()}`;
const providerProfile = normalizeCodeAgentProviderProfile(params.providerProfile ?? params.codeAgentProviderProfile);
const now = new Date().toISOString();
const session = await options.accessController.recordAgentSessionOwner({
ownerUserId: options.actor?.id,
ownerRole: options.actor?.role,
sessionId,
projectId,
agentId: "hwlab-code-agent",
status: "idle",
conversationId,
threadId: null,
traceId: null,
session: {
source: "manual-session-create",
providerProfile,
sessionStatus: "idle",
createdAt: now,
valuesRedacted: true,
secretMaterialStored: false
}
});
const workspace = await updateManualSessionWorkspace({ params, options, session, providerProfile, source: "code-agent-manual-session-create" });
sendJson(response, 201, {
ok: true,
status: "created",
contractVersion: "code-agent-manual-session-v1",
session: publicManualAgentSession(session),
workspace,
nextCommands: manualSessionNextCommands(session),
valuesRedacted: true,
secretMaterialStored: false
});
}
async function getManualCodeAgentSession(response, options, sessionId) {
const session = await getVisibleManualAgentSession(options, sessionId);
if (!session) {
sendJson(response, 404, manualSessionErrorPayload({ code: "session_not_found", message: "Code Agent session is not visible to the current actor.", sessionId }));
return;
}
sendJson(response, 200, {
ok: true,
status: "found",
contractVersion: "code-agent-manual-session-v1",
session: publicManualAgentSession(session),
valuesRedacted: true,
secretMaterialStored: false
});
}
async function selectManualCodeAgentSession(request, response, options, sessionId) {
const body = await readJsonObjectBody(request, options.bodyLimitBytes);
if (!body.ok) {
sendJson(response, 400, manualSessionErrorPayload({ code: body.code, message: body.message, reason: body.reason, sessionId }));
return;
}
const session = await getVisibleManualAgentSession(options, sessionId);
if (!session) {
sendJson(response, 404, manualSessionErrorPayload({ code: "session_not_found", message: "Code Agent session is not visible to the current actor.", sessionId }));
return;
}
if (!manualSessionUsable(session.status)) {
sendJson(response, 409, manualSessionErrorPayload({ code: "session_not_usable", message: `Code Agent session ${sessionId} is ${session.status}; create a new session before continuing.`, sessionId, session }));
return;
}
const providerProfile = textValue(body.value.providerProfile) || session.session?.providerProfile || DEFAULT_CODE_AGENT_PROVIDER_PROFILE;
const workspace = await updateManualSessionWorkspace({ params: body.value, options, session, providerProfile, source: "code-agent-manual-session-select" });
sendJson(response, 200, {
ok: true,
status: "selected",
contractVersion: "code-agent-manual-session-v1",
session: publicManualAgentSession(session),
workspace,
nextCommands: manualSessionNextCommands(session),
valuesRedacted: true,
secretMaterialStored: false
});
}
async function prepareManualCodeAgentSession({ params, options, traceId, response }) {
if (!options.actor?.id) {
sendJson(response, 401, manualSessionErrorPayload({
code: "auth_required",
message: "Authentication is required before using an explicit HWLAB Code Agent session.",
traceId,
retryable: true
}));
return { blocked: true };
}
const sessionId = safeSessionId(params.sessionId);
if (!sessionId) {
sendJson(response, 409, manualSessionErrorPayload({
code: "session_required",
message: "Code Agent session is explicit in HWLAB v0.2; create/select a session before sending a turn.",
traceId,
retryable: true,
nextCommands: ["hwlab-cli client agent session create --provider-profile minimax-m3", "hwlab-cli client agent send --session-id <SESSION_ID> --message TEXT"]
}));
return { blocked: true };
}
const session = await getVisibleManualAgentSession(options, sessionId);
if (!session) {
sendJson(response, 404, manualSessionErrorPayload({ code: "session_not_found", message: "Code Agent session is not visible to the current actor.", traceId, sessionId }));
return { blocked: true };
}
if (!manualSessionUsable(session.status)) {
sendJson(response, 409, manualSessionErrorPayload({ code: "session_not_usable", message: `Code Agent session ${sessionId} is ${session.status}; create a new session before continuing.`, traceId, sessionId, session }));
return { blocked: true };
}
const storedConversationId = safeConversationId(session.conversationId);
const requestedConversationId = safeConversationId(params.conversationId);
if (requestedConversationId && storedConversationId && requestedConversationId !== storedConversationId) {
sendJson(response, 409, manualSessionErrorPayload({ code: "session_conversation_mismatch", message: `sessionId ${sessionId} is bound to ${storedConversationId}; requested ${requestedConversationId}.`, traceId, sessionId, session }));
return { blocked: true };
}
const conversationId = requestedConversationId || storedConversationId;
if (!conversationId) {
sendJson(response, 409, manualSessionErrorPayload({ code: "session_conversation_required", message: "Code Agent session has no bound conversationId; create a new session before continuing.", traceId, sessionId, session }));
return { blocked: true };
}
params.conversationId = conversationId;
params.sessionId = sessionId;
params.projectId = textValue(params.projectId) || session.projectId || DEFAULT_CODE_AGENT_PROJECT_ID;
params.threadId = safeOpaqueId(params.threadId) || safeOpaqueId(session.threadId) || undefined;
return { session };
}
async function getVisibleManualAgentSession(options, sessionId) {
if (!safeSessionId(sessionId) || !options.actor?.id || !options.accessController?.getAgentSession) return null;
const session = await options.accessController.getAgentSession(sessionId);
if (!session) return null;
if (session.ownerUserId === options.actor.id) return session;
return null;
}
async function updateManualSessionWorkspace({ params = {}, options = {}, session, providerProfile, source }) {
const store = options.accessController?.store;
if (!store || !options.actor?.id || !session) return null;
const workspaceId = safeWorkspaceId(params.workspaceId);
const projectId = textValue(params.projectId) || session.projectId || DEFAULT_CODE_AGENT_PROJECT_ID;
const current = workspaceId
? await store.getWorkspaceForUser?.({ workspaceId, ownerUserId: options.actor.id, actorRole: options.actor.role })
: await store.getOrCreateDefaultWorkspace?.({ ownerUserId: options.actor.id, projectId });
if (!current) return null;
const updated = await store.updateWorkspace?.({
workspaceId: current.id,
ownerUserId: options.actor.id,
actorRole: options.actor.role,
projectId: current.projectId ?? projectId,
name: current.name,
status: current.status,
selectedConversationId: session.conversationId,
selectedAgentSessionId: session.id,
selectedDevicePodId: current.selectedDevicePodId,
activeTraceId: null,
providerProfile,
patch: {
...(current.workspace && typeof current.workspace === "object" ? current.workspace : {}),
selectedConversationId: session.conversationId,
selectedAgentSessionId: session.id,
activeTraceId: null,
sessionStatus: session.status,
threadId: safeOpaqueId(session.threadId) || null,
providerProfile,
source,
updatedAt: new Date().toISOString(),
valuesRedacted: true,
secretMaterialStored: false
},
updatedBySessionId: options.authSession?.id ?? null,
updatedByClient: textValue(params.updatedByClient ?? params.client) || source,
now: new Date().toISOString()
});
if (typeof options.accessController.publicWorkbenchWorkspace === "function") {
return await options.accessController.publicWorkbenchWorkspace(updated ?? current, options.actor);
}
return updated ?? current;
}
function publicManualAgentSession(session) {
if (!session || typeof session !== "object") return null;
return {
sessionId: session.id,
projectId: session.projectId ?? null,
agentId: session.agentId ?? "hwlab-code-agent",
status: session.status ?? null,
usable: manualSessionUsable(session.status),
conversationId: safeConversationId(session.conversationId) || null,
threadId: safeOpaqueId(session.threadId) || null,
lastTraceId: safeTraceId(session.lastTraceId) || null,
providerProfile: textValue(session.session?.providerProfile) || null,
createdAt: session.startedAt ?? null,
updatedAt: session.updatedAt ?? null,
valuesRedacted: true,
secretMaterialStored: false
};
}
function manualSessionUsable(status) {
const value = String(status ?? "").trim().toLowerCase().replace(/_/gu, "-");
return !["failed", "timeout", "canceled", "cancelled", "error", "blocked", "expired", "interrupted", "stale", "thread-resume-failed"].includes(value);
}
function manualSessionErrorPayload({ code, message, reason = null, traceId = null, sessionId = null, session = null, retryable = true, nextCommands = undefined } = {}) {
return {
ok: false,
accepted: false,
status: "blocked",
traceId: safeTraceId(traceId) || null,
sessionId: safeSessionId(sessionId) || safeSessionId(session?.id) || null,
session: session ? publicManualAgentSession(session) : null,
error: {
code,
layer: "api",
category: "session_blocked",
retryable,
message,
reason,
userMessage: message,
route: "/v1/agent/chat"
},
blocker: {
code,
layer: "api",
retryable,
summary: message
},
nextCommands,
valuesRedacted: true,
secretMaterialStored: false
};
}
function manualSessionNextCommands(session) {
const sessionId = session?.id ?? "<SESSION_ID>";
return [
`hwlab-cli client agent send --session-id ${sessionId} --message TEXT`,
`hwlab-cli client agent session status ${sessionId}`
];
}
async function readJsonObjectBody(request, bodyLimitBytes) {
const body = await readBody(request, bodyLimitBytes);
try {
const value = body ? JSON.parse(body) : {};
if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false, code: "invalid_params", message: "body must be a JSON object" };
return { ok: true, value };
} catch (error) {
return { ok: false, code: "parse_error", message: "Invalid JSON body", reason: error.message };
}
}
async function runCodeAgentChat(params, options) {
return handleCodeAgentChat(params, await codeAgentChatExecutionOptions(options, params));
}
@@ -1060,7 +1374,6 @@ async function recordCodeAgentSessionOwner({ payload = {}, params = {}, options
function codeAgentOwnerStatusForResult(result = {}) {
if (result?.status === "completed") return "active";
if (result?.status === "canceled" || result?.status === "cancelled") return "canceled";
if (result?.agentRun && result?.reuseEligible !== false && result?.agentRun?.reuseEligible !== false) return "active";
return result?.status ?? "active";
}
+86 -2
View File
@@ -3,6 +3,10 @@ import { chmod, mkdtemp, mkdir, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const TEST_AGENT_USERNAME = "server-test-agent";
const TEST_AGENT_PASSWORD = "server-test-agent-password";
const testAuthByPort = new Map();
export function durableBlockedRuntime({ blocker, blockedLayer, queryResult, gates }) {
return {
adapter: "postgres",
@@ -587,18 +591,98 @@ export function delay(ms) {
}
export async function postAgent(port, body) {
const nextBody = { ...body };
let auth = testAuthByPort.get(port) ?? null;
const manual = nextBody.sessionId ? null : await createManualAgentSession(port, {
conversationId: nextBody.conversationId,
projectId: nextBody.projectId,
providerProfile: nextBody.providerProfile
});
auth = auth ?? manual;
if (manual) {
nextBody.conversationId = manual.conversationId;
nextBody.sessionId = manual.sessionId;
}
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
method: "POST",
headers: {
"content-type": "application/json",
...(body.traceId ? { "x-trace-id": body.traceId } : {})
...(auth?.cookie ? { cookie: auth.cookie } : {}),
...(nextBody.traceId ? { "x-trace-id": nextBody.traceId } : {})
},
body: JSON.stringify(body)
body: JSON.stringify(nextBody)
});
assert.equal(response.status, 200);
return response.json();
}
export async function createManualAgentSession(port, input = {}) {
const auth = await ensureTestAgentAuth(port);
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/sessions`, {
method: "POST",
headers: {
"content-type": "application/json",
cookie: auth.cookie
},
body: JSON.stringify({
conversationId: input.conversationId,
sessionId: input.sessionId,
projectId: input.projectId,
providerProfile: input.providerProfile,
updatedByClient: "server-agent-chat-test"
})
});
const body = await response.json();
assert.equal(response.status, 201);
assert.equal(body.ok, true);
assert.equal(body.session.usable, true);
return {
cookie: auth.cookie,
headers: {
"content-type": "application/json",
cookie: auth.cookie
},
session: body.session,
sessionId: body.session.sessionId,
conversationId: body.session.conversationId
};
}
export async function ensureTestAgentAuth(port) {
const cached = testAuthByPort.get(port);
if (cached?.cookie) return cached;
const setup = await fetch(`http://127.0.0.1:${port}/v1/setup/first-admin`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
username: TEST_AGENT_USERNAME,
displayName: "Server Test Agent",
password: TEST_AGENT_PASSWORD
})
});
let cookie = sessionCookieFromResponse(setup);
if (!cookie) {
const login = await fetch(`http://127.0.0.1:${port}/auth/login`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ username: TEST_AGENT_USERNAME, password: TEST_AGENT_PASSWORD })
});
cookie = sessionCookieFromResponse(login);
assert.equal(login.status, 200);
} else {
assert.equal(setup.status, 201);
}
assert.ok(cookie);
const auth = { cookie };
testAuthByPort.set(port, auth);
return auth;
}
function sessionCookieFromResponse(response) {
const value = response.headers.get("set-cookie") ?? "";
return value.split(";")[0] || "";
}
export async function pollAgentResult(port, traceId) {
let last = null;
for (let attempt = 0; attempt < 20; attempt += 1) {
+10 -2
View File
@@ -51,6 +51,7 @@ import {
handleCodeAgentChatHttp,
handleCodeAgentChatResultHttp,
handleCodeAgentInspectHttp,
handleCodeAgentSessionsHttp,
handleCodeAgentSteerHttp,
handleCodeAgentTraceHttp
} from "./server-code-agent-http.ts";
@@ -437,6 +438,13 @@ async function handleRestAdapter(request, response, url, options) {
return;
}
if (url.pathname === "/v1/agent/sessions" || url.pathname.startsWith("/v1/agent/sessions/")) {
const nextOptions = await codeAgentOptions(request, response, options, { required: true });
if (!nextOptions) return;
await handleCodeAgentSessionsHttp(request, response, url, nextOptions);
return;
}
if (request.method === "GET" && url.pathname === "/v1/agent/chat/inspect") {
const nextOptions = await codeAgentOptions(request, response, options);
if (!nextOptions) return;
@@ -625,8 +633,8 @@ async function handleInternalDevicePodGatewayDispatch(request, response, options
}));
}
async function codeAgentOptions(request, response, options) {
const auth = await options.accessController.authenticate(request, { required: options.accessController.required });
async function codeAgentOptions(request, response, options, authOptions = {}) {
const auth = await options.accessController.authenticate(request, { required: authOptions.required ?? options.accessController.required });
if (!auth.ok) {
sendJson(response, auth.status, auth);
return null;
@@ -7,6 +7,7 @@ const POST_PROXY_ROUTES = new Set([
"/v1/agent/chat",
"/v1/agent/chat/cancel",
"/v1/agent/chat/steer",
"/v1/agent/sessions",
"/v1/m3/io",
"/v1/skills/uploads"
]);
@@ -45,6 +46,7 @@ export function isCloudApiProxyRoute(method, pathname) {
}
if (normalizedMethod === "POST") {
return POST_PROXY_ROUTES.has(normalizedPath) ||
normalizedPath.startsWith("/v1/agent/sessions/") ||
isDevicePodPostProxyRoute(normalizedPath) ||
isWorkbenchWorkspaceWriteProxyRoute(normalizedMethod, normalizedPath);
}
+83 -2
View File
@@ -407,6 +407,81 @@ test("hwlab-cli client agent send submits async and returns trace without waitin
assert.ok(result.payload.waitPolicy.nextCommands.some((command: string) => command.includes("agent result trc_test")));
});
test("hwlab-cli client agent send requires an explicit manual session", async () => {
const calls: any[] = [];
const result = await runHwlabCli([
"client",
"agent",
"send",
"--base-url",
"http://web.test",
"--cookie",
"hwlab_session=session-a",
"--message",
"hello without session",
"--trace-id",
"trc_no_session"
], {
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null });
if (String(url).endsWith("/v1/workbench/workspace?projectId=prj_device_pod_workbench")) {
return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_no_session", revision: 1, workspace: {} } }), { status: 200 });
}
return new Response(JSON.stringify({ accepted: true }), { status: 202 });
}
});
assert.equal(result.exitCode, 1);
assert.equal(result.payload.error.code, "session_required");
assert.equal(calls.length, 1);
assert.equal(calls[0].url, "http://web.test/v1/workbench/workspace?projectId=prj_device_pod_workbench");
});
test("hwlab-cli client agent session create stores selected workspace", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-agent-session-create-"));
const calls: any[] = [];
const result = await runHwlabCli([
"client",
"agent",
"session",
"create",
"--base-url",
"http://web.test",
"--cookie",
"hwlab_session=session-a",
"--provider-profile",
"minimax-m3"
], {
cwd,
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null });
if (String(url).endsWith("/v1/workbench/workspace?projectId=prj_device_pod_workbench")) {
return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_session_create", revision: 2, workspace: {} } }), { status: 200 });
}
if (String(url).endsWith("/v1/agent/sessions")) {
return new Response(JSON.stringify({
ok: true,
status: "created",
session: { sessionId: "ses_manual_create", conversationId: "cnv_manual_create", status: "idle", usable: true, providerProfile: "minimax-m3" },
workspace: { workspaceId: "wsp_session_create", revision: 3, selectedConversationId: "cnv_manual_create", selectedAgentSessionId: "ses_manual_create", workspace: { sessionStatus: "idle", providerProfile: "minimax-m3" } }
}), { status: 201 });
}
return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_test_route" } }), { status: 404 });
}
});
assert.equal(result.exitCode, 0);
assert.equal(calls[1].url, "http://web.test/v1/agent/sessions");
assert.equal(calls[1].body.providerProfile, "minimax-m3");
assert.equal(calls[1].body.workspaceId, "wsp_session_create");
assert.equal(result.payload.session.sessionId, "ses_manual_create");
assert.equal(result.payload.workspace.selectedAgentSessionId, "ses_manual_create");
const session = JSON.parse(await readFile(path.join(cwd, ".state/hwlab-cli/session.json"), "utf8"));
assert.equal(session.workspace.selectedConversationId, "cnv_manual_create");
assert.equal(session.workspace.selectedAgentSessionId, "ses_manual_create");
});
test("hwlab-cli client agent send prefers selected conversation thread over stale workspace thread", async () => {
const calls: any[] = [];
const result = await runHwlabCli([
@@ -531,6 +606,8 @@ test("hwlab-cli client agent send saves completed workspace response for new con
"trc_new_workspace",
"--conversation-id",
"cnv_new_workspace",
"--session-id",
"ses_new_workspace",
"--wait",
"--poll-interval-ms",
"1",
@@ -594,7 +671,7 @@ test("hwlab-cli client agent send saves completed workspace response for new con
assert.equal(result.exitCode, 0);
assert.equal(calls[1].url, "http://web.test/v1/agent/chat");
assert.equal(calls[1].body.conversationId, "cnv_new_workspace");
assert.equal(Object.hasOwn(calls[1].body, "sessionId"), false);
assert.equal(calls[1].body.sessionId, "ses_new_workspace");
assert.equal(Object.hasOwn(calls[1].body, "threadId"), false);
assert.equal(calls[3].url, "http://web.test/v1/workbench/workspace/wsp_new_workspace");
assert.equal(result.payload.workspace.selectedConversationId, "cnv_new_workspace");
@@ -626,6 +703,8 @@ test("hwlab-cli client agent send restores workspace when completed patch omits
"trc_restore_workspace",
"--conversation-id",
"cnv_restore_workspace",
"--session-id",
"ses_restore_workspace",
"--wait",
"--poll-interval-ms",
"1",
@@ -688,7 +767,7 @@ test("hwlab-cli client agent send restores workspace when completed patch omits
assert.equal(result.exitCode, 0);
assert.equal(calls[1].url, "http://web.test/v1/agent/chat");
assert.equal(Object.hasOwn(calls[1].body, "sessionId"), false);
assert.equal(calls[1].body.sessionId, "ses_restore_workspace");
assert.equal(Object.hasOwn(calls[1].body, "threadId"), false);
assert.equal(calls[3].url, "http://web.test/v1/workbench/workspace/wsp_restore_workspace");
assert.equal(calls[4].url, "http://web.test/v1/workbench/workspace?projectId=prj_device_pod_workbench");
@@ -719,6 +798,8 @@ test("hwlab-cli client agent send output uses completed result when restore is s
"trc_stale_restore",
"--conversation-id",
"cnv_stale_restore",
"--session-id",
"ses_stale_restore",
"--wait",
"--poll-interval-ms",
"1",
+169 -6
View File
@@ -1,7 +1,7 @@
import { createHash } from "node:crypto";
import { chmod, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { computeCodeAgentComposerState } from "../../web/hwlab-cloud-web/composer-policy.ts";
import { computeCodeAgentComposerState, isCodeAgentSessionUnusableStatus } from "../../web/hwlab-cloud-web/composer-policy.ts";
import { traceDisplayRows, traceNoiseEventCount } from "../../web/hwlab-cloud-web/app-trace.ts";
import { resolveRuntimeEndpoint, runtimeEndpointVisibility, sameRuntimeEndpointScope } from "./runtime-endpoint-resolver.ts";
@@ -130,6 +130,8 @@ function help() {
"hwlab-cli client workbench reset --profile NAME --confirm",
"hwlab-cli client request GET /v1/access/status [--full]",
"hwlab-cli client rpc system.health [--full]",
"hwlab-cli client agent session create --provider-profile minimax-m3",
"hwlab-cli client agent session select SESSION_ID",
"hwlab-cli client agent send --message TEXT|--message-file PATH [--from-trace TRACE] [--conversation-id ID] [--session-id ID] [--thread-id ID] [--retry-of TRACE] --provider-profile deepseek|codex-api|minimax-m3",
"hwlab-cli client agent send --message TEXT --wait --timeout-ms 50000",
"hwlab-cli client agent result TRACE_ID",
@@ -592,6 +594,7 @@ function gatewayScenarioPass({ scenario, response, dispatch, stdout, stderr, gat
async function agentCommand(context: any) {
const subcommand = context.rest[0] || "send";
if (wantsHelp(context)) return agentHelp();
if (subcommand === "session" || subcommand === "sessions") return agentSessionCommand(context);
if (subcommand === "send") return agentSend(context);
if (subcommand === "composer") return agentComposer(context);
if (subcommand === "result") {
@@ -626,6 +629,10 @@ function agentHelp() {
serviceRuntime: false,
imagePublished: false,
commands: [
"session create [--provider-profile deepseek|codex-api|minimax-m3] [--conversation-id ID]",
"session select SESSION_ID [--workspace-id ID]",
"session status SESSION_ID",
"session list [--limit N]",
"send --message TEXT|--message-file PATH [--from-trace TRACE] [--conversation-id ID] [--session-id ID] [--thread-id ID] [--retry-of TRACE] [default: short return]",
"composer status [--profile NAME] [--full]",
"composer submit --message TEXT|--message-file PATH [--profile NAME] [default: auto turn/steer]",
@@ -761,6 +768,87 @@ async function sessionDeleteCommand(context: any) {
return responsePayload("client.session.delete", response, context, { route: route("DELETE", pathName), workspace: normalizeWorkbenchWorkspace(response.body?.workspace), body: responseBodyForCli(response.body, context.parsed) });
}
async function agentSessionCommand(context: any) {
const subcommand = context.rest[1] || "status";
if (["help", "--help", "-h"].includes(subcommand)) return agentSessionHelp();
if (subcommand === "create" || subcommand === "new") return agentSessionCreate(context);
if (subcommand === "select") return agentSessionSelect(context);
if (subcommand === "status" || subcommand === "show") return agentSessionStatus(context);
if (subcommand === "list" || subcommand === "ls") return agentSessionList(context);
throw cliError("unsupported_agent_session_command", `unsupported agent session command: ${subcommand}`, { subcommand });
}
function agentSessionHelp() {
return ok("client.agent.session.help", {
serviceRuntime: false,
imagePublished: false,
commands: [
"create [--provider-profile deepseek|codex-api|minimax-m3] [--conversation-id ID]",
"select SESSION_ID [--workspace-id ID]",
"status SESSION_ID",
"list [--limit N]"
]
});
}
async function agentSessionCreate(context: any) {
const workspaceState = context.parsed.noWorkspace === true ? null : await restoreWorkbenchWorkspace(context, { quiet: true });
const body = clean({
conversationId: text(context.parsed.conversationId),
sessionId: text(context.parsed.sessionId),
providerProfile: text(context.parsed.providerProfile) || "minimax-m3",
projectId: text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID,
workspaceId: text(context.parsed.workspaceId) || text(workspaceState?.workspaceId),
updatedByClient: "hwlab-cli"
});
const response = await requestJson({ ...context, method: "POST", path: "/v1/agent/sessions", body, timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS });
if (responseSucceeded(response)) await saveManualAgentSessionWorkspaceState(context, response.body, body);
return responsePayload("client.agent.session.create", response, context, {
route: route("POST", "/v1/agent/sessions"),
session: response.body?.session ?? null,
workspace: workspaceSummaryFromSession(await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() })),
body: responseBodyForCli(response.body, context.parsed)
});
}
async function agentSessionSelect(context: any) {
const sessionId = requiredText(context.rest[2] ?? context.parsed.sessionId, "sessionId");
const workspaceState = context.parsed.noWorkspace === true ? null : await restoreWorkbenchWorkspace(context, { quiet: true });
const pathName = `/v1/agent/sessions/${encodeURIComponent(sessionId)}/select`;
const body = clean({
projectId: text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID,
workspaceId: text(context.parsed.workspaceId) || text(workspaceState?.workspaceId),
providerProfile: text(context.parsed.providerProfile),
updatedByClient: "hwlab-cli"
});
const response = await requestJson({ ...context, method: "POST", path: pathName, body, timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS });
if (responseSucceeded(response)) await saveManualAgentSessionWorkspaceState(context, response.body, body);
return responsePayload("client.agent.session.select", response, context, {
route: route("POST", pathName),
sessionId,
session: response.body?.session ?? null,
workspace: workspaceSummaryFromSession(await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() })),
body: responseBodyForCli(response.body, context.parsed)
});
}
async function agentSessionStatus(context: any) {
const sessionId = requiredText(context.rest[2] ?? context.parsed.sessionId, "sessionId");
const pathName = `/v1/agent/sessions/${encodeURIComponent(sessionId)}`;
const response = await requestJson({ ...context, method: "GET", path: pathName });
return responsePayload("client.agent.session.status", response, context, { route: route("GET", pathName), sessionId, body: responseBodyForCli(response.body, context.parsed) });
}
async function agentSessionList(context: any) {
const params = new URLSearchParams();
params.set("projectId", text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID);
const limit = numberOption(context.parsed.limit);
if (limit) params.set("limit", String(limit));
const pathName = `/v1/agent/sessions?${params.toString()}`;
const response = await requestJson({ ...context, method: "GET", path: pathName });
return responsePayload("client.agent.session.list", response, context, { route: route("GET", pathName), body: responseBodyForCli(response.body, context.parsed) });
}
async function agentComposer(context: any) {
const subcommand = context.rest[1] || "status";
if (subcommand === "status") return agentComposerStatus(context);
@@ -943,6 +1031,17 @@ async function resolveAgentReplayContext(context: any) {
const conversationId = safeReplayConversationId(body.query?.conversationId) || safeReplayConversationId(body.conversationFacts?.conversationId) || safeReplayConversationId(body.session?.conversationId) || "";
const sessionId = safeReplaySessionId(body.query?.sessionId) || safeReplaySessionId(body.conversationFacts?.sessionId) || safeReplaySessionId(body.session?.sessionId) || "";
const threadId = text(body.query?.threadId) || text(body.session?.threadId) || text(body.conversationFacts?.threadId);
const sessionStatus = text(body.session?.status ?? body.conversationFacts?.sessionStatus ?? body.runnerTrace?.sessionStatus ?? body.status);
if (isCodeAgentSessionUnusableStatus(sessionStatus)) {
throw cliError("session_not_usable", "client agent send --from-trace refuses to reuse a failed/stale Code Agent session; create/select a new session first", {
fromTrace: traceId,
conversationId: conversationId || null,
sessionId: sessionId || null,
threadId: threadId || null,
sessionStatus,
nextCommands: ["hwlab-cli client agent session create --provider-profile minimax-m3"]
});
}
return {
fromTrace: traceId,
conversationId,
@@ -957,6 +1056,7 @@ async function resolveAgentReplayContext(context: any) {
latestTraceId: body.latestTraceId,
conversationId,
sessionId,
sessionStatus: sessionStatus || null,
threadId,
valuesPrinted: false
})
@@ -1169,10 +1269,38 @@ async function agentSend(context: any) {
const explicitConversationId = text(parsed.conversationId);
const replayConversationId = replay.conversationId;
const workspaceConversationId = text(workspaceState?.selectedConversationId);
const conversationId = explicitConversationId || replayConversationId || workspaceConversationId || makeId("cnv");
const explicitNewConversation = Boolean(explicitConversationId && explicitConversationId !== replayConversationId && explicitConversationId !== workspaceConversationId);
const sessionId = text(parsed.sessionId) || replay.sessionId || (explicitNewConversation ? "" : text(workspaceState?.selectedAgentSessionId));
const threadId = text(parsed.threadId) || replay.threadId || (explicitNewConversation ? "" : text(workspaceState?.threadId));
const sessionId = text(parsed.sessionId) || replay.sessionId || text(workspaceState?.selectedAgentSessionId);
const conversationId = explicitConversationId || replayConversationId || workspaceConversationId;
const workspaceSessionId = text(workspaceState?.selectedAgentSessionId);
const workspaceMatchesSelectedSession = workspaceSessionId === sessionId && (!conversationId || workspaceConversationId === conversationId);
if (!sessionId) {
throw cliError("session_required", "client agent send requires an explicit Code Agent session; run client agent session create first", {
traceId,
conversationId: conversationId || null,
nextCommands: [
"hwlab-cli client agent session create --provider-profile minimax-m3",
"hwlab-cli client agent send --session-id <SESSION_ID> --message TEXT --provider-profile minimax-m3"
]
});
}
const sessionStatus = workspaceSessionId === sessionId ? text(workspaceState?.sessionStatus) : "";
if (isCodeAgentSessionUnusableStatus(sessionStatus) && !text(parsed.ignoreSessionStatus)) {
throw cliError("session_not_usable", `client agent send refuses to reuse ${sessionStatus} Code Agent session; create/select a new session first`, {
traceId,
conversationId: conversationId || null,
sessionId,
sessionStatus,
nextCommands: ["hwlab-cli client agent session create --provider-profile minimax-m3"]
});
}
if (!conversationId) {
throw cliError("session_conversation_required", "selected Code Agent session does not expose conversationId; create a new session before sending", {
traceId,
sessionId,
nextCommands: ["hwlab-cli client agent session create --provider-profile minimax-m3"]
});
}
const threadId = text(parsed.threadId) || replay.threadId || (workspaceMatchesSelectedSession ? text(workspaceState?.threadId) : "");
const retryOf = text(parsed.retryOf) || replay.retryOf;
const continuation = agentContinuationSummary({
conversationId,
@@ -1233,6 +1361,36 @@ async function agentSend(context: any) {
}, result.final ? "succeeded" : "timeout");
}
async function saveManualAgentSessionWorkspaceState(context: any, body: any, fallback: any = {}) {
const workspace = body?.workspace ? normalizeWorkbenchWorkspace(body.workspace) : null;
if (workspace) {
await saveWorkspaceState(context, body.workspace);
return workspace;
}
const sessionRecord = body?.session && typeof body.session === "object" ? body.session : null;
if (!sessionRecord) return null;
const session = await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }) ?? {};
const existing = session.workspace && typeof session.workspace === "object" ? session.workspace : {};
const nextWorkspace = clean({
...existing,
selectedConversationId: text(sessionRecord.conversationId) || text(existing.selectedConversationId),
selectedAgentSessionId: text(sessionRecord.sessionId) || text(existing.selectedAgentSessionId),
threadId: text(sessionRecord.threadId) || text(existing.threadId),
activeTraceId: null,
providerProfile: text(fallback.providerProfile) || text(sessionRecord.providerProfile) || text(existing.providerProfile),
sessionStatus: text(sessionRecord.status) || text(existing.sessionStatus),
selectedConversation: clean({
conversationId: text(sessionRecord.conversationId) || text(existing.selectedConversation?.conversationId),
sessionId: text(sessionRecord.sessionId) || text(existing.selectedConversation?.sessionId),
threadId: text(sessionRecord.threadId) || text(existing.selectedConversation?.threadId)
}),
updatedByClient: "hwlab-cli",
updatedAt: new Date().toISOString()
});
await saveSession(context, { ...session, baseUrl: baseUrl(context.parsed, context.env), workspace: nextWorkspace, updatedAt: context.now() });
return nextWorkspace;
}
async function pollAgentResult(context: any, traceId: string, acceptedBody: any) {
const timeoutMs = numberOption(context.parsed.timeoutMs) ?? DEFAULT_AGENT_TIMEOUT_MS;
const pollIntervalMs = numberOption(context.parsed.pollIntervalMs) ?? DEFAULT_POLL_INTERVAL_MS;
@@ -1891,13 +2049,18 @@ async function restoreWorkbenchWorkspace(context: any, { quiet = false } = {}) {
async function saveAcceptedAgentWorkspaceState(context: any, acceptedBody: any, fallback: any) {
const session = await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }) ?? {};
const existing = session.workspace && typeof session.workspace === "object" ? session.workspace : {};
const existingMatchesFallback = Boolean(
text(fallback.conversationId) && text(fallback.sessionId) &&
text(existing.selectedConversationId) === text(fallback.conversationId) &&
text(existing.selectedAgentSessionId) === text(fallback.sessionId)
);
const workspace = clean({
...existing,
workspaceId: text(acceptedBody?.workspaceId) || text(existing.workspaceId),
revision: numberOption(acceptedBody?.workspaceRevision) ?? existing.revision,
selectedConversationId: text(fallback.conversationId) || text(existing.selectedConversationId),
selectedAgentSessionId: text(fallback.sessionId) || text(existing.selectedAgentSessionId),
threadId: text(acceptedBody?.threadId ?? acceptedBody?.session?.threadId ?? acceptedBody?.providerTrace?.threadId) || text(fallback.threadId) || text(existing.threadId),
threadId: text(acceptedBody?.threadId ?? acceptedBody?.session?.threadId ?? acceptedBody?.providerTrace?.threadId) || text(fallback.threadId) || (existingMatchesFallback ? text(existing.threadId) : ""),
activeTraceId: text(acceptedBody?.traceId) || text(fallback.traceId),
providerProfile: text(fallback.providerProfile) || text(existing.providerProfile),
updatedByClient: "hwlab-cli",
+68
View File
@@ -55,6 +55,14 @@ function initCommandBar() {
await submitAgentMessage(value);
});
el.commandNewSession.addEventListener("click", () => {
createManualAgentSession().catch((error) => {
state.sessionStatus = "failed";
renderAgentChatStatus("failed", { error });
renderCodeAgentSummary();
});
});
el.commandClear.addEventListener("click", () => {
for (const close of state.traceStreams.values()) close();
state.traceStreams.clear();
@@ -102,6 +110,10 @@ function syncCommandInputHeight() {
async function submitAgentMessage(value, options = {}) {
if (!value) return;
const composer = currentComposerState();
if (composer.disabled) {
pushManualSessionBlockedMessage(value, composer.disabledReason);
return;
}
if (composer.submitMode === "steer") {
await submitAgentSteer(value, { ...options, composer });
return;
@@ -330,6 +342,62 @@ function annotateSteerFailure(targetTraceId, error, { steerTraceId, value }) {
};
}
function pushManualSessionBlockedMessage(value, reason) {
state.chatMessages.push({
id: nextProtocolId("msg"),
role: "agent",
title: reason === "session_not_usable" ? "Code Agent session 不可继续" : "需要新建 session",
text: reason === "session_not_usable"
? "当前 Code Agent session 已失败、过期或失效;输入已保留,请先新建 session 后再发送。"
: "当前没有已选择的 Code Agent session;输入已保留,请先新建 session 后再发送。",
status: "blocked",
conversationId: state.conversationId,
sessionId: state.sessionId,
threadId: state.threadId,
retryInput: value,
error: { code: reason || "session_required", category: "session_blocked", retryable: true },
createdAt: new Date().toISOString()
});
el.commandInput.value = value;
syncCommandInputHeight();
renderAgentChatStatus("blocked", latestChatResult());
renderCodeAgentSummary();
renderConversation();
}
async function createManualAgentSession() {
const response = await fetchJson("/v1/agent/sessions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
projectId: "prj_device_pod_workbench",
workspaceId: state.workspaceId,
providerProfile: CODE_AGENT_PROVIDER_PROFILE,
updatedByClient: "hwlab-cloud-web"
})
});
const session = response.session && typeof response.session === "object" ? response.session : null;
const workspace = response.workspace && typeof response.workspace === "object" ? response.workspace : null;
state.conversationId = nonEmptyString(session?.conversationId) ?? nonEmptyString(workspace?.selectedConversationId) ?? state.conversationId;
state.sessionId = nonEmptyString(session?.sessionId) ?? nonEmptyString(workspace?.selectedAgentSessionId) ?? state.sessionId;
state.threadId = nonEmptyString(session?.threadId) ?? null;
state.sessionStatus = nonEmptyString(session?.status) ?? "idle";
if (workspace) {
state.workspaceId = nonEmptyString(workspace.workspaceId) ?? state.workspaceId;
state.workspaceRevision = Number.isInteger(workspace.revision) ? workspace.revision : state.workspaceRevision;
state.workspaceUpdatedAt = nonEmptyString(workspace.updatedAt) ?? state.workspaceUpdatedAt;
}
state.activeTraceId = null;
state.currentRequest = null;
state.chatPending = false;
state.activeSessionKey = state.sessionId || state.conversationId || state.activeSessionKey;
renderAgentChatStatus("idle", latestChatResult());
renderCodeAgentSummary();
renderConversation();
persistCodeAgentSessionState();
return response;
}
function sessionIdForNextRequest() {
return isTerminalSessionStatus(state.sessionStatus) ? undefined : state.sessionId;
}
+1
View File
@@ -154,6 +154,7 @@ const el = {
skillPreview: byId("skill-preview"),
commandForm: byId("command-form"),
commandInput: byId("command-input"),
commandNewSession: byId("command-new-session"),
codeAgentProviderProfile: byId("code-agent-provider-profile"),
codeAgentTimeout: byId("code-agent-timeout"),
gatewayShellTimeout: byId("gateway-shell-timeout"),
+17 -1
View File
@@ -70,8 +70,24 @@ test("composer does not steer from stale terminal activeTraceId", () => {
});
expect(state.locked).toBe(false);
expect(state.disabled).toBe(false);
expect(state.disabled).toBe(true);
expect(state.disabledReason).toBe("session_required");
expect(state.submitMode).toBe("turn");
expect(state.targetTraceId).toBeNull();
expect(state.route).toBe("/v1/agent/chat");
});
test("composer requires a new manual session after failed session", () => {
const state = computeCodeAgentComposerState({
sessionStatus: "failed",
workspace: {
selectedConversationId: "cnv_failed",
selectedAgentSessionId: "ses_failed"
}
});
expect(state.submitMode).toBe("turn");
expect(state.disabled).toBe(true);
expect(state.disabledReason).toBe("session_not_usable");
expect(state.sessionUsable).toBe(false);
});
+33 -5
View File
@@ -1,5 +1,7 @@
const ACTIVE_STATUSES = Object.freeze(["running", "pending", "accepted", "processing"]);
const TERMINAL_STATUSES = Object.freeze(["completed", "source", "failed", "timeout", "canceled", "cancelled", "error", "blocked"]);
const COMPLETED_STATUSES = Object.freeze(["completed", "source", "idle", "ready", "active"]);
const UNUSABLE_SESSION_STATUSES = Object.freeze(["failed", "timeout", "canceled", "cancelled", "error", "blocked", "expired", "interrupted", "stale", "thread-resume-failed"]);
const TERMINAL_STATUSES = Object.freeze([...COMPLETED_STATUSES, ...UNUSABLE_SESSION_STATUSES]);
export function computeCodeAgentComposerState(input = {}) {
const latest = latestAgentMessage(input.messages ?? input.chatMessages);
@@ -21,25 +23,47 @@ export function computeCodeAgentComposerState(input = {}) {
input.chatPending === true
);
const mode = active ? "steer" : "turn";
const conversationId = firstNonEmptyString(input.conversationId, current?.conversationId, latest?.conversationId, workspace?.selectedConversationId);
const sessionId = firstNonEmptyString(input.sessionId, current?.sessionId, latest?.sessionId, workspace?.selectedAgentSessionId);
const sessionUsable = Boolean(sessionId) && !unusableSessionStatus(explicitStatus);
const disabledReason = active
? null
: !sessionId
? "session_required"
: unusableSessionStatus(explicitStatus)
? "session_not_usable"
: null;
return pruneUndefined({
contract: "hwlab-code-agent-composer-policy-v1",
webEquivalent: true,
locked: false,
disabled: false,
disabled: Boolean(disabledReason),
disabledReason,
sessionRequired: !active && !sessionId,
sessionUsable,
submitMode: mode,
mode,
route: mode === "steer" ? "/v1/agent/chat/steer" : "/v1/agent/chat",
method: "POST",
targetTraceId: active ? traceId : null,
conversationId: firstNonEmptyString(input.conversationId, current?.conversationId, latest?.conversationId, workspace?.selectedConversationId),
sessionId: firstNonEmptyString(input.sessionId, current?.sessionId, latest?.sessionId, workspace?.selectedAgentSessionId),
conversationId,
sessionId,
threadId: firstNonEmptyString(input.threadId, current?.threadId, latest?.threadId, workspace?.threadId),
workspaceId: firstNonEmptyString(input.workspaceId, workspace?.workspaceId),
workspaceRevision: integerOrNull(input.workspaceRevision ?? workspace?.revision),
reason: active ? "active-turn-steer" : terminalStatus(explicitStatus) ? "terminal-turn" : "idle-turn"
reason: active ? "active-turn-steer" : disabledReason ?? (terminalStatus(explicitStatus) ? "terminal-turn" : "idle-turn")
});
}
export function isCodeAgentSessionUsableStatus(value) {
const status = normalizedStatus(value);
return !status || COMPLETED_STATUSES.includes(status);
}
export function isCodeAgentSessionUnusableStatus(value) {
return unusableSessionStatus(value);
}
export function latestAgentMessage(messages = []) {
if (!Array.isArray(messages)) return null;
for (const message of [...messages].reverse()) {
@@ -66,6 +90,10 @@ function terminalStatus(value) {
return TERMINAL_STATUSES.includes(normalizedStatus(value));
}
function unusableSessionStatus(value) {
return UNUSABLE_SESSION_STATUSES.includes(normalizedStatus(value));
}
function normalizedStatus(value) {
return String(value ?? "").trim().toLowerCase();
}
+1
View File
@@ -246,6 +246,7 @@
placeholder="输入要发送给 Code Agent 的消息;不会直接触发硬件变更。"
></textarea>
</div>
<button class="command-button secondary" id="command-new-session" type="button">新建 Session</button>
<button class="command-button" id="command-send" type="submit">发送</button>
<button class="command-button secondary" id="command-clear" type="button">清空</button>
</form>
+4
View File
@@ -68,6 +68,10 @@ assert.ok(rightSidebar, "Device Pod right sidebar must exist");
assert.match(html, /data-route="skills"/u, "Cloud Web must expose the skills activity route");
assert.match(html, /id="session-tabs"[^>]*role="tablist"/u, "Cloud Web workspace must expose session tabs");
assert.match(html, /class="session-sidebar"[^>]*id="session-sidebar"/u, "Cloud Web must place sessions in a second-level sidebar");
assert.match(html, /id="command-new-session"/u, "Cloud Web command bar must expose explicit Code Agent session creation");
assertIncludes(app, "/v1/agent/sessions", "Cloud Web must create/select Code Agent sessions explicitly");
assertIncludes(app, "session_required", "Cloud Web must block normal turns until a session is explicitly created");
assertIncludes(app, "session_not_usable", "Cloud Web must block failed/stale Code Agent sessions instead of auto rolling");
assert.match(html, /id="skills"[^>]*data-view="skills"/u, "Cloud Web must expose a skills view");
assert.match(html, /id="skill-upload-input"[^>]*webkitdirectory/u, "Skills view must support directory upload");
assertIncludes(app, "/v1/skills", "skills frontend must call /v1/skills");