feat: 手动化 Code Agent session 管理
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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: "你好"
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user