diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 3f1dbd52..f85f6cd9 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -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) { diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 12353dc8..f98de73f 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -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 = {}) { diff --git a/internal/cloud/code-agent-session-lifecycle.ts b/internal/cloud/code-agent-session-lifecycle.ts index de4d25c8..d5f3106d 100644 --- a/internal/cloud/code-agent-session-lifecycle.ts +++ b/internal/cloud/code-agent-session-lifecycle.ts @@ -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") { diff --git a/internal/cloud/code-agent-session-registry.test.ts b/internal/cloud/code-agent-session-registry.test.ts index b3ce8b1a..c00a29cb 100644 --- a/internal/cloud/code-agent-session-registry.test.ts +++ b/internal/cloud/code-agent-session-registry.test.ts @@ -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"); diff --git a/internal/cloud/code-agent-session-registry.ts b/internal/cloud/code-agent-session-registry.ts index b91b48c3..5bbc30fa 100644 --- a/internal/cloud/code-agent-session-registry.ts +++ b/internal/cloud/code-agent-session-registry.ts @@ -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({ diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index e909b031..9f199e2d 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -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: "你好" }) }); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 8a03c339..f428886b 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -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 --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 ?? ""; + 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"; } diff --git a/internal/cloud/server-test-helpers.ts b/internal/cloud/server-test-helpers.ts index ca885003..982096dd 100644 --- a/internal/cloud/server-test-helpers.ts +++ b/internal/cloud/server-test-helpers.ts @@ -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) { diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 7d9c1eed..830c09cd 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -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; diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index 8dc75f67..692676f5 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -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); } diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index a45e5b79..22f66f54 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -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", diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 65889151..f2f74891 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -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 --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", diff --git a/web/hwlab-cloud-web/app-device-pod.ts b/web/hwlab-cloud-web/app-device-pod.ts index 117e6684..ac78dbe1 100644 --- a/web/hwlab-cloud-web/app-device-pod.ts +++ b/web/hwlab-cloud-web/app-device-pod.ts @@ -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; } diff --git a/web/hwlab-cloud-web/app.ts b/web/hwlab-cloud-web/app.ts index ef34f94e..6515e049 100644 --- a/web/hwlab-cloud-web/app.ts +++ b/web/hwlab-cloud-web/app.ts @@ -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"), diff --git a/web/hwlab-cloud-web/composer-policy.test.ts b/web/hwlab-cloud-web/composer-policy.test.ts index e0f247d9..f2e996df 100644 --- a/web/hwlab-cloud-web/composer-policy.test.ts +++ b/web/hwlab-cloud-web/composer-policy.test.ts @@ -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); +}); diff --git a/web/hwlab-cloud-web/composer-policy.ts b/web/hwlab-cloud-web/composer-policy.ts index f79c2da6..d1ee3f57 100644 --- a/web/hwlab-cloud-web/composer-policy.ts +++ b/web/hwlab-cloud-web/composer-policy.ts @@ -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(); } diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index 593a8ab0..77eb8b38 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -246,6 +246,7 @@ placeholder="输入要发送给 Code Agent 的消息;不会直接触发硬件变更。" > + diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index ec28f184..8ad6bab0 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -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");