diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts deleted file mode 100644 index 21d60aed..00000000 --- a/internal/cloud/access-control.test.ts +++ /dev/null @@ -1,2641 +0,0 @@ -import assert from "node:assert/strict"; -import { generateKeyPairSync, sign } from "node:crypto"; -import { mkdtemp } from "node:fs/promises"; -import { createServer } from "node:http"; -import { test } from "bun:test"; - -import { createAccessController } from "./access-control.ts"; -import { createCloudApiServer } from "./server.ts"; - -const INTERNAL_TOKEN = "test-internal-token"; -const OIDC_ISSUER = "https://auth.74-48-78-17.nip.io/realms/hwlab"; -const OIDC_CLIENT_ID = "hwlab-cloud-web"; -const HWLAB_PUBLIC_ENDPOINT = "https://hwlab.74-48-78-17.nip.io"; -const OIDC_TEST_KEY_ID = "hwlab-test-key"; -const OIDC_TEST_KEYS = generateKeyPairSync("rsa", { modulusLength: 2048 }); -const OIDC_TEST_JWK = Object.freeze({ - ...OIDC_TEST_KEYS.publicKey.export({ format: "jwk" }), - kid: OIDC_TEST_KEY_ID, - alg: "RS256", - use: "sig" -}); - -test("Postgres workspace update query preserves parameter numbering", async () => { - const calls = []; - const accessController = createAccessController({ - now: () => "2026-06-01T00:00:00.000Z", - runtimeStore: { - query: async (sql, params = []) => { - calls.push({ sql, params }); - if (sql.startsWith("CREATE ") || sql.startsWith("ALTER ")) return { rows: [] }; - if (sql.startsWith("SELECT * FROM account_workspaces")) { - return { rows: [{ - id: "wsp_pg_param_guard", - owner_user_id: "usr_pg_param_guard", - project_id: "prj_hwpod_workbench", - name: "Default Workbench", - status: "active", - is_default: true, - selected_conversation_id: null, - selected_agent_session_id: null, - active_trace_id: null, - provider_profile: null, - workspace_json: "{}", - revision: 1, - updated_by_session_id: null, - updated_by_client: null, - created_at: "2026-06-01T00:00:00.000Z", - updated_at: "2026-06-01T00:00:00.000Z" - }] }; - } - if (sql.startsWith("UPDATE account_workspaces")) return { rows: [] }; - return { rows: [] }; - } - } - }); - await accessController.store.updateWorkspace({ - workspaceId: "wsp_pg_param_guard", - ownerUserId: "usr_pg_param_guard", - actorRole: "user", - providerProfile: "deepseek", - patch: { issue655AcceptanceMarker: "postgres-param-guard" }, - updatedByClient: "test-suite" - }); - - const update = calls.find((call) => call.sql.startsWith("UPDATE account_workspaces")); - assert.ok(update); - assert.match(update.sql, /created_at=\$15, updated_at=\$16/u); - assert.match(update.sql, /\$17::text = 'admin'/u); - assert.equal(update.params.length, 17); - assert.equal(update.params[14], "2026-06-01T00:00:00.000Z"); - assert.equal(update.params[15], "2026-06-01T00:00:00.000Z"); - assert.equal(update.params[16], "user"); -}); - -test("cloud api /auth/oidc/login returns 302 to Keycloak when issuer and client are configured", async () => { - const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_KEYCLOAK_ISSUER: OIDC_ISSUER, HWLAB_KEYCLOAK_CLIENT_ID: OIDC_CLIENT_ID, HWLAB_PUBLIC_ENDPOINT }, - now: () => "2026-06-03T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - try { - const { port } = server.address(); - const response = await fetch(`http://127.0.0.1:${port}/auth/oidc/login?returnTo=/workbench`, { redirect: "manual" }); - assert.equal(response.status, 302); - const location = response.headers.get("location"); - assert.ok(location?.startsWith("https://auth.74-48-78-17.nip.io/realms/hwlab/protocol/openid-connect/auth"), `unexpected location: ${location}`); - assert.ok(location?.includes("client_id=hwlab-cloud-web")); - assert.ok(location?.includes("redirect_uri=https%3A%2F%2Fhwlab.74-48-78-17.nip.io%2Fauth%2Foidc%2Fcallback")); - assert.ok(location?.includes("response_type=code")); - assert.ok(location?.includes("state=")); - assert.ok(location?.includes("nonce=")); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api /auth/oidc/login stores only same-origin relative returnTo", async () => { - const accessController = createAccessController({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_KEYCLOAK_ISSUER: OIDC_ISSUER, HWLAB_KEYCLOAK_CLIENT_ID: OIDC_CLIENT_ID, HWLAB_PUBLIC_ENDPOINT }, - now: () => "2026-06-03T00:00:00.000Z" - }); - const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - accessController, - now: () => "2026-06-03T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - try { - const { port } = server.address(); - const response = await fetch(`http://127.0.0.1:${port}/auth/oidc/login?returnTo=https%3A%2F%2Fevil.example%2F`, { redirect: "manual" }); - assert.equal(response.status, 302); - const state = new URL(response.headers.get("location") ?? "http://127.0.0.1").searchParams.get("state"); - assert.ok(state); - const stored = await accessController.store.consumeOidcState(state); - assert.equal(stored.returnTo, "/workbench"); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api /auth/oidc/callback upserts user by issuer+sub and issues 24h web session", async () => { - const accessController = createAccessController({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_KEYCLOAK_ISSUER: OIDC_ISSUER, HWLAB_KEYCLOAK_CLIENT_ID: OIDC_CLIENT_ID, HWLAB_KEYCLOAK_CLIENT_SECRET: "secret", HWLAB_PUBLIC_ENDPOINT }, - fetchImpl: makeOidcFetchMock(), - now: () => "2026-06-03T00:00:00.000Z" - }); - const state = "test-state"; - await accessController.store.upsertOidcState({ state, nonce: "test-nonce", returnTo: "/workbench", createdAt: "2026-06-03T00:00:00.000Z", expiresAt: "2026-06-03T00:10:00.000Z" }); - const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, - accessController, - now: () => "2026-06-03T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - try { - const { port } = server.address(); - const response = await fetch(`http://127.0.0.1:${port}/auth/oidc/callback?code=oidc-code&state=${state}`, { redirect: "manual", headers: { "x-forwarded-proto": "https" } }); - assert.equal(response.status, 302); - const cookie = response.headers.get("set-cookie") ?? ""; - assert.match(cookie, /hwlab_session=[A-Za-z0-9_-]+;/); - assert.match(cookie, /HttpOnly/); - assert.match(cookie, /(?:^|;\s*)Secure(?:;|$)/u); - assert.match(cookie, /Max-Age=86400/); - const sessionResponse = await fetch(`http://127.0.0.1:${port}/v1/auth/session`, { headers: { cookie: cookie.split(";")[0] } }); - assert.equal(sessionResponse.status, 200); - const sessionBody = await sessionResponse.json(); - assert.equal(sessionBody.authenticated, true); - assert.equal(sessionBody.actor.username, "oidc-user"); - assert.equal(sessionBody.actor.role, "user"); - assert.equal(sessionBody.authMethod, "web-session"); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -function makeOidcFetchMock({ nonce = "test-nonce", subject = "keycloak-sub-1234", issuer = OIDC_ISSUER, clientId = OIDC_CLIENT_ID } = {}) { - return async (url) => { - const u = new URL(String(url)); - if (u.pathname.endsWith("/protocol/openid-connect/token")) { - return new Response(JSON.stringify({ - access_token: "oidc-access-token", - refresh_token: "oidc-refresh-token", - id_token: signedOidcIdToken({ issuer, clientId, nonce, subject }), - token_type: "Bearer", - expires_in: 300 - }), { status: 200, headers: { "content-type": "application/json" } }); - } - if (u.pathname.endsWith("/protocol/openid-connect/certs")) { - return new Response(JSON.stringify({ keys: [OIDC_TEST_JWK] }), { status: 200, headers: { "content-type": "application/json" } }); - } - if (u.pathname.endsWith("/protocol/openid-connect/userinfo")) { - return new Response(JSON.stringify({ - sub: subject, - preferred_username: "oidc-user", - email: "oidc-user@hwlab.local", - name: "OIDC User", - email_verified: true - }), { status: 200, headers: { "content-type": "application/json" } }); - } - return new Response("not found", { status: 404 }); - }; -} - -function signedOidcIdToken({ issuer = OIDC_ISSUER, clientId = OIDC_CLIENT_ID, nonce = "test-nonce", subject = "keycloak-sub-1234", nowSeconds = 1_780_444_800 } = {}) { - const header = base64UrlEncodeJson({ alg: "RS256", typ: "JWT", kid: OIDC_TEST_KEY_ID }); - const payload = base64UrlEncodeJson({ - iss: issuer, - aud: clientId, - sub: subject, - nonce, - exp: nowSeconds + 600, - iat: nowSeconds, - preferred_username: "oidc-user", - email: "oidc-user@hwlab.local", - name: "OIDC User" - }); - const body = `${header}.${payload}`; - const signature = sign("RSA-SHA256", Buffer.from(body), OIDC_TEST_KEYS.privateKey).toString("base64url"); - return `${body}.${signature}`; -} - -function base64UrlEncodeJson(value) { - return Buffer.from(JSON.stringify(value)).toString("base64url"); -} - -test("workbench workspace permits a new turn after AgentRun active trace reaches terminal status", async () => { - const agentRunCalls = []; - const agentSessions = new Map(); - const agentRunServer = createServer(async (request, response) => { - const url = new URL(request.url || "/", "http://127.0.0.1"); - agentRunCalls.push({ method: request.method, path: url.pathname, search: url.search }); - const send = (data) => { - response.writeHead(200, { "content-type": "application/json" }); - response.end(`${JSON.stringify({ ok: true, data })}\n`); - }; - if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_done/events") { - return send({ items: [ - { id: "evt_done", runId: "run_workspace_done", seq: 1, type: "terminal_status", payload: { commandId: "cmd_workspace_done", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:01.000Z" } - ] }); - } - if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_done/commands/cmd_workspace_done/result") { - return send({ - runId: "run_workspace_done", - commandId: "cmd_workspace_done", - status: "completed", - runStatus: "completed", - commandState: "completed", - terminalStatus: "completed", - completed: true, - reply: "previous turn completed", - lastSeq: 1, - sessionRef: { sessionId: "ses_issue655_shared", conversationId: "cnv_issue655_shared", threadId: "thread-issue-655" } - }); - } - if (request.method === "POST" && url.pathname === "/api/v1/runs") { - return send({ id: "run_workspace_next", status: "pending", backendProfile: "deepseek", sessionRef: { sessionId: "ses_issue655_shared", conversationId: "cnv_issue655_shared" } }); - } - if (request.method === "POST" && url.pathname === "/api/v1/runs/run_workspace_next/commands") { - return send({ id: "cmd_workspace_next", runId: "run_workspace_next", state: "pending", type: "turn", seq: 1 }); - } - if (request.method === "POST" && url.pathname === "/api/v1/runs/run_workspace_next/runner-jobs") { - return send({ - action: "create-kubernetes-job", - runId: "run_workspace_next", - commandId: "cmd_workspace_next", - attemptId: "attempt_workspace_next", - runnerId: "runner_workspace_next", - namespace: "agentrun-v01", - jobName: "agentrun-v01-runner-workspace-next" - }); - } - response.writeHead(404, { "content-type": "application/json" }); - response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`); - }); - await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve)); - - const agentRunPort = agentRunServer.address().port; - const accessController = createAccessController({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - now: () => "2026-06-01T00:00:00.000Z" - }); - accessController.getAgentSessionByTraceId = async (traceId) => agentSessions.get(traceId) ?? null; - const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", - AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`, - HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", - HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "D601", - HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567", - HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek" - }, - accessController, - now: () => "2026-06-01T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-terminal", password: "alice-pass" }, adminLogin.cookie); - assert.equal(alice.status, 201); - const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-terminal", password: "alice-pass" }); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(workspace.status, 200); - const conversation = await putJson(port, "/v1/agent/conversations/cnv_issue655_shared", { - projectId: "prj_hwpod_workbench", - sessionId: "ses_issue655_shared", - threadId: "thread-issue-655", - sessionStatus: "active", - lastTraceId: "trc_issue655_previous", - messages: [{ role: "agent", text: "previous completed turn", traceId: "trc_issue655_previous" }] - }, aliceLogin.cookie); - assert.equal(conversation.status, 200); - - const previousPayload = { - messageId: "msg_workspace_done", - agentRun: { - runId: "run_workspace_done", - commandId: "cmd_workspace_done", - backendProfile: "deepseek", - managerUrl: `http://127.0.0.1:${agentRunPort}`, - lastSeq: 0, - sessionId: "ses_issue655_shared", - conversationId: "cnv_issue655_shared", - threadId: "thread-issue-655" - } - }; - agentSessions.set("trc_issue655_active_done", { - id: "ses_issue655_shared", - ownerUserId: alice.body.user.id, - conversationId: "cnv_issue655_shared", - threadId: "thread-issue-655", - status: "active", - session: previousPayload, - updatedAt: "2026-06-01T00:00:00.000Z" - }); - - const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, { - expectedRevision: 1, - selectedConversationId: "cnv_issue655_shared", - selectedAgentSessionId: "ses_issue655_shared", - activeTraceId: "trc_issue655_active_done", - providerProfile: "deepseek", - sessionStatus: "running", - updatedByClient: "test-suite" - }, aliceLogin.cookie); - assert.equal(update.status, 200); - assert.equal(update.body.workspace.activeTraceId, "trc_issue655_active_done"); - - const next = await postJson(port, "/v1/agent/chat", { - message: "new turn after completed active trace", - traceId: "trc_issue655_after_done", - conversationId: "cnv_issue655_shared", - sessionId: "ses_issue655_shared", - workspaceId: workspace.body.workspace.workspaceId, - expectedWorkspaceRevision: 2, - shortConnection: true - }, aliceLogin.cookie, { prefer: "respond-async", "x-trace-id": "trc_issue655_after_done" }); - assert.equal(next.status, 202); - assert.equal(next.body.status, "running"); - assert.equal(next.body.traceId, "trc_issue655_after_done"); - await waitForCondition(() => agentRunCalls.some((call) => call.method === "POST" && call.path === "/api/v1/runs")); - assert.equal(agentRunCalls.some((call) => call.path === "/api/v1/runs/run_workspace_done/commands/cmd_workspace_done/result"), false); - - const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(restored.body.workspace.activeTraceId, "trc_issue655_after_done"); - assert.equal(restored.body.workspace.workspace.sessionStatus, "running"); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("workbench workspace permits continuation when stale active trace has idle active conversation", async () => { - const workspaceDir = await mkdtemp("/tmp/hwlab-stale-active-idle-"); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspaceDir, - HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access", - OPENAI_API_KEY: "test-openai-key-material" - }, - codexStdioManager: { - describe: () => ({ available: true, ready: true, workspace: workspaceDir, sandbox: "danger-full-access" }), - probe: async () => ({ available: true, ready: true, workspace: workspaceDir, sandbox: "danger-full-access" }), - chat: async (params = {}) => ({ - status: "completed", - traceId: params.traceId, - conversationId: params.conversationId, - sessionId: params.sessionId, - threadId: params.threadId, - reply: { role: "assistant", content: "ok" }, - runnerTrace: { traceId: params.traceId, status: "completed", events: [], eventCount: 0 }, - session: { sessionId: params.sessionId, conversationId: params.conversationId, threadId: params.threadId, status: "idle" } - }), - cancel() {}, - reapIdle() {} - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", adminLogin.cookie); - const conversation = await putJson(port, "/v1/agent/conversations/cnv_stale_active_idle", { - projectId: "prj_hwpod_workbench", - sessionId: "ses_stale_active_idle", - threadId: "thread-stale-active-idle", - status: "active", - sessionStatus: "active", - lastTraceId: "trc_stale_active_idle_missing", - messages: [{ role: "agent", text: "previous completed turn", status: "completed", traceId: "trc_stale_active_idle_missing" }] - }, adminLogin.cookie); - assert.equal(conversation.status, 200); - - const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, { - expectedRevision: 1, - selectedConversationId: "cnv_stale_active_idle", - selectedAgentSessionId: "ses_stale_active_idle", - activeTraceId: "trc_stale_active_idle_missing", - sessionStatus: "running", - updatedByClient: "test-suite" - }, adminLogin.cookie); - assert.equal(update.status, 200); - - const next = await postJson(port, "/v1/agent/chat", { - message: "new turn after stale active idle trace", - traceId: "trc_stale_active_idle_next", - conversationId: "cnv_stale_active_idle", - sessionId: "ses_stale_active_idle", - threadId: "thread-stale-active-idle", - workspaceId: workspace.body.workspace.workspaceId, - expectedWorkspaceRevision: 2, - shortConnection: true - }, adminLogin.cookie, { prefer: "respond-async", "x-trace-id": "trc_stale_active_idle_next" }); - - assert.equal(next.status, 202); - assert.equal(next.body.traceId, "trc_stale_active_idle_next"); - assert.equal(next.body.conversationId, "cnv_stale_active_idle"); - assert.equal(next.body.sessionId, "ses_stale_active_idle"); - - const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", adminLogin.cookie); - assert.equal(restored.body.workspace.activeTraceId, "trc_stale_active_idle_next"); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("workbench workspace read does not clear completed AgentRun active trace", async () => { - const agentRunCalls = []; - const agentSessions = new Map(); - const agentRunServer = createServer(async (request, response) => { - const url = new URL(request.url || "/", "http://127.0.0.1"); - agentRunCalls.push({ method: request.method, path: url.pathname, search: url.search }); - const send = (data) => { - response.writeHead(200, { "content-type": "application/json" }); - response.end(`${JSON.stringify({ ok: true, data })}\n`); - }; - if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_status/events") { - return send({ items: [ - { id: "evt_status_done", runId: "run_workspace_status", seq: 1, type: "terminal_status", payload: { commandId: "cmd_workspace_status", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:01.000Z" } - ] }); - } - if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_status/commands") { - return send({ items: [ - { id: "cmd_workspace_status", runId: "run_workspace_status", state: "completed", type: "turn", seq: 1, idempotencyKey: "trc_issue664_status_done", payload: { traceId: "trc_issue664_status_done", conversationId: "cnv_issue664_status", hwlabSessionId: "ses_issue664_status", threadId: "thread-issue-664", providerProfile: "deepseek" } } - ] }); - } - if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_status/commands/cmd_workspace_status/result") { - return send({ - runId: "run_workspace_status", - commandId: "cmd_workspace_status", - status: "completed", - runStatus: "completed", - commandState: "completed", - terminalStatus: "completed", - completed: true, - reply: "previous turn completed", - lastSeq: 1, - sessionRef: { sessionId: "ses_issue664_status", conversationId: "cnv_issue664_status", threadId: "thread-issue-664" } - }); - } - response.writeHead(404, { "content-type": "application/json" }); - response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`); - }); - await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve)); - - const agentRunPort = agentRunServer.address().port; - const accessController = createAccessController({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - now: () => "2026-06-01T00:00:00.000Z" - }); - accessController.getAgentSessionByTraceId = async (traceId) => agentSessions.get(traceId) ?? null; - const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", - AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`, - HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", - HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek" - }, - accessController, - now: () => "2026-06-01T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-status", password: "alice-pass" }, adminLogin.cookie); - assert.equal(alice.status, 201); - const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-status", password: "alice-pass" }); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(workspace.status, 200); - const conversation = await putJson(port, "/v1/agent/conversations/cnv_issue664_status", { - projectId: "prj_hwpod_workbench", - sessionId: "ses_issue664_status", - threadId: "thread-issue-664", - sessionStatus: "active", - lastTraceId: "trc_issue664_previous", - messages: [{ role: "agent", text: "previous turn still running", status: "running", traceId: "trc_issue664_status_done" }] - }, aliceLogin.cookie); - assert.equal(conversation.status, 200); - - const previousPayload = { - messageId: "msg_workspace_status_done", - agentRun: { - runId: "run_workspace_status", - commandId: "cmd_workspace_status", - backendProfile: "deepseek", - managerUrl: `http://127.0.0.1:${agentRunPort}`, - lastSeq: 0, - sessionId: "ses_issue664_status", - conversationId: "cnv_issue664_status", - threadId: "thread-issue-664" - } - }; - agentSessions.set("trc_issue664_status_done", { - id: "ses_issue664_status", - ownerUserId: alice.body.user.id, - conversationId: "cnv_issue664_status", - threadId: "thread-issue-664", - status: "active", - session: previousPayload, - updatedAt: "2026-06-01T00:00:00.000Z" - }); - - const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, { - expectedRevision: 1, - selectedConversationId: "cnv_issue664_status", - selectedAgentSessionId: "ses_issue664_status", - activeTraceId: "trc_issue664_status_done", - providerProfile: "deepseek", - sessionStatus: "running", - updatedByClient: "test-suite" - }, aliceLogin.cookie); - assert.equal(update.status, 200); - assert.equal(update.body.workspace.activeTraceId, "trc_issue664_status_done"); - - const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(restored.status, 200); - assert.equal(restored.body.workspace.activeTraceId, "trc_issue664_status_done"); - assert.equal(restored.body.workspace.workspace.sessionStatus, "running"); - assert.equal(restored.body.workspace.selectedConversation.lastTraceId, "trc_issue664_previous"); - assert.equal(restored.body.workspace.selectedConversation.messages.length, 1); - assert.equal(restored.body.workspace.selectedConversation.messages[0].status, "running"); - assert.equal(restored.body.workspace.selectedConversation.messages[0].text, "previous turn still running"); - assert.equal(restored.body.workspace.revision, 2); - assert.equal(agentRunCalls.length, 0); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("workbench workspace read preserves a newer selected conversation without terminal sync", async () => { - const agentRunCalls = []; - const agentSessions = new Map(); - const agentRunServer = createServer(async (request, response) => { - const url = new URL(request.url || "/", "http://127.0.0.1"); - agentRunCalls.push({ method: request.method, path: url.pathname, search: url.search }); - const send = (data) => { - response.writeHead(200, { "content-type": "application/json" }); - response.end(`${JSON.stringify({ ok: true, data })}\n`); - }; - if (request.method === "GET" && url.pathname === "/api/v1/runs/run_issue808_old/events") { - return send({ items: [ - { id: "evt_issue808_done", runId: "run_issue808_old", seq: 1, type: "terminal_status", payload: { commandId: "cmd_issue808_old", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:01.000Z" } - ] }); - } - if (request.method === "GET" && url.pathname === "/api/v1/runs/run_issue808_old/commands") { - return send({ items: [ - { id: "cmd_issue808_old", runId: "run_issue808_old", state: "completed", type: "turn", seq: 1, idempotencyKey: "trc_issue808_old_done", payload: { traceId: "trc_issue808_old_done", conversationId: "cnv_issue808_old", hwlabSessionId: "ses_issue808_old", threadId: "thread-issue-808-old", providerProfile: "deepseek" } } - ] }); - } - if (request.method === "GET" && url.pathname === "/api/v1/runs/run_issue808_old/commands/cmd_issue808_old/result") { - return send({ - runId: "run_issue808_old", - commandId: "cmd_issue808_old", - status: "completed", - runStatus: "completed", - commandState: "completed", - terminalStatus: "completed", - completed: true, - reply: "old turn completed", - lastSeq: 1, - sessionRef: { sessionId: "ses_issue808_old", conversationId: "cnv_issue808_old", threadId: "thread-issue-808-old" } - }); - } - response.writeHead(404, { "content-type": "application/json" }); - response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`); - }); - await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve)); - - const agentRunPort = agentRunServer.address().port; - const accessController = createAccessController({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - now: () => "2026-06-01T00:00:00.000Z" - }); - accessController.getAgentSessionByTraceId = async (traceId) => agentSessions.get(traceId) ?? null; - const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", - AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`, - HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", - HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek" - }, - accessController, - now: () => "2026-06-01T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-issue808", password: "alice-pass" }, adminLogin.cookie); - assert.equal(alice.status, 201); - const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-issue808", password: "alice-pass" }); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(workspace.status, 200); - - const oldConversation = await putJson(port, "/v1/agent/conversations/cnv_issue808_old", { - projectId: "prj_hwpod_workbench", - sessionId: "ses_issue808_old", - threadId: "thread-issue-808-old", - sessionStatus: "running", - lastTraceId: "trc_issue808_old_done", - messages: [{ role: "agent", text: "old turn running", status: "running", traceId: "trc_issue808_old_done" }] - }, aliceLogin.cookie); - assert.equal(oldConversation.status, 200); - const newConversation = await putJson(port, "/v1/agent/conversations/cnv_issue808_new", { - projectId: "prj_hwpod_workbench", - sessionId: "ses_issue808_new", - threadId: "thread-issue-808-new", - sessionStatus: "idle", - messages: [] - }, aliceLogin.cookie); - assert.equal(newConversation.status, 200); - - agentSessions.set("trc_issue808_old_done", { - id: "ses_issue808_old", - ownerUserId: alice.body.user.id, - conversationId: "cnv_issue808_old", - threadId: "thread-issue-808-old", - status: "active", - session: { - messageId: "msg_issue808_old_done", - agentRun: { - runId: "run_issue808_old", - commandId: "cmd_issue808_old", - backendProfile: "deepseek", - managerUrl: `http://127.0.0.1:${agentRunPort}`, - lastSeq: 0, - sessionId: "ses_issue808_old", - conversationId: "cnv_issue808_old", - threadId: "thread-issue-808-old" - } - }, - updatedAt: "2026-06-01T00:00:00.000Z" - }); - - const oldActive = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, { - expectedRevision: 1, - selectedConversationId: "cnv_issue808_old", - selectedAgentSessionId: "ses_issue808_old", - activeTraceId: "trc_issue808_old_done", - lastTraceId: "trc_issue808_old_done", - providerProfile: "deepseek", - sessionStatus: "running", - updatedByClient: "test-suite" - }, aliceLogin.cookie); - assert.equal(oldActive.status, 200); - assert.equal(oldActive.body.workspace.selectedConversationId, "cnv_issue808_old"); - assert.equal(oldActive.body.workspace.activeTraceId, "trc_issue808_old_done"); - - const selectedNew = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, { - projectId: "prj_hwpod_workbench", - conversationId: "cnv_issue808_new", - sessionId: "ses_issue808_new", - updatedByClient: "test-suite" - }, aliceLogin.cookie); - assert.equal(selectedNew.status, 200); - assert.equal(selectedNew.body.workspace.selectedConversationId, "cnv_issue808_new"); - assert.equal(selectedNew.body.workspace.selectedAgentSessionId, "ses_issue808_new"); - - const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(restored.status, 200); - assert.equal(restored.body.workspace.activeTraceId, null); - assert.equal(restored.body.workspace.selectedConversationId, "cnv_issue808_new"); - assert.equal(restored.body.workspace.selectedAgentSessionId, "ses_issue808_new"); - assert.equal(restored.body.workspace.selectedConversation.conversationId, "cnv_issue808_new"); - assert.equal(restored.body.workspace.selectedConversation.sessionId, "ses_issue808_new"); - assert.equal(restored.body.workspace.workspace.selectedConversationId, "cnv_issue808_new"); - assert.equal(restored.body.workspace.workspace.selectedAgentSessionId, "ses_issue808_new"); - assert.equal(restored.body.workspace.workspace.lastTraceId, undefined); - assert.notEqual(restored.body.workspace.workspace.sessionStatus, "running"); - assert.equal(agentRunCalls.length, 0); - - const selectedOld = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, { - projectId: "prj_hwpod_workbench", - conversationId: "cnv_issue808_old", - sessionId: "ses_issue808_old", - updatedByClient: "test-suite" - }, aliceLogin.cookie); - assert.equal(selectedOld.status, 200); - assert.equal(selectedOld.body.workspace.selectedConversationId, "cnv_issue808_old"); - - const repaired = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(repaired.status, 200); - assert.equal(repaired.body.workspace.activeTraceId, null); - assert.equal(repaired.body.workspace.selectedConversationId, "cnv_issue808_old"); - assert.equal(repaired.body.workspace.selectedAgentSessionId, "ses_issue808_old"); - assert.equal(repaired.body.workspace.selectedConversation.status, "running"); - assert.equal(repaired.body.workspace.selectedConversation.lastTraceId, "trc_issue808_old_done"); - assert.equal(repaired.body.workspace.selectedConversation.messages[0].status, "running"); - assert.equal(repaired.body.workspace.selectedConversation.messages[0].text, "old turn running"); - assert.equal(agentRunCalls.length, 0); - - const directOld = await getJson(port, "/v1/agent/conversations/cnv_issue808_old", aliceLogin.cookie); - assert.equal(directOld.status, 200); - assert.equal(directOld.body.conversation.status, "running"); - assert.equal(directOld.body.conversation.lastTraceId, "trc_issue808_old_done"); - assert.equal(directOld.body.conversation.messages[0].status, "running"); - assert.equal(directOld.body.conversation.messages[0].text, "old turn running"); - assert.equal(agentRunCalls.length, 0); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("workbench selected conversation rejects cross-project authority drift (#1413/#1453)", async () => { - const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - now: () => "2026-06-17T12:40:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const port = server.address().port; - try { - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-issue1413", password: "alice-pass" }, adminLogin.cookie); - assert.equal(alice.status, 201); - const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-issue1413", password: "alice-pass" }); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(workspace.status, 200); - - const oldConversation = await putJson(port, "/v1/agent/conversations/cnv_issue1413_old", { - projectId: "prj_hwpod_workbench", - sessionId: "ses_issue1413_old", - threadId: "thread-issue-1413-old", - sessionStatus: "running", - lastTraceId: "trc_issue1413_old_running", - messages: [{ role: "agent", text: "old turn running", status: "running", traceId: "trc_issue1413_old_running" }] - }, aliceLogin.cookie); - assert.equal(oldConversation.status, 200); - const targetConversation = await putJson(port, "/v1/agent/conversations/cnv_issue1413_target", { - projectId: "prj_v02_code_agent", - sessionId: "ses_issue1413_target", - threadId: "thread-issue-1413-target", - sessionStatus: "thread-resume-failed", - lastTraceId: "trc_issue1413_target_failed", - messages: [{ role: "agent", text: "target thread resume failed", status: "failed", traceId: "trc_issue1413_target_failed" }] - }, aliceLogin.cookie); - assert.equal(targetConversation.status, 200); - - const oldActive = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, { - selectedConversationId: "cnv_issue1413_old", - selectedAgentSessionId: "ses_issue1413_old", - activeTraceId: "trc_issue1413_old_running", - lastTraceId: "trc_issue1413_old_running", - sessionStatus: "running", - updatedByClient: "test-suite" - }, aliceLogin.cookie); - assert.equal(oldActive.status, 200); - - const selected = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, { - projectId: "prj_hwpod_workbench", - conversationId: "cnv_issue1413_target", - sessionId: "ses_issue1413_target", - updatedByClient: "test-suite" - }, aliceLogin.cookie); - assert.equal(selected.status, 404); - assert.equal(selected.body.error.code, "agent_conversation_not_found"); - - const mismatchedCreate = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, { - projectId: "prj_v02_code_agent", - create: true, - conversationId: "cnv_issue1413_cross_create", - sessionId: "ses_issue1413_cross_create", - updatedByClient: "test-suite" - }, aliceLogin.cookie); - assert.equal(mismatchedCreate.status, 409); - assert.equal(mismatchedCreate.body.error.code, "workspace_project_mismatch"); - - const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(restored.status, 200); - assert.equal(restored.body.workspace.selectedConversationId, "cnv_issue1413_old"); - assert.equal(restored.body.workspace.selectedConversation.conversationId, "cnv_issue1413_old"); - assert.equal(restored.body.workspace.selectedConversation.projectId, "prj_hwpod_workbench"); - assert.equal(restored.body.workspace.workspace.selectedConversationId, "cnv_issue1413_old"); - assert.equal(restored.body.workspace.workspace.lastTraceId, "trc_issue1413_old_running"); - assert.equal(restored.body.workspace.workspace.sessionStatus, "running"); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("agent session owner keeps project and completed status authority when result evidence is late (#1453)", async () => { - const projectId = "prj_hwpod_workbench"; - const ownerUserId = "usr_issue1453_owner"; - const conversationId = "cnv_issue1453_project_status"; - const sessionId = "ses_issue1453_project_status"; - const traceId = "trc_issue1453_project_status"; - const threadId = "thread-issue-1453-project-status"; - const accessController = createAccessController({ now: () => "2026-06-18T01:57:22.000Z" }); - - await accessController.recordAgentSessionOwner({ - ownerUserId, - sessionId, - projectId, - agentId: "hwlab-code-agent", - status: "running", - conversationId, - threadId, - traceId, - session: { - source: "workbench-turn-accepted", - sessionStatus: "running", - lastTraceId: traceId, - messages: [ - { id: "msg_issue1453_user", role: "user", text: "RUNNING_ISOLATION_1781747703017", status: "sent", traceId, conversationId, sessionId, threadId }, - { id: "msg_issue1453_agent", role: "agent", text: "", status: "running", traceId, conversationId, sessionId, threadId } - ], - valuesRedacted: true, - secretMaterialStored: false - } - }); - - await accessController.recordAgentSessionOwner({ - ownerUserId, - sessionId, - agentId: "hwlab-code-agent", - status: "completed", - conversationId, - threadId, - traceId, - session: { - source: "code-agent-terminal-result", - sessionStatus: "active", - finalResponse: "助手最终消息,轮次完成 ... OK", - messages: [ - { id: "msg_issue1453_agent", role: "agent", text: "助手最终消息,轮次完成 ... OK", status: "completed", traceId, conversationId, sessionId, threadId } - ], - valuesRedacted: true, - secretMaterialStored: false - } - }); - - const session = await accessController.getAgentSession(sessionId); - const conversation = await accessController.visibleConversationForActor({ id: ownerUserId, role: "user" }, conversationId, projectId); - const wrongProject = await accessController.visibleConversationForActor({ id: ownerUserId, role: "user" }, conversationId, "prj_v02_code_agent"); - - assert.equal(session.projectId, projectId); - assert.equal(session.status, "completed"); - assert.equal(conversation.projectId, projectId); - assert.equal(conversation.status, "completed"); - assert.equal(conversation.lastTraceId, traceId); - assert.equal(conversation.messages.at(-1).status, "completed"); - assert.equal(conversation.messages.at(-1).text, "助手最终消息,轮次完成 ... OK"); - assert.equal(wrongProject, null); -}); - -test("manual Code Agent session select restores running active trace", async () => { - const accessController = createAccessController({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - now: () => "2026-06-01T00:00:00.000Z" - }); - const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "minimax-m3" - }, - accessController, - now: () => "2026-06-01T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-issue810", password: "alice-pass" }, adminLogin.cookie); - assert.equal(alice.status, 201); - const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-issue810", password: "alice-pass" }); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(workspace.status, 200); - - await accessController.recordAgentSessionOwner({ - ownerUserId: alice.body.user.id, - ownerRole: "user", - sessionId: "ses_issue810_running", - projectId: "prj_hwpod_workbench", - agentId: "hwlab-code-agent", - status: "running", - conversationId: "cnv_issue810_running", - threadId: "thread-issue-810-running", - traceId: "trc_issue810_running", - session: { - source: "test-running-session", - providerProfile: "minimax-m3", - sessionStatus: "running", - currentTraceId: "trc_issue810_running", - agentRun: { - runId: "run_issue810_running", - commandId: "cmd_issue810_running", - runStatus: "running", - commandState: "running", - backendProfile: "minimax-m3" - }, - valuesRedacted: true, - secretMaterialStored: false - } - }); - await accessController.recordAgentSessionOwner({ - ownerUserId: alice.body.user.id, - ownerRole: "user", - sessionId: "ses_issue810_idle", - projectId: "prj_hwpod_workbench", - agentId: "hwlab-code-agent", - status: "idle", - conversationId: "cnv_issue810_idle", - threadId: "thread-issue-810-idle", - traceId: null, - session: { - source: "test-idle-session", - providerProfile: "minimax-m3", - sessionStatus: "idle", - valuesRedacted: true, - secretMaterialStored: false - } - }); - - const selectedIdle = await postJson(port, "/v1/agent/sessions/ses_issue810_idle/select", { - projectId: "prj_hwpod_workbench", - workspaceId: workspace.body.workspace.workspaceId, - updatedByClient: "test-suite" - }, aliceLogin.cookie); - assert.equal(selectedIdle.status, 200); - assert.equal(selectedIdle.body.workspace.selectedConversationId, "cnv_issue810_idle"); - assert.equal(selectedIdle.body.workspace.selectedAgentSessionId, "ses_issue810_idle"); - assert.equal(selectedIdle.body.workspace.activeTraceId, null); - - const selectedRunning = await postJson(port, "/v1/agent/sessions/ses_issue810_running/select", { - projectId: "prj_hwpod_workbench", - workspaceId: workspace.body.workspace.workspaceId, - updatedByClient: "test-suite" - }, aliceLogin.cookie); - assert.equal(selectedRunning.status, 200); - assert.equal(selectedRunning.body.status, "selected"); - assert.equal(selectedRunning.body.session.sessionId, "ses_issue810_running"); - assert.equal(selectedRunning.body.session.status, "running"); - assert.equal(selectedRunning.body.session.lastTraceId, "trc_issue810_running"); - assert.equal(selectedRunning.body.workspace.selectedConversationId, "cnv_issue810_running"); - assert.equal(selectedRunning.body.workspace.selectedAgentSessionId, "ses_issue810_running"); - assert.equal(selectedRunning.body.workspace.activeTraceId, "trc_issue810_running"); - assert.equal(selectedRunning.body.workspace.workspace.activeTraceId, "trc_issue810_running"); - assert.equal(selectedRunning.body.workspace.workspace.lastTraceId, "trc_issue810_running"); - assert.equal(selectedRunning.body.workspace.workspace.sessionStatus, "running"); - assert.equal(selectedRunning.body.workspace.workspace.providerProfile, "minimax-m3"); - - const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(restored.status, 200); - assert.equal(restored.body.workspace.selectedConversationId, "cnv_issue810_running"); - assert.equal(restored.body.workspace.selectedAgentSessionId, "ses_issue810_running"); - assert.equal(restored.body.workspace.activeTraceId, "trc_issue810_running"); - assert.equal(restored.body.workspace.workspace.activeTraceId, "trc_issue810_running"); - assert.equal(restored.body.workspace.workspace.lastTraceId, "trc_issue810_running"); - assert.equal(restored.body.workspace.workspace.sessionStatus, "running"); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("workbench workspace read does not repair terminal selected conversation after active trace was cleared", async () => { - const agentRunCalls = []; - const agentSessions = new Map(); - const agentRunServer = createServer(async (request, response) => { - const url = new URL(request.url || "/", "http://127.0.0.1"); - agentRunCalls.push({ method: request.method, path: url.pathname, search: url.search }); - const send = (data) => { - response.writeHead(200, { "content-type": "application/json" }); - response.end(`${JSON.stringify({ ok: true, data })}\n`); - }; - if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_repair/events") { - return send({ items: [] }); - } - if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_repair/commands") { - return send({ items: [ - { id: "cmd_workspace_repair", runId: "run_workspace_repair", state: "completed", type: "turn", seq: 1, idempotencyKey: "trc_issue664_repair_done", payload: { traceId: "trc_issue664_repair_done", conversationId: "cnv_issue664_repair", hwlabSessionId: "ses_issue664_repair", threadId: "thread-issue-664-repair", providerProfile: "deepseek" } } - ] }); - } - if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_repair/commands/cmd_workspace_repair/result") { - return send({ - runId: "run_workspace_repair", - commandId: "cmd_workspace_repair", - status: "completed", - runStatus: "completed", - commandState: "completed", - terminalStatus: "completed", - completed: true, - reply: "repair completed", - lastSeq: 2, - sessionRef: { sessionId: "ses_issue664_repair", conversationId: "cnv_issue664_repair", threadId: "thread-issue-664-repair" } - }); - } - response.writeHead(404, { "content-type": "application/json" }); - response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`); - }); - await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve)); - - const agentRunPort = agentRunServer.address().port; - const accessController = createAccessController({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - now: () => "2026-06-01T00:00:00.000Z" - }); - accessController.getAgentSessionByTraceId = async (traceId) => agentSessions.get(traceId) ?? null; - const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", - AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`, - HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", - HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek" - }, - accessController, - now: () => "2026-06-01T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-repair", password: "alice-pass" }, adminLogin.cookie); - assert.equal(alice.status, 201); - const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-repair", password: "alice-pass" }); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(workspace.status, 200); - const conversation = await putJson(port, "/v1/agent/conversations/cnv_issue664_repair", { - projectId: "prj_hwpod_workbench", - sessionId: "ses_issue664_repair", - threadId: "thread-issue-664-repair", - sessionStatus: "running", - lastTraceId: "trc_issue664_repair_done", - messages: [] - }, aliceLogin.cookie); - assert.equal(conversation.status, 200); - - agentSessions.set("trc_issue664_repair_done", { - id: "ses_issue664_repair", - ownerUserId: alice.body.user.id, - conversationId: "cnv_issue664_repair", - threadId: "thread-issue-664-repair", - status: "active", - session: { - messageId: "msg_workspace_repair_done", - agentRun: { - runId: "run_workspace_repair", - commandId: "cmd_workspace_repair", - backendProfile: "deepseek", - managerUrl: `http://127.0.0.1:${agentRunPort}`, - lastSeq: 0, - sessionId: "ses_issue664_repair", - conversationId: "cnv_issue664_repair", - threadId: "thread-issue-664-repair" - } - }, - updatedAt: "2026-06-01T00:00:00.000Z" - }); - - const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, { - expectedRevision: 1, - selectedConversationId: "cnv_issue664_repair", - selectedAgentSessionId: "ses_issue664_repair", - activeTraceId: null, - providerProfile: "deepseek", - sessionStatus: "idle", - lastTraceId: "trc_issue664_repair_done", - updatedByClient: "test-suite" - }, aliceLogin.cookie); - assert.equal(update.status, 200); - assert.equal(update.body.workspace.activeTraceId, null); - assert.equal(update.body.workspace.workspace.lastTraceId, undefined); - assert.equal(update.body.workspace.workspace.sessionStatus, "idle"); - assert.equal(update.body.workspace.selectedConversation.status, "running"); - assert.equal(update.body.workspace.selectedConversation.messages.some((message) => message.text === "repair completed"), false); - - const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(restored.status, 200); - assert.equal(restored.body.workspace.activeTraceId, null); - assert.equal(restored.body.workspace.workspace.lastTraceId, undefined); - assert.equal(restored.body.workspace.workspace.sessionStatus, "idle"); - assert.equal(restored.body.workspace.selectedConversation.status, "running"); - assert.equal(restored.body.workspace.selectedConversation.lastTraceId, "trc_issue664_repair_done"); - assert.equal(restored.body.workspace.selectedConversation.messages.some((message) => message.text === "repair completed"), false); - assert.equal(restored.body.workspace.revision, 2); - assert.equal(agentRunCalls.length, 0); - - const legacyStale = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, { - expectedRevision: 2, - workspace: { - selectedConversationId: "cnv_legacy_stale_workspace", - selectedAgentSessionId: "ses_legacy_stale_workspace", - providerProfile: "deepseek", - sessionStatus: "running", - lastTraceId: "trc_legacy_stale_workspace" - }, - updatedByClient: "test-suite-legacy-stale" - }, aliceLogin.cookie); - assert.equal(legacyStale.status, 200); - assert.equal(legacyStale.body.workspace.activeTraceId, null); - assert.equal(legacyStale.body.workspace.workspace.lastTraceId, undefined); - assert.equal(legacyStale.body.workspace.workspace.sessionStatus, undefined); - - const legacyRestored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(legacyRestored.status, 200); - assert.equal(legacyRestored.body.workspace.activeTraceId, null); - assert.equal(legacyRestored.body.workspace.workspace.lastTraceId, undefined); - assert.equal(legacyRestored.body.workspace.workspace.sessionStatus, undefined); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api stores and restores Code Agent conversations by authenticated account", async () => { - const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - now: () => "2026-05-31T07:30:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const adminCookie = adminLogin.cookie; - const aliceCreate = await postJson(port, "/v1/admin/users", { username: "alice", password: "alice-pass" }, adminCookie); - const bobCreate = await postJson(port, "/v1/admin/users", { username: "bob", password: "bob-pass" }, adminCookie); - assert.equal(aliceCreate.status, 201); - assert.equal(bobCreate.status, 201); - - const aliceLogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" }); - const bobLogin = await postJson(port, "/auth/login", { username: "bob", password: "bob-pass" }); - - const stored = await putJson(port, "/v1/agent/conversations/cnv_account_sync", { - projectId: "prj_hwpod_workbench", - sessionId: "ses_account_sync", - threadId: "thread-account-sync", - lastTraceId: "trc_account_sync", - sessionStatus: "idle", - messages: [ - { id: "msg_user", role: "user", title: "用户", text: "在吗", status: "sent", conversationId: "cnv_account_sync", sessionId: "ses_account_sync", threadId: "thread-account-sync" }, - { id: "msg_agent", role: "agent", title: "Agent", text: "在", status: "completed", traceId: "trc_account_sync", conversationId: "cnv_account_sync", sessionId: "ses_account_sync", threadId: "thread-account-sync" } - ] - }, aliceLogin.cookie); - assert.equal(stored.status, 200); - assert.equal(stored.body.conversation.conversationId, "cnv_account_sync"); - assert.equal(stored.body.conversation.sessionId, "ses_account_sync"); - assert.equal(stored.body.conversation.threadId, "thread-account-sync"); - assert.equal(stored.body.conversation.status, "idle"); - assert.equal(stored.body.conversation.messages.length, 2); - - const aliceList = await getJson(port, "/v1/agent/conversations?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(aliceList.status, 200); - assert.equal(aliceList.body.count, 1); - assert.equal(aliceList.body.defaultConversation.conversationId, "cnv_account_sync"); - assert.equal(aliceList.body.defaultConversation.messages[1].text, "在"); - assert.equal(aliceList.body.defaultConversation.valuesRedacted, true); - - const bobList = await getJson(port, "/v1/agent/conversations?projectId=prj_hwpod_workbench", bobLogin.cookie); - assert.equal(bobList.status, 200); - assert.deepEqual(bobList.body.conversations, []); - - const bobDirect = await getJson(port, "/v1/agent/conversations/cnv_account_sync", bobLogin.cookie); - assert.equal(bobDirect.status, 404); - assert.equal(bobDirect.body.error.code, "agent_conversation_not_found"); - - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(workspace.status, 200); - const selected = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, { - projectId: "prj_hwpod_workbench", - conversationId: "cnv_account_sync", - updatedByClient: "test-suite" - }, aliceLogin.cookie); - assert.equal(selected.status, 200); - assert.equal(selected.body.workspace.selectedConversationId, "cnv_account_sync"); - - const deleted = await deleteJson(port, "/v1/agent/conversations/cnv_account_sync", { - projectId: "prj_hwpod_workbench", - workspaceId: workspace.body.workspace.workspaceId, - updatedByClient: "test-suite" - }, aliceLogin.cookie); - assert.equal(deleted.status, 200); - assert.equal(deleted.body.status, "deleted"); - assert.equal(deleted.body.archivedCount, 1); - assert.equal(deleted.body.workspace.selectedConversationId, null); - - const afterDeleteList = await getJson(port, "/v1/agent/conversations?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(afterDeleteList.status, 200); - assert.equal(afterDeleteList.body.count, 0); - - const archivedDirect = await getJson(port, "/v1/agent/conversations/cnv_account_sync", aliceLogin.cookie); - assert.equal(archivedDirect.status, 404); - assert.equal(archivedDirect.body.error.code, "agent_conversation_not_found"); - - const logout = await postJson(port, "/auth/logout", {}, aliceLogin.cookie); - assert.equal(logout.status, 200); - const relogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" }); - const restored = await getJson(port, "/v1/agent/conversations/cnv_account_sync", relogin.cookie); - assert.equal(restored.status, 404); - assert.equal(restored.body.error.code, "agent_conversation_not_found"); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - -test("cloud api conversation detail rejects a mismatched requested project", async () => { - const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - now: () => "2026-06-17T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const aliceCreate = await postJson(port, "/v1/admin/users", { username: "alice-issue1429", password: "alice-pass" }, adminLogin.cookie); - assert.equal(aliceCreate.status, 201); - const aliceLogin = await postJson(port, "/auth/login", { username: "alice-issue1429", password: "alice-pass" }); - - const stored = await putJson(port, "/v1/agent/conversations/cnv_issue1429_project_scope", { - projectId: "prj_v02_code_agent", - sessionId: "ses_issue1429_project_scope", - threadId: "thread-issue1429-project-scope", - lastTraceId: "trc_issue1429_project_scope", - sessionStatus: "completed", - messages: [ - { id: "msg_issue1429_user", role: "user", title: "用户", text: "跨项目读取", status: "sent", conversationId: "cnv_issue1429_project_scope", sessionId: "ses_issue1429_project_scope", threadId: "thread-issue1429-project-scope" }, - { id: "msg_issue1429_agent", role: "agent", title: "Agent", text: "ok", status: "completed", traceId: "trc_issue1429_project_scope", conversationId: "cnv_issue1429_project_scope", sessionId: "ses_issue1429_project_scope", threadId: "thread-issue1429-project-scope" } - ] - }, aliceLogin.cookie); - assert.equal(stored.status, 200); - - const wrongProject = await getJson(port, "/v1/agent/conversations/cnv_issue1429_project_scope?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(wrongProject.status, 404); - assert.equal(wrongProject.body.error.code, "conversation_project_mismatch"); - - const rightProject = await getJson(port, "/v1/agent/conversations/cnv_issue1429_project_scope?projectId=prj_v02_code_agent", aliceLogin.cookie); - assert.equal(rightProject.status, 200); - assert.equal(rightProject.body.conversation.projectId, "prj_v02_code_agent"); - - const compatibilityDirect = await getJson(port, "/v1/agent/conversations/cnv_issue1429_project_scope", aliceLogin.cookie); - assert.equal(compatibilityDirect.status, 200); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - -test("cloud api includes an explicitly requested conversation in the conversations list API", async () => { - let now = "2026-06-16T08:00:00.000Z"; - const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - now: () => now - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const aliceCreate = await postJson(port, "/v1/admin/users", { username: "alice-issue1338", password: "alice-pass" }, adminLogin.cookie); - assert.equal(aliceCreate.status, 201); - const aliceLogin = await postJson(port, "/auth/login", { username: "alice-issue1338", password: "alice-pass" }); - - const selected = await putJson(port, "/v1/agent/conversations/cnv_issue1338_selected", { - projectId: "prj_hwpod_workbench", - sessionId: "ses_issue1338_selected", - threadId: "thread-issue1338-selected", - sessionStatus: "completed", - messages: [ - { id: "msg_issue1338_selected_user", role: "user", title: "用户", text: "被选中的旧会话", status: "sent", conversationId: "cnv_issue1338_selected", sessionId: "ses_issue1338_selected", threadId: "thread-issue1338-selected" } - ] - }, aliceLogin.cookie); - assert.equal(selected.status, 200); - - now = "2026-06-16T08:05:00.000Z"; - const recent = await putJson(port, "/v1/agent/conversations/cnv_issue1338_recent", { - projectId: "prj_hwpod_workbench", - sessionId: "ses_issue1338_recent", - threadId: "thread-issue1338-recent", - sessionStatus: "completed", - messages: [ - { id: "msg_issue1338_recent_user", role: "user", title: "用户", text: "列表窗口内较新的会话", status: "sent", conversationId: "cnv_issue1338_recent", sessionId: "ses_issue1338_recent", threadId: "thread-issue1338-recent" } - ] - }, aliceLogin.cookie); - assert.equal(recent.status, 200); - - const limited = await getJson(port, "/v1/agent/conversations?projectId=prj_hwpod_workbench&limit=1", aliceLogin.cookie); - assert.equal(limited.status, 200); - assert.deepEqual(limited.body.conversations.map((conversation) => conversation.conversationId), ["cnv_issue1338_recent"]); - assert.equal(limited.body.count, 1); - - const included = await getJson(port, "/v1/agent/conversations?projectId=prj_hwpod_workbench&limit=1&includeConversationId=cnv_issue1338_selected", aliceLogin.cookie); - assert.equal(included.status, 200); - assert.deepEqual(included.body.conversations.map((conversation) => conversation.conversationId), ["cnv_issue1338_selected", "cnv_issue1338_recent"]); - assert.equal(included.body.defaultConversation.conversationId, "cnv_issue1338_selected"); - assert.equal(included.body.count, 2); - - const invalidInclude = await getJson(port, "/v1/agent/conversations?projectId=prj_hwpod_workbench&includeConversationId=bad", aliceLogin.cookie); - assert.equal(invalidInclude.status, 400); - assert.equal(invalidInclude.body.error.code, "invalid_include_conversation_id"); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - -test("cloud api exposes terminal conversation status when stored session status is stale running", async () => { - const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - now: () => "2026-06-04T09:30:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const aliceCreate = await postJson(port, "/v1/admin/users", { username: "alice-issue834", password: "alice-pass" }, adminLogin.cookie); - assert.equal(aliceCreate.status, 201); - const aliceLogin = await postJson(port, "/auth/login", { username: "alice-issue834", password: "alice-pass" }); - - const stored = await putJson(port, "/v1/agent/conversations/cnv_issue834_final", { - projectId: "prj_hwpod_workbench", - sessionId: "ses_issue834_final", - threadId: "thread-issue-834", - sessionStatus: "running", - lastTraceId: "trc_issue834_stale_running", - messages: [ - { id: "msg_issue834_user", role: "user", title: "用户", text: "final response 显示异常", status: "sent", conversationId: "cnv_issue834_final", sessionId: "ses_issue834_final", threadId: "thread-issue-834" }, - { id: "msg_issue834_agent", role: "agent", title: "Agent", text: "最终回复已经生成。", status: "completed", traceId: "trc_issue834_final", conversationId: "cnv_issue834_final", sessionId: "ses_issue834_final", threadId: "thread-issue-834" } - ] - }, aliceLogin.cookie); - assert.equal(stored.status, 200); - assert.equal(stored.body.conversation.status, "completed"); - assert.equal(stored.body.conversation.session.status, "completed"); - assert.equal(stored.body.conversation.lastTraceId, "trc_issue834_final"); - - const direct = await getJson(port, "/v1/agent/conversations/cnv_issue834_final", aliceLogin.cookie); - assert.equal(direct.status, 200); - assert.equal(direct.body.conversation.status, "completed"); - assert.equal(direct.body.conversation.session.status, "completed"); - assert.equal(direct.body.conversation.lastTraceId, "trc_issue834_final"); - assert.equal(direct.body.conversation.messages[1].text, "最终回复已经生成。"); - - const list = await getJson(port, "/v1/agent/conversations?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(list.status, 200); - const listed = list.body.conversations.find((conversation) => conversation.conversationId === "cnv_issue834_final"); - assert.ok(listed, "expected stale-running conversation to be listed"); - assert.equal(listed.status, "completed"); - assert.equal(listed.session.status, "completed"); - assert.equal(listed.lastTraceId, "trc_issue834_final"); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - -test("cloud api preserves conversation snapshot when persisted result advances last trace", async () => { - const codeAgentChatResults = new Map(); - const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", - AGENTRUN_MGR_URL: "http://127.0.0.1:9", - HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1" - }, - codeAgentChatResults, - now: () => "2026-06-04T10:05:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const aliceCreate = await postJson(port, "/v1/admin/users", { username: "alice-issue842-snapshot", password: "alice-pass" }, adminLogin.cookie); - assert.equal(aliceCreate.status, 201); - const aliceLogin = await postJson(port, "/auth/login", { username: "alice-issue842-snapshot", password: "alice-pass" }); - const finalText = [ - "持久化验收OK。", - "", - "| Skill | 状态 |", - "|---|---|", - "| hwlab-agent-runtime | 可用 |" - ].join("\n"); - codeAgentChatResults.set("trc_issue842_persisted_final", { - status: "completed", - traceId: "trc_issue842_persisted_final", - conversationId: "cnv_issue842_snapshot", - sessionId: "ses_issue842_snapshot", - threadId: "thread-issue-842-snapshot", - ownerUserId: aliceCreate.body.user.id, - reply: { role: "assistant", content: finalText, messageId: "msg_issue842_persisted_final" }, - agentRun: { terminalStatus: "completed" }, - session: { - sessionId: "ses_issue842_snapshot", - conversationId: "cnv_issue842_snapshot", - threadId: "thread-issue-842-snapshot", - status: "idle" - } - }); - - const stored = await putJson(port, "/v1/agent/conversations/cnv_issue842_snapshot", { - projectId: "prj_hwpod_workbench", - sessionId: "ses_issue842_snapshot", - threadId: "thread-issue-842-snapshot", - sessionStatus: "idle", - lastTraceId: "trc_issue842_persisted_final", - messages: [ - { id: "msg_issue842_user", role: "user", title: "用户", text: "请短答", status: "sent", conversationId: "cnv_issue842_snapshot", sessionId: "ses_issue842_snapshot", threadId: "thread-issue-842-snapshot" }, - { id: "msg_issue842_old_agent", role: "agent", title: "Agent", text: "上一轮回复", status: "idle", traceId: "trc_issue842_previous", conversationId: "cnv_issue842_snapshot", sessionId: "ses_issue842_snapshot", threadId: "thread-issue-842-snapshot" } - ] - }, aliceLogin.cookie); - assert.equal(stored.status, 200); - assert.equal(stored.body.conversation.lastTraceId, "trc_issue842_persisted_final"); - assert.equal(stored.body.conversation.messages.at(-1).text, "上一轮回复"); - - const list = await getJson(port, "/v1/agent/conversations?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(list.status, 200); - const listed = list.body.conversations.find((conversation) => conversation.conversationId === "cnv_issue842_snapshot"); - assert.ok(listed, "expected conversation to be listed"); - assert.equal(listed.lastTraceId, "trc_issue842_persisted_final"); - assert.equal(listed.messages.at(-1).traceId, "trc_issue842_previous"); - assert.equal(listed.messages.at(-1).text, "上一轮回复"); - assert.doesNotMatch(listed.messages.at(-1).text, /\n\|---\|---\|\n/u); - assert.equal(listed.messages.at(-1).status, "idle"); - - const direct = await getJson(port, "/v1/agent/conversations/cnv_issue842_snapshot", aliceLogin.cookie); - assert.equal(direct.status, 200); - assert.equal(direct.body.conversation.messages.at(-1).traceId, "trc_issue842_previous"); - assert.equal(direct.body.conversation.messages.at(-1).text, "上一轮回复"); - - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(workspace.status, 200); - const selected = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, { - projectId: "prj_hwpod_workbench", - conversationId: "cnv_issue842_snapshot", - sessionId: "ses_issue842_snapshot", - updatedByClient: "test-suite" - }, aliceLogin.cookie); - assert.equal(selected.status, 200); - assert.equal(selected.body.workspace.selectedConversation.messages.at(-1).traceId, "trc_issue842_previous"); - assert.equal(selected.body.workspace.selectedConversation.messages.at(-1).text, "上一轮回复"); - assert.doesNotMatch(selected.body.workspace.selectedConversation.messages.at(-1).text, /\n\|---\|---\|\n/u); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - -test("cloud api workspace read preserves saved messages without terminal sync", async () => { - const codeAgentChatResults = new Map(); - const traceId = "trc_issue853_fast_fail_trace"; - const conversationId = "cnv_issue853_fast_fail"; - const sessionId = "ses_issue853_fast_fail"; - const threadId = "thread-issue-853-fast-fail"; - const env = { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", - AGENTRUN_MGR_URL: "http://127.0.0.1:9", - HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1" - }; - const now = () => "2026-06-04T10:25:00.000Z"; - const accessController = createAccessController({ env, now }); - const server = createCloudApiServer({ - env, - accessController, - codeAgentChatResults, - now - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const aliceCreate = await postJson(port, "/v1/admin/users", { username: "alice-issue853-fast-fail", password: "alice-pass" }, adminLogin.cookie); - assert.equal(aliceCreate.status, 201); - const aliceLogin = await postJson(port, "/auth/login", { username: "alice-issue853-fast-fail", password: "alice-pass" }); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(workspace.status, 200); - - const savedMessages = [ - { id: "msg_issue853_user", role: "user", title: "用户", text: "请执行 hwpod inspect", status: "sent", conversationId, sessionId, threadId, createdAt: "2026-06-04T10:24:59.000Z" }, - { id: "msg_issue853_pending", role: "agent", title: "Code Agent 处理中", text: "Code Agent 仍在处理,可以继续 steer 或等待 trace 完成。", status: "running", traceId, conversationId, sessionId, threadId, createdAt: "2026-06-04T10:24:59.000Z" } - ]; - const stored = await putJson(port, `/v1/agent/conversations/${conversationId}`, { - projectId: "prj_hwpod_workbench", - sessionId, - threadId, - sessionStatus: "active", - lastTraceId: traceId, - messages: savedMessages - }, aliceLogin.cookie); - assert.equal(stored.status, 200); - - await accessController.recordAgentSessionOwner({ - ownerUserId: aliceCreate.body.user.id, - sessionId, - projectId: "prj_hwpod_workbench", - agentId: "hwlab-code-agent", - status: "failed", - conversationId, - threadId, - traceId, - session: { - source: "code-agent-result", - status: "failed", - sessionStatus: "failed", - messages: [], - messageCount: 0, - finalResponse: null, - valuesRedacted: true, - secretMaterialStored: false - } - }); - const preserved = await accessController.getAgentSession(sessionId); - assert.equal(preserved.session.messages.length, 2); - assert.equal(preserved.session.messages[0].text, "请执行 hwpod inspect"); - - codeAgentChatResults.set(traceId, { - status: "failed", - traceId, - conversationId, - sessionId, - threadId, - ownerUserId: aliceCreate.body.user.id, - error: { code: "provider_insufficient_balance", message: "HyueAPI 403 INSUFFICIENT_BALANCE" }, - agentRun: { terminalStatus: "failed" }, - runnerTrace: { - traceId, - status: "failed", - eventCount: 35, - events: [], - lastEvent: { label: "agentrun:result:failed", status: "failed", type: "result" } - }, - session: { sessionId, conversationId, threadId, status: "failed" } - }); - const selected = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, { - expectedRevision: 1, - selectedConversationId: conversationId, - selectedAgentSessionId: sessionId, - activeTraceId: traceId, - sessionStatus: "running", - messages: savedMessages, - updatedByClient: "test-suite" - }, aliceLogin.cookie); - assert.equal(selected.status, 200); - assert.equal(selected.body.workspace.activeTraceId, traceId); - - const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(restored.status, 200); - assert.equal(restored.body.workspace.activeTraceId, traceId); - const messages = restored.body.workspace.selectedConversation.messages; - assert.equal(messages.length, 2); - assert.equal(messages[0].role, "user"); - assert.equal(messages[0].text, "请执行 hwpod inspect"); - assert.equal(messages[1].role, "agent"); - assert.equal(messages[1].status, "running"); - assert.equal(messages[1].text, "Code Agent 仍在处理,可以继续 steer 或等待 trace 完成。"); - assert.equal(Object.hasOwn(messages[1], "runnerTrace"), false); - assert.equal(restored.body.workspace.selectedConversation.messageCount, 2); - assert.equal(restored.body.workspace.selectedConversation.firstUserMessagePreview, "请执行 hwpod inspect"); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - -test("cloud api workspace read preserves preview-only conversation without terminal sync", async () => { - const codeAgentChatResults = new Map(); - const traceId = "trc_issue853_preview_only_trace"; - const conversationId = "cnv_issue853_preview_only"; - const sessionId = "ses_issue853_preview_only"; - const threadId = "thread-issue-853-preview-only"; - const userText = "请执行 hwpod inspect,并在最终回复里原样包含 HWLAB-853-FINAL-preview-only"; - const env = { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", - AGENTRUN_MGR_URL: "http://127.0.0.1:9", - HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1" - }; - const now = () => "2026-06-04T10:35:00.000Z"; - const accessController = createAccessController({ env, now }); - const server = createCloudApiServer({ env, accessController, codeAgentChatResults, now }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const aliceCreate = await postJson(port, "/v1/admin/users", { username: "alice-issue853-preview-only", password: "alice-pass" }, adminLogin.cookie); - assert.equal(aliceCreate.status, 201); - const aliceLogin = await postJson(port, "/auth/login", { username: "alice-issue853-preview-only", password: "alice-pass" }); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(workspace.status, 200); - - await accessController.recordAgentSessionOwner({ - ownerUserId: aliceCreate.body.user.id, - sessionId, - projectId: "prj_hwpod_workbench", - agentId: "hwlab-code-agent", - status: "failed", - conversationId, - threadId, - traceId, - session: { - source: "code-agent-result", - sessionStatus: "failed", - lastTraceId: traceId, - messages: [ - { id: "msg_issue853_preview_agent", role: "agent", title: "Code Agent result", text: "HyueAPI 403 INSUFFICIENT_BALANCE", status: "failed", traceId, conversationId, sessionId, threadId, createdAt: "2026-06-04T10:34:59.000Z" } - ], - messageCount: 1, - firstUserMessagePreview: userText, - valuesRedacted: true, - secretMaterialStored: false - } - }); - - codeAgentChatResults.set(traceId, { - status: "failed", - traceId, - conversationId, - sessionId, - threadId, - ownerUserId: aliceCreate.body.user.id, - error: { code: "provider_insufficient_balance", message: "HyueAPI 403 INSUFFICIENT_BALANCE" }, - agentRun: { terminalStatus: "failed" }, - runnerTrace: { - traceId, - status: "failed", - eventCount: 35, - events: [], - lastEvent: { label: "agentrun:result:failed", status: "failed", type: "result" } - }, - session: { sessionId, conversationId, threadId, status: "failed" } - }); - - const selected = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, { - expectedRevision: 1, - selectedConversationId: conversationId, - selectedAgentSessionId: sessionId, - activeTraceId: traceId, - sessionStatus: "running", - messages: [], - updatedByClient: "test-suite" - }, aliceLogin.cookie); - assert.equal(selected.status, 200); - - const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); - assert.equal(restored.status, 200); - assert.equal(restored.body.workspace.activeTraceId, traceId); - const messages = restored.body.workspace.selectedConversation.messages; - assert.equal(messages.length, 2); - assert.equal(messages[0].role, "user"); - assert.equal(messages[0].text, userText); - assert.equal(messages[0].traceId, traceId); - assert.equal(messages[1].role, "agent"); - assert.equal(messages[1].status, "failed"); - assert.equal(messages[1].text, "HyueAPI 403 INSUFFICIENT_BALANCE"); - assert.equal(Object.hasOwn(messages[1], "runnerTrace"), false); - assert.equal(restored.body.workspace.selectedConversation.messageCount, 2); - assert.equal(restored.body.workspace.selectedConversation.firstUserMessagePreview, userText); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - -test("access controller preserves user message when owner evidence updates with agent-only result", async () => { - const projectId = "prj_hwpod_workbench"; - const ownerUserId = "usr_issue853_owner_merge"; - const conversationId = "cnv_issue853_owner_merge"; - const sessionId = "ses_issue853_owner_merge"; - const traceId = "trc_issue853_owner_merge"; - const userMessageId = "msg_issue853_owner_merge_user"; - const assistantMessageId = "msg_issue853_owner_merge_agent"; - const threadId = "thread-issue-853-owner-merge"; - const nowValue = "2026-06-04T10:45:00.000Z"; - const userText = "请执行 hwpod inspect,并在最终回复里原样包含 HWLAB-853-FINAL-owner-merge"; - const accessController = createAccessController({ now: () => nowValue }); - - await accessController.recordAgentSessionOwner({ - ownerUserId, - sessionId, - projectId, - agentId: "hwlab-code-agent", - status: "active", - conversationId, - threadId, - traceId, - session: { - source: "code-agent-result", - sessionStatus: "running", - lastTraceId: traceId, - messages: [ - { id: userMessageId, messageId: userMessageId, role: "user", title: "用户", text: userText, status: "sent", traceId, turnId: traceId, conversationId, sessionId, threadId, createdAt: nowValue }, - { id: assistantMessageId, messageId: assistantMessageId, role: "agent", title: "Code Agent 处理中", text: "Code Agent 仍在处理,可以继续 steer 或等待 trace 完成。", status: "running", traceId, turnId: traceId, conversationId, sessionId, threadId, createdAt: nowValue } - ], - messageCount: 2, - firstUserMessagePreview: userText, - valuesRedacted: true, - secretMaterialStored: false - } - }); - - await accessController.recordAgentSessionOwner({ - ownerUserId, - sessionId, - projectId, - agentId: "hwlab-code-agent", - status: "failed", - conversationId, - threadId, - traceId, - session: { - source: "code-agent-result", - status: "failed", - sessionStatus: "failed", - messages: [ - { id: assistantMessageId, messageId: assistantMessageId, role: "agent", title: "Code Agent result", text: "HyueAPI 403 INSUFFICIENT_BALANCE", status: "failed", traceId, turnId: traceId, conversationId, sessionId, threadId, createdAt: nowValue } - ], - messageCount: 1, - firstUserMessagePreview: userText, - valuesRedacted: true, - secretMaterialStored: false - } - }); - - const session = await accessController.getAgentSession(sessionId); - const conversation = await accessController.visibleConversationForActor({ id: ownerUserId, role: "user" }, conversationId, projectId, { includeArchived: true }); - assert.deepEqual(session.session.messages.map((message) => message.role), ["user", "agent"]); - assert.deepEqual(conversation.messages.map((message) => message.role), ["user", "agent"]); - assert.equal(session.session.messages[0].text, userText); - assert.equal(conversation.messages[0].text, userText); - assert.equal(session.session.messages[0].traceId, traceId); - assert.equal(conversation.messages[0].traceId, traceId); - assert.equal(session.session.messages[0].messageId, userMessageId); - assert.equal(session.session.messages[0].turnId, traceId); - assert.equal(conversation.messages[0].messageId, userMessageId); - assert.equal(conversation.messages[0].turnId, traceId); - assert.equal(session.session.messages[1].messageId, assistantMessageId); - assert.equal(session.session.messages[1].turnId, traceId); - assert.equal(conversation.messages[1].messageId, assistantMessageId); - assert.equal(conversation.messages[1].turnId, traceId); - assert.equal(session.session.messages[1].text, "HyueAPI 403 INSUFFICIENT_BALANCE"); - assert.equal(conversation.messages[1].status, "failed"); - assert.equal(session.session.messageCount, 2); - assert.equal(conversation.messageCount, 2); - assert.equal(conversation.firstUserMessagePreview, userText); -}); - -test("access controller dedupes duplicate refreshed assistant messages by id and trace", async () => { - const projectId = "prj_hwpod_workbench"; - const ownerUserId = "usr_issue932_dedupe"; - const conversationId = "cnv_issue932_dedupe"; - const sessionId = "ses_issue932_dedupe"; - const traceId = "trc_issue932_dedupe"; - const threadId = "thread-issue-932-dedupe"; - const nowValue = "2026-06-05T09:30:00.000Z"; - const accessController = createAccessController({ now: () => nowValue }); - - await accessController.recordAgentSessionOwner({ - ownerUserId, - sessionId, - projectId, - agentId: "hwlab-code-agent", - status: "idle", - conversationId, - threadId, - traceId, - session: { - source: "cloud-web-react-account-sync", - sessionStatus: "completed", - lastTraceId: traceId, - messages: [ - { id: "msg_issue932_user", role: "user", title: "用户", text: "你好", status: "sent", traceId, conversationId, sessionId, threadId, createdAt: nowValue }, - { id: "msg_issue932_agent", role: "agent", title: "Code Agent 回复", text: "最终回复", status: "completed", traceId, conversationId, sessionId, threadId, createdAt: nowValue }, - { id: "msg_issue932_agent", role: "agent", title: "Code Agent 回复", text: "最终回复", status: "completed", traceId, conversationId, sessionId, threadId, createdAt: nowValue } - ], - messageCount: 3, - firstUserMessagePreview: "你好", - valuesRedacted: true, - secretMaterialStored: false - } - }); - - const session = await accessController.getAgentSession(sessionId); - const conversation = await accessController.visibleConversationForActor({ id: ownerUserId, role: "user" }, conversationId, projectId, { includeArchived: true }); - - assert.deepEqual(session.session.messages.map((message) => message.role), ["user", "agent"]); - assert.deepEqual(conversation.messages.map((message) => message.role), ["user", "agent"]); - assert.equal(session.session.messageCount, 2); - assert.equal(conversation.messageCount, 2); - assert.equal(conversation.messages[1].text, "最终回复"); -}); - -test("access controller restores AgentRun mapping by Code Agent traceId", async () => { - const accessController = createAccessController({ now: () => "2026-06-01T00:00:00.000Z" }); - await accessController.recordAgentSessionOwner({ - ownerUserId: "usr_agent_owner", - sessionId: "ses_agentrun_trace_lookup", - projectId: "prj_v02_code_agent", - conversationId: "cnv_agentrun_trace_lookup", - threadId: "thread-agentrun-trace-lookup", - traceId: "trc_agentrun_trace_lookup", - status: "running", - session: { - source: "hwlab-cloud-api-agentrun-v01-adapter", - agentRun: { - adapter: "agentrun-v01", - runId: "run_trace_lookup", - commandId: "cmd_trace_lookup", - jobName: "agentrun-v01-runner-trace-lookup", - namespace: "agentrun-v01", - backendProfile: "deepseek", - valuesPrinted: false - }, - valuesRedacted: true - } - }); - - const restored = await accessController.getAgentSessionByTraceId("trc_agentrun_trace_lookup"); - assert.equal(restored.id, "ses_agentrun_trace_lookup"); - assert.equal(restored.lastTraceId, "trc_agentrun_trace_lookup"); - assert.equal(restored.session.agentRun.runId, "run_trace_lookup"); - assert.equal(restored.session.agentRun.commandId, "cmd_trace_lookup"); - assert.equal(restored.session.agentRun.namespace, "agentrun-v01"); -}); - -test("access controller preserves AgentRun runner mapping across conversation snapshot updates", async () => { - const accessController = createAccessController({ now: () => "2026-06-01T00:00:00.000Z" }); - await accessController.recordAgentSessionOwner({ - ownerUserId: "usr_agent_owner", - sessionId: "ses_agentrun_reuse_mapping", - projectId: "prj_v02_code_agent", - conversationId: "cnv_agentrun_reuse_mapping", - threadId: "thread-agentrun-requested", - traceId: "trc_agentrun_reuse_first", - status: "running", - session: { - source: "hwlab-cloud-api-agentrun-v01-adapter", - agentRun: { - adapter: "agentrun-v01", - runId: "run_reuse_mapping", - commandId: "cmd_reuse_mapping_first", - runnerId: "runner_reuse_mapping", - jobName: "agentrun-v01-runner-reuse-mapping", - namespace: "agentrun-v01", - backendProfile: "deepseek", - reuseEligible: true, - valuesPrinted: false - }, - valuesRedacted: true - } - }); - - await accessController.recordAgentSessionOwner({ - ownerUserId: "usr_agent_owner", - sessionId: "ses_agentrun_reuse_mapping", - projectId: "prj_v02_code_agent", - conversationId: "cnv_agentrun_reuse_mapping", - threadId: "thread-agentrun-requested", - traceId: "trc_agentrun_reuse_second", - status: "active", - session: { - source: "workbench-conversation-snapshot", - messages: [{ role: "assistant", content: "done" }], - valuesRedacted: true - } - }); - - const restored = await accessController.getAgentSession("ses_agentrun_reuse_mapping"); - assert.equal(restored.lastTraceId, "trc_agentrun_reuse_second"); - assert.equal(restored.session.source, "workbench-conversation-snapshot"); - assert.equal(restored.session.agentRun.runId, "run_reuse_mapping"); - assert.equal(restored.session.agentRun.commandId, "cmd_reuse_mapping_first"); - assert.equal(restored.session.agentRun.jobName, "agentrun-v01-runner-reuse-mapping"); - assert.equal(restored.session.agentRun.reuseEligible, true); -}); - -test("cloud api accepts user API keys as Bearer auth for the same account", async () => { - const accessController = createAccessController({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_ID: "usr_v02_admin", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - now: () => "2026-05-28T00:00:00.000Z" - }); - const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_ID: "usr_v02_admin", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - accessController, - now: () => "2026-05-28T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - assert.equal(adminLogin.status, 200); - - const createdKey = await postJson(port, "/v1/api-keys", { name: "Code Agent runner" }, adminLogin.cookie); - assert.equal(createdKey.status, 201); - const apiKey = createdKey.body.key.displaySecret; - assert.ok(apiKey.startsWith("hwl_live_")); - - const me = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${apiKey}` }); - assert.equal(me.status, 200); - assert.equal(me.body.authMethod, "api-key"); - assert.equal(me.body.actor.id, "usr_v02_admin"); - assert.equal(me.body.actor.role, "admin"); - assert.equal(JSON.stringify(me.body).includes(apiKey), false); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api bootstraps the master server admin API key from env", async () => { - const apiKey = "hwl_live_master_server_admin_test_key_930"; - const openFgaAuthorizer = createFakeOpenFgaAuthorizer(); - const accessController = createAccessController({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_ID: "usr_v02_admin", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_BOOTSTRAP_ADMIN_API_KEY: apiKey, - HWLAB_BOOTSTRAP_ADMIN_API_KEY_ID: "key_master_server_admin" - }, - openFgaAuthorizer, - now: () => "2026-06-05T00:00:00.000Z" - }); - const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, - accessController, - now: () => "2026-06-05T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const me = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${apiKey}` }); - assert.equal(me.status, 200); - assert.equal(me.body.authMethod, "api-key"); - assert.equal(me.body.actor.id, "usr_v02_admin"); - assert.equal(me.body.actor.username, "admin"); - assert.equal(me.body.actor.role, "admin"); - assert.equal(JSON.stringify(me.body).includes(apiKey), false); - - const summary = await getJson(port, "/v1/admin/access/summary", null, { authorization: `Bearer ${apiKey}` }); - assert.equal(summary.status, 200); - assert.equal(summary.body.actor.id, "usr_v02_admin"); - assert.equal(summary.body.supported.toolIds.includes("hwpod"), true); - - for (const toolId of summary.body.supported.toolIds) { - const check = await postJson(port, "/v1/admin/access/check", { userId: "usr_v02_admin", relation: "can_use", object: `tool:${toolId}` }, null, { authorization: `Bearer ${apiKey}` }); - assert.equal(check.status, 200); - assert.equal(check.body.authorization.allowed, true, `expected admin key to access ${toolId}`); - } - - const keys = await accessController.store.listApiKeysForUser("usr_v02_admin"); - const masterKey = keys.find((key) => key.id === "key_master_server_admin"); - assert.ok(masterKey); - assert.equal(masterKey.name, "Master server admin API key"); - assert.equal(masterKey.displaySecret, null); - assert.deepEqual(masterKey.scopes, ["admin", "system:hwlab", "tool:*"]); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api exposes v1 access status routes and returns structured REST errors", async () => { - const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, - now: () => "2026-05-28T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const session = await getJson(port, "/v1/auth/session"); - assert.equal(session.status, 200); - assert.equal(session.body.authenticated, false); - assert.equal(session.body.setupRequired, true); - assert.equal(session.body.contractVersion, "user-access-v1"); - - const access = await getJson(port, "/v1/access/status"); - assert.equal(access.status, 200); - assert.equal(access.body.accessControlRequired, true); - assert.equal(access.body.routes.currentUser, "/v1/users/me"); - - const setup = await getJson(port, "/v1/setup/status"); - assert.equal(setup.status, 200); - assert.equal(setup.body.setupRequired, true); - assert.equal(setup.body.bootstrapAdminConfigured, false); - - const me = await getJson(port, "/v1/users/me"); - assert.equal(me.status, 401); - assert.equal(me.body.error.code, "auth_required"); - - const missing = await getJson(port, "/v1/not-implemented"); - assert.equal(missing.status, 404); - assert.equal(missing.body.error.code, "not_found"); - assert.equal(missing.body.error.audit.source.serviceId, "hwlab-cloud-api"); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api first-admin setup opens access when bootstrap secret is absent", async () => { - const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, - now: () => "2026-05-28T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const before = await getJson(port, "/v1/setup/status"); - assert.equal(before.status, 200); - assert.equal(before.body.setupRequired, true); - assert.equal(before.body.bootstrapAdminConfigured, false); - assert.equal(before.body.routes.firstAdminSetup, "/v1/setup/first-admin"); - - const setup = await postJson(port, "/v1/setup/first-admin", { - username: "admin", - password: "admin-pass", - displayName: "Initial Admin" - }); - assert.equal(setup.status, 201); - assert.equal(setup.body.created, true); - assert.equal(setup.body.authenticated, true); - assert.equal(setup.body.actor.role, "admin"); - assert.equal(setup.body.actor.username, "admin"); - assert.equal(setup.body.setupRequired, false); - assert.equal(JSON.stringify(setup.body).includes("admin-pass"), false); - assert.equal(typeof setup.cookie, "string"); - - const session = await getJson(port, "/v1/users/me", setup.cookie); - assert.equal(session.status, 200); - assert.equal(session.body.actor.role, "admin"); - assert.equal(JSON.stringify(session.body).includes("passwordHash"), false); - - const second = await postJson(port, "/v1/setup/first-admin", { - username: "other-admin", - password: "other-pass" - }); - assert.equal(second.status, 409); - assert.equal(second.body.error.code, "setup_already_completed"); - - const login = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - assert.equal(login.status, 200); - assert.equal(login.body.actor.role, "admin"); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api bootstrap password synchronizes existing admin", async () => { - const now = () => "2026-05-28T00:00:00.000Z"; - const firstAccessController = createAccessController({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, - now - }); - const firstServer = createCloudApiServer({ - accessController: firstAccessController, - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, - now - }); - await new Promise((resolve) => firstServer.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = firstServer.address(); - const setup = await postJson(port, "/v1/setup/first-admin", { - username: "admin", - password: "old-pass" - }); - assert.equal(setup.status, 201); - - const downgraded = await patchJson(port, "/v1/admin/access/users/usr_bootstrap_admin", { role: "user", status: "active" }, setup.cookie); - assert.equal(downgraded.status, 200); - assert.equal(downgraded.body.user.role, "user"); - } finally { - await new Promise((resolve, reject) => firstServer.close((error) => (error ? reject(error) : resolve()))); - } - - const syncAccessController = createAccessController({ - store: firstAccessController.store, - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_ID: "usr_v02_admin", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "rotated-pass" - }, - now - }); - const server = createCloudApiServer({ - accessController: syncAccessController, - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, - now - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const oldLogin = await postJson(port, "/auth/login", { username: "admin", password: "old-pass" }); - assert.equal(oldLogin.status, 401); - - const rotatedLogin = await postJson(port, "/auth/login", { username: "admin", password: "rotated-pass" }); - assert.equal(rotatedLogin.status, 200); - assert.equal(rotatedLogin.body.actor.username, "admin"); - assert.equal(rotatedLogin.body.actor.role, "admin"); - assert.equal(JSON.stringify(rotatedLogin.body).includes("rotated-pass"), false); - - const summary = await getJson(port, "/v1/admin/access/summary", rotatedLogin.cookie); - assert.equal(summary.status, 200); - assert.equal(summary.body.actor.username, "admin"); - assert.equal(summary.body.actor.role, "admin"); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api access matrix shows admin inherited tool authority", async () => { - const env = { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_ID: "usr_v02_admin", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_OPENFGA_MODE: "enforce", - HWLAB_OPENFGA_API_URL: "http://openfga.test" - }; - const tuples = new Set(); - const accessController = createAccessController({ - env, - fetchImpl: async (url, init = {}) => { - const pathname = new URL(String(url)).pathname; - if (pathname === "/healthz") return new Response("ok", { status: 200 }); - if (pathname === "/stores") return new Response(JSON.stringify({ id: "store_admin_tools" }), { status: 200 }); - if (pathname.endsWith("/authorization-models")) return new Response(JSON.stringify({ authorization_model_id: "model_admin_tools" }), { status: 200 }); - if (pathname.endsWith("/write")) { - const body = JSON.parse(String(init.body ?? "{}")); - for (const tuple of body.writes?.tuple_keys ?? []) tuples.add(`${tuple.user}|${tuple.relation}|${tuple.object}`); - for (const tuple of body.deletes?.tuple_keys ?? []) tuples.delete(`${tuple.user}|${tuple.relation}|${tuple.object}`); - return new Response(JSON.stringify({ ok: true }), { status: 200 }); - } - if (pathname.endsWith("/check")) { - const body = JSON.parse(String(init.body ?? "{}")); - const tuple = body.tuple_key ?? {}; - return new Response(JSON.stringify({ allowed: tuples.has(`${tuple.user}|${tuple.relation}|${tuple.object}`) }), { status: 200 }); - } - return new Response(JSON.stringify({ error: "unexpected route" }), { status: 404 }); - }, - now: () => "2026-05-28T00:00:00.000Z" - }); - const server = createCloudApiServer({ env, accessController, now: () => "2026-05-28T00:00:00.000Z" }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const login = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - assert.equal(login.status, 200); - - const users = await getJson(port, "/v1/admin/access/users", login.cookie); - assert.equal(users.status, 200); - const admin = users.body.users.find((item) => item.user.id === "usr_v02_admin"); - assert.equal(admin.user.role, "admin"); - assert.deepEqual(admin.tools, { hwpod: true, unidesk_ssh: true, trans_cmd: true, github_pr: true }); - - const matrix = await getJson(port, "/v1/admin/access/users/usr_v02_admin", login.cookie); - assert.equal(matrix.status, 200); - assert.deepEqual(matrix.body.tools, { hwpod: true, unidesk_ssh: true, trans_cmd: true, github_pr: true }); - assert.deepEqual(matrix.body.tuples.map((tuple) => `${tuple.relation}:${tuple.object}`), ["admin:system:hwlab"]); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -async function postJson(port, path, body, cookie = null, extraHeaders = {}) { - const response = await fetch(`http://127.0.0.1:${port}${path}`, { - method: "POST", - headers: { - "content-type": "application/json", - ...(cookie ? { cookie } : {}), - ...extraHeaders - }, - body: JSON.stringify(body) - }); - return { - status: response.status, - cookie: response.headers.get("set-cookie"), - body: await response.json() - }; -} - -async function putJson(port, path, body, cookie = null, extraHeaders = {}) { - const response = await fetch(`http://127.0.0.1:${port}${path}`, { - method: "PUT", - headers: { - "content-type": "application/json", - ...(cookie ? { cookie } : {}), - ...extraHeaders - }, - body: JSON.stringify(body) - }); - return { - status: response.status, - body: await response.json(), - cookie: response.headers.get("set-cookie") - }; -} - -async function patchJson(port, path, body, cookie = null, extraHeaders = {}) { - const response = await fetch(`http://127.0.0.1:${port}${path}`, { - method: "PATCH", - headers: { - "content-type": "application/json", - ...(cookie ? { cookie } : {}), - ...extraHeaders - }, - body: JSON.stringify(body) - }); - return { - status: response.status, - body: await response.json(), - cookie: response.headers.get("set-cookie") - }; -} - -async function getJson(port, path, cookie = null, extraHeaders = {}) { - const response = await fetch(`http://127.0.0.1:${port}${path}`, { - headers: { - ...(cookie ? { cookie } : {}), - ...extraHeaders - } - }); - return { - status: response.status, - body: await response.json() - }; -} - -async function deleteJson(port, path, body = {}, cookie = null, extraHeaders = {}) { - const response = await fetch(`http://127.0.0.1:${port}${path}`, { - method: "DELETE", - headers: { - "content-type": "application/json", - ...(cookie ? { cookie } : {}), - ...extraHeaders - }, - body: JSON.stringify(body) - }); - return { - status: response.status, - body: await response.json(), - cookie: response.headers.get("set-cookie") - }; -} - -function createFakeOpenFgaAuthorizer({ mode = "enforce" } = {}) { - const tuples = new Set(); - function key({ userId, relation, object }) { return `user:${userId}#${relation}@${object}`; } - return { - mode, - tuples, - async describe() { - return { contractVersion: "openfga-authorization-v1", mode, configured: true, ready: true, storeId: "fake-store", modelId: "fake-model", tupleCount: tuples.size }; - }, - async check({ actor, userId, relation, object }) { - const subject = actor?.id ?? userId; - const allowed = tuples.has(key({ userId: subject, relation, object })) || tuples.has(key({ userId: subject, relation: "admin", object: "system:hwlab" })); - return { contractVersion: "openfga-authorization-v1", mode, user: `user:${subject}`, relation, object, allowed, decisionSource: "fake-openfga", degraded: false }; - }, - async writeTuple({ userId, relation, object }) { - tuples.add(key({ userId, relation, object })); - return { ok: true, mode, written: 1, deleted: 0 }; - }, - async deleteTuple({ userId, relation, object }) { - tuples.delete(key({ userId, relation, object })); - return { ok: true, mode, written: 0, deleted: 1 }; - } - }; -} - -async function requestJson(request) { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const text = Buffer.concat(chunks).toString("utf8").trim(); - return text ? JSON.parse(text) : {}; -} - -async function waitForCondition(predicate, timeoutMs = 500) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 10)); - } -} - -test("cloud api issues a default user API key and authenticates Bearer hwl_live_ tokens", async () => { - const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, - now: () => "2026-06-03T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - try { - const { port } = server.address(); - const firstAdmin = await postJson(port, "/v1/setup/first-admin", { username: "apikey-admin", password: "apikey-pass" }); - assert.equal(firstAdmin.status, 201); - assert.equal(firstAdmin.body.actor.role, "admin"); - const cookie = firstAdmin.cookie; - const defaultKey = await getJson(port, "/v1/api-keys/default", cookie); - assert.equal(defaultKey.status, 200); - assert.equal(defaultKey.body.created, false); - assert.equal(defaultKey.body.key.displaySecret, undefined); - const createdKey = await postJson(port, "/v1/api-keys", { name: "CLI test key" }, cookie); - assert.equal(createdKey.status, 201); - const apiKey = createdKey.body.key; - assert.equal(apiKey.name, "CLI test key"); - assert.match(apiKey.keyPrefix, /^hwl_live_/u); - assert.ok(apiKey.displaySecret, "created key should expose display secret once"); - assert.equal(apiKey.status, "active"); - const listed = await getJson(port, "/v1/api-keys", cookie); - assert.equal(listed.status, 200); - assert.equal(listed.body.contractVersion, "user-api-key-v1"); - assert.equal(listed.body.keys.length, 2); - assert.equal(listed.body.keys.find((key) => key.id === apiKey.id)?.displaySecret, undefined); - const me = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${apiKey.displaySecret}` }); - assert.equal(me.status, 200); - assert.equal(me.body.actor.id, firstAdmin.body.actor.id); - assert.equal(me.body.actor.username, "apikey-admin"); - assert.equal(me.body.actor.role, "admin"); - assert.equal(me.body.authMethod, "api-key"); - assert.equal(JSON.stringify(me.body).includes(apiKey.displaySecret), false); - const bad = await getJson(port, "/v1/users/me", null, { authorization: "Bearer hwl_live_not-a-real-key" }); - assert.equal(bad.status, 401); - assert.equal(bad.body.error.code, "api_key_invalid"); - const wrongScheme = await getJson(port, "/v1/users/me", null, { authorization: `Basic ${apiKey.displaySecret}` }); - assert.equal(wrongScheme.status, 401); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api can list, create, regenerate, and revoke a user API key", async () => { - const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, - now: () => "2026-06-03T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - try { - const { port } = server.address(); - const setup = await postJson(port, "/v1/setup/first-admin", { username: "rotate-admin", password: "rotate-pass" }); - assert.equal(setup.status, 201); - const cookie = setup.cookie; - const originalKey = await postJson(port, "/v1/api-keys", { name: "Original CLI key" }, cookie); - assert.equal(originalKey.status, 201); - const original = originalKey.body.key.displaySecret; - const created = await postJson(port, "/v1/api-keys", { name: "AgentRun runner" }, cookie); - assert.equal(created.status, 201); - assert.equal(created.body.key.name, "AgentRun runner"); - const createdSecret = created.body.key.displaySecret; - const listAfterCreate = await getJson(port, "/v1/api-keys", cookie); - assert.equal(listAfterCreate.body.keys.length, 3); - assert.equal(listAfterCreate.body.keys.some((key) => key.displaySecret), false); - const regenerate = await postJson(port, `/v1/api-keys/${created.body.key.id}/regenerate`, {}, cookie); - assert.equal(regenerate.status, 200); - assert.notEqual(regenerate.body.key.displaySecret, createdSecret); - const usedOld = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${createdSecret}` }); - assert.equal(usedOld.status, 401); - const usedNew = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${regenerate.body.key.displaySecret}` }); - assert.equal(usedNew.status, 200); - const revoke = await deleteJson(port, `/v1/api-keys/${regenerate.body.key.id}`, {}, cookie); - assert.equal(revoke.status, 200); - const afterRevoke = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${regenerate.body.key.displaySecret}` }); - assert.equal(afterRevoke.status, 401); - const originalStillWorks = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${original}` }); - assert.equal(originalStillWorks.status, 200); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api rejects API key auth for a disabled user", async () => { - const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, - now: () => "2026-06-03T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - try { - const { port } = server.address(); - const adminSetup = await postJson(port, "/v1/setup/first-admin", { username: "root", password: "root-pass" }); - assert.equal(adminSetup.status, 201); - const adminCookie = adminSetup.cookie; - await postJson(port, "/v1/admin/users", { username: "soon-disabled", password: "pw", role: "user", status: "active" }, adminCookie); - const userLogin = await postJson(port, "/auth/login", { username: "soon-disabled", password: "pw" }); - assert.equal(userLogin.status, 200); - const userCookie = userLogin.cookie; - const issued = await postJson(port, "/v1/api-keys", { name: "Disabled user CLI key" }, userCookie); - const apiKey = issued.body.key.displaySecret; - const ok = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${apiKey}` }); - assert.equal(ok.status, 200); - const adminIssue = await postJson(port, "/v1/admin/users/soon-disabled/disable", {}, adminCookie); - assert.ok([200, 204, 404].includes(adminIssue.status), `disable endpoint should be wired; got ${adminIssue.status}`); - const blocked = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${apiKey}` }); - if (adminIssue.status === 404) { - assert.equal(blocked.status, 200); - } else { - assert.equal(blocked.status, 401); - } - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api issues a 24h Web session with Secure/SameSite cookie for HTTPS forwarded requests", async () => { - const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, - now: () => "2026-06-03T12:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - try { - const { port } = server.address(); - const setup = await postJson(port, "/v1/setup/first-admin", { username: "session-admin", password: "session-pass" }, null, { "x-forwarded-proto": "https" }); - assert.equal(setup.status, 201); - assert.ok(setup.cookie, "first admin should set a session cookie"); - assert.match(setup.cookie, /HttpOnly/u); - assert.match(setup.cookie, /(?:^|;\s*)Secure(?:;|$)/u); - assert.match(setup.cookie, /SameSite=Lax/u); - assert.match(setup.cookie, /Max-Age=86400/u); - assert.equal(setup.body.session.expiresAt, "2026-06-04T12:00:00.000Z"); - - const login = await postJson(port, "/auth/login", { username: "session-admin", password: "session-pass" }, null, { "x-forwarded-proto": "https" }); - assert.equal(login.status, 200); - assert.match(login.cookie, /(?:^|;\s*)Secure(?:;|$)/u); - assert.match(login.cookie, /Max-Age=86400/u); - assert.equal(login.body.session.expiresAt, "2026-06-04T12:00:00.000Z"); - - const session = await getJson(port, "/v1/auth/session", login.cookie); - assert.equal(session.status, 200); - assert.equal(session.body.authenticated, true); - assert.equal(session.body.authMethod, "web-session"); - assert.equal(session.body.sessionExpiresAt, "2026-06-04T12:00:00.000Z"); - assert.equal(session.body.sessionTtlSeconds, 86400); - - const logout = await postJson(port, "/auth/logout", {}, login.cookie, { "x-forwarded-proto": "https" }); - assert.equal(logout.status, 200); - assert.match(logout.cookie, /(?:^|;\s*)Secure(?:;|$)/u); - assert.match(logout.cookie, /Max-Age=0/u); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api keeps HTTP Web session cookies browser-storable for the v0.2 public entry", async () => { - const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_PUBLIC_ENDPOINT }, - now: () => "2026-06-03T12:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - try { - const { port } = server.address(); - const setup = await postJson(port, "/v1/setup/first-admin", { username: "http-session-admin", password: "session-pass" }, null, { host: "74.48.78.17:19666" }); - assert.equal(setup.status, 201); - assert.ok(setup.cookie, "first admin should set a session cookie"); - assert.match(setup.cookie, /HttpOnly/u); - assert.doesNotMatch(setup.cookie, /(?:^|;\s*)Secure(?:;|$)/u); - assert.match(setup.cookie, /SameSite=Lax/u); - assert.match(setup.cookie, /Max-Age=86400/u); - - const login = await postJson(port, "/auth/login", { username: "http-session-admin", password: "session-pass" }, null, { host: "74.48.78.17:19666" }); - assert.equal(login.status, 200); - assert.doesNotMatch(login.cookie, /(?:^|;\s*)Secure(?:;|$)/u); - - const session = await getJson(port, "/v1/auth/session", login.cookie, { host: "74.48.78.17:19666" }); - assert.equal(session.status, 200); - assert.equal(session.body.authenticated, true); - assert.equal(session.body.authMethod, "web-session"); - - const logout = await postJson(port, "/auth/logout", {}, login.cookie, { host: "74.48.78.17:19666" }); - assert.equal(logout.status, 200); - assert.doesNotMatch(logout.cookie, /(?:^|;\s*)Secure(?:;|$)/u); - assert.match(logout.cookie, /Max-Age=0/u); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api /v1/users/me returns actor with authMethod=api-key for HWLAB_API_KEY Bearer token", async () => { - const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, - now: () => "2026-06-03T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - try { - const { port } = server.address(); - const setup = await postJson(port, "/v1/setup/first-admin", { username: "actor-admin", password: "actor-pass" }); - const cookie = setup.cookie; - const apiKey = (await postJson(port, "/v1/api-keys", { name: "Actor CLI key" }, cookie)).body.key.displaySecret; - const me = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${apiKey}` }); - assert.equal(me.status, 200); - assert.equal(me.body.authMethod, "api-key"); - assert.equal(me.body.actor.username, "actor-admin"); - assert.equal(me.body.actor.role, "admin"); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api AgentRun trace records real actor.userId from OIDC-upserted user (issue 788 spec)", async () => { - const accessController = createAccessController({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_KEYCLOAK_ISSUER: OIDC_ISSUER, HWLAB_KEYCLOAK_CLIENT_ID: OIDC_CLIENT_ID, HWLAB_KEYCLOAK_CLIENT_SECRET: "secret", HWLAB_PUBLIC_ENDPOINT }, - fetchImpl: makeOidcFetchMock({ nonce: "actor-nonce" }), - now: () => "2026-06-03T00:00:00.000Z" - }); - const state = "actor-state"; - await accessController.store.upsertOidcState({ state, nonce: "actor-nonce", returnTo: "/", createdAt: "2026-06-03T00:00:00.000Z", expiresAt: "2026-06-03T00:10:00.000Z" }); - const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, - accessController, - now: () => "2026-06-03T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - try { - const { port } = server.address(); - const response = await fetch(`http://127.0.0.1:${port}/auth/oidc/callback?code=oidc-code&state=${state}`, { redirect: "manual" }); - const cookie = (response.headers.get("set-cookie") ?? "").split(";")[0]; - const session = await getJson(port, "/v1/auth/session", cookie); - const actor = session.body.actor; - assert.equal(actor.username, "oidc-user"); - assert.equal(actor.role, "user"); - const defaultApiKey = await getJson(port, "/v1/api-keys/default", cookie); - assert.equal(defaultApiKey.body.created, false); - assert.equal(defaultApiKey.body.key.displaySecret, undefined); - const runnerKey = await postJson(port, "/v1/api-keys", { name: "OIDC runner key" }, cookie); - const apiKey = runnerKey.body.key.displaySecret; - const me = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${apiKey}` }); - assert.equal(me.body.actor.id, actor.id); - assert.equal(me.body.authMethod, "api-key"); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index ef97defa..a7ac3839 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -1026,353 +1026,6 @@ class AccessController { return this.store.getAgentSession?.(id) ?? null; } - async listAgentConversations(request, response, url) { - await this.ensureBootstrap(); - const auth = await this.authenticate(request, { required: true }); - if (!auth.ok) return sendJson(response, auth.status, auth); - const projectId = textOr(url.searchParams.get("projectId"), ""); - const limit = boundedListLimit(url.searchParams.get("limit")); - const includeConversationId = textOr(url.searchParams.get("includeConversationId"), ""); - if (includeConversationId && !safeConversationIdLocal(includeConversationId)) { - return sendJson(response, 400, errorPayload("invalid_include_conversation_id", "includeConversationId must start with cnv_", 400)); - } - const sessions = await this.store.listAgentSessionsForUser?.({ - ownerUserId: auth.actor.id, - actorRole: auth.actor.role, - ownerScoped: true, - projectId, - limit, - includeArchived: false, - now: this.now() - }) ?? []; - let conversations = conversationsFromAgentSessions(sessions); - conversations = includeConversation(conversations, includeConversationId ? await this.visibleConversationForActor(auth.actor, includeConversationId, projectId) : null); - return sendJson(response, 200, { - ok: true, - status: "succeeded", - contractVersion: "agent-conversations-v1", - actor: publicActor(auth.actor), - projectId: projectId || null, - conversations, - defaultConversation: conversations[0] ?? null, - count: conversations.length - }); - } - - async getAgentConversation(request, response, conversationId) { - await this.ensureBootstrap(); - const auth = await this.authenticate(request, { required: true }); - if (!auth.ok) return sendJson(response, auth.status, auth); - const url = new URL(request.url || "/", "http://localhost"); - const projectId = textOr(url.searchParams.get("projectId"), ""); - if (!safeConversationIdLocal(conversationId)) { - return sendJson(response, 400, errorPayload("invalid_conversation_id", "conversationId must start with cnv_", 400)); - } - const sessions = await this.store.listAgentSessionsForUser?.({ - ownerUserId: auth.actor.id, - actorRole: auth.actor.role, - ownerScoped: true, - conversationId, - projectId, - limit: 20, - includeArchived: false, - now: this.now() - }) ?? []; - let conversation = conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null; - if (!conversation && projectId) { - const visibleElsewhere = await this.visibleConversationForActor(auth.actor, conversationId, "", { includeArchived: false }); - if (visibleElsewhere) { - return sendJson(response, 404, errorPayload("conversation_project_mismatch", "Agent conversation is not visible in the requested project", 404)); - } - } - if (!conversation) return sendJson(response, 404, errorPayload("agent_conversation_not_found", "Agent conversation is not visible to the current actor", 404)); - return sendJson(response, 200, { ok: true, status: "found", contractVersion: "agent-conversations-v1", actor: publicActor(auth.actor), conversation }); - } - - async updateAgentConversation(request, response, conversationId) { - await this.ensureBootstrap(); - const auth = await this.authenticate(request, { required: true }); - if (!auth.ok) return sendJson(response, auth.status, auth); - if (!safeConversationIdLocal(conversationId)) { - return sendJson(response, 400, errorPayload("invalid_conversation_id", "conversationId must start with cnv_", 400)); - } - const body = await jsonBody(request); - const sessionId = safeAgentSessionId(body.sessionId) || safeAgentSessionId(body.session?.sessionId) || `ses_${conversationId.slice(4)}`; - const session = await this.recordAgentSessionOwner({ - ownerUserId: auth.actor.id, - sessionId, - projectId: textOr(body.projectId, "prj_hwpod_workbench"), - agentId: textOr(body.agentId, "hwlab-code-agent"), - status: textOr(body.status ?? body.sessionStatus, "active"), - conversationId, - threadId: textOr(body.threadId, null), - traceId: textOr(body.lastTraceId ?? body.traceId, null), - session: normalizeConversationSnapshot(body, auth.actor) - }); - const conversation = conversationsFromAgentSessions(session ? [session] : []).find((item) => item.conversationId === conversationId) ?? null; - return sendJson(response, 200, { ok: true, status: "stored", contractVersion: "agent-conversations-v1", conversation }); - } - - async deleteAgentConversation(request, response, conversationId) { - await this.ensureBootstrap(); - const auth = await this.authenticate(request, { required: true }); - if (!auth.ok) return sendJson(response, auth.status, auth); - if (!safeConversationIdLocal(conversationId)) { - return sendJson(response, 400, errorPayload("invalid_conversation_id", "conversationId must start with cnv_", 400)); - } - const body = await jsonBody(request); - const projectId = textOr(body.projectId, "prj_hwpod_workbench"); - const visible = await this.visibleConversationForActor(auth.actor, conversationId, projectId, { includeArchived: true }); - if (!visible) return sendJson(response, 404, errorPayload("agent_conversation_not_found", "Agent conversation is not visible to the current actor", 404)); - const archived = await this.store.archiveAgentConversation?.({ - ownerUserId: auth.actor.id, - actorRole: auth.actor.role, - ownerScoped: true, - conversationId, - projectId, - now: this.now() - }); - let workspace = null; - const workspaceId = textOr(body.workspaceId, ""); - if (safeWorkspaceId(workspaceId)) { - const current = await this.store.getWorkspaceForUser?.({ workspaceId, ownerUserId: auth.actor.id, actorRole: auth.actor.role }); - if (current?.selectedConversationId === conversationId) { - const next = await this.nextVisibleConversationForActor(auth.actor, projectId, conversationId); - workspace = await this.store.updateWorkspace?.({ - workspaceId, - ownerUserId: auth.actor.id, - actorRole: auth.actor.role, - selectedConversationId: next?.conversationId ?? null, - selectedAgentSessionId: next?.sessionId ?? null, - activeTraceId: null, - patch: { - selectedConversationId: next?.conversationId ?? null, - selectedAgentSessionId: next?.sessionId ?? null, - activeTraceId: null, - messages: next?.messages ?? [], - deletedConversationId: conversationId, - source: "workbench-delete-conversation" - }, - updatedBySessionId: currentSessionIdFromAuth(auth), - updatedByClient: textOr(body.updatedByClient ?? body.client, "unknown"), - now: this.now() - }); - } - } - return sendJson(response, 200, { - ok: true, - status: "deleted", - contractVersion: "agent-conversations-v1", - conversationId, - archivedCount: archived?.count ?? 0, - workspace: workspace ? await this.publicWorkbenchWorkspace(workspace, auth.actor) : null - }); - } - - async getWorkbenchWorkspace(request, response, url) { - await this.ensureBootstrap(); - const auth = await this.authenticate(request, { required: true }); - if (!auth.ok) return sendJson(response, auth.status, auth); - const projectId = textOr(url.searchParams.get("projectId"), "prj_hwpod_workbench"); - let workspace = await this.store.getOrCreateDefaultWorkspace?.({ - ownerUserId: auth.actor.id, - projectId, - now: this.now() - }); - return sendJson(response, 200, await this.workbenchWorkspacePayload(workspace, auth.actor)); - } - - async updateWorkbenchWorkspace(request, response, workspaceId) { - await this.ensureBootstrap(); - const auth = await this.authenticate(request, { required: true }); - if (!auth.ok) return sendJson(response, auth.status, auth); - if (!safeWorkspaceId(workspaceId)) { - return sendJson(response, 400, errorPayload("invalid_workspace_id", "workspaceId must start with wsp_", 400)); - } - const current = await this.store.getWorkspaceForUser?.({ workspaceId, ownerUserId: auth.actor.id, actorRole: auth.actor.role }); - if (!current) return sendJson(response, 404, errorPayload("workbench_workspace_not_found", "Workbench workspace is not visible to the current actor", 404)); - const body = await jsonBody(request); - const requestedProjectId = textOr(body.projectId, current.projectId); - if (requestedProjectId !== current.projectId) { - return sendJson(response, 409, errorPayload("workspace_project_mismatch", "Workbench workspace project does not match the requested project", 409)); - } - const expectedRevision = Number.parseInt(String(body.expectedRevision ?? body.revision ?? ""), 10); - if (Number.isInteger(expectedRevision) && expectedRevision > 0 && expectedRevision !== current.revision) { - return sendJson(response, 409, { - ok: false, - status: "revision_conflict", - error: { code: "workspace_revision_conflict", message: "Workbench workspace revision changed; reload and retry." }, - expectedRevision, - workspace: await this.publicWorkbenchWorkspace(current, auth.actor) - }); - } - const selectedConversationId = textOr(body.selectedConversationId ?? body.conversationId, current.selectedConversationId ?? ""); - const selectedConversation = selectedConversationId - ? await this.visibleConversationForWorkspaceAuthority(auth.actor, selectedConversationId, requestedProjectId) - : null; - if (selectedConversationId && !selectedConversation) return sendJson(response, 403, errorPayload("workspace_conversation_forbidden", "Selected conversation is not visible to the current actor", 403)); - const workspace = await this.store.updateWorkspace?.({ - workspaceId, - ownerUserId: auth.actor.id, - actorRole: auth.actor.role, - projectId: requestedProjectId, - name: textOr(body.name, current.name), - status: body.status === "archived" ? "archived" : "active", - selectedConversationId, - selectedAgentSessionId: safeAgentSessionId(body.selectedAgentSessionId ?? body.sessionId) || current.selectedAgentSessionId, - activeTraceId: safeTraceIdLocal(body.activeTraceId ?? body.traceId) || null, - providerProfile: textOr(body.providerProfile, current.providerProfile ?? ""), - patch: normalizeWorkspacePatch(body, auth.actor), - updatedBySessionId: currentSessionIdFromAuth(auth), - updatedByClient: textOr(body.updatedByClient ?? body.client, "unknown"), - now: this.now() - }); - return sendJson(response, 200, await this.workbenchWorkspacePayload(workspace, auth.actor)); - } - - async selectWorkbenchConversation(request, response, workspaceId) { - await this.ensureBootstrap(); - const auth = await this.authenticate(request, { required: true }); - if (!auth.ok) return sendJson(response, auth.status, auth); - if (!safeWorkspaceId(workspaceId)) return sendJson(response, 400, errorPayload("invalid_workspace_id", "workspaceId must start with wsp_", 400)); - const current = await this.store.getWorkspaceForUser?.({ workspaceId, ownerUserId: auth.actor.id, actorRole: auth.actor.role }); - if (!current) return sendJson(response, 404, errorPayload("workbench_workspace_not_found", "Workbench workspace is not visible to the current actor", 404)); - const body = await jsonBody(request); - const requestedProjectId = textOr(body.projectId, current.projectId); - if (requestedProjectId !== current.projectId) { - return sendJson(response, 409, errorPayload("workspace_project_mismatch", "Workbench workspace project does not match the requested project", 409)); - } - const conversationId = textOr(body.conversationId, "") || `cnv_${randomUUID()}`; - if (!safeConversationIdLocal(conversationId)) return sendJson(response, 400, errorPayload("invalid_conversation_id", "conversationId must start with cnv_", 400)); - const existing = await this.visibleConversationForWorkspaceAuthority(auth.actor, conversationId, requestedProjectId); - if (!existing && body.create !== true) return sendJson(response, 404, errorPayload("agent_conversation_not_found", "Agent conversation is not visible to the current actor", 404)); - const sessionId = safeAgentSessionId(body.sessionId ?? existing?.sessionId) || `ses_${conversationId.slice(4)}`; - if (!existing) { - await this.recordAgentSessionOwner({ - ownerUserId: auth.actor.id, - sessionId, - projectId: requestedProjectId, - agentId: "hwlab-code-agent", - status: "active", - conversationId, - threadId: textOr(body.threadId, null), - traceId: textOr(body.lastTraceId ?? body.traceId, null), - session: normalizeConversationSnapshot({ ...body, snapshot: { source: "workbench-select-conversation" } }, auth.actor) - }); - } - const workspace = await this.store.updateWorkspace?.({ - workspaceId, - ownerUserId: auth.actor.id, - actorRole: auth.actor.role, - projectId: requestedProjectId, - selectedConversationId: conversationId, - selectedAgentSessionId: sessionId, - activeTraceId: safeTraceIdLocal(body.activeTraceId ?? body.traceId) || null, - patch: normalizeWorkspacePatch({ ...body, selectedConversationId: conversationId, sessionId }, auth.actor), - updatedBySessionId: currentSessionIdFromAuth(auth), - updatedByClient: textOr(body.updatedByClient ?? body.client, "unknown"), - now: this.now() - }); - return sendJson(response, 200, await this.workbenchWorkspacePayload(workspace, auth.actor)); - } - - async resetWorkbenchWorkspace(request, response, workspaceId) { - await this.ensureBootstrap(); - const auth = await this.authenticate(request, { required: true }); - if (!auth.ok) return sendJson(response, auth.status, auth); - if (!safeWorkspaceId(workspaceId)) return sendJson(response, 400, errorPayload("invalid_workspace_id", "workspaceId must start with wsp_", 400)); - const current = await this.store.getWorkspaceForUser?.({ workspaceId, ownerUserId: auth.actor.id, actorRole: auth.actor.role }); - if (!current) return sendJson(response, 404, errorPayload("workbench_workspace_not_found", "Workbench workspace is not visible to the current actor", 404)); - const body = await jsonBody(request); - if (body.confirm !== true && body.confirmReset !== true) { - return sendJson(response, 400, errorPayload("workspace_reset_confirmation_required", "Reset requires confirm=true", 400)); - } - const workspace = await this.store.updateWorkspace?.({ - workspaceId, - ownerUserId: auth.actor.id, - actorRole: auth.actor.role, - selectedConversationId: null, - selectedAgentSessionId: null, - activeTraceId: null, - providerProfile: null, - patch: { resetAt: this.now(), resetBy: publicActor(auth.actor), messages: [] }, - replaceJson: true, - updatedBySessionId: currentSessionIdFromAuth(auth), - updatedByClient: textOr(body.updatedByClient ?? body.client, "unknown"), - now: this.now() - }); - return sendJson(response, 200, { ...(await this.workbenchWorkspacePayload(workspace, auth.actor)), reset: true }); - } - - async workbenchWorkspaceEvents(request, response, url, workspaceId) { - await this.ensureBootstrap(); - const auth = await this.authenticate(request, { required: true }); - if (!auth.ok) return sendJson(response, auth.status, auth); - let workspace = await this.store.getWorkspaceForUser?.({ workspaceId, ownerUserId: auth.actor.id, actorRole: auth.actor.role }); - if (!workspace) return sendJson(response, 404, errorPayload("workbench_workspace_not_found", "Workbench workspace is not visible to the current actor", 404)); - const afterRevision = Number.parseInt(String(url.searchParams.get("afterRevision") ?? ""), 10) || 0; - const changed = workspace.revision > afterRevision; - return sendJson(response, 200, { - ok: true, - status: changed ? "changed" : "unchanged", - contractVersion: "workbench-workspace-v1", - afterRevision, - latestRevision: workspace.revision, - events: changed ? [{ type: "workspace", revision: workspace.revision, updatedAt: workspace.updatedAt }] : [], - workspace: changed ? await this.publicWorkbenchWorkspace(workspace, auth.actor) : null - }); - } - - async workbenchWorkspacePayload(workspace, actor) { - return { - ok: true, - status: "succeeded", - contractVersion: "workbench-workspace-v1", - actor: publicActor(actor), - workspace: await this.publicWorkbenchWorkspace(workspace, actor) - }; - } - - async publicWorkbenchWorkspace(workspace, actor) { - const workspaceProjectId = textOr(workspace?.projectId, ""); - const conversation = workspace?.selectedConversationId - ? await this.visibleConversationForWorkspaceAuthority(actor, workspace.selectedConversationId, workspaceProjectId) - : null; - return publicWorkbenchWorkspace(workspace, { conversation }); - } - - async visibleConversationForWorkspaceAuthority(actor, conversationId, projectId = "", options = {}) { - return this.visibleConversationForActor(actor, conversationId, projectId, options); - } - - async visibleConversationForActor(actor, conversationId, projectId = "", options = {}) { - if (!safeConversationIdLocal(conversationId)) return null; - const sessions = await this.store.listAgentSessionsForUser?.({ - ownerUserId: actor.id, - actorRole: actor.role, - ownerScoped: true, - conversationId, - projectId: textOr(projectId, ""), - limit: 20, - includeArchived: options.includeArchived === true, - now: this.now() - }) ?? []; - return conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null; - } - - async nextVisibleConversationForActor(actor, projectId = "", excludedConversationId = "") { - const sessions = await this.store.listAgentSessionsForUser?.({ - ownerUserId: actor.id, - actorRole: actor.role, - ownerScoped: true, - projectId: textOr(projectId, ""), - limit: 20, - includeArchived: false, - now: this.now() - }) ?? []; - return conversationsFromAgentSessions(sessions).find((item) => item.conversationId !== excludedConversationId) ?? null; - } - } class MemoryAccessStore { @@ -2062,28 +1715,8 @@ function firstMessageField(messagesSource, key) { return null; } function agentSessionIdForRecord(sessionId, traceId) { const sessionText = textOr(sessionId, ""); if (/^ses_[A-Za-z0-9_.:-]+$/u.test(sessionText)) return sessionText; const traceText = textOr(traceId, ""); return /^trc_[A-Za-z0-9_.:-]+$/u.test(traceText) ? `ses_pending_${traceText.slice(4)}` : null; } -function safeConversationIdLocal(value) { return /^cnv_[A-Za-z0-9_.:-]+$/u.test(textOr(value, "")); } function boundedListLimit(value) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : 20, 1), 100); } function defaultWorkspaceId(ownerUserId, projectId) { return `wsp_${sha256(`${ownerUserId}:${projectId}`).slice(0, 24)}`; } -function currentSessionIdFromAuth(auth) { return textOr(auth?.session?.id, null); } -function normalizeWorkspacePatch(body = {}, actor = null) { - const workspace = normalizeObject(body.workspace ?? body.workspaceJson ?? body.snapshot ?? body); - const activeTraceCleared = (Object.hasOwn(body, "activeTraceId") && body.activeTraceId === null) || (Object.hasOwn(workspace, "activeTraceId") && workspace.activeTraceId === null); - const normalized = { - ...workspace, - selectedConversationId: textOr(body.selectedConversationId ?? body.conversationId ?? workspace.selectedConversationId, workspace.selectedConversationId ?? null), - selectedAgentSessionId: safeAgentSessionId(body.selectedAgentSessionId ?? body.sessionId ?? workspace.selectedAgentSessionId) || workspace.selectedAgentSessionId, - activeTraceId: safeTraceIdLocal(body.activeTraceId ?? body.traceId ?? workspace.activeTraceId) || null, - providerProfile: textOr(body.providerProfile ?? workspace.providerProfile, workspace.providerProfile ?? null), - messages: Array.isArray(body.messages) ? dedupeConversationMessages(body.messages) : Array.isArray(workspace.messages) ? dedupeConversationMessages(workspace.messages) : undefined, - actor: actor ? publicActor(actor) : undefined, - secretMaterialStored: false, - valuesRedacted: true - }; - delete normalized.selectedConversationProjectId; - if (activeTraceCleared) normalized.lastTraceId = null; - return normalized; -} function normalizeWorkspaceRecord(input = {}, existing = null, now = new Date().toISOString(), { create = false, replaceJson = false } = {}) { const patch = normalizeObject(input.patch); const workspaceJson = replaceJson ? patch : { ...normalizeObject(existing?.workspace), ...patch }; @@ -2111,105 +1744,9 @@ function nullableText(value, fallback = null) { const text = textOr(value, ""); return text || fallback || null; } -function publicWorkbenchWorkspace(workspace, { conversation = null } = {}) { - if (!workspace) return null; - return { - workspaceId: workspace.id, - ownerUserId: workspace.ownerUserId, - projectId: workspace.projectId, - name: workspace.name, - status: workspace.status, - isDefault: workspace.isDefault, - revision: workspace.revision, - selectedConversationId: workspace.selectedConversationId, - selectedAgentSessionId: workspace.selectedAgentSessionId, - activeTraceId: workspace.activeTraceId, - providerProfile: workspace.providerProfile, - selectedConversation: conversation, - workspace: redactedWorkspaceJson(workspaceJsonForSelectedConversation(workspace, conversation)), - updatedBySessionId: workspace.updatedBySessionId, - updatedByClient: workspace.updatedByClient, - createdAt: workspace.createdAt, - updatedAt: workspace.updatedAt, - valuesRedacted: true, - secretMaterialStored: false - }; -} -function workspaceJsonForSelectedConversation(workspace, conversation = null) { - const snapshot = normalizeObject(workspace?.workspace); - const selectedConversationId = textOr(workspace?.selectedConversationId, ""); - if (!conversation || textOr(conversation.conversationId, "") !== selectedConversationId) return snapshot; - const activeTraceId = textOr(workspace.activeTraceId, ""); - const conversationStatus = textOr(conversation.status, ""); - const snapshotStatus = textOr(snapshot.sessionStatus, ""); - const sessionStatus = activeTraceId || !snapshotStatus || isActiveConversationStatus(snapshotStatus) ? conversationStatus : snapshotStatus; - return { - ...snapshot, - selectedConversationId, - selectedAgentSessionId: textOr(workspace.selectedAgentSessionId ?? conversation.sessionId, ""), - activeTraceId, - providerProfile: textOr(workspace.providerProfile, snapshot.providerProfile), - sessionStatus, - lastTraceId: activeTraceId ? textOr(conversation.lastTraceId, "") : "" - }; -} -function redactedWorkspaceJson(value = {}) { - const workspace = normalizeObject(value); - const activeTraceId = textOr(workspace.activeTraceId, ""); - const sessionStatus = textOr(workspace.sessionStatus, ""); - const staleActiveSessionStatus = !activeTraceId && isActiveConversationStatus(sessionStatus); - return pruneEmpty({ - selectedConversationId: textOr(workspace.selectedConversationId, ""), - selectedAgentSessionId: textOr(workspace.selectedAgentSessionId, ""), - activeTraceId, - previousActiveTraceId: textOr(workspace.previousActiveTraceId, ""), - providerProfile: textOr(workspace.providerProfile, ""), - sessionStatus: staleActiveSessionStatus ? "" : sessionStatus, - lastTraceId: activeTraceId ? textOr(workspace.lastTraceId, "") : "", - workspaceRevisionConflict: workspace.workspaceRevisionConflict === true, - expectedWorkspaceRevision: Number.isFinite(Number(workspace.expectedWorkspaceRevision)) ? Number(workspace.expectedWorkspaceRevision) : undefined, - staleContinuationCleared: workspace.staleContinuationCleared === true, - staleContinuationReason: textOr(workspace.staleContinuationReason, ""), - staleConversationId: textOr(workspace.staleConversationId, ""), - staleAgentSessionId: textOr(workspace.staleAgentSessionId, ""), - staleThreadId: textOr(workspace.staleThreadId, ""), - recoveryAction: textOr(workspace.recoveryAction, ""), - messages: Array.isArray(workspace.messages) ? dedupeConversationMessages(workspace.messages) : undefined, - actor: workspace.actor && typeof workspace.actor === "object" ? publicActor(workspace.actor) : undefined, - updatedAt: textOr(workspace.updatedAt, ""), - resetAt: textOr(workspace.resetAt, ""), - secretMaterialStored: false, - valuesRedacted: true - }); -} function workspaceParams(workspace) { return [workspace.id, workspace.ownerUserId, workspace.projectId, workspace.name, workspace.status, workspace.isDefault, workspace.selectedConversationId, workspace.selectedAgentSessionId, workspace.activeTraceId, workspace.providerProfile, stableJson(workspace.workspace ?? {}), workspace.revision, workspace.updatedBySessionId, workspace.updatedByClient, workspace.createdAt, workspace.updatedAt]; } -function normalizeConversationSnapshot(body = {}, actor = null) { - const snapshot = normalizeObject(body.snapshot ?? body); - const messagesSource = Array.isArray(body.messages) ? body.messages : snapshot.messages; - const chatMessagesSource = Array.isArray(body.chatMessages) ? body.chatMessages : snapshot.chatMessages; - const messages = dedupeConversationMessages(messagesSource); - const chatMessages = Array.isArray(chatMessagesSource) ? dedupeConversationMessages(chatMessagesSource) : undefined; - const lastUserMessageAt = lastUserMessageAtFromMessages(messages); - return { - ...snapshot, - messages, - chatMessages, - messageCount: resolveSnapshotMessageCount(messages, snapshot.messageCount), - firstUserMessagePreview: firstUserPreviewFromMessages(messages) ?? snapshot.firstUserMessagePreview ?? null, - lastUserMessageAt: lastUserMessageAt ?? textOr(snapshot.lastUserMessageAt, null), - actor: actor ? publicActor(actor) : undefined, - secretMaterialStored: false, - valuesRedacted: true - }; -} -function resolveSnapshotMessageCount(messagesSource, existingCount) { - const explicit = numberOrNull(existingCount); - const fromSource = Array.isArray(messagesSource) ? messagesSource.length : null; - if (fromSource !== null) return fromSource; - return explicit ?? fromSource ?? 0; -} function firstUserPreviewFromMessages(messagesSource) { if (!Array.isArray(messagesSource)) return null; for (const message of messagesSource) { @@ -2364,128 +1901,6 @@ function redactAgentRunSummary(value) { reuseEligible: typeof agentRun.reuseEligible === "boolean" ? agentRun.reuseEligible : undefined }); } -function conversationsFromAgentSessions(sessions = []) { - const byConversation = new Map(); - for (const session of sessions) { - const conversationId = textOr(session.conversationId, ""); - if (!conversationId) continue; - const previous = byConversation.get(conversationId); - if (previous && String(previous.updatedAt ?? "") >= String(session.updatedAt ?? "")) continue; - byConversation.set(conversationId, publicAgentConversation(session)); - } - return [...byConversation.values()].sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? ""))); -} -function includeConversation(conversations = [], included = null) { - const includedConversationId = textOr(included?.conversationId, ""); - if (!includedConversationId) return conversations; - if (conversations.some((conversation) => conversation.conversationId === includedConversationId)) return conversations; - return [included, ...conversations]; -} -function publicAgentConversation(session) { - const snapshot = normalizeObject(session.session); - const rawMessages = Array.isArray(snapshot.messages) ? snapshot.messages : Array.isArray(snapshot.chatMessages) ? snapshot.chatMessages : []; - const messages = dedupeConversationMessages(ensureTerminalUserMessage(rawMessages, { - traceId: session.lastTraceId ?? snapshot.lastTraceId, - conversationId: session.conversationId, - sessionId: session.id, - threadId: session.threadId, - now: session.updatedAt, - firstUserPreview: snapshot.firstUserMessagePreview - })); - const displayMessages = normalizeFinalResponseConversationMessages(messages, snapshot.finalResponse); - const status = resolvedConversationStatus(session.status, snapshot, displayMessages); - const lastTraceId = resolvedConversationLastTraceId(session.lastTraceId, status, displayMessages, snapshot); - const lastUserMessageAt = lastUserMessageAtFromMessages(displayMessages) ?? textOr(snapshot.lastUserMessageAt, null); - const displayUpdatedAt = lastUserMessageAt ?? session.startedAt ?? session.updatedAt; - return { - conversationId: session.conversationId, - sessionId: session.id, - threadId: session.threadId, - status, - projectId: session.projectId, - agentId: session.agentId, - ownerUserId: session.ownerUserId, - lastTraceId, - updatedAt: displayUpdatedAt, - lastUserMessageAt, - startedAt: session.startedAt, - endedAt: session.endedAt, - session: pruneEmpty({ sessionId: session.id, threadId: session.threadId, status }), - messages: displayMessages, - messageCount: displayMessages.length, - firstUserMessagePreview: conversationText(snapshot.firstUserMessagePreview, 240) || null, - snapshot: pruneEmpty({ - sessionStatus: snapshot.sessionStatus, - source: snapshot.source, - updatedAt: displayUpdatedAt, - lastUserMessageAt, - valuesRedacted: snapshot.valuesRedacted !== false - }), - valuesRedacted: true - }; -} - -function resolvedConversationStatus(status, snapshot, messages = []) { - const stored = textOr(status, "").toLowerCase(); - const snapshotStatus = textOr(snapshot.sessionStatus ?? snapshot.status, "").toLowerCase(); - const latestAgent = latestAgentConversationMessage(messages); - const latestStatus = textOr(latestAgent?.status, "").toLowerCase(); - if (isActiveConversationStatus(stored)) { - if (snapshotStatus && !isActiveConversationStatus(snapshotStatus)) return snapshotStatus === "cancelled" ? "canceled" : snapshotStatus; - if (latestStatus && !isActiveConversationStatus(latestStatus)) return latestStatus === "cancelled" ? "canceled" : latestStatus; - } - if (isActiveConversationStatus(latestStatus) && !isTerminalConversationStatus(stored) && !isTerminalConversationStatus(snapshotStatus)) return latestStatus === "active" ? "running" : latestStatus; - return stored || snapshotStatus || latestStatus || "idle"; -} - -function resolvedConversationLastTraceId(storedTraceId, status, messages = [], snapshot = {}) { - const latestAgent = latestAgentConversationMessage(messages); - const latestTraceId = textOr(latestAgent?.traceId, ""); - const latestStatus = textOr(latestAgent?.status, "").toLowerCase(); - const snapshotTraceId = textOr(snapshot.lastTraceId, ""); - const stored = textOr(storedTraceId, ""); - const snapshotStatus = textOr(snapshot.sessionStatus ?? snapshot.status, "").toLowerCase(); - const latestTurnTraceId = latestConversationTurnTraceId(messages); - if (latestTurnTraceId && (latestTurnTraceId !== stored || isActiveConversationStatus(status))) return latestTurnTraceId; - if (stored && snapshotStatus && !isActiveConversationStatus(snapshotStatus)) return stored; - if (!isActiveConversationStatus(status)) { - return latestTraceId || snapshotTraceId || stored; - } - return stored || latestTraceId || snapshotTraceId; -} - -function isActiveConversationStatus(value) { - return ["running", "busy", "pending", "active", "processing", "accepted", "creating"].includes(textOr(value, "").toLowerCase().replace(/_/gu, "-")); -} - -function isTerminalConversationStatus(value) { - return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed", "interrupted", "expired"].includes(textOr(value, "").toLowerCase().replace(/_/gu, "-")); -} - -function latestConversationTurnTraceId(messages = []) { - if (!Array.isArray(messages)) return ""; - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (String(message?.role ?? "").toLowerCase() !== "agent") continue; - const traceId = textOr(message?.traceId, ""); - if (!traceId) return ""; - for (let prev = index - 1; prev >= 0; prev -= 1) { - const previous = messages[prev]; - if (String(previous?.role ?? "").toLowerCase() !== "user") continue; - return textOr(previous?.traceId, "") === traceId ? traceId : ""; - } - return ""; - } - return ""; -} - -function latestAgentConversationMessage(messages = []) { - if (!Array.isArray(messages)) return null; - for (const message of [...messages].reverse()) { - if (String(message?.role ?? "").toLowerCase() === "agent") return message; - } - return null; -} function boundedText(value, maxBytes) { const text = String(value ?? ""); const buffer = Buffer.from(text, "utf8"); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index b462bde7..24ab54a4 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -99,8 +99,6 @@ export async function handleCodeAgentChatHttp(request, response, options) { }; const manualSession = perf ? await perf.measure("manual_session", () => prepareManualCodeAgentSession({ params: chatParams, options, traceId, response })) : await prepareManualCodeAgentSession({ params: chatParams, options, traceId, response }); if (manualSession?.blocked) return; - const workspaceClaim = perf ? await perf.measure("workspace_claim", () => claimWorkbenchWorkspaceTurn({ params: chatParams, options, traceId, response })) : await claimWorkbenchWorkspaceTurn({ params: chatParams, options, traceId, response }); - if (workspaceClaim?.blocked) return; const billingPreflight = perf ? await perf.measure("billing_preflight", () => preflightCodeAgentBilling({ params: chatParams, options, traceId, response })) : await preflightCodeAgentBilling({ params: chatParams, options, traceId, response }); if (billingPreflight?.blocked) return; const nativeSessionChatParams = stripSyntheticConversationContext({ ...chatParams, userBillingReservation: billingPreflight?.reservation ?? null }, traceId, options); @@ -121,9 +119,6 @@ export async function handleCodeAgentChatHttp(request, response, options) { controlSemantics: "submit-and-poll", traceId, ...lifecycle, - workspaceId: workspaceClaim?.workspace?.id ?? null, - workspaceRevision: workspaceClaim?.workspace?.revision ?? null, - conversationId: safeConversationId(nativeSessionChatParams.conversationId) || null, sessionId: safeSessionId(nativeSessionChatParams.sessionId) || null, traceUrl, resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}`, @@ -227,13 +222,11 @@ async function createManualCodeAgentSession(request, response, options) { 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 @@ -271,14 +264,11 @@ async function selectManualCodeAgentSession(request, response, options, sessionI 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 @@ -347,86 +337,13 @@ async function getVisibleManualAgentSession(options, sessionId) { 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 selectedActiveTraceId = manualSessionSelectableActiveTraceId(session); - const selectedLastTraceId = safeTraceId(session.lastTraceId) || selectedActiveTraceId || 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, - activeTraceId: selectedActiveTraceId, - providerProfile, - patch: { - ...(current.workspace && typeof current.workspace === "object" ? current.workspace : {}), - selectedConversationId: session.conversationId, - selectedAgentSessionId: session.id, - activeTraceId: selectedActiveTraceId, - ...(selectedLastTraceId ? { lastTraceId: selectedLastTraceId } : {}), - 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 manualSessionSelectableActiveTraceId(session = {}) { - if (!manualSessionHasRunningTraceStatus(session)) return null; - return safeTraceId( - session.currentTraceId - ?? session.session?.currentTraceId - ?? session.session?.traceId - ?? session.session?.runnerTrace?.traceId - ?? session.session?.agentRun?.traceId - ) || safeTraceId(session.lastTraceId) || null; -} - -function manualSessionHasRunningTraceStatus(session = {}) { - return [ - session.status, - session.session?.sessionStatus, - session.session?.lifecycleStatus, - session.session?.runnerTrace?.sessionStatus, - session.session?.runnerTrace?.status, - session.session?.agentRun?.runStatus, - session.session?.agentRun?.commandState, - session.session?.agentRun?.terminalStatus - ].map((value) => String(value ?? "").trim().toLowerCase().replace(/_/gu, "-")) - .some((value) => ["running", "busy", "pending", "creating"].includes(value)); -} - 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, @@ -1916,7 +1833,6 @@ export async function handleCodeAgentSteerHttp(request, response, options) { return; } if (!currentResult?.agentRun?.runId || !currentResult?.agentRun?.commandId) { - await clearStaleWorkbenchSteerTrace({ params, options, traceId, reason: "steer-trace-not-found" }); sendJson(response, 404, { ok: false, status: "not_found", @@ -1961,7 +1877,6 @@ export async function handleCodeAgentSteerHttp(request, response, options) { status: "failed", traceId, route: "/v1/agent/chat/steer", - conversationId: safeConversationId(currentResult.conversationId ?? params.conversationId) || null, sessionId: safeSessionId(currentResult.sessionId ?? params.sessionId) || null, threadId: safeOpaqueId(currentResult.threadId ?? params.threadId) || null, agentRun: currentResult.agentRun, @@ -2025,135 +1940,6 @@ function codeAgentOwnerStatusForResult(result = {}) { return result?.status ?? "active"; } -async function claimWorkbenchWorkspaceTurn({ params = {}, options = {}, traceId, response } = {}) { - const workspaceId = safeWorkspaceId(params.workspaceId); - if (!workspaceId || !options.accessController?.store || !options.actor) return null; - const store = options.accessController.store; - const workspace = await store.getWorkspaceForUser?.({ workspaceId, ownerUserId: options.actor.id, actorRole: options.actor.role }); - if (!workspace) { - sendJson(response, 404, { - ok: false, - status: "failed", - error: { code: "workbench_workspace_not_found", message: "Workbench workspace is not visible to the current actor" }, - workspaceId - }); - return { blocked: true }; - } - const requestedProjectId = textValue(params.projectId); - const workspaceProjectId = textValue(workspace.projectId); - if (requestedProjectId && workspaceProjectId && requestedProjectId !== workspaceProjectId) { - sendJson(response, 409, { - ok: false, - status: "failed", - error: { code: "workspace_project_mismatch", message: "Workbench workspace project does not match the requested project" }, - projectId: requestedProjectId, - workspaceProjectId, - workspaceId - }); - return { blocked: true }; - } - const expectedRevision = Number.parseInt(String(params.expectedWorkspaceRevision ?? params.workspaceRevision ?? ""), 10); - const workspaceRevisionConflict = Number.isInteger(expectedRevision) && expectedRevision > 0 && workspace.revision !== expectedRevision; - const requestedConversationId = safeConversationId(params.conversationId) || null; - const requestedSessionId = safeSessionId(params.sessionId) || null; - const nextSelectedConversationId = requestedConversationId || workspace.selectedConversationId; - const nextSelectedAgentSessionId = requestedSessionId || (requestedConversationId && requestedConversationId !== workspace.selectedConversationId ? null : workspace.selectedAgentSessionId); - const activeTraceId = safeTraceId(workspace.activeTraceId); - const activeTraceSnapshot = activeTraceId ? (options.traceStore ?? defaultCodeAgentTraceStore).snapshot(activeTraceId) : null; - const activeTraceOrphaned = Boolean(activeTraceId && isWorkbenchOrphanActiveTrace(activeTraceSnapshot)); - if (activeTraceOrphaned) { - await store.updateWorkspace?.({ - workspaceId, - ownerUserId: options.actor.id, - actorRole: options.actor.role, - selectedConversationId: workspace.selectedConversationId, - selectedAgentSessionId: workspace.selectedAgentSessionId, - activeTraceId: null, - providerProfile: workspace.providerProfile, - patch: { - ...(workspace.workspace && typeof workspace.workspace === "object" ? workspace.workspace : {}), - activeTraceId: null, - staleActiveTraceId: activeTraceId, - staleActiveTraceReason: "orphan-cancel-session-missing", - sessionStatus: "failed", - updatedAt: new Date().toISOString(), - source: "code-agent-submit-active-trace-repair", - valuesRedacted: true, - secretMaterialStored: false - }, - updatedBySessionId: options.authSession?.id ?? null, - updatedByClient: "code-agent-submit-active-trace-repair", - now: new Date().toISOString() - }); - } - const updated = await store.updateWorkspace?.({ - workspaceId, - ownerUserId: options.actor.id, - actorRole: options.actor.role, - selectedConversationId: nextSelectedConversationId, - selectedAgentSessionId: nextSelectedAgentSessionId, - activeTraceId: traceId, - providerProfile: textValue(params.providerProfile) || workspace.providerProfile, - patch: { - selectedConversationId: nextSelectedConversationId, - selectedAgentSessionId: nextSelectedAgentSessionId, - previousActiveTraceId: activeTraceId || null, - workspaceRevisionConflict, - expectedWorkspaceRevision: Number.isInteger(expectedRevision) && expectedRevision > 0 ? expectedRevision : null, - activeTraceId: traceId, - providerProfile: textValue(params.providerProfile) || workspace.providerProfile, - sessionStatus: "running", - updatedAt: new Date().toISOString(), - source: "code-agent-submit", - valuesRedacted: true, - secretMaterialStored: false - }, - updatedBySessionId: options.authSession?.id ?? null, - updatedByClient: textValue(params.updatedByClient ?? params.client) || "code-agent-chat", - now: new Date().toISOString() - }); - return { workspace: updated ?? workspace }; -} - -async function clearStaleWorkbenchSteerTrace({ params = {}, options = {}, traceId, reason }) { - const workspaceId = safeWorkspaceId(params.workspaceId); - if (!workspaceId || !safeTraceId(traceId) || !options.accessController?.store || !options.actor) return null; - const store = options.accessController.store; - const workspace = await store.getWorkspaceForUser?.({ workspaceId, ownerUserId: options.actor.id, actorRole: options.actor.role }); - if (!workspace || safeTraceId(workspace.activeTraceId) !== traceId) return null; - return await store.updateWorkspace?.({ - workspaceId, - ownerUserId: options.actor.id, - actorRole: options.actor.role, - selectedConversationId: workspace.selectedConversationId, - selectedAgentSessionId: workspace.selectedAgentSessionId, - activeTraceId: null, - providerProfile: workspace.providerProfile, - patch: { - ...(workspace.workspace && typeof workspace.workspace === "object" ? workspace.workspace : {}), - activeTraceId: null, - staleActiveTraceId: traceId, - staleActiveTraceReason: reason, - sessionStatus: "failed", - updatedAt: new Date().toISOString(), - source: "code-agent-steer-active-trace-repair", - valuesRedacted: true, - secretMaterialStored: false - }, - updatedBySessionId: options.authSession?.id ?? null, - updatedByClient: "code-agent-steer-active-trace-repair", - now: new Date().toISOString() - }); -} - -function isWorkbenchOrphanActiveTrace(snapshot) { - if (!snapshot || typeof snapshot !== "object") return false; - const last = snapshot.lastEvent && typeof snapshot.lastEvent === "object" ? snapshot.lastEvent : null; - if (!last) return false; - if (last.errorCode !== "cancel_session_missing" && last.label !== "cancel:not_cancelable") return false; - return !snapshot.sessionId && !snapshot.runId && !snapshot.commandId && !snapshot.sessionStatus && !snapshot.turn; -} - async function resolveWorkbenchActiveTraceResult(traceId, options = {}) { if (!safeTraceId(traceId)) return null; const cached = options.codeAgentChatResults?.get?.(traceId) ?? null; @@ -2177,11 +1963,6 @@ async function resolveWorkbenchActiveTraceResult(traceId, options = {}) { } } -function safeWorkspaceId(value) { - const text = textValue(value); - return /^wsp_[A-Za-z0-9_.:-]+$/u.test(text) ? text : ""; -} - function textValue(value) { return String(value ?? "").trim(); } diff --git a/internal/cloud/server-workbench-http.test.ts b/internal/cloud/server-workbench-http.test.ts index 494a957e..a3088ed9 100644 --- a/internal/cloud/server-workbench-http.test.ts +++ b/internal/cloud/server-workbench-http.test.ts @@ -54,7 +54,7 @@ test("workbench read model exposes session, messages, turn, and trace without wr store: { async listAgentSessionsForUser(input = {}) { listInputs.push(input); - return [session].filter((item) => !input.projectId || item.projectId === input.projectId); + return [session]; }, async getAgentSession(sessionId) { return sessionId === session.id ? session : null; }, async getAgentSessionByTraceId(requestTraceId) { return requestTraceId === traceId ? session : null; }, @@ -72,20 +72,19 @@ test("workbench read model exposes session, messages, turn, and trace without wr const sessions = await getJson(port, "/v1/workbench/sessions"); assert.equal(sessions.status, 200); assert.equal(sessions.body.contractVersion, "workbench-sessions-v1"); - assert.equal(sessions.body.projectId, null); assert.equal(sessions.body.sessions[0].sessionId, session.id); assert.equal(sessions.body.sessions[0].turnSummary.traceId, traceId); assert.equal(listInputs[0].projectId, undefined); const staleProject = await getJson(port, `/v1/workbench/sessions?projectId=prj_stale_filter&includeSessionId=${encodeURIComponent(session.id)}`); - assert.equal(staleProject.status, 200); - assert.equal(staleProject.body.projectId, "prj_stale_filter"); - assert.equal(staleProject.body.sessions[0].sessionId, session.id); - assert.equal(staleProject.body.sessions[0].projectId, session.projectId); + assert.equal(staleProject.status, 400); + assert.equal(staleProject.body.error.code, "workbench_authority_removed"); + assert.equal(listInputs.length, 1); const detail = await getJson(port, `/v1/workbench/sessions/${encodeURIComponent(session.id)}`); assert.equal(detail.status, 200); - assert.equal(detail.body.session.conversationId, session.conversationId); + assert.equal(detail.body.session.sessionId, session.id); + assert.equal(detail.body.session.messagePageUrl, `/v1/workbench/sessions/${encodeURIComponent(session.id)}/messages`); assert.equal(detail.body.session.messageCount, 2); const messages = await getJson(port, `/v1/workbench/sessions/${encodeURIComponent(session.id)}/messages?limit=1`); @@ -265,29 +264,15 @@ test("workbench realtime stream accepts session authority without project or wor updatedAt: "2026-06-17T02:00:00.000Z", session: { sessionStatus: "running", lastTraceId: traceId, messages: [{ role: "user", text: "stream", traceId }] } }; - const workspace = { - workspaceId: "wsp_workbench_realtime", - ownerUserId: ACTOR.id, - projectId: "prj_hwpod_workbench", - revision: 7, - selectedConversationId: session.conversationId, - selectedAgentSessionId: session.id, - activeTraceId: traceId, - updatedAt: "2026-06-17T02:00:00.000Z", - workspace: { selectedConversationId: session.conversationId, selectedAgentSessionId: session.id, lastTraceId: traceId } - }; traceStore.append(traceId, { type: "request", status: "accepted", label: "request:accepted" }); - results.set(traceId, { status: "running", traceId, ownerUserId: ACTOR.id, conversationId: session.conversationId, sessionId: session.id, threadId: session.threadId }); + results.set(traceId, { status: "running", traceId, ownerUserId: ACTOR.id, sessionId: session.id, threadId: session.threadId }); const accessController = { store: { - async getOrCreateDefaultWorkspace() { return workspace; }, - async getWorkspaceForUser() { return workspace; }, async getAgentSession(sessionId) { return sessionId === session.id ? session : null; }, async getAgentSessionByTraceId(requestTraceId) { return requestTraceId === traceId ? session : null; } }, async ensureBootstrap() {}, - async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; }, - async publicWorkbenchWorkspace(value) { return value; } + async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; } }; const server = createCloudApiServer({ accessController, traceStore, codeAgentChatResults: results, env: { HWLAB_WORKBENCH_SSE_HEARTBEAT_MS: "60000" } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -302,7 +287,6 @@ test("workbench realtime stream accepts session authority without project or wor "workbench.trace.snapshot", "workbench.turn.snapshot" ]); - assert.equal(events[0].data.projectId, null); assert.equal(events[0].data.filters.sessionId, session.id); assert.equal(events[1].data.traceId, traceId); assert.equal(events[2].data.turn.traceId, traceId); diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index c4d546cb..53937aa8 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -7,7 +7,6 @@ import { createHash } from "node:crypto"; import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; import { parsePositiveInteger, - safeConversationId, safeOpaqueId, safeSessionId, safeTraceId, @@ -68,14 +67,16 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option const auth = perf ? await perf.measure("workbench_auth", () => authenticateWorkbenchRead(request, response, options)) : await authenticateWorkbenchRead(request, response, options); if (!auth) return; - const projectId = textValue(url.searchParams.get("projectId")) || null; + if (url.searchParams.has("projectId") || url.searchParams.has("workspaceId")) { + sendJson(response, 400, workbenchError("workbench_authority_removed", "Workbench read model is keyed by sessionId/threadId/traceId only.")); + return; + } + const requestedSessionId = safeSessionId(url.searchParams.get("sessionId") ?? url.searchParams.get("includeSessionId")); - const requestedWorkspaceId = safeWorkspaceId(url.searchParams.get("workspaceId")); const requestedTraceId = safeTraceId(url.searchParams.get("traceId")); const heartbeatMs = parsePositiveInteger(options.env?.HWLAB_WORKBENCH_SSE_HEARTBEAT_MS, DEFAULT_WORKBENCH_SSE_HEARTBEAT_MS); const traceStore = options.traceStore ?? defaultCodeAgentTraceStore; let closed = false; - let latestWorkspaceRevision = 0; const cleanup = []; response.writeHead(200, { @@ -92,7 +93,7 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option const eventCreatedAt = realtimeEventCreatedAt(payload); const traceSeq = realtimeTraceSeq(payload); response.write(`event: ${name}\n`); - response.write(`data: ${JSON.stringify({ contractVersion: "workbench-events-v1", projectId, serverSentAt: new Date().toISOString(), eventCreatedAt, traceSeq, ...payload })}\n\n`); + response.write(`data: ${JSON.stringify({ contractVersion: "workbench-events-v1", serverSentAt: new Date().toISOString(), eventCreatedAt, traceSeq, ...payload })}\n\n`); perf?.recordPhase({ phase: "sse_write", durationMs: nowMs() - startedAt, outcome: "ok" }); }; @@ -101,33 +102,16 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option status: "connected", heartbeatMs, filters: { - workspaceId: requestedWorkspaceId, sessionId: requestedSessionId, traceId: requestedTraceId } }); const initialSession = requestedSessionId ? await visibleSessionById(options.accessController?.store, requestedSessionId, auth.actor) : null; - let initialWorkspace = null; - if (requestedWorkspaceId || projectId) { - initialWorkspace = perf - ? await perf.measure("workbench_initial_workspace", () => visibleWorkbenchWorkspace(options, auth.actor, { workspaceId: requestedWorkspaceId, projectId })) - : await visibleWorkbenchWorkspace(options, auth.actor, { workspaceId: requestedWorkspaceId, projectId }); - } - if (initialWorkspace) { - latestWorkspaceRevision = Number(initialWorkspace.revision ?? 0) || 0; - writeEvent("workbench.workspace.snapshot", { - type: "workspace.snapshot", - reason: "initial", - workspace: await publicWorkbenchWorkspace(options, initialWorkspace, auth.actor), - cursor: { workspaceRevision: latestWorkspaceRevision } - }); - } - const initialSessionSnapshot = objectValue(initialSession?.session); const activeTraceId = requestedTraceId ?? safeTraceId(initialSession?.lastTraceId ?? initialSessionSnapshot.lastTraceId ?? initialSessionSnapshot.currentTraceId) - ?? safeTraceId(initialWorkspace?.activeTraceId ?? initialWorkspace?.workspace?.activeTraceId ?? initialWorkspace?.workspace?.lastTraceId); + ?? null; if (activeTraceId) { await (perf ? perf.measure("workbench_initial_trace", () => writeTraceRealtimeSnapshot({ writeEvent, options, actor: auth.actor, traceId: activeTraceId, reason: "initial" })) : writeTraceRealtimeSnapshot({ writeEvent, options, actor: auth.actor, traceId: activeTraceId, reason: "initial" })); const unsubscribe = traceStore.subscribe(activeTraceId, (event, snapshot) => { @@ -157,24 +141,9 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option const heartbeat = setInterval(async () => { try { - const heartbeatWorkspaceId = requestedWorkspaceId ?? initialWorkspace?.workspaceId; - const workspace = heartbeatWorkspaceId || projectId - ? await visibleWorkbenchWorkspace(options, auth.actor, { workspaceId: heartbeatWorkspaceId, projectId }) - : null; - const revision = Number(workspace?.revision ?? 0) || 0; - if (workspace && revision > latestWorkspaceRevision) { - latestWorkspaceRevision = revision; - writeEvent("workbench.workspace.snapshot", { - type: "workspace.snapshot", - reason: "revision-change", - workspace: await publicWorkbenchWorkspace(options, workspace, auth.actor), - cursor: { workspaceRevision: revision } - }); - } writeEvent("workbench.heartbeat", { type: "heartbeat", - workspaceId: workspace?.workspaceId ?? requestedWorkspaceId ?? null, - workspaceRevision: revision || latestWorkspaceRevision, + sessionId: requestedSessionId ?? null, traceId: activeTraceId ?? null, observedAt: new Date().toISOString() }); @@ -194,7 +163,7 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option } function realtimeEventCreatedAt(payload) { - const candidates = [payload?.event?.createdAt, payload?.snapshot?.lastEvent?.createdAt, payload?.turn?.updatedAt, payload?.workspace?.updatedAt]; + const candidates = [payload?.event?.createdAt, payload?.snapshot?.lastEvent?.createdAt, payload?.turn?.updatedAt]; for (const value of candidates) { const text = textValue(value); if (text) return text; @@ -228,7 +197,7 @@ async function authenticateWorkbenchRead(request, response, options) { } async function handleWorkbenchSessionList(response, url, options, actor) { - const projectId = textValue(url.searchParams.get("projectId")) || null; + if (url.searchParams.has("projectId") || url.searchParams.has("workspaceId")) return sendJson(response, 400, workbenchError("workbench_authority_removed", "Workbench session list is keyed by sessionId only.")); const limit = boundedLimit(url.searchParams.get("limit")); const includeSessionId = safeSessionId(url.searchParams.get("includeSessionId")); const store = options.accessController?.store; @@ -239,7 +208,6 @@ async function handleWorkbenchSessionList(response, url, options, actor) { limit, includeArchived: false }; - if (projectId) listInput.projectId = projectId; let sessions = await store?.listAgentSessionsForUser?.(listInput) ?? []; if (includeSessionId && !sessions.some((session) => session.id === includeSessionId)) { const included = await visibleSessionById(store, includeSessionId, actor); @@ -250,9 +218,7 @@ async function handleWorkbenchSessionList(response, url, options, actor) { ok: true, status: "succeeded", contractVersion: "workbench-sessions-v1", - projectId, sessions: summaries, - conversations: summaries.map(sessionConversationSeed), count: summaries.length, nextCursor: null, valuesRedacted: true, @@ -286,7 +252,6 @@ async function handleWorkbenchMessagePage(response, url, options, actor, session status: "succeeded", contractVersion: "workbench-message-page-v1", sessionId: session.id, - conversationId: safeConversationId(session.conversationId) ?? null, messages: page, count: page.length, total: messages.length, @@ -371,9 +336,7 @@ function sessionSummary(session, options) { const status = normalizeStatus(snapshot.sessionStatus ?? session.status ?? trace?.status); return { sessionId: session.id, - conversationId: safeConversationId(session.conversationId) ?? null, threadId: safeOpaqueId(session.threadId) ?? (textValue(session.threadId) || null), - projectId: session.projectId ?? null, agentId: session.agentId ?? "hwlab-code-agent", status, running: RUNNING_STATUSES.has(status), @@ -410,20 +373,6 @@ function sessionDetail(session, options) { }; } -function sessionConversationSeed(summary) { - return { - conversationId: summary.conversationId, - sessionId: summary.sessionId, - threadId: summary.threadId, - status: summary.status, - lastTraceId: summary.lastTraceId, - messageCount: summary.messageCount, - firstUserMessagePreview: summary.firstUserMessagePreview, - updatedAt: summary.updatedAt, - valuesRedacted: true - }; -} - function sessionMessages(session) { const snapshot = objectValue(session.session); const raw = Array.isArray(snapshot.messages) ? snapshot.messages : Array.isArray(snapshot.chatMessages) ? snapshot.chatMessages : []; @@ -449,7 +398,6 @@ function messageFact(message, index, session, snapshot) { messageId, role, sessionId: session.id, - conversationId: safeConversationId(session.conversationId) ?? null, traceId, turnId: safeTurnId(message?.turnId) || traceId, status: normalizeStatus(message?.status ?? (isAssistantLikeRole(role) ? session.status : "completed")), @@ -488,7 +436,6 @@ function turnSnapshot({ turnId, traceId, status, result, session, trace }) { running: RUNNING_STATUSES.has(status), terminal: TERMINAL_STATUSES.has(status) && !RUNNING_STATUSES.has(status), sessionId: safeSessionId(result?.sessionId ?? result?.session?.sessionId ?? session?.id) ?? null, - conversationId: safeConversationId(result?.conversationId ?? session?.conversationId) ?? null, threadId: safeOpaqueId(result?.threadId ?? result?.session?.threadId ?? session?.threadId) ?? (textValue(session?.threadId) || null), userMessageId: userMessage?.messageId ?? null, assistantMessageId: assistantMessage?.messageId ?? null, @@ -501,10 +448,7 @@ function turnSnapshot({ turnId, traceId, status, result, session, trace }) { }, urls: { self: `/v1/workbench/turns/${encodeURIComponent(turnId)}`, - traceEvents: `/v1/workbench/traces/${encodeURIComponent(traceId)}/events`, - compatResult: `/v1/agent/chat/result/${encodeURIComponent(traceId)}`, - compatTurn: `/v1/agent/turns/${encodeURIComponent(traceId)}`, - compatTrace: `/v1/agent/traces/${encodeURIComponent(traceId)}` + traceEvents: `/v1/workbench/traces/${encodeURIComponent(traceId)}/events` } }; } @@ -601,30 +545,6 @@ function durableTraceStatus(events) { return "running"; } -async function visibleWorkbenchWorkspace(options, actor, input = {}) { - const store = options.accessController?.store; - if (!store) return null; - const workspaceId = safeWorkspaceId(input.workspaceId); - if (workspaceId) { - return await store.getWorkspaceForUser?.({ workspaceId, ownerUserId: actor.id, actorRole: actor.role }) ?? null; - } - const projectId = textValue(input.projectId); - if (!projectId) return null; - return await store.getOrCreateDefaultWorkspace?.({ - ownerUserId: actor.id, - projectId, - now: new Date().toISOString() - }) ?? null; -} - -async function publicWorkbenchWorkspace(options, workspace, actor) { - if (!workspace) return null; - if (typeof options.accessController?.publicWorkbenchWorkspace === "function") { - return await options.accessController.publicWorkbenchWorkspace(workspace, actor); - } - return workspace; -} - async function writeTraceRealtimeSnapshot({ writeEvent, options, actor, traceId, reason }) { const context = await visibleTraceContext(options, actor, traceId); if (!context.visible) { @@ -794,11 +714,6 @@ function safePartId(value) { return /^prt_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null; } -function safeWorkspaceId(value) { - const text = textValue(value); - return /^wsp_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null; -} - function hash(value) { return createHash("sha256").update(String(value)).digest("hex"); } diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 5c0e8b67..ee34ceb7 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -487,11 +487,6 @@ async function handleRestAdapter(request, response, url, options) { return; } - if (url.pathname === "/v1/agent/conversations" && request.method === "GET") { - await options.accessController.listAgentConversations(request, response, url); - return; - } - if (url.pathname === "/v1/workbench/events") { await handleWorkbenchRealtimeHttp(request, response, url, options); return; @@ -502,48 +497,6 @@ async function handleRestAdapter(request, response, url, options) { return; } - const agentConversationMatch = url.pathname.match(/^\/v1\/agent\/conversations\/([^/]+)$/u); - if (agentConversationMatch && request.method === "GET") { - await options.accessController.getAgentConversation(request, response, decodeURIComponent(agentConversationMatch[1])); - return; - } - - if (agentConversationMatch && (request.method === "PUT" || request.method === "PATCH")) { - await options.accessController.updateAgentConversation(request, response, decodeURIComponent(agentConversationMatch[1])); - return; - } - - if (agentConversationMatch && request.method === "DELETE") { - await options.accessController.deleteAgentConversation(request, response, decodeURIComponent(agentConversationMatch[1])); - return; - } - - if (url.pathname === "/v1/workbench/workspace" && request.method === "GET") { - await options.accessController.getWorkbenchWorkspace(request, response, url); - return; - } - - const workbenchWorkspaceMatch = url.pathname.match(/^\/v1\/workbench\/workspace\/([^/]+)(?:\/(events|reset|select-conversation))?$/u); - if (workbenchWorkspaceMatch && !workbenchWorkspaceMatch[2] && (request.method === "PATCH" || request.method === "PUT")) { - await options.accessController.updateWorkbenchWorkspace(request, response, decodeURIComponent(workbenchWorkspaceMatch[1])); - return; - } - - if (workbenchWorkspaceMatch && workbenchWorkspaceMatch[2] === "select-conversation" && request.method === "POST") { - await options.accessController.selectWorkbenchConversation(request, response, decodeURIComponent(workbenchWorkspaceMatch[1])); - return; - } - - if (workbenchWorkspaceMatch && workbenchWorkspaceMatch[2] === "reset" && request.method === "POST") { - await options.accessController.resetWorkbenchWorkspace(request, response, decodeURIComponent(workbenchWorkspaceMatch[1])); - return; - } - - if (workbenchWorkspaceMatch && workbenchWorkspaceMatch[2] === "events" && request.method === "GET") { - await options.accessController.workbenchWorkspaceEvents(request, response, url, decodeURIComponent(workbenchWorkspaceMatch[1])); - return; - } - if (url.pathname === "/v1/access/status" || url.pathname === "/v1/setup/status") { await options.accessController.handleAccessStatusRoute(request, response, url); return; @@ -1889,11 +1842,6 @@ function safeSessionId(value) { return /^ses_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null; } -function safeConversationId(value) { - const text = String(value ?? "").trim(); - return /^cnv_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null; -} - function safeOpaqueId(value) { const text = String(value ?? "").trim(); return /^[A-Za-z0-9_.:-]{3,180}$/u.test(text) ? text : null; diff --git a/internal/cloud/web-performance.test.ts b/internal/cloud/web-performance.test.ts index b7c90b9d..2f38dc6a 100644 --- a/internal/cloud/web-performance.test.ts +++ b/internal/cloud/web-performance.test.ts @@ -182,7 +182,7 @@ test("web performance store filters observability self-noise", () => { }); test("web performance route templates keep metrics low-cardinality", () => { - assert.equal(webPerformanceRouteTemplate("/v1/workbench/workspace/cnv_abcdef/select-conversation?traceId=trc_secret"), "/v1/workbench/workspace/:id/select-conversation"); + assert.equal(webPerformanceRouteTemplate("/v1/workbench/sessions/ses_abcdef/messages?traceId=trc_secret"), "/v1/workbench/sessions/:id/messages"); assert.equal(webPerformanceRouteTemplate("#/skills"), "/skills"); }); diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index e73aff3b..16a231a8 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -56,22 +56,18 @@ export function isCloudApiProxyRoute(method, pathname) { isApiKeyWriteProxyRoute(normalizedMethod, normalizedPath) || isAdminBillingWriteProxyRoute(normalizedMethod, normalizedPath) || normalizedPath.startsWith("/v1/rpc/") || - normalizedPath.startsWith("/v1/agent/sessions/") || - isWorkbenchWorkspaceWriteProxyRoute(normalizedMethod, normalizedPath); + normalizedPath.startsWith("/v1/agent/sessions/"); } if (normalizedMethod === "PATCH" || normalizedMethod === "PUT") { return isAdminAccessWriteProxyRoute(normalizedMethod, normalizedPath) || isAdminBillingWriteProxyRoute(normalizedMethod, normalizedPath) || isProviderProfileManagementProxyRoute(normalizedMethod, normalizedPath) || - isApiKeyWriteProxyRoute(normalizedMethod, normalizedPath) || - isWorkbenchWorkspaceWriteProxyRoute(normalizedMethod, normalizedPath) || - isAgentConversationWriteProxyRoute(normalizedMethod, normalizedPath); + isApiKeyWriteProxyRoute(normalizedMethod, normalizedPath); } if (normalizedMethod === "DELETE") { return isAdminAccessWriteProxyRoute(normalizedMethod, normalizedPath) || isProviderProfileManagementProxyRoute(normalizedMethod, normalizedPath) || - isApiKeyWriteProxyRoute(normalizedMethod, normalizedPath) || - isAgentConversationWriteProxyRoute(normalizedMethod, normalizedPath); + isApiKeyWriteProxyRoute(normalizedMethod, normalizedPath); } return false; } @@ -116,20 +112,6 @@ function isAdminBillingWriteProxyRoute(method, pathname) { return false; } -function isWorkbenchWorkspaceWriteProxyRoute(method, pathname) { - const parts = pathname.split("/").filter(Boolean); - if (parts.length < 4 || parts[0] !== "v1" || parts[1] !== "workbench" || parts[2] !== "workspace") return false; - if ((method === "PATCH" || method === "PUT") && parts.length === 4) return true; - if (method === "POST" && parts.length === 5) return parts[4] === "select-conversation" || parts[4] === "reset"; - return false; -} - -function isAgentConversationWriteProxyRoute(method, pathname) { - const parts = pathname.split("/").filter(Boolean); - if (parts.length !== 4 || parts[0] !== "v1" || parts[1] !== "agent" || parts[2] !== "conversations") return false; - return ["PUT", "PATCH", "DELETE"].includes(method); -} - function isPublicCodeAgentPollRoute(method, pathname) { return method === "GET" && ( pathname.startsWith("/v1/agent/turns/") || diff --git a/internal/dev-entrypoint/cloud-web-routes.test.mjs b/internal/dev-entrypoint/cloud-web-routes.test.mjs index f8163481..6815fb48 100644 --- a/internal/dev-entrypoint/cloud-web-routes.test.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.test.mjs @@ -192,39 +192,6 @@ test("cloud web proxies authenticated hwpod node-ops route", () => { }); }); -test("cloud web proxies authenticated account workbench write routes", () => { - assert.deepEqual(cloudWebProxyRoutePolicy("PATCH", "/v1/workbench/workspace/ws_default"), { - proxy: true, - authRequired: true, - publicRoute: false, - routeKey: "PATCH /v1/workbench/workspace/ws_default" - }); - assert.deepEqual(cloudWebProxyRoutePolicy("PUT", "/v1/workbench/workspace/ws_default"), { - proxy: true, - authRequired: true, - publicRoute: false, - routeKey: "PUT /v1/workbench/workspace/ws_default" - }); - assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/workbench/workspace/ws_default/select-conversation"), { - proxy: true, - authRequired: true, - publicRoute: false, - routeKey: "POST /v1/workbench/workspace/ws_default/select-conversation" - }); - assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/workbench/workspace/ws_default/reset"), { - proxy: true, - authRequired: true, - publicRoute: false, - routeKey: "POST /v1/workbench/workspace/ws_default/reset" - }); - assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/workbench/workspace/ws_default/events"), { - proxy: false, - authRequired: false, - publicRoute: false, - routeKey: "POST /v1/workbench/workspace/ws_default/events" - }); -}); - test("cloud web proxies Code Agent steer through the Web-equivalent public API path", () => { assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/agent/chat/steer"), { proxy: true, @@ -234,11 +201,3 @@ test("cloud web proxies Code Agent steer through the Web-equivalent public API p }); }); -test("cloud web proxies authenticated session deletion through account conversation API", () => { - assert.deepEqual(cloudWebProxyRoutePolicy("DELETE", "/v1/agent/conversations/cnv_test"), { - proxy: true, - authRequired: true, - publicRoute: false, - routeKey: "DELETE /v1/agent/conversations/cnv_test" - }); -}); diff --git a/scripts/src/check-plan.mjs b/scripts/src/check-plan.mjs index d9061708..66042163 100644 --- a/scripts/src/check-plan.mjs +++ b/scripts/src/check-plan.mjs @@ -49,7 +49,6 @@ export const checkProfiles = Object.freeze({ { id: "check-029-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/code-agent-chat.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-030-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/m3-io-control.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-030a-cloud-api-access-control", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/access-control.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, - { id: "check-030b-cloud-api-access-control-test", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/access-control.test.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-031-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/server.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-032-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/server-test-helpers.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-033-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/code-agent-trace-store.test.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, @@ -127,7 +126,6 @@ export const checkProfiles = Object.freeze({ { id: "check-118-smoke-l2-runtime-contract-smoke", group: "smoke", command: ["node","--check","scripts/l2-runtime-contract-smoke.mjs"] }, { id: "check-121-tools-hwlab-agent-runtime-cli", group: "tools", command: ["node","--check","skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs"] }, { id: "check-122-smoke-m3-io-skill-client", group: "smoke", command: ["node","--check","skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"] }, - { id: "check-123-tools-hwlab-cli-client", group: "tools", command: ["node","scripts/run-bun.mjs","test","tools/hwlab-cli/client.test.ts"] }, { id: "check-126-cloud-web-check", group: "cloud-web", cwd: "web/hwlab-cloud-web", command: ["bun","run","check"] }, { id: "check-127-repo-run-bun", group: "repo", command: ["node","scripts/run-bun.mjs","scripts/validate-contract.mjs"] }, { id: "check-128-dev-runtime-validate-dev-gate-report", group: "dev-runtime", command: ["node","scripts/validate-dev-gate-report.mjs"] }, @@ -145,7 +143,7 @@ export const checkProfiles = Object.freeze({ { id: "check-143-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","scripts/code-agent-chat-smoke.mjs"] }, { id: "check-144-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","scripts/cloud-api-runtime-smoke.mjs"] }, { id: "check-145-repo-bootstrap-skills-sh", group: "repo", command: ["sh","-n","scripts/bootstrap-skills.sh","scripts/worker-entrypoint.sh"] }, - { id: "check-146-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","test","cmd/hwlab-cloud-api/runtime-options.test.ts","internal/cloud/access-control.test.ts","internal/cloud/code-agent-session-registry.test.ts","internal/cloud/json-rpc.test.ts","internal/cloud/m3-io-control.test.ts","internal/cloud/code-agent-trace-store.test.ts","internal/cloud/server-live-builds.test.ts","internal/cloud/server-health.test.ts","internal/cloud/server-agent-chat.test.ts","internal/cloud/server-m3-http.test.ts","internal/cloud/web-performance.test.ts","internal/db/runtime-store.test.ts","internal/db/schema.test.ts"] } + { id: "check-146-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","test","cmd/hwlab-cloud-api/runtime-options.test.ts","internal/cloud/code-agent-session-registry.test.ts","internal/cloud/json-rpc.test.ts","internal/cloud/m3-io-control.test.ts","internal/cloud/code-agent-trace-store.test.ts","internal/cloud/server-live-builds.test.ts","internal/cloud/server-health.test.ts","internal/cloud/server-agent-chat.test.ts","internal/cloud/server-m3-http.test.ts","internal/cloud/web-performance.test.ts","internal/db/runtime-store.test.ts","internal/db/schema.test.ts"] } ]) }); diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts deleted file mode 100644 index 5a306421..00000000 --- a/tools/hwlab-cli/client.test.ts +++ /dev/null @@ -1,2393 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { test } from "bun:test"; - -import { runHwlabCli } from "../src/hwlab-cli-lib.ts"; - -test("hwlab-cli client login uses Cloud Web auth and stores only cookie session", async () => { - const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-")); - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "auth", - "login", - "--base-url", - "http://web.test", - "--username", - "admin", - "--password-env", - "HWLAB_PASSWORD" - ], { - cwd, - env: { HWLAB_PASSWORD: "secret-password" }, - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) }); - return new Response(JSON.stringify({ authenticated: true, user: { id: "usr_admin", username: "admin", role: "admin" }, expiresAt: "2026-05-30T12:00:00.000Z" }), { - status: 200, - headers: { "content-type": "application/json", "set-cookie": "hwlab_session=session-a; Path=/; HttpOnly" } - }); - }, - now: () => "2026-05-30T00:00:00.000Z" - }); - - assert.equal(result.exitCode, 0); - assert.equal(result.payload.action, "client.auth.login"); - assert.equal(result.payload.cookieStored, true); - assert.equal(calls[0].url, "http://web.test/auth/login"); - assert.deepEqual(calls[0].body, { username: "admin", password: "secret-password" }); - assert.equal(JSON.stringify(result.payload).includes("secret-password"), false); - - const session = JSON.parse(await readFile(path.join(cwd, ".state/hwlab-cli/session.json"), "utf8")); - assert.equal(session.baseUrl, "http://web.test"); - assert.equal(session.cookie, "hwlab_session=session-a"); - assert.equal(JSON.stringify(session).includes("secret-password"), false); -}); - -test("hwlab-cli client access uses API key auth on Cloud Web admin access routes", async () => { - const calls: any[] = []; - const grant = await runHwlabCli(["client", "access", "tools", "grant", "usr_alice", "hwpod", "--base-url", "http://web.test"], { - env: { HWLAB_API_KEY: "hwl_live_admin_test_key" }, - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init, body: String(init?.body ?? "") }); - return new Response(JSON.stringify({ ok: true, changed: true, access: { user: { id: "usr_alice" }, tools: {}, tuples: [], openfga: { mode: "enforce", ready: true } } }), { status: 201 }); - } - }); - assert.equal(grant.exitCode, 0); - assert.equal(grant.payload.action, "client.access.tools.grant"); - assert.equal(calls[0].url, "http://web.test/v1/admin/access/users/usr_alice/tools/hwpod/can-use"); - assert.equal(calls[0].init.method, "PUT"); - assert.equal(calls[0].init.headers.authorization, "Bearer hwl_live_admin_test_key"); - assert.equal(calls[0].init.headers.cookie, undefined); - assert.equal(grant.payload.auth.authMethod, "api-key"); - assert.equal(grant.payload.route.path, "/v1/admin/access/users/usr_alice/tools/hwpod/can-use"); - assert.equal(grant.payload.body.openfga.mode, "enforce"); - - const user = await runHwlabCli(["client", "access", "users", "inspect", "usr_alice", "--base-url", "http://web.test"], { - env: { HWLAB_API_KEY: "hwl_live_admin_test_key" }, - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init }); - return new Response(JSON.stringify({ contractVersion: "admin-access-v1", user: { id: "usr_alice", username: "alice" }, tools: {}, tuples: [], openfga: { mode: "enforce", ready: true } }), { status: 200 }); - } - }); - assert.equal(user.exitCode, 0); - assert.equal(calls[1].url, "http://web.test/v1/admin/access/users/usr_alice"); - assert.equal(calls[1].init.method, "GET"); - assert.equal(calls[1].init.headers.authorization, "Bearer hwl_live_admin_test_key"); - assert.equal(user.payload.route.path, "/v1/admin/access/users/usr_alice"); - assert.equal(user.payload.body.user.username, "alice"); -}); - -test("hwlab-cli client provider-profiles uses Cloud Web admin routes", async () => { - const calls: any[] = []; - const result = await runHwlabCli(["client", "provider-profiles", "list", "--base-url", "http://web.test"], { - env: { HWLAB_API_KEY: "hwl_live_admin_test_key" }, - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init }); - return new Response(JSON.stringify({ - ok: true, - contractVersion: "provider-profile-management-v1", - delegation: { provider: "agentrun-v01", agentRunHost: "agentrun-mgr.agentrun-v01.svc.cluster.local", valuesPrinted: false }, - items: [{ profile: "deepseek", configured: true, secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-provider-deepseek", keys: ["auth.json", "config.toml"] }, credentialHashSuffix: "abc12345", bridge: { route: "HWLAB Moon Bridge -> DeepSeek official upstream", serviceUrl: "http://hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local:4000/v1", responsesPath: "/v1/responses" }, valuesPrinted: false }] - }), { status: 200 }); - } - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[0].url, "http://web.test/v1/admin/provider-profiles"); - assert.equal(calls[0].init.method, "GET"); - assert.equal(calls[0].init.headers.authorization, "Bearer hwl_live_admin_test_key"); - assert.equal(calls[0].init.headers.cookie, undefined); - assert.equal(result.payload.route.path, "/v1/admin/provider-profiles"); - assert.equal(result.payload.auth.authMethod, "api-key"); - assert.equal(result.payload.body.items[0].profile, "deepseek"); - assert.equal(result.payload.body.items[0].bridge.route, "HWLAB Moon Bridge -> DeepSeek official upstream"); - assert.equal(JSON.stringify(result.payload).includes("sk-"), false); -}); - -test("hwlab-cli client provider-profiles set-key reads stdin and never prints key", async () => { - const calls: any[] = []; - const secret = "sk-test-provider-secret-123456"; - const result = await runHwlabCli(["client", "provider-profiles", "set-key", "deepseek", "--key-stdin", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-admin"], { - stdinText: secret, - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) }); - return new Response(JSON.stringify({ ok: true, status: "updated", profile: "deepseek", data: { profile: "deepseek", configured: true, secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-provider-deepseek", keys: ["auth.json", "config.toml"] }, credentialHashSuffix: "abc12345", configHashSuffix: "def67890", requiresExternalBridgeUpdate: true, valuesPrinted: false }, valuesPrinted: false }), { status: 200 }); - } - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[0].url, "http://web.test/v1/admin/provider-profiles/deepseek/credential"); - assert.equal(calls[0].init.method, "PUT"); - assert.equal(calls[0].body.apiKey, secret); - assert.equal(result.payload.body.profileStatus.profile, "deepseek"); - assert.equal(result.payload.body.profileStatus.requiresExternalBridgeUpdate, true); - assert.equal(JSON.stringify(result.payload).includes(secret), false); -}); - -test("hwlab-cli client provider-profiles set-auth-json reads stdin and never prints auth json", async () => { - const calls: any[] = []; - const authJson = `${JSON.stringify({ OPENAI_API_KEY: "sk-test-provider-secret-123456" })}\n`; - const result = await runHwlabCli(["client", "provider-profiles", "set-auth-json", "hy", "--auth-json-stdin", "--base-url", "http://web.test"], { - stdinText: authJson, - env: { HWLAB_API_KEY: "hwl_live_admin_test_key" }, - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) }); - return new Response(JSON.stringify({ ok: true, status: "updated", profile: "hy", data: { profile: "hy", configured: true, credentialSource: "auth-json", secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-provider-hy", keys: ["auth.json", "config.toml"] }, credentialHashSuffix: "abc12345", configHashSuffix: "def67890", valuesPrinted: false }, valuesPrinted: false }), { status: 200 }); - } - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[0].url, "http://web.test/v1/admin/provider-profiles/hy/credential"); - assert.equal(calls[0].init.method, "PUT"); - assert.equal(calls[0].body.authJson, authJson); - assert.equal(calls[0].body.apiKey, undefined); - assert.equal(result.payload.action, "client.provider-profiles.set-auth-json"); - assert.equal(result.payload.body.profileStatus.profile, "hy"); - assert.equal(JSON.stringify(result.payload).includes("OPENAI_API_KEY"), false); - assert.equal(JSON.stringify(result.payload).includes("sk-test-provider-secret-123456"), false); -}); - -test("hwlab-cli client provider-profiles config reads and writes config.toml", async () => { - const calls: any[] = []; - const configToml = 'model = "deepseek-chat"\nbase_url = "http://bridge/v1"\n'; - const read = await runHwlabCli(["client", "provider-profiles", "config", "deepseek", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-admin"], { - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init }); - return new Response(JSON.stringify({ ok: true, status: "ok", profile: "deepseek", data: { profile: "deepseek", configured: true, configToml, configHashSuffix: "def67890", configTomlPrinted: true, credentialValuesPrinted: false, valuesPrinted: false } }), { status: 200 }); - } - }); - assert.equal(read.exitCode, 0); - assert.equal(calls[0].url, "http://web.test/v1/admin/provider-profiles/deepseek/config"); - assert.equal(calls[0].init.method, "GET"); - assert.equal(read.payload.body.configToml, configToml); - assert.equal(read.payload.body.configTomlPrinted, true); - - const write = await runHwlabCli(["client", "provider-profiles", "set-config", "deepseek", "--config-stdin", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-admin"], { - stdinText: `${configToml}# edited\n`, - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) }); - return new Response(JSON.stringify({ ok: true, status: "updated", profile: "deepseek", data: { profile: "deepseek", configured: true, configHashSuffix: "fedcba98", valuesPrinted: false } }), { status: 200 }); - } - }); - assert.equal(write.exitCode, 0); - assert.equal(calls[1].url, "http://web.test/v1/admin/provider-profiles/deepseek/config"); - assert.equal(calls[1].init.method, "PUT"); - assert.equal(calls[1].body.configToml, `${configToml}# edited\n`); - assert.equal(write.payload.body.profileStatus.profile, "deepseek"); - assert.equal(JSON.stringify(write.payload).includes("# edited"), false); -}); - -test("hwlab-cli client provider-profiles remove delegates to Cloud Web admin route", async () => { - const calls: any[] = []; - const result = await runHwlabCli(["client", "provider-profiles", "remove", "deepseek", "--base-url", "http://web.test"], { - env: { HWLAB_API_KEY: "hwl_live_admin_test_key" }, - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init }); - return new Response(JSON.stringify({ ok: true, status: "removed", profile: "deepseek", data: { profile: "deepseek", configured: false, removed: true, builtinCapabilityRetained: true, deletedResourceVersion: "rv-removed", secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-provider-deepseek", keys: ["auth.json", "config.toml"] }, credentialHashSuffix: "abc12345", configHashSuffix: "def67890", valuesPrinted: false }, valuesPrinted: false }), { status: 200 }); - } - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[0].url, "http://web.test/v1/admin/provider-profiles/deepseek"); - assert.equal(calls[0].init.method, "DELETE"); - assert.equal(calls[0].init.headers.authorization, "Bearer hwl_live_admin_test_key"); - assert.equal(result.payload.route.path, "/v1/admin/provider-profiles/deepseek"); - assert.equal(result.payload.body.profileStatus.profile, "deepseek"); - assert.equal(result.payload.body.profileStatus.removed, true); - assert.equal(result.payload.body.profileStatus.builtinCapabilityRetained, true); -}); - -test("hwlab-cli client provider-profiles validate can wait by polling validation result", async () => { - const calls: any[] = []; - const result = await runHwlabCli(["client", "provider-profiles", "validate", "deepseek", "--wait", "--timeout-ms", "5000", "--poll-interval-ms", "10", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-admin"], { - sleep: async () => undefined, - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init }); - if (String(url).endsWith("/v1/admin/provider-profiles/deepseek/validate")) { - return new Response(JSON.stringify({ ok: true, data: { validationId: "val_0123456789abcdef0123456789abcdef", profile: "deepseek", runId: "run_1", commandId: "cmd_1", status: "running", valuesPrinted: false } }), { status: 200 }); - } - return new Response(JSON.stringify({ ok: true, data: { validationId: "val_0123456789abcdef0123456789abcdef", profile: "deepseek", runId: "run_1", commandId: "cmd_1", status: "completed", result: { terminalStatus: "completed", agentRun: { jobName: "agentrun-v01-runner-test" } }, valuesPrinted: false } }), { status: 200 }); - } - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[0].url, "http://web.test/v1/admin/provider-profiles/deepseek/validate"); - assert.equal(calls[1].url, "http://web.test/v1/admin/provider-profiles/deepseek/validations/val_0123456789abcdef0123456789abcdef"); - assert.equal(result.payload.waited, true); - assert.equal(result.payload.timedOut, false); - assert.equal(result.payload.body.validation.terminalStatus, "completed"); - assert.equal(result.payload.body.validation.jobName, "agentrun-v01-runner-test"); -}); - -test("hwlab-cli client request defaults to API key instead of selected Web session", async () => { - const calls: any[] = []; - const result = await runHwlabCli(["client", "request", "GET", "/v1/access/status", "--base-url", "http://web.test", "--profile", "alice"], { - env: { HWLAB_API_KEY: "hwl_live_alice_test_key" }, - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init }); - return new Response(JSON.stringify({ ok: true, authenticated: true, actor: { username: "alice" }, authMethod: "api-key" }), { status: 200 }); - } - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[0].init.headers.authorization, "Bearer hwl_live_alice_test_key"); - assert.equal(calls[0].init.headers.cookie, undefined); - assert.equal(result.payload.auth.authMethod, "api-key"); -}); - -test("hwlab-cli rejects API key aliases and requires HWLAB_API_KEY", async () => { - const aliasFromEnv = await runHwlabCli(["client", "auth", "whoami", "--base-url", "http://web.test"], { - env: { HWLAB_BEARER_TOKEN: "hwl_live_legacy_alias" }, - fetchImpl: async () => { - throw new Error("request should not be sent when an API key alias is present"); - } - }); - assert.equal(aliasFromEnv.exitCode, 1); - assert.equal(aliasFromEnv.payload.error.code, "unsupported_api_key_source"); - assert.deepEqual(aliasFromEnv.payload.error.details.unsupported, ["HWLAB_BEARER_TOKEN"]); - assert.equal(JSON.stringify(aliasFromEnv.payload).includes("hwl_live_legacy_alias"), false); - - const aliasFromFlag = await runHwlabCli(["client", "auth", "whoami", "--base-url", "http://web.test", "--api-key", "hwl_live_flag_alias"], { - fetchImpl: async () => { - throw new Error("request should not be sent when an API key flag alias is present"); - } - }); - assert.equal(aliasFromFlag.exitCode, 1); - assert.equal(aliasFromFlag.payload.error.code, "unsupported_api_key_source"); - assert.deepEqual(aliasFromFlag.payload.error.details.unsupported, ["--api-key"]); - assert.equal(JSON.stringify(aliasFromFlag.payload).includes("hwl_live_flag_alias"), false); -}); - -test("hwlab-cli client auth status exposes local session state and next commands", async () => { - const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-auth-status-")); - const missing = await runHwlabCli(["client", "auth", "status", "--base-url", "http://web.test"], { cwd }); - assert.equal(missing.exitCode, 0); - assert.equal(missing.payload.action, "client.auth.status"); - assert.equal(missing.payload.localSession.exists, false); - assert.equal(missing.payload.localSession.usable, false); - assert.equal(missing.payload.localSession.ignoredReason, "state_file_missing"); - assert.equal(missing.payload.authBoundary.defaultAuthMethod, "api-key"); - assert.equal(missing.payload.authBoundary.localSessionDefault, false); - assert.match(missing.payload.nextCommands[1], /client auth whoami/u); - - await runHwlabCli(["client", "auth", "login", "--base-url", "http://old-web.test", "--username", "admin", "--password", "pw"], { - cwd, - fetchImpl: async () => new Response(JSON.stringify({ authenticated: true, user: { username: "admin" } }), { status: 200, headers: { "set-cookie": "hwlab_session=session-old; Path=/" } }) - }); - const mismatch = await runHwlabCli(["client", "auth", "status", "--base-url", "http://web.test"], { cwd }); - assert.equal(mismatch.exitCode, 0); - assert.equal(mismatch.payload.localSession.exists, true); - assert.equal(mismatch.payload.localSession.usable, false); - assert.equal(mismatch.payload.localSession.ignoredReason, "base_url_mismatch"); - assert.equal(JSON.stringify(mismatch.payload).includes("session-old"), false); - assert.match(mismatch.payload.nextCommands[1], /^bun tools\/hwlab-cli\/bin\/hwlab-cli\.ts client auth whoami/u); - assert.equal(JSON.stringify(mismatch.payload.nextCommands).includes("run-bun.mjs"), false); -}); - -test("hwlab-cli auth status reports public v02 session but keeps it out of default CLI auth", async () => { - const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-runtime-scope-")); - await runHwlabCli(["client", "auth", "login", "--base-url", "http://74.48.78.17:19666", "--username", "admin", "--password", "pw"], { - cwd, - fetchImpl: async () => new Response(JSON.stringify({ authenticated: true, user: { username: "admin" } }), { status: 200, headers: { "set-cookie": "hwlab_session=session-v02; Path=/" } }) - }); - - const status = await runHwlabCli(["client", "auth", "status"], { - cwd, - env: { - HWLAB_RUNTIME_NAMESPACE: "hwlab-v02", - HWLAB_RUNTIME_LANE: "v02", - HWLAB_RUNTIME_ENDPOINT_LOCKED: "1", - HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: "1" - } - }); - - assert.equal(status.exitCode, 0); - assert.equal(status.payload.baseUrl, "http://74.48.78.17:19666"); - assert.equal(status.payload.localSession.usable, true); - assert.equal(status.payload.localSession.ignoredReason, null); - assert.equal(status.payload.localSession.baseUrlMatch, "exact"); - assert.equal(status.payload.localSession.baseUrl, "http://74.48.78.17:19666"); - assert.equal(status.payload.authBoundary.defaultAuthMethod, "api-key"); - assert.equal(status.payload.authBoundary.localSessionDefault, false); -}); - -test("hwlab-cli resolves runtime namespace to service DNS inside kubernetes", async () => { - const result = await runHwlabCli(["client", "auth", "status"], { - env: { - HWLAB_RUNTIME_NAMESPACE: "hwlab-v02", - HWLAB_RUNTIME_LANE: "v02", - KUBERNETES_SERVICE_HOST: "10.43.0.1", - POD_NAMESPACE: "hwlab-v02" - } - }); - - assert.equal(result.exitCode, 0); - assert.equal(result.payload.baseUrl, "http://hwlab-cloud-web.hwlab-v02.svc.cluster.local:8080"); - assert.equal(result.payload.runtimeEndpoint.source, "runtime-namespace"); -}); - -test("hwlab-cli client group help is visible and does not issue HTTP requests", async () => { - let fetchCount = 0; - const fetchImpl = async () => { - fetchCount += 1; - return new Response(JSON.stringify({ ok: false }), { status: 500 }); - }; - - const auth = await runHwlabCli(["client", "auth", "--help", "--base-url", "http://web.test"], { fetchImpl }); - assert.equal(auth.exitCode, 0); - assert.equal(auth.payload.action, "client.auth.help"); - assert.equal(auth.payload.serviceRuntime, false); - assert.ok(auth.payload.commands.some((command: string) => command.startsWith("login "))); - - const agent = await runHwlabCli(["client", "agent", "--help", "--base-url", "http://web.test"], { fetchImpl }); - assert.equal(agent.exitCode, 0); - assert.equal(agent.payload.action, "client.agent.help"); - assert.equal(agent.payload.serviceRuntime, false); - assert.ok(agent.payload.commands.some((command: string) => command.startsWith("send "))); - assert.ok(agent.payload.commands.some((command: string) => command.startsWith("send ") && command.includes("--provider-profile"))); - - const session = await runHwlabCli(["client", "session", "--help", "--base-url", "http://web.test"], { fetchImpl }); - assert.equal(session.exitCode, 0); - assert.equal(session.payload.action, "client.session.help"); - assert.equal(session.payload.webEquivalent, true); - assert.ok(session.payload.commands.some((command: string) => command.startsWith("create "))); - assert.equal(fetchCount, 0); -}); - -test("hwlab-cli client session management uses Cloud Web workspace and conversation paths", async () => { - const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-session-management-")); - const calls: any[] = []; - const fetchImpl = async (url: string | URL, init: any = {}) => { - const body = init?.body ? JSON.parse(String(init.body)) : null; - calls.push({ url: String(url), method: init?.method ?? "GET", body }); - if (String(url).endsWith("/v1/workbench/workspace?projectId=prj_hwpod_workbench")) { - return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_cli_session", revision: 4, selectedConversationId: "cnv_existing", selectedAgentSessionId: "ses_existing", workspace: {} } }), { status: 200 }); - } - if (String(url).endsWith("/v1/workbench/workspace/wsp_cli_session/select-conversation")) { - const conversationId = body.conversationId || "cnv_created"; - const sessionId = body.sessionId || "ses_created"; - return new Response(JSON.stringify({ - ok: true, - workspace: { - workspaceId: "wsp_cli_session", - revision: 5, - selectedConversationId: conversationId, - selectedAgentSessionId: sessionId, - selectedConversation: { conversationId, sessionId, threadId: body.threadId || "thread-cli", status: "active", messages: [] }, - workspace: {} - } - }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/conversations/cnv_existing")) { - return new Response(JSON.stringify({ ok: true, status: "deleted", archivedCount: 1, conversationId: "cnv_existing", workspace: { workspaceId: "wsp_cli_session", revision: 6, selectedConversationId: null, selectedAgentSessionId: null, workspace: {} } }), { status: 200 }); - } - return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_test_route" } }), { status: 404 }); - }; - - const created = await runHwlabCli(["client", "session", "create", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a", "--conversation-id", "cnv_created", "--session-id", "ses_created", "--thread-id", "thread-created"], { cwd, fetchImpl }); - const switched = await runHwlabCli(["client", "session", "switch", "cnv_existing", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a", "--session-id", "ses_existing", "--thread-id", "thread-existing"], { cwd, fetchImpl }); - const deleted = await runHwlabCli(["client", "session", "delete", "cnv_existing", "--confirm", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], { cwd, fetchImpl }); - - assert.equal(created.exitCode, 0); - assert.equal(switched.exitCode, 0); - assert.equal(deleted.exitCode, 0); - assert.equal(calls[1].url, "http://web.test/v1/workbench/workspace/wsp_cli_session/select-conversation"); - assert.equal(calls[1].method, "POST"); - assert.equal(calls[1].body.create, true); - assert.equal(calls[1].body.updatedByClient, "hwlab-cli"); - assert.equal(calls[3].url, "http://web.test/v1/workbench/workspace/wsp_cli_session/select-conversation"); - assert.equal(calls[3].body.conversationId, "cnv_existing"); - assert.equal(calls[5].url, "http://web.test/v1/agent/conversations/cnv_existing"); - assert.equal(calls[5].method, "DELETE"); - assert.equal(calls[5].body.workspaceId, "wsp_cli_session"); - assert.equal(created.payload.route.path, "/v1/workbench/workspace/wsp_cli_session/select-conversation"); - assert.equal(switched.payload.current.conversationId, "cnv_existing"); - assert.equal(deleted.payload.body.status, "deleted"); -}); - -test("hwlab-cli client session final-response verifies original conversation across project list and result", async () => { - const calls: any[] = []; - const finalText = "**直接答:能,但只到一半。**\n\n真实 final response"; - const conversation = { - conversationId: "cnv_issue834", - projectId: "prj_v02_code_agent", - sessionId: "ses_issue834", - threadId: "thread_issue834", - status: "idle", - lastTraceId: "trc_issue834_final", - snapshot: { source: "code-agent-result" }, - messages: [{ role: "agent", traceId: "trc_issue834_final", status: "idle", text: finalText }] - }; - const fetchImpl = async (url: string | URL) => { - calls.push(String(url)); - if (String(url).endsWith("/v1/agent/conversations/cnv_issue834")) { - return new Response(JSON.stringify({ ok: true, conversation }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/conversations?projectId=prj_v02_code_agent&limit=100")) { - return new Response(JSON.stringify({ ok: true, conversations: [conversation] }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/turns/trc_issue834_final")) { - return new Response(JSON.stringify({ ok: true, status: "completed", terminal: true, traceId: "trc_issue834_final", reply: { content: finalText } }), { status: 200 }); - } - return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_route" } }), { status: 404 }); - }; - - const result = await runHwlabCli(["client", "session", "final-response", "cnv_issue834", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], { fetchImpl }); - - assert.equal(result.exitCode, 0); - assert.equal(result.payload.action, "client.session.final-response"); - assert.equal(result.payload.validation.state, "passed"); - assert.equal(result.payload.projectId, "prj_v02_code_agent"); - assert.equal(result.payload.validation.checks.listFound, true); - assert.equal(result.payload.validation.checks.inspectTextMatchesResult, true); - assert.equal(result.payload.validation.checks.listTextMatchesResult, true); - assert.equal(calls[1], "http://web.test/v1/agent/conversations?projectId=prj_v02_code_agent&limit=100"); - assert.deepEqual(result.payload.validation.failures, []); -}); - -test("hwlab-cli client session final-response preserves inspect auth failure visibility", async () => { - const fetchImpl = async (url: string | URL) => { - if (String(url).endsWith("/v1/agent/conversations/cnv_issue834")) { - return new Response(JSON.stringify({ ok: false, status: 401, error: { code: "auth_required", message: "Authentication is required" } }), { status: 401 }); - } - return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_route" } }), { status: 404 }); - }; - - const result = await runHwlabCli(["client", "session", "final-response", "cnv_issue834", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], { fetchImpl }); - - assert.equal(result.exitCode, 1); - assert.equal(result.payload.action, "client.session.final-response"); - assert.equal(result.payload.httpStatus, 401); - assert.equal(result.payload.validation.failures[0], "inspect-conversation-missing"); - assert.equal(result.payload.validation.error.code, "auth_required"); - assert.equal(result.payload.error?.code, undefined); -}); - -test("hwlab-cli rejects manual endpoint override in locked assembled runtime", async () => { - const result = await runHwlabCli(["client", "auth", "status", "--base-url", "http://74.48.78.17:17666"], { - env: { - HWLAB_RUNTIME_WEB_URL: "http://cloud-v02.test", - HWLAB_RUNTIME_NAMESPACE: "hwlab-v02", - HWLAB_RUNTIME_LANE: "v02", - HWLAB_RUNTIME_ENDPOINT_LOCKED: "1", - HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: "1" - } - }); - - assert.equal(result.exitCode, 1); - assert.equal(result.payload.error.code, "runtime_endpoint_manual_url_forbidden"); - assert.equal(result.payload.error.details.kind, "web"); -}); - -test("hwlab-cli client runtime routes reports UniDesk pod passthrough route", async () => { - const result = await runHwlabCli(["client", "runtime", "routes", "--base-url", "http://web.test", "--service-id", "hwlab-cloud-api"], { - fetchImpl: async () => new Response(JSON.stringify({ - ok: true, - status: "ok", - services: [{ - serviceId: "hwlab-cloud-api", - runtime: { pod: { name: "hwlab-cloud-api-abc", namespace: "hwlab-v02", container: "hwlab-cloud-api" } } - }] - }), { status: 200 }) - }); - - assert.equal(result.exitCode, 0); - assert.equal(result.payload.items[0].unideskRoute, "G14:k3s:hwlab-v02:pod:hwlab-cloud-api-abc:hwlab-cloud-api"); - assert.match(result.payload.items[0].useWith, /bun scripts\/cli\.ts ssh/u); - assert.equal(result.payload.discovery.ok, true); - assert.equal(result.payload.discovery.source, "cloud-web:/v1/live-builds"); - assert.equal(result.payload.discovery.readyRouteCount, 1); -}); - -test("hwlab-cli client gateway sessions uses direct Cloud API short connection", async () => { - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "gateway", - "sessions", - "--api-base-url", - "http://api.test", - "--no-auth", - "--full" - ], { - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init }); - return new Response(JSON.stringify({ ok: true, sessions: [{ gatewaySessionId: "gws_D601_F103", inflightCount: 0 }] }), { status: 200 }); - } - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[0].url, "http://api.test/v1/gateway/sessions"); - assert.equal(calls[0].init.headers.cookie, undefined); - assert.equal(result.payload.action, "client.gateway.sessions"); - assert.equal(result.payload.baseUrl, "http://api.test"); - assert.equal(result.payload.body.sessions[0].gatewaySessionId, "gws_D601_F103"); -}); - -test("hwlab-cli client gateway pressure reports byte/truncation visibility", async () => { - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "gateway", - "pressure", - "--api-base-url", - "http://api.test", - "--gateway-session-id", - "gws_test", - "--large-bytes", - "8192", - "--parallel", - "2", - "--request-timeout-ms", - "10000", - "--timeout-scenario-ms", - "500" - ], { - fetchImpl: async (url, init) => { - const callIndex = calls.length; - const body = JSON.parse(String(init?.body ?? "{}")); - calls.push({ url: String(url), init, body }); - const dispatch: any = { dispatchStatus: "succeeded", shellExecuted: true, exitCode: 0, stdout: "", stderr: "" }; - if (callIndex === 0) dispatch.stdout = "hwlab-gateway-pressure-small-ok\r\n"; - else if (callIndex === 1 || callIndex === 2) Object.assign(dispatch, { stdout: "O".repeat(65536), stdoutTruncated: true }); - else if (callIndex === 3) Object.assign(dispatch, { stderr: "E".repeat(65536), stderrTruncated: true }); - else if (callIndex === 4) Object.assign(dispatch, { dispatchStatus: "timed_out", timedOut: true, exitCode: null }); - else dispatch.stdout = `parallel-${callIndex - 4}-ok\r\n`; - return new Response(JSON.stringify({ ok: true, result: { status: "succeeded", dispatch } }), { status: 200 }); - } - }); - - assert.equal(result.exitCode, 0); - assert.equal(result.payload.action, "client.gateway.pressure"); - assert.equal(result.payload.status, "succeeded"); - assert.equal(result.payload.scenarioCount, 7); - assert.equal(result.payload.failedCount, 0); - assert.equal(calls[0].url, "http://api.test/v1/rpc/hardware.invoke.shell"); - assert.equal(calls[0].init.headers.cookie, undefined); - assert.equal(calls[0].body.gatewaySessionId, "gws_test"); - assert.match(calls[1].body.input.command, /-EncodedCommand/u); - assert.equal(result.payload.results.find((item: any) => item.name === "large-stdout").stdoutTruncated, true); - assert.equal(result.payload.results.find((item: any) => item.name === "stderr-flood").stderrTruncated, true); - assert.equal(result.payload.results.find((item: any) => item.name === "timeout-kill").reason, "timeout_structured"); - assert.match(result.payload.results.find((item: any) => item.name === "large-stdout").stdoutSha256, /^[a-f0-9]{64}$/u); -}); - -test("hwlab-cli client agent send submits async and returns trace without waiting by default", async () => { - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "agent", - "send", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a", - "--message", - "hello", - "--trace-id", - "trc_test", - "--conversation-id", - "cnv_test" - ], { - 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_hwpod_workbench")) { - return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_test", revision: 3, selectedConversationId: "cnv_test", selectedAgentSessionId: "ses_test", selectedConversation: { conversationId: "cnv_test", sessionId: "ses_test", threadId: "thread-test" }, workspace: {} } }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/conversations/cnv_test?projectId=prj_hwpod_workbench")) { - return new Response(JSON.stringify({ ok: true, conversation: { conversationId: "cnv_test", sessionId: "ses_test", threadId: "thread-test", providerProfile: "deepseek", status: "idle" } }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/chat")) { - return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_test", resultUrl: "/v1/agent/chat/result/trc_test" }), { status: 202 }); - } - return new Response(JSON.stringify({ status: "completed", traceId: "trc_test", conversationId: "cnv_test", reply: { role: "assistant", content: "hi" } }), { status: 200 }); - }, - sleep: async () => {} - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[0].url, "http://web.test/v1/workbench/workspace?projectId=prj_hwpod_workbench"); - assert.equal(calls[1].url, "http://web.test/v1/agent/conversations/cnv_test?projectId=prj_hwpod_workbench"); - assert.equal(calls[2].url, "http://web.test/v1/agent/chat"); - assert.equal(calls[2].init.headers.prefer, "respond-async"); - assert.equal(calls[2].body.shortConnection, true); - assert.equal(calls[2].body.workspaceId, "wsp_test"); - assert.equal(calls[2].body.expectedWorkspaceRevision, 3); - assert.equal(calls[2].body.sessionId, "ses_test"); - assert.equal(calls[2].body.threadId, "thread-test"); - assert.equal(Object.hasOwn(calls[2].body, "conversationContext"), false); - assert.equal(Object.hasOwn(calls[2].body, "messages"), false); - assert.equal(calls.length, 3); - assert.equal(result.payload.traceId, "trc_test"); - assert.equal(result.payload.continuation.threadId, "thread-test"); - assert.equal(result.payload.waited, false); - assert.equal(result.payload.waitPolicy.defaultWait, false); - assert.equal(result.payload.waitPolicy.webEquivalent, true); - 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_hwpod_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_hwpod_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_hwpod_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.sessionId, "ses_manual_create"); - assert.equal(result.payload.conversationId, "cnv_manual_create"); - assert.equal(result.payload.providerProfile, "minimax-m3"); - assert.equal(result.payload.sessionStatus, "idle"); - 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 does not inherit workspace session for explicit new conversation", async () => { - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "agent", - "send", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a", - "--message", - "new conversation needs session", - "--trace-id", - "trc_new_conversation", - "--conversation-id", - "cnv_new_conversation" - ], { - 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_hwpod_workbench")) { - return new Response(JSON.stringify({ - ok: true, - workspace: { - workspaceId: "wsp_old_session", - revision: 8, - selectedConversationId: "cnv_old_conversation", - selectedAgentSessionId: "ses_old_session", - threadId: "thread-old", - workspace: { sessionStatus: "idle" } - } - }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/conversations/cnv_new_conversation?projectId=prj_hwpod_workbench")) { - return new Response(JSON.stringify({ ok: true, conversation: { conversationId: "cnv_new_conversation" } }), { status: 200 }); - } - return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_test_route" } }), { status: 404 }); - } - }); - - assert.equal(result.exitCode, 1); - assert.equal(result.payload.error.code, "session_required"); - assert.equal(result.payload.error.details.conversationId, "cnv_new_conversation"); - assert.equal(calls.length, 2); - assert.equal(calls[0].url, "http://web.test/v1/workbench/workspace?projectId=prj_hwpod_workbench"); - assert.equal(calls[1].url, "http://web.test/v1/agent/conversations/cnv_new_conversation?projectId=prj_hwpod_workbench"); -}); - -test("hwlab-cli client agent send resolves explicit session conversation before using workspace conversation", async () => { - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "agent", - "send", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a", - "--message", - "send through explicit session", - "--trace-id", - "trc_explicit_session", - "--session-id", - "ses_manual_current" - ], { - 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_hwpod_workbench")) { - return new Response(JSON.stringify({ - ok: true, - workspace: { - workspaceId: "wsp_stale_selection", - revision: 12, - selectedConversationId: "cnv_stale_workspace", - selectedAgentSessionId: "ses_stale_workspace", - selectedConversation: { - conversationId: "cnv_stale_workspace", - sessionId: "ses_stale_workspace", - threadId: "thread-stale-workspace" - }, - providerProfile: "deepseek", - workspace: { sessionStatus: "idle", threadId: "thread-stale-workspace", providerProfile: "deepseek" } - } - }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/sessions/ses_manual_current")) { - return new Response(JSON.stringify({ - ok: true, - status: "found", - session: { - sessionId: "ses_manual_current", - conversationId: "cnv_manual_current", - threadId: "thread-manual-current", - status: "idle", - usable: true, - providerProfile: "minimax-m3" - } - }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/chat")) { - return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_explicit_session", resultUrl: "/v1/agent/chat/result/trc_explicit_session" }), { status: 202 }); - } - return new Response(JSON.stringify({ status: "completed", traceId: "trc_explicit_session", conversationId: "cnv_manual_current", sessionId: "ses_manual_current", reply: { role: "assistant", content: "explicit session ok" } }), { status: 200 }); - }, - sleep: async () => {} - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[0].url, "http://web.test/v1/workbench/workspace?projectId=prj_hwpod_workbench"); - assert.equal(calls[1].url, "http://web.test/v1/agent/sessions/ses_manual_current"); - assert.equal(calls[2].url, "http://web.test/v1/agent/chat"); - assert.equal(calls[2].body.conversationId, "cnv_manual_current"); - assert.equal(calls[2].body.sessionId, "ses_manual_current"); - assert.equal(calls[2].body.threadId, "thread-manual-current"); - assert.equal(calls[2].body.providerProfile, "minimax-m3"); - assert.equal(calls[2].body.workspaceId, "wsp_stale_selection"); - assert.equal(result.payload.continuation.conversationId, "cnv_manual_current"); - assert.equal(result.payload.continuation.sessionId, "ses_manual_current"); - assert.equal(result.payload.continuation.threadId, "thread-manual-current"); -}); - -test("hwlab-cli client agent send does not use matching workspace session as implicit context", async () => { - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "agent", - "send", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a", - "--message", - "continue after failed turn", - "--trace-id", - "trc_thread_current", - "--conversation-id", - "cnv_thread_current" - ], { - 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_hwpod_workbench")) { - return new Response(JSON.stringify({ - ok: true, - workspace: { - workspaceId: "wsp_thread_current", - revision: 9, - selectedConversationId: "cnv_thread_current", - selectedAgentSessionId: "ses_thread_current", - selectedConversation: { - conversationId: "cnv_thread_current", - sessionId: "ses_thread_current", - threadId: "thread-current" - }, - workspace: { - threadId: "thread-stale" - } - } - }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/conversations/cnv_thread_current?projectId=prj_hwpod_workbench")) { - return new Response(JSON.stringify({ ok: true, conversation: { conversationId: "cnv_thread_current" } }), { status: 200 }); - } - return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_test_route" } }), { status: 404 }); - }, - sleep: async () => {} - }); - - assert.equal(result.exitCode, 1); - assert.equal(result.payload.error.code, "session_required"); - assert.equal(result.payload.error.details.conversationId, "cnv_thread_current"); - assert.equal(calls.length, 2); - assert.equal(calls[0].url, "http://web.test/v1/workbench/workspace?projectId=prj_hwpod_workbench"); - assert.equal(calls[1].url, "http://web.test/v1/agent/conversations/cnv_thread_current?projectId=prj_hwpod_workbench"); -}); - -test("hwlab-cli client agent send waits only when --wait is explicit", async () => { - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "agent", - "send", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a", - "--message", - "hello", - "--trace-id", - "trc_wait", - "--conversation-id", - "cnv_wait", - "--session-id", - "ses_wait", - "--wait", - "--poll-interval-ms", - "1", - "--timeout-ms", - "1000" - ], { - 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_hwpod_workbench")) { - return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_wait", revision: 3, selectedConversationId: "cnv_wait", selectedAgentSessionId: "ses_wait", selectedConversation: { conversationId: "cnv_wait", sessionId: "ses_wait", threadId: "thread-wait" }, workspace: {} } }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/sessions/ses_wait")) { - return new Response(JSON.stringify({ ok: true, session: { sessionId: "ses_wait", conversationId: "cnv_wait", threadId: "thread-wait", status: "idle", providerProfile: "deepseek" } }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/chat")) { - return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_wait", turnUrl: "/v1/agent/turns/trc_wait", resultUrl: "/v1/agent/chat/result/trc_wait" }), { status: 202 }); - } - if (String(url).endsWith("/v1/agent/turns/trc_wait")) { - return new Response(JSON.stringify({ ok: true, terminal: true, status: "completed", traceId: "trc_wait", conversationId: "cnv_wait", reply: { role: "assistant", content: "hi" } }), { status: 200 }); - } - if (String(url).endsWith("/v1/workbench/workspace/wsp_wait")) { - return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_wait", revision: 5, selectedConversationId: "cnv_wait", selectedAgentSessionId: "ses_wait", selectedConversation: { conversationId: "cnv_wait", sessionId: "ses_wait", threadId: "thread-wait" }, workspace: { sessionStatus: "completed" } } }), { status: 200 }); - } - return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_test_route" } }), { status: 404 }); - }, - sleep: async () => {} - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[1].url, "http://web.test/v1/agent/sessions/ses_wait"); - assert.equal(calls[2].body.threadId, "thread-wait"); - assert.equal(calls[3].url, "http://web.test/v1/agent/turns/trc_wait"); - assert.equal(calls[4].url, "http://web.test/v1/workbench/workspace/wsp_wait"); - assert.equal(Object.hasOwn(calls[4].body, "activeTraceId"), true); - assert.equal(calls[4].body.activeTraceId, null); - assert.equal(calls[4].body.threadId, "thread-wait"); - assert.equal(result.payload.traceId, "trc_wait"); - assert.equal(result.payload.continuation.threadId, "thread-wait"); - assert.equal(result.payload.result.body.status, "completed"); - assert.equal(result.payload.result.body.assistantText, "hi"); - assert.equal(result.payload.result.body.reply.content, "hi"); -}); - -test("hwlab-cli client agent send saves completed workspace response for new conversation", async () => { - const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-agent-save-workspace-")); - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "agent", - "send", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a", - "--message", - "start a fresh conversation", - "--trace-id", - "trc_new_workspace", - "--conversation-id", - "cnv_new_workspace", - "--session-id", - "ses_new_workspace", - "--wait", - "--poll-interval-ms", - "1", - "--timeout-ms", - "1000" - ], { - 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_hwpod_workbench")) { - return new Response(JSON.stringify({ - ok: true, - workspace: { - workspaceId: "wsp_new_workspace", - revision: 8, - selectedConversationId: "cnv_old_workspace", - selectedAgentSessionId: "ses_old_workspace", - selectedConversation: { - conversationId: "cnv_old_workspace", - sessionId: "ses_old_workspace", - threadId: "thread-old-workspace" - }, - workspace: { - sessionStatus: "idle", - threadId: "thread-old-workspace" - } - } - }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/sessions/ses_new_workspace")) { - return new Response(JSON.stringify({ - ok: true, - status: "found", - session: { - sessionId: "ses_new_workspace", - conversationId: "cnv_new_workspace", - threadId: "thread-new-workspace", - status: "idle", - usable: true, - providerProfile: "deepseek" - } - }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/chat")) { - return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_new_workspace", turnUrl: "/v1/agent/turns/trc_new_workspace", resultUrl: "/v1/agent/chat/result/trc_new_workspace" }), { status: 202 }); - } - if (String(url).endsWith("/v1/agent/turns/trc_new_workspace")) { - return new Response(JSON.stringify({ ok: true, terminal: true, status: "completed", traceId: "trc_new_workspace", conversationId: "cnv_new_workspace", sessionId: "ses_new_workspace", reply: { role: "assistant", content: "new workspace ready" } }), { status: 200 }); - } - if (String(url).endsWith("/v1/workbench/workspace/wsp_new_workspace")) { - return new Response(JSON.stringify({ - ok: true, - workspace: { - workspaceId: "wsp_new_workspace", - revision: 9, - selectedConversationId: "cnv_new_workspace", - selectedAgentSessionId: "ses_new_workspace", - selectedConversation: { - conversationId: "cnv_new_workspace", - sessionId: "ses_new_workspace", - threadId: "thread-new-workspace" - }, - workspace: { - sessionStatus: "idle", - threadId: "thread-new-workspace" - } - } - }), { status: 200 }); - } - return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_test_route" } }), { status: 404 }); - }, - sleep: async () => {} - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[2].url, "http://web.test/v1/agent/chat"); - assert.equal(calls[2].body.conversationId, "cnv_new_workspace"); - assert.equal(calls[2].body.sessionId, "ses_new_workspace"); - assert.equal(Object.hasOwn(calls[2].body, "threadId"), true); - assert.equal(calls[4].url, "http://web.test/v1/workbench/workspace/wsp_new_workspace"); - assert.equal(result.payload.workspace.selectedConversationId, "cnv_new_workspace"); - assert.equal(result.payload.workspace.selectedAgentSessionId, "ses_new_workspace"); - assert.equal(result.payload.workspace.threadId, "thread-new-workspace"); - assert.equal(result.payload.workspace.selectedConversation.conversationId, "cnv_new_workspace"); - - const session = JSON.parse(await readFile(path.join(cwd, ".state/hwlab-cli/session.json"), "utf8")); - assert.equal(session.workspace.selectedConversationId, "cnv_new_workspace"); - assert.equal(session.workspace.selectedAgentSessionId, "ses_new_workspace"); - assert.equal(session.workspace.threadId, "thread-new-workspace"); - assert.equal(session.workspace.selectedConversation.conversationId, "cnv_new_workspace"); -}); - -test("hwlab-cli client agent send restores workspace when completed patch omits workspace body", async () => { - const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-agent-restore-workspace-")); - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "agent", - "send", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a", - "--message", - "start a fresh conversation", - "--trace-id", - "trc_restore_workspace", - "--conversation-id", - "cnv_restore_workspace", - "--session-id", - "ses_restore_workspace", - "--wait", - "--poll-interval-ms", - "1", - "--timeout-ms", - "1000" - ], { - 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_hwpod_workbench")) { - const restored = calls.filter((call) => String(call.url).endsWith("/v1/workbench/workspace?projectId=prj_hwpod_workbench")).length > 1; - return new Response(JSON.stringify({ - ok: true, - workspace: restored - ? { - workspaceId: "wsp_restore_workspace", - revision: 12, - selectedConversationId: "cnv_restore_workspace", - selectedAgentSessionId: "ses_restore_workspace", - selectedConversation: { - conversationId: "cnv_restore_workspace", - sessionId: "ses_restore_workspace", - threadId: "thread-restore-workspace" - }, - workspace: { - sessionStatus: "idle", - threadId: "thread-restore-workspace" - } - } - : { - workspaceId: "wsp_restore_workspace", - revision: 11, - selectedConversationId: "cnv_previous_workspace", - selectedAgentSessionId: "ses_previous_workspace", - selectedConversation: { - conversationId: "cnv_previous_workspace", - sessionId: "ses_previous_workspace", - threadId: "thread-previous-workspace" - }, - workspace: { - sessionStatus: "idle", - threadId: "thread-previous-workspace" - } - } - }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/sessions/ses_restore_workspace")) { - return new Response(JSON.stringify({ - ok: true, - status: "found", - session: { - sessionId: "ses_restore_workspace", - conversationId: "cnv_restore_workspace", - threadId: "thread-restore-workspace", - status: "idle", - usable: true, - providerProfile: "deepseek" - } - }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/chat")) { - return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_restore_workspace", turnUrl: "/v1/agent/turns/trc_restore_workspace", resultUrl: "/v1/agent/chat/result/trc_restore_workspace" }), { status: 202 }); - } - if (String(url).endsWith("/v1/agent/turns/trc_restore_workspace")) { - return new Response(JSON.stringify({ ok: true, terminal: true, status: "completed", traceId: "trc_restore_workspace", conversationId: "cnv_restore_workspace", sessionId: "ses_restore_workspace", reply: { role: "assistant", content: "restore workspace ready" } }), { status: 200 }); - } - if (String(url).endsWith("/v1/workbench/workspace/wsp_restore_workspace")) { - return new Response(JSON.stringify({ - ok: true, - workspace: { - workspaceId: "wsp_restore_workspace", - revision: 12, - selectedConversationId: "cnv_restore_workspace", - selectedAgentSessionId: "ses_restore_workspace", - selectedConversation: { - conversationId: "cnv_previous_workspace", - sessionId: "ses_previous_workspace", - threadId: "thread-previous-workspace" - }, - workspace: { - sessionStatus: "completed", - threadId: "thread-restore-workspace" - } - } - }), { status: 200 }); - } - return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_test_route" } }), { status: 404 }); - }, - sleep: async () => {} - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[2].url, "http://web.test/v1/agent/chat"); - assert.equal(calls[2].body.sessionId, "ses_restore_workspace"); - assert.equal(Object.hasOwn(calls[2].body, "threadId"), true); - assert.equal(calls[4].url, "http://web.test/v1/workbench/workspace/wsp_restore_workspace"); - assert.equal(result.payload.workspace.selectedConversationId, "cnv_restore_workspace"); - assert.equal(result.payload.workspace.selectedAgentSessionId, "ses_restore_workspace"); - assert.equal(result.payload.workspace.threadId, "thread-restore-workspace"); - assert.equal(result.payload.workspace.selectedConversation.conversationId, "cnv_restore_workspace"); - assert.equal(result.payload.workspace.selectedConversation.sessionId, "ses_restore_workspace"); - - const session = JSON.parse(await readFile(path.join(cwd, ".state/hwlab-cli/session.json"), "utf8")); - assert.equal(session.workspace.selectedConversationId, "cnv_restore_workspace"); - assert.equal(session.workspace.selectedAgentSessionId, "ses_restore_workspace"); - assert.equal(session.workspace.threadId, "thread-restore-workspace"); - assert.equal(session.workspace.selectedConversation.conversationId, "cnv_restore_workspace"); - assert.equal(session.workspace.selectedConversation.sessionId, "ses_restore_workspace"); -}); - -test("hwlab-cli client agent send output uses completed result when restore is stale", async () => { - const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-agent-stale-restore-")); - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "agent", - "send", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a", - "--message", - "start a fresh conversation", - "--trace-id", - "trc_stale_restore", - "--conversation-id", - "cnv_stale_restore", - "--session-id", - "ses_stale_restore", - "--wait", - "--poll-interval-ms", - "1", - "--timeout-ms", - "1000" - ], { - 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_hwpod_workbench")) { - return new Response(JSON.stringify({ - ok: true, - workspace: { - workspaceId: "wsp_stale_restore", - revision: 21, - selectedConversationId: "cnv_previous_restore", - selectedAgentSessionId: "ses_previous_restore", - selectedConversation: { - conversationId: "cnv_previous_restore", - sessionId: "ses_previous_restore", - threadId: "thread-previous-restore" - }, - workspace: { - sessionStatus: "idle", - threadId: "thread-previous-restore" - } - } - }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/sessions/ses_stale_restore")) { - return new Response(JSON.stringify({ - ok: true, - status: "found", - session: { - sessionId: "ses_stale_restore", - conversationId: "cnv_stale_restore", - threadId: "thread-stale-restore", - status: "idle", - usable: true, - providerProfile: "deepseek" - } - }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/sessions/ses_web_continue")) { - return new Response(JSON.stringify({ - ok: true, - status: "found", - session: { - sessionId: "ses_web_continue", - conversationId: "cnv_web_continue", - threadId: "thread-web-continue", - status: "idle", - usable: true, - providerProfile: "deepseek" - } - }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/chat")) { - return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_stale_restore", turnUrl: "/v1/agent/turns/trc_stale_restore", resultUrl: "/v1/agent/chat/result/trc_stale_restore" }), { status: 202 }); - } - if (String(url).endsWith("/v1/agent/turns/trc_stale_restore")) { - return new Response(JSON.stringify({ ok: true, terminal: true, status: "completed", traceId: "trc_stale_restore", conversationId: "cnv_stale_restore", sessionId: "ses_stale_restore", reply: { role: "assistant", content: "stale restore ready" } }), { status: 200 }); - } - if (String(url).endsWith("/v1/workbench/workspace/wsp_stale_restore")) { - return new Response(JSON.stringify({ ok: true }), { status: 200 }); - } - return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_test_route" } }), { status: 404 }); - }, - sleep: async () => {} - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[5].url, "http://web.test/v1/workbench/workspace?projectId=prj_hwpod_workbench"); - assert.equal(result.payload.workspace.selectedConversationId, "cnv_stale_restore"); - assert.equal(result.payload.workspace.selectedAgentSessionId, "ses_stale_restore"); - assert.equal(Object.hasOwn(result.payload.workspace, "threadId"), true); - assert.equal(result.payload.workspace.selectedConversation.conversationId, "cnv_stale_restore"); - assert.equal(result.payload.workspace.selectedConversation.sessionId, "ses_stale_restore"); - assert.equal(Object.hasOwn(result.payload.workspace.selectedConversation, "threadId"), true); - - const session = JSON.parse(await readFile(path.join(cwd, ".state/hwlab-cli/session.json"), "utf8")); - assert.equal(session.workspace.selectedConversationId, "cnv_stale_restore"); - assert.equal(session.workspace.selectedAgentSessionId, "ses_stale_restore"); - assert.equal(Object.hasOwn(session.workspace, "threadId"), true); -}); - -test("hwlab-cli client agent send preserves Web continuation fields", async () => { - const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-agent-continuation-")); - await writeFile(path.join(cwd, "prompt.txt"), "看看有什么 HWPOD 能用?", "utf8"); - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "agent", - "send", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a", - "--message-file", - "prompt.txt", - "--trace-id", - "trc_web_continue", - "--conversation-id", - "cnv_web_continue", - "--session-id", - "ses_web_continue", - "--thread-id", - "019e8078-db67-7750-a5d9-1a99f3abd445", - "--retry-of", - "trc_previous" - ], { - cwd, - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null }); - if (String(url).includes("/v1/workbench/workspace?")) { - return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_continue", revision: 7 } }), { status: 200 }); - } - return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_web_continue", resultUrl: "/v1/agent/chat/result/trc_web_continue" }), { status: 202 }); - }, - stdinText: "fallback stdin", - sleep: async () => {} - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[0].url, "http://web.test/v1/workbench/workspace?projectId=prj_hwpod_workbench"); - assert.equal(calls[2].url, "http://web.test/v1/agent/chat"); - assert.equal(calls[2].body.message, "看看有什么 HWPOD 能用?"); - assert.equal(calls[2].body.conversationId, "cnv_web_continue"); - assert.equal(calls[2].body.sessionId, "ses_web_continue"); - assert.equal(calls[2].body.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); - assert.equal(calls[2].body.retryOf, "trc_previous"); - assert.equal(result.payload.continuation.webEquivalent, true); - assert.equal(result.payload.continuation.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); - assert.equal(result.payload.continuation.retryOf, "trc_previous"); - assert.equal(JSON.stringify(result.payload).includes("hwlab_session=session-a"), false); -}); - -test("hwlab-cli client agent send can replay continuation from an existing trace", async () => { - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "agent", - "send", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a", - "--message", - "retry HWPOD question", - "--from-trace", - "trc_source_web", - "--trace-id", - "trc_replay_web" - ], { - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null }); - if (String(url).endsWith("/v1/agent/chat/inspect?traceId=trc_source_web")) { - return new Response(JSON.stringify({ - ok: true, - status: "found", - latestTraceId: "trc_source_web", - query: { traceId: "trc_source_web" }, - conversationFacts: { conversationId: "cnv_source", sessionId: "ses_source" }, - session: { conversationId: "cnv_source", sessionId: "ses_source", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445" } - }), { status: 200 }); - } - if (String(url).endsWith("/v1/workbench/workspace?projectId=prj_hwpod_workbench")) { - return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_replay", revision: 4 } }), { status: 200 }); - } - return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_replay_web", resultUrl: "/v1/agent/chat/result/trc_replay_web" }), { status: 202 }); - }, - sleep: async () => {} - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[0].url, "http://web.test/v1/agent/chat/inspect?traceId=trc_source_web"); - assert.equal(calls[1].url, "http://web.test/v1/workbench/workspace?projectId=prj_hwpod_workbench"); - assert.equal(calls[2].url, "http://web.test/v1/agent/chat"); - assert.equal(calls[2].body.conversationId, "cnv_source"); - assert.equal(calls[2].body.sessionId, "ses_source"); - assert.equal(calls[2].body.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); - assert.equal(calls[2].body.retryOf, "trc_source_web"); - assert.equal(calls[2].body.workspaceId, "wsp_replay"); - assert.equal(calls[2].body.expectedWorkspaceRevision, 4); - assert.equal(result.payload.continuation.replayedFromTrace, "trc_source_web"); - assert.equal(result.payload.replay.source, "cloud-web:/v1/agent/chat/inspect"); - assert.equal(JSON.stringify(result.payload).includes("hwlab_session=session-a"), false); -}); - -test("hwlab-cli client agent steer posts to Web-equivalent steer route", async () => { - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "agent", - "steer", - "trc_target_steer", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a", - "--message", - "please include STEER_MARK", - "--steer-trace-id", - "trc_steer_cli", - "--conversation-id", - "cnv_steer", - "--session-id", - "ses_steer", - "--thread-id", - "thread-steer" - ], { - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null }); - return new Response(JSON.stringify({ - ok: true, - accepted: true, - status: "running", - route: "/v1/agent/chat/steer", - traceId: "trc_target_steer", - steerTraceId: "trc_steer_cli", - conversationId: "cnv_steer", - sessionId: "ses_steer", - agentRun: { runId: "run_steer", targetCommandId: "cmd_turn", steerCommandId: "cmd_steer" }, - traceUrl: "/v1/agent/traces/trc_target_steer", - resultUrl: "/v1/agent/chat/result/trc_target_steer" - }), { status: 202 }); - }, - sleep: async () => {} - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[0].url, "http://web.test/v1/agent/chat/steer"); - assert.equal(calls[0].init.headers["x-trace-id"], "trc_target_steer"); - assert.equal(calls[0].init.headers.prefer, "respond-async"); - assert.equal(calls[0].body.traceId, "trc_target_steer"); - assert.equal(calls[0].body.targetTraceId, "trc_target_steer"); - assert.equal(calls[0].body.steerTraceId, "trc_steer_cli"); - assert.equal(calls[0].body.message, "please include STEER_MARK"); - assert.equal(calls[0].body.conversationId, "cnv_steer"); - assert.equal(calls[0].body.sessionId, "ses_steer"); - assert.equal(calls[0].body.threadId, "thread-steer"); - assert.equal(result.payload.action, "client.agent.steer"); - assert.equal(result.payload.route.path, "/v1/agent/chat/steer"); - assert.equal(result.payload.traceId, "trc_target_steer"); - assert.equal(result.payload.steerTraceId, "trc_steer_cli"); - assert.equal(result.payload.body.agentRun.steerCommandId, "cmd_steer"); - assert.equal(result.payload.waitPolicy.webEquivalent, true); -}); - -test("hwlab-cli agent composer status detects Web unlocked steer mode", async () => { - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "agent", - "composer", - "status", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a" - ], { - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init }); - return new Response(JSON.stringify({ - ok: true, - workspace: { - workspaceId: "wsp_composer", - revision: 9, - selectedConversationId: "cnv_composer", - selectedAgentSessionId: "ses_composer", - selectedConversation: { - conversationId: "cnv_composer", - sessionId: "ses_composer", - threadId: "thread-composer", - status: "running", - messages: [{ role: "agent", status: "running", traceId: "trc_composer_active", conversationId: "cnv_composer", sessionId: "ses_composer", threadId: "thread-composer" }] - }, - workspace: { - activeTraceId: "trc_composer_active", - threadId: "thread-stale-workspace", - sessionStatus: "running" - } - } - }), { status: 200 }); - } - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[0].url, "http://web.test/v1/workbench/workspace?projectId=prj_hwpod_workbench"); - assert.equal(result.payload.action, "client.agent.composer.status"); - assert.equal(result.payload.composer.webEquivalent, true); - assert.equal(result.payload.composer.locked, false); - assert.equal(result.payload.composer.disabled, false); - assert.equal(result.payload.composer.submitMode, "steer"); - assert.equal(result.payload.composer.route, "/v1/agent/chat/steer"); - assert.equal(result.payload.composer.targetTraceId, "trc_composer_active"); - assert.equal(result.payload.route.path, "/v1/agent/chat/steer"); -}); - -test("hwlab-cli agent composer status ignores stale workspace activeTrace after selected conversation completed", async () => { - const result = await runHwlabCli([ - "client", - "agent", - "composer", - "status", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a" - ], { - fetchImpl: async () => new Response(JSON.stringify({ - ok: true, - workspace: { - workspaceId: "wsp_composer", - revision: 10, - selectedConversationId: "cnv_completed", - selectedAgentSessionId: "ses_completed", - activeTraceId: "trc_stale_running", - selectedConversation: { - conversationId: "cnv_completed", - sessionId: "ses_completed", - threadId: "thread-completed", - status: "completed", - messages: [{ role: "agent", status: "completed", traceId: "trc_completed", conversationId: "cnv_completed", sessionId: "ses_completed", threadId: "thread-completed" }] - }, - workspace: { - activeTraceId: "trc_stale_running", - threadId: "thread-stale-workspace", - sessionStatus: "running" - } - } - }), { status: 200 }) - }); - - assert.equal(result.exitCode, 0); - assert.equal(result.payload.composer.locked, false); - assert.equal(result.payload.composer.disabled, false); - assert.equal(result.payload.composer.submitMode, "turn"); - assert.equal(result.payload.composer.route, "/v1/agent/chat"); - assert.equal(result.payload.composer.targetTraceId, null); - assert.equal(result.payload.composer.reason, "terminal-turn"); -}); - -test("hwlab-cli agent composer submit auto routes active Web turn to steer", async () => { - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "agent", - "composer", - "submit", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a", - "--message", - "please steer from composer", - "--steer-trace-id", - "trc_steer_composer" - ], { - 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_hwpod_workbench")) { - return new Response(JSON.stringify({ - ok: true, - workspace: { - workspaceId: "wsp_composer", - revision: 9, - selectedConversationId: "cnv_composer", - selectedAgentSessionId: "ses_composer", - selectedConversation: { - conversationId: "cnv_composer", - sessionId: "ses_composer", - threadId: "thread-composer", - status: "running", - messages: [{ role: "agent", status: "running", traceId: "trc_composer_active", conversationId: "cnv_composer", sessionId: "ses_composer", threadId: "thread-composer" }] - }, - workspace: { - activeTraceId: "trc_composer_active", - threadId: "thread-stale-workspace", - sessionStatus: "running" - } - } - }), { status: 200 }); - } - return new Response(JSON.stringify({ - ok: true, - accepted: true, - status: "running", - traceId: "trc_composer_active", - steerTraceId: "trc_steer_composer", - agentRun: { steerCommandId: "cmd_composer_steer" } - }), { status: 202 }); - }, - sleep: async () => {} - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[1].url, "http://web.test/v1/agent/chat/steer"); - assert.equal(calls[1].init.headers["x-trace-id"], "trc_composer_active"); - assert.equal(calls[1].body.traceId, "trc_composer_active"); - assert.equal(calls[1].body.targetTraceId, "trc_composer_active"); - assert.equal(calls[1].body.steerTraceId, "trc_steer_composer"); - assert.equal(calls[1].body.message, "please steer from composer"); - assert.equal(calls[1].body.conversationId, "cnv_composer"); - assert.equal(calls[1].body.sessionId, "ses_composer"); - assert.equal(calls[1].body.threadId, "thread-composer"); - assert.equal(result.payload.action, "client.agent.composer.submit"); - assert.equal(result.payload.composer.locked, false); - assert.equal(result.payload.composer.submitMode, "steer"); - assert.equal(result.payload.route.path, "/v1/agent/chat/steer"); - assert.equal(result.payload.body.agentRun.steerCommandId, "cmd_composer_steer"); -}); - -test("hwlab-cli client agent trace shows assistant stream text in compact output", async () => { - const result = await runHwlabCli(["client", "agent", "trace", "trc_test", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], { - fetchImpl: async () => new Response(JSON.stringify({ - status: "completed", - traceId: "trc_test", - assistantStreams: [{ status: "streaming", text: "hello from agent", chunkCount: 3 }], - events: [{ status: "completed" }] - }), { status: 200 }) - }); - - assert.equal(result.exitCode, 0); - assert.equal(result.payload.body.assistantText, "hello from agent"); - assert.equal(result.payload.body.assistantStreams[0].text, "hello from agent"); - assert.equal(result.payload.body.eventsCount, 1); -}); - -test("hwlab-cli client agent trace can render with the Web trace row path", async () => { - const result = await runHwlabCli(["client", "agent", "trace", "trc_render", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a", "--render", "web"], { - fetchImpl: async () => new Response(JSON.stringify({ - status: "completed", - traceId: "trc_render", - events: [ - { traceId: "trc_render", seq: 1, label: "request:accepted", status: "accepted", createdAt: "2026-06-01T13:00:00.000Z", promptSummary: "看看有什么 HWPOD 能用?" }, - { traceId: "trc_render", seq: 2, label: "session:reused", status: "observed", createdAt: "2026-06-01T13:00:01.000Z" }, - { traceId: "trc_render", seq: 3, label: "assistant:completed", type: "assistant_message", terminal: true, status: "completed", itemId: "assistant-final", createdAt: "2026-06-01T13:00:02.000Z", message: "当前有两个 HWPOD 可用。" } - ] - }), { status: 200 }) - }); - - assert.equal(result.exitCode, 0); - assert.equal(result.payload.body.render, "web"); - assert.equal(result.payload.body.renderer, "tools/src/hwlab-cli/trace-renderer:traceDisplayRows"); - assert.equal(result.payload.body.sourceEventCount, 3); - assert.ok(result.payload.body.renderedRowCount >= 1); - assert.ok(result.payload.body.rows.some((row: any) => /助手最终消息/u.test(row.header))); - assert.equal(JSON.stringify(result.payload.body.rows).includes("request accepted"), false); - assert.equal(JSON.stringify(result.payload.body.rows).includes("request:accepted"), false); - assert.equal(JSON.stringify(result.payload.body.rows).includes("session:reused"), false); - assert.equal(result.payload.body.rows[0].body, "当前有两个 HWPOD 可用。"); - assert.equal(result.payload.traceStatus, "completed"); - assert.equal(result.payload.rendered.traceStatus, "completed"); - assert.equal(result.payload.rendered.render, "web"); - assert.equal(result.payload.rendered.renderedRowCount, result.payload.body.renderedRowCount); - assert.deepEqual(result.payload.rendered.rows, result.payload.body.rows); - assert.deepEqual(result.payload.data, result.payload.rendered); -}); - -test("hwlab-cli client agent trace exposes missing Web render state without a body-path trap", async () => { - const result = await runHwlabCli(["client", "agent", "trace", "trc_missing", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a", "--render", "web"], { - fetchImpl: async () => new Response(JSON.stringify({ - status: "missing", - traceId: "trc_missing", - eventCount: 0, - events: [] - }), { status: 200 }) - }); - - assert.equal(result.exitCode, 0); - assert.equal(result.payload.status, "succeeded"); - assert.equal(result.payload.traceStatus, "missing"); - assert.equal(result.payload.body.status, "missing"); - assert.equal(result.payload.rendered.traceStatus, "missing"); - assert.equal(result.payload.rendered.sourceEventCount, 0); - assert.equal(result.payload.rendered.renderedRowCount, 0); - assert.deepEqual(result.payload.rendered.rows, []); - assert.deepEqual(result.payload.data, result.payload.rendered); -}); - -test("hwlab-cli client agent trace renders AgentRun terminal evidence for expired traces", async () => { - const finalText = "历史 trace 已过期,但 final response 仍可审计。"; - const result = await runHwlabCli(["client", "agent", "trace", "trc_expired", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a", "--render", "web"], { - fetchImpl: async () => new Response(JSON.stringify({ - ok: true, - status: "expired", - traceStatus: "expired", - traceId: "trc_expired", - eventCount: 0, - events: [], - conversationId: "cnv_expired", - sessionId: "ses_expired", - threadId: "thread-expired", - retention: { traceStatus: "expired", liveTraceStore: "expired-or-evicted" }, - agentRun: { runId: "run_expired", commandId: "cmd_expired", valuesPrinted: false }, - finalResponse: { text: finalText, textChars: finalText.length, role: "assistant", status: "completed", source: "agentrun-command-result", valuesPrinted: false }, - traceSummary: { source: "agentrun-command-result", sourceEventCount: 26, terminalStatus: "completed", valuesPrinted: false }, - terminalEvidence: { - available: true, - source: "agentrun-command-result", - conversationId: "cnv_expired", - sessionId: "ses_expired", - threadId: "thread-expired", - agentRun: { runId: "run_expired", commandId: "cmd_expired", valuesPrinted: false }, - finalResponse: { text: finalText, textChars: finalText.length, role: "assistant", status: "completed", valuesPrinted: false }, - traceSummary: { source: "agentrun-command-result", sourceEventCount: 26, terminalStatus: "completed", valuesPrinted: false }, - valuesPrinted: false - } - }), { status: 200 }) - }); - - assert.equal(result.exitCode, 0); - assert.equal(result.payload.status, "succeeded"); - assert.equal(result.payload.traceStatus, "expired"); - assert.equal(result.payload.body.traceStatus, "expired"); - assert.equal(result.payload.body.assistantText, finalText); - assert.equal(result.payload.rendered.traceStatus, "expired"); - assert.equal(result.payload.rendered.sourceEventCount, 0); - assert.equal(result.payload.rendered.renderedRowCount, 1); - assert.match(result.payload.rendered.rows[0].header, /历史 trace 已过期/u); - assert.match(result.payload.rendered.rows[0].body, /final response 仍可审计/u); - assert.match(result.payload.rendered.rows[0].body, /run_expired/u); - assert.equal(result.payload.rendered.terminalEvidence.available, true); - assert.equal(result.payload.rendered.traceSummary.sourceEventCount, 26); - assert.deepEqual(result.payload.data, result.payload.rendered); -}); - -test("hwlab-cli Web trace render does not treat AgentRun result-ready as final assistant text", async () => { - const result = await runHwlabCli(["client", "agent", "trace", "trc_result_ready", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a", "--render", "web"], { - fetchImpl: async () => new Response(JSON.stringify({ - status: "completed", - traceId: "trc_result_ready", - events: [ - { traceId: "trc_result_ready", seq: 1, label: "agentrun:request:accepted", status: "running", type: "request", createdAt: "2026-06-01T13:00:00.000Z" }, - { traceId: "trc_result_ready", seq: 2, label: "agentrun:result:completed", type: "result", status: "completed", terminal: true, message: "AgentRun result is ready for HWLAB short-connection polling.", createdAt: "2026-06-01T13:00:02.000Z" } - ] - }), { status: 200 }) - }); - - assert.equal(result.exitCode, 0); - const text = JSON.stringify(result.payload.body.rows); - assert.equal(text.includes("助手最终消息"), false); - assert.equal(text.includes("助手消息"), false); - assert.match(text, /轮次完成/u); - assert.match(text, /总耗时 00:00:02/u); - assert.equal(text.includes("总耗时 00:00:00"), false); - assert.equal(text.includes("AgentRun result is ready for HWLAB short-connection polling"), false); -}); - -test("hwlab-cli Web trace render reports suppressed noise and command tool details", async () => { - const result = await runHwlabCli(["client", "agent", "trace", "trc_render_agentrun", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a", "--render", "web"], { - fetchImpl: async () => new Response(JSON.stringify({ - status: "completed", - traceId: "trc_render_agentrun", - events: [ - { traceId: "trc_render_agentrun", seq: 1, label: "agentrun:backend:command-created", status: "running", type: "backend", createdAt: "2026-06-01T13:00:00.000Z" }, - { traceId: "trc_render_agentrun", seq: 2, label: "agentrun:backend:thread/status/changed", status: "running", type: "backend", createdAt: "2026-06-01T13:00:01.000Z" }, - { traceId: "trc_render_agentrun", seq: 3, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", itemId: "call_1", command: "/bin/sh -lc 'hwpod inspect --dry-run'", exitCode: 0, stdoutSummary: '{"ok":true,"action":"hwpod-cli.plan"}', createdAt: "2026-06-01T13:00:02.000Z" }, - { traceId: "trc_render_agentrun", seq: 4, label: "agentrun:tool:item/started", type: "tool_call", toolName: "commandExecution", status: "started", itemId: "call_2", command: "/bin/sh -lc 'printf TOOL_RENDER_OK'", createdAt: "2026-06-01T13:00:02.100Z" }, - { traceId: "trc_render_agentrun", seq: 5, label: "agentrun:tool:item/completed", type: "tool_call", toolName: "commandExecution", status: "completed", itemId: "call_2", command: "/bin/sh -lc 'printf TOOL_RENDER_OK'", exitCode: 0, stdoutSummary: "TOOL_RENDER_OK", outputBytes: 14, createdAt: "2026-06-01T13:00:02.200Z" }, - { traceId: "trc_render_agentrun", seq: 6, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "当前可见 HWPOD。", createdAt: "2026-06-01T13:00:03.000Z" } - ] - }), { status: 200 }) - }); - - assert.equal(result.exitCode, 0); - assert.equal(result.payload.body.render, "web"); - assert.equal(result.payload.body.sourceEventCount, 6); - assert.equal(result.payload.body.noiseEventCount, 2); - const text = JSON.stringify(result.payload.body.rows); - assert.equal(text.includes("thread/status/changed"), false); - assert.match(text, /hwpod inspect --dry-run/u); - assert.match(text, /hwpod-cli\.plan/u); - assert.match(text, /TOOL_RENDER_OK/u); - assert.equal(text.includes("toolName=item/started"), false); - assert.equal(text.includes("toolName=item/completed"), false); - assert.match(text, /当前可见 HWPOD/u); -}); - -test("hwlab-cli Web trace render keeps full tool details with --full", async () => { - const longStdout = `first line\n${"X".repeat(1500)}\nlast line`; - const command = "/bin/sh -lc 'cat > /tmp/main_patch.txt << '\''PATCHMAIN'\''\n*** Begin Patch\n*** Update File: projects/01_baseline/User/main.c\n@@\n+d601_arm2d_demo_task();\n*** End Patch\nPATCHMAIN\nhwpod workspace apply-patch --spec .hwlab/hwpod-spec.yaml --reason demo'"; - const result = await runHwlabCli(["client", "agent", "trace", "trc_render_full", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a", "--render", "web", "--full"], { - fetchImpl: async () => new Response(JSON.stringify({ - status: "completed", - traceId: "trc_render_full", - events: [ - { traceId: "trc_render_full", seq: 1, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", itemId: "call_full", command, exitCode: 0, stdoutSummary: longStdout, createdAt: "2026-06-01T13:00:02.000Z" } - ] - }), { status: 200 }) - }); - - assert.equal(result.exitCode, 0); - const row = result.payload.body.rows[0]; - assert.match(row.body, /cat > \/tmp\/main_patch\.txt << '+PATCHMAIN'+/u); - assert.match(row.body, /\n\*\*\* Begin Patch/u); - assert.match(row.body, /stdout:\nfirst line\nX{1500}\nlast line/u); - assert.match(row.body, /exitCode=0/u); - assert.doesNotMatch(row.body, /last line\.\.\./u); -}); - -test("hwlab-cli Web trace render keeps AgentRun middle assistant message", async () => { - const result = await runHwlabCli(["client", "agent", "trace", "trc_render_agentrun_middle", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a", "--render", "web"], { - fetchImpl: async () => new Response(JSON.stringify({ - status: "completed", - traceId: "trc_render_agentrun_middle", - events: [ - { traceId: "trc_render_agentrun_middle", seq: 1, label: "agentrun:request:accepted", status: "running", type: "request", createdAt: "2026-06-01T13:00:00.000Z" }, - { traceId: "trc_render_agentrun_middle", seq: 2, label: "agentrun:assistant:message", type: "assistant", status: "running", itemId: "msg_progress", messageIndex: 1, messageCount: 2, replyAuthority: false, final: false, message: "我先检查当前 HWPOD 列表。", createdAt: "2026-06-01T13:00:01.000Z" }, - { traceId: "trc_render_agentrun_middle", seq: 3, label: "agentrun:assistant:message", type: "assistant", status: "completed", itemId: "msg_final", messageIndex: 2, messageCount: 2, replyAuthority: true, final: true, terminal: true, message: "当前有 2 个 HWPOD 可用。", createdAt: "2026-06-01T13:00:02.000Z" }, - { traceId: "trc_render_agentrun_middle", seq: 4, label: "agentrun:terminal:completed", type: "result", terminal: true, status: "completed", createdAt: "2026-06-01T13:00:03.000Z" } - ] - }), { status: 200 }) - }); - - assert.equal(result.exitCode, 0); - assert.equal(result.payload.body.render, "web"); - const text = JSON.stringify(result.payload.body.rows); - assert.match(text, /我先检查当前 HWPOD 列表/u); - assert.match(text, /当前有 2 个 HWPOD 可用/u); - assert.match(text, /助手最终消息/u); - assert.match(text, /总耗时 00:00:03/u); - assert.equal(text.includes("agentrun:request:accepted"), false); - assert.equal(text.includes("总耗时 00:00:00"), false); -}); - -test("hwlab-cli Web trace render keeps the full final-response body without truncating long agent summaries", async () => { - const longSummary = "下面是详细总结。".repeat(2000); - const result = await runHwlabCli(["client", "agent", "trace", "trc_final_long", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a", "--render", "web"], { - fetchImpl: async () => new Response(JSON.stringify({ - status: "completed", - traceId: "trc_final_long", - events: [ - { traceId: "trc_final_long", seq: 1, label: "request:accepted", status: "accepted", createdAt: "2026-06-01T13:00:00.000Z", promptSummary: "请给出详细总结" }, - { traceId: "trc_final_long", seq: 2, label: "assistant:completed", type: "assistant_message", terminal: true, status: "completed", itemId: "assistant-final", createdAt: "2026-06-01T13:00:02.000Z", message: longSummary } - ] - }), { status: 200 }) - }); - - assert.equal(result.exitCode, 0); - const terminalRow = (result.payload.body.rows as any[]).find((row) => row.terminal === true); - assert.ok(terminalRow, "expected a terminal row to be rendered for the final response"); - assert.equal(terminalRow.body, longSummary); - assert.equal(terminalRow.body?.includes("..."), false); -}); - -test("hwlab-cli client agent trace does not auto login with Web session credentials", async () => { - const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-auth-")); - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "agent", - "trace", - "trc_protected", - "--base-url", - "http://web.test", - "--username", - "admin" - ], { - cwd, - env: { HWLAB_PASSWORD: "secret-password" }, - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null }); - return new Response(JSON.stringify({ ok: false, error: { code: "auth_required" } }), { status: 401 }); - }, - now: () => "2026-05-31T07:20:00.000Z" - }); - - assert.equal(result.exitCode, 1); - assert.deepEqual(calls.map((call) => call.url), [ - "http://web.test/v1/agent/turns/trc_protected", - "http://web.test/v1/agent/traces/trc_protected" - ]); - assert.equal(calls[0].init.headers.cookie, undefined); - assert.equal(calls[0].init.headers.authorization, undefined); - assert.equal(result.payload.auth.requiredAuthMethod, "api-key"); - assert.equal(result.payload.authDiagnosis.code, "api_key_required"); - assert.equal(JSON.stringify(result.payload).includes("secret-password"), false); -}); - -test("hwlab-cli client agent result ignores stored Web session by default after 401", async () => { - const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-auth-refresh-")); - await runHwlabCli(["client", "auth", "login", "--base-url", "http://web.test", "--username", "admin", "--password", "old"], { - cwd, - fetchImpl: async () => new Response(JSON.stringify({ authenticated: true, user: { username: "admin" } }), { status: 200, headers: { "set-cookie": "hwlab_session=session-old; Path=/" } }) - }); - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "agent", - "result", - "trc_refresh", - "--base-url", - "http://web.test", - "--username", - "admin" - ], { - cwd, - env: { HWLAB_PASSWORD: "new-password" }, - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null }); - return new Response(JSON.stringify({ error: { code: "auth_required" } }), { status: 401 }); - }, - sleep: async () => {}, - now: () => "2026-05-31T07:21:00.000Z" - }); - - assert.equal(result.exitCode, 1); - assert.deepEqual(calls.map((call) => call.url), [ - "http://web.test/v1/agent/turns/trc_refresh" - ]); - assert.equal(calls[0].init.headers.cookie, undefined); - assert.equal(result.payload.auth.requiredAuthMethod, "api-key"); - assert.equal(result.payload.auth.webSessionIgnoredReason, "cli_requires_api_key"); - assert.equal(result.payload.auth.localSession.usable, true); - assert.equal(result.payload.action, "client.agent.result"); - assert.equal(result.payload.authDiagnosis.code, "api_key_required"); -}); - -test("hwlab-cli client protected agent commands return structured auth diagnosis for forbidden trace", async () => { - const result = await runHwlabCli(["client", "agent", "trace", "trc_forbidden", "--base-url", "http://web.test"], { - env: { HWLAB_API_KEY: "hwl_live_owner_test_key" }, - fetchImpl: async () => new Response(JSON.stringify({ error: { code: "agent_session_owner_required" } }), { status: 403 }) - }); - - assert.equal(result.exitCode, 1); - assert.equal(result.payload.httpStatus, 403); - assert.equal(result.payload.authDiagnosis.code, "auth_forbidden"); - assert.equal(result.payload.auth.authMethod, "api-key"); - assert.equal(result.payload.authDiagnosis.cookieSource, null); - assert.match(result.payload.authDiagnosis.message, /无权访问/u); - assert.equal(result.payload.request.url, "http://web.test/v1/agent/traces/trc_forbidden"); - assert.match(result.payload.authDiagnosis.nextCommands[1], /client auth whoami/u); -}); - -test("hwlab-cli client protected agent command reports missing credentials with state visibility", async () => { - const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-auth-missing-")); - const result = await runHwlabCli(["client", "agent", "result", "trc_missing_auth", "--base-url", "http://web.test"], { - cwd, - env: {}, - fetchImpl: async () => new Response(JSON.stringify({ ok: false, error: { code: "auth_required" } }), { status: 401 }) - }); - - assert.equal(result.exitCode, 1); - assert.equal(result.payload.action, "client.agent.result"); - assert.equal(result.payload.auth.required, true); - assert.equal(result.payload.auth.localSession.exists, false); - assert.equal(result.payload.auth.requiredAuthMethod, "api-key"); - assert.equal(result.payload.authDiagnosis.code, "api_key_required"); - assert.equal(result.payload.authDiagnosis.localSession.ignoredReason, "state_file_missing"); - assert.match(result.payload.authDiagnosis.nextCommands[0], /HWLAB_API_KEY/u); - assert.equal(result.payload.request.httpStatus, 401); -}); - -test("hwlab-cli client agent inspect builds protected inspect query", async () => { - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "agent", - "inspect", - "--trace-id", - "trc_inspect", - "--conversation-id", - "cnv_inspect", - "--session-id", - "ses_inspect", - "--thread-id", - "thread-1", - "--base-url", - "http://web.test" - ], { - env: { HWLAB_API_KEY: "hwl_live_owner_test_key" }, - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init }); - return new Response(JSON.stringify({ ok: true, status: "found", latestTraceId: "trc_inspect" }), { status: 200 }); - } - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[0].url, "http://web.test/v1/agent/chat/inspect?traceId=trc_inspect&conversationId=cnv_inspect&sessionId=ses_inspect&threadId=thread-1"); - assert.equal(calls[0].init.headers.authorization, "Bearer hwl_live_owner_test_key"); - assert.equal(calls[0].init.headers.cookie, undefined); - assert.equal(result.payload.action, "client.agent.inspect"); - assert.equal(result.payload.body.status, "found"); -}); - -test("hwlab-cli client harness submits waits and audits trace friction", async () => { - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "harness", - "submit", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a", - "--message", - "hello", - "--trace-id", - "trc_harness", - "--conversation-id", - "cnv_harness" - ], { - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) }); - return new Response(JSON.stringify({ - accepted: true, - status: "running", - resultUrl: "/v1/agent/chat/result/trc_harness", - traceUrl: "/v1/agent/traces/trc_harness" - }), { status: 202 }); - } - }); - assert.equal(result.exitCode, 0); - assert.equal(result.payload.action, "client.harness.submit"); - assert.equal(calls[0].url, "http://web.test/v1/agent/chat"); - assert.equal(calls[0].init.headers.prefer, "respond-async"); - assert.equal(calls[0].body.shortConnection, true); - - let polls = 0; - const wait = await runHwlabCli([ - "client", - "harness-opt", - "wait", - "trc_harness", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a", - "--poll-interval-ms", - "1", - "--timeout-ms", - "1000" - ], { - fetchImpl: async () => { - polls += 1; - return new Response(JSON.stringify(polls === 1 - ? { status: "running", traceId: "trc_harness" } - : { status: "completed", traceId: "trc_harness", reply: { content: "done" } }), { status: 200 }); - }, - sleep: async () => {} - }); - assert.equal(wait.exitCode, 0); - assert.equal(wait.payload.timedOut, false); - assert.equal(wait.payload.timeoutMsRequested, 1000); - assert.equal(wait.payload.timeoutMsEffective, 1000); - assert.equal(wait.payload.waitPolicy.remotePassthroughSafe, true); - assert.equal(wait.payload.body.assistantText, "done"); - - const cappedWait = await runHwlabCli([ - "client", - "harness", - "wait", - "trc_harness", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a", - "--poll-interval-ms", - "1000", - "--timeout-ms", - "60000" - ], { - fetchImpl: async () => new Response(JSON.stringify({ status: "completed", traceId: "trc_harness", reply: { content: "done" } }), { status: 200 }), - sleep: async () => {} - }); - assert.equal(cappedWait.exitCode, 0); - assert.equal(cappedWait.payload.status, "succeeded"); - assert.equal(cappedWait.payload.timeoutMsRequested, 60000); - assert.equal(cappedWait.payload.timeoutMsEffective, 50000); - assert.equal(cappedWait.payload.waitPolicy.capped, true); - assert.ok(cappedWait.payload.waitPolicy.nextCommands.some((command) => command.includes("harness result trc_harness"))); - -}); - -test("hwlab-cli client harness audit treats standard hwpod flow as clean", async () => { - const audit = await runHwlabCli([ - "client", - "harness", - "audit", - "trc_hwpod", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a" - ], { - fetchImpl: async () => new Response(JSON.stringify({ - traceId: "trc_hwpod", - events: [ - { type: "tool_call", command: "hwpod inspect --dry-run" }, - { type: "tool_call", command: "hwpod workspace ls ." } - ] - }), { status: 200 }) - }); - assert.equal(audit.exitCode, 0); - assert.equal(audit.payload.status, "clean"); - assert.equal(audit.payload.summary.hasHwpod, true); -}); - -test("hwlab-cli client workbench summary probes Cloud Web non-visual surfaces", async () => { - const seen: string[] = []; - const result = await runHwlabCli(["client", "workbench", "summary", "--base-url", "http://web.test", "--cookie", "session-a"], { - fetchImpl: async (url) => { - seen.push(String(url)); - return new Response(JSON.stringify({ ok: true, status: "ok" }), { status: 200 }); - } - }); - - assert.equal(result.exitCode, 0); - assert.equal(seen.includes("http://web.test/health/live"), true); - assert.equal(seen.includes("http://web.test/v1"), true); - assert.equal(seen.includes("http://web.test/v1/live-builds"), true); - assert.equal(seen.includes("http://web.test/v1/skills"), true); - assert.equal(result.payload.serviceRuntime, undefined); -}); - -test("hwlab-cli client request covers arbitrary Cloud Web same-origin API routes", async () => { - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "request", - "GET", - "/v1/access/status", - "--base-url", - "http://web.test", - "--trace-id", - "trc_request" - ], { - env: { HWLAB_API_KEY: "hwl_live_admin_test_key" }, - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init }); - return new Response(JSON.stringify({ ok: true, authenticated: true, actor: { username: "admin" }, roles: ["admin"] }), { status: 200 }); - } - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[0].url, "http://web.test/v1/access/status"); - assert.equal(calls[0].init.headers.authorization, "Bearer hwl_live_admin_test_key"); - assert.equal(calls[0].init.headers.cookie, undefined); - assert.equal(calls[0].init.headers["x-trace-id"], "trc_request"); - assert.equal(result.payload.action, "client.request"); - assert.equal(result.payload.route.path, "/v1/access/status"); - assert.equal(result.payload.body.actor.username, "admin"); - assert.equal(result.payload.body.fullBodyAvailable, true); -}); - -test("hwlab-cli client user query wrappers expose typed redacted summaries", async () => { - const calls: any[] = []; - const fetchImpl = async (url: string | URL, init: any = {}) => { - calls.push({ url: String(url), init }); - if (String(url).endsWith("/v1/users/me")) { - return new Response(JSON.stringify({ ok: true, authenticated: true, actor: { id: "usr_test", username: "inner-test", role: "user", status: "active" }, authMethod: "user-billing-api-key", session: { id: "key_test", keyPrefix: "hwl_live_Zci", key: "hwl_live_should_not_print" } }), { status: 200 }); - } - if (String(url).endsWith("/v1/access/status")) { - return new Response(JSON.stringify({ ok: true, authenticated: true, actor: { id: "usr_test", username: "inner-test", role: "user" }, authMethod: "user-billing-api-key", permissions: ["code_agent"], routes: { currentUser: "/v1/users/me" } }), { status: 200 }); - } - if (String(url).endsWith("/v1/api-keys")) { - return new Response(JSON.stringify({ ok: true, count: 1, items: [{ id: "key_test", name: "fixed", keyPrefix: "hwl_live_Zci", key: "hwl_live_raw_secret", scopes: ["api"], status: "active", valuesRedacted: true }] }), { status: 200 }); - } - if (String(url).endsWith("/v1/billing/summary?limit=7")) { - return new Response(JSON.stringify({ ok: true, credits: { balance: 99, reserved: 0 }, ledger: { count: 1, items: [{ id: "led_test", kind: "usage", reason: "hwlab-code-agent", deltaCredits: -1, metadata: { traceId: "trc_test", sessionId: "ses_test" } }] }, usage: { recordCount: 1, byService: [{ service: "hwlab-code-agent", credits: 1 }] } }), { status: 200 }); - } - if (String(url).endsWith("/v1/usage/summary")) { - return new Response(JSON.stringify({ ok: true, usage: { recordCount: 1, byService: [{ service: "hwlab-code-agent", credits: 1 }], lastUsedAt: "2026-06-15T00:00:00Z" }, records: [{ id: "usage_test", service: "hwlab-code-agent", credits: 1, metadata: { traceId: "trc_test" } }] }), { status: 200 }); - } - return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_test_route" } }), { status: 404 }); - }; - const env = { HWLAB_API_KEY: "hwl_live_user_test_key" }; - - const account = await runHwlabCli(["client", "account", "status", "--base-url", "http://web.test"], { env, fetchImpl }); - const keys = await runHwlabCli(["client", "api-keys", "list", "--base-url", "http://web.test"], { env, fetchImpl }); - const billing = await runHwlabCli(["client", "billing", "summary", "--limit", "7", "--base-url", "http://web.test"], { env, fetchImpl }); - const usage = await runHwlabCli(["client", "usage", "summary", "--base-url", "http://web.test"], { env, fetchImpl }); - - assert.equal(account.exitCode, 0); - assert.equal(account.payload.action, "client.account.status"); - assert.equal(account.payload.identity.actor.username, "inner-test"); - assert.equal(account.payload.access.permissions[0], "code_agent"); - assert.equal(keys.exitCode, 0); - assert.equal(keys.payload.action, "client.api-keys.list"); - assert.equal(keys.payload.body.items[0].prefix, "hwl_live_Zci"); - assert.equal(billing.exitCode, 0); - assert.equal(billing.payload.route.path, "/v1/billing/summary?limit=7"); - assert.equal(billing.payload.body.ledger.items[0].traceId, "trc_test"); - assert.equal(usage.exitCode, 0); - assert.equal(usage.payload.body.usage.recordCount, 1); - assert.equal(calls.every((call) => call.init.headers.authorization === "Bearer hwl_live_user_test_key"), true); - assert.equal(calls.every((call) => call.init.headers.cookie === undefined), true); - assert.equal(JSON.stringify([account.payload, keys.payload, billing.payload, usage.payload]).includes("hwl_live_raw_secret"), false); - assert.equal(JSON.stringify([account.payload, keys.payload, billing.payload, usage.payload]).includes("hwl_live_should_not_print"), false); -}); - -test("hwlab-cli client request compacts array responses by default", async () => { - const result = await runHwlabCli([ - "client", - "request", - "GET", - "/v1/skills", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a" - ], { - fetchImpl: async () => new Response(JSON.stringify([{ name: "hwpod-cli", status: "ready" }, { name: "hwpod-ctl", status: "ready" }]), { status: 200 }) - }); - - assert.equal(result.exitCode, 0); - assert.equal(result.payload.body.itemCount, 2); - assert.equal(result.payload.body.items[0].name, "hwpod-cli"); - assert.equal(result.payload.body.fullBodyAvailable, true); -}); - -test("hwlab-cli client rpc mirrors Cloud Web JSON-RPC envelope metadata", async () => { - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "rpc", - "system.health", - "--base-url", - "http://web.test", - "--trace-id", - "trc_rpc", - "--id", - "req_rpc", - "--params-json", - "{}" - ], { - env: { HWLAB_API_KEY: "hwl_live_admin_test_key" }, - fetchImpl: async (url, init) => { - calls.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) }); - return new Response(JSON.stringify({ jsonrpc: "2.0", id: "req_rpc", result: { status: "ok" }, meta: { traceId: "trc_rpc", serviceId: "hwlab-cloud-api", environment: "dev" } }), { status: 200 }); - } - }); - - assert.equal(result.exitCode, 0); - assert.equal(calls[0].url, "http://web.test/json-rpc"); - assert.equal(calls[0].init.headers.authorization, "Bearer hwl_live_admin_test_key"); - assert.equal(calls[0].init.headers.cookie, undefined); - assert.equal(calls[0].body.method, "system.health"); - assert.deepEqual(calls[0].body.params, {}); - assert.deepEqual(calls[0].body.meta, { traceId: "trc_rpc", serviceId: "hwlab-cloud-web", environment: "dev" }); - assert.equal(result.payload.action, "client.rpc"); - assert.equal(result.payload.rpcMethod, "system.health"); - assert.equal(result.payload.body.result.status, "ok"); - assert.equal(result.payload.body.fullBodyAvailable, true); -}); - -test("hwlab-cli client request rejects absolute URLs", async () => { - const result = await runHwlabCli(["client", "request", "GET", "http://internal.test/v1", "--base-url", "http://web.test"], { - fetchImpl: async () => new Response("{}", { status: 200 }) - }); - - assert.equal(result.exitCode, 1); - assert.equal(result.payload.error.code, "invalid_request_path"); -}); - -test("hwlab-cli client request keeps command visibility on transport failure", async () => { - const result = await runHwlabCli(["client", "request", "GET", "/v1/access/status", "--base-url", "http://web.test", "--public"], { - fetchImpl: async () => { - throw Object.assign(new Error("connect ECONNREFUSED"), { name: "FetchError" }); - } - }); - - assert.equal(result.exitCode, 1); - assert.equal(result.payload.action, "client.request"); - assert.equal(result.payload.httpStatus, 0); - assert.equal(result.payload.request.url, "http://web.test/v1/access/status"); - assert.equal(result.payload.request.transportError.code, "request_failed"); - assert.equal(result.payload.body.error.code, "request_failed"); -}); - -test("hwlab-cli client agent send with --session-id and --conversation-id still inherits explicit session providerProfile (#792)", async () => { - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "agent", - "send", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a", - "--message", - "send with explicit session + conversation", - "--trace-id", - "trc_explicit_session_with_conv", - "--session-id", - "ses_explicit", - "--conversation-id", - "cnv_explicit" - ], { - 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_hwpod_workbench")) { - return new Response(JSON.stringify({ - ok: true, - workspace: { - workspaceId: "wsp_other", - revision: 4, - providerProfile: "deepseek", - workspace: { sessionStatus: "idle", providerProfile: "deepseek" } - } - }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/sessions/ses_explicit")) { - return new Response(JSON.stringify({ - ok: true, - status: "found", - session: { - sessionId: "ses_explicit", - conversationId: "cnv_explicit", - threadId: "thread-explicit", - status: "idle", - usable: true, - providerProfile: "minimax-m3" - } - }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/chat")) { - return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_explicit_session_with_conv", resultUrl: "/v1/agent/chat/result/trc_explicit_session_with_conv" }), { status: 202 }); - } - return new Response(JSON.stringify({ status: "completed", traceId: "trc_explicit_session_with_conv", conversationId: "cnv_explicit", sessionId: "ses_explicit", reply: { role: "assistant", content: "ok" } }), { status: 200 }); - }, - sleep: async () => {} - }); - - assert.equal(result.exitCode, 0); - const sessionLookup = calls.find((c) => String(c.url).endsWith("/v1/agent/sessions/ses_explicit")); - assert.ok(sessionLookup, "CLI must look up explicit session when --session-id is given alongside --conversation-id (without --provider-profile)"); - const chatCall = calls.find((c) => String(c.url).endsWith("/v1/agent/chat")); - assert.ok(chatCall); - assert.equal(chatCall.body.providerProfile, "minimax-m3"); - assert.equal(chatCall.body.sessionId, "ses_explicit"); - assert.equal(chatCall.body.conversationId, "cnv_explicit"); - assert.equal(chatCall.body.threadId, "thread-explicit"); -}); - -test("hwlab-cli client agent send fails when explicit session conversationId does not match requested conversationId (#792)", async () => { - const calls: any[] = []; - const result = await runHwlabCli([ - "client", - "agent", - "send", - "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a", - "--message", - "wrong conversation", - "--trace-id", - "trc_mismatch", - "--session-id", - "ses_a", - "--conversation-id", - "cnv_b" - ], { - 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_hwpod_workbench")) { - return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_test", revision: 1, workspace: {} } }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/sessions/ses_a")) { - return new Response(JSON.stringify({ - ok: true, - status: "found", - session: { - sessionId: "ses_a", - conversationId: "cnv_a", - threadId: "thread-a", - status: "idle", - usable: true, - providerProfile: "minimax-m3" - } - }), { status: 200 }); - } - if (String(url).endsWith("/v1/agent/chat")) { - return new Response(JSON.stringify({ accepted: true }), { status: 202 }); - } - return new Response(JSON.stringify({ status: "completed" }), { status: 200 }); - } - }); - - assert.equal(result.exitCode, 1); - assert.equal(result.payload.error.code, "session_conversation_mismatch"); - assert.equal(result.payload.error.details.sessionId, "ses_a"); - assert.equal(result.payload.error.details.requestedConversationId, "cnv_b"); - assert.equal(result.payload.error.details.sessionConversationId, "cnv_a"); - const chatCall = calls.find((c) => String(c.url).endsWith("/v1/agent/chat")); - assert.equal(chatCall, undefined); -}); diff --git a/tools/hwlab-cli/package.json b/tools/hwlab-cli/package.json index 294976a9..71d7aed3 100644 --- a/tools/hwlab-cli/package.json +++ b/tools/hwlab-cli/package.json @@ -7,7 +7,7 @@ "hwlab-cli": "./bin/hwlab-cli.ts" }, "scripts": { - "check": "node ../../scripts/run-bun.mjs test ../../tools/hwlab-cli/client.test.ts", + "check": "bun bin/hwlab-cli.ts client session --help", "client": "node ../../scripts/run-bun.mjs bin/hwlab-cli.ts client" } } diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index c6b95e36..4f6a5daa 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -153,20 +153,16 @@ function help() { "hwlab-cli client gateway pressure --gateway-session-id gws_D601_F103", "hwlab-cli client session list", "hwlab-cli client session create", - "hwlab-cli client session switch cnv_...", - "hwlab-cli client session delete cnv_... --confirm", - "hwlab-cli client session inspect cnv_... [--trace-id trc_...]", + "hwlab-cli client session switch ses_...", + "hwlab-cli client session inspect ses_... [--trace-id trc_...]", "hwlab-cli client workbench summary", - "hwlab-cli client workbench restore --profile NAME", "hwlab-cli client workbench status --profile NAME", - "hwlab-cli client workbench watch --profile NAME --after-revision REVISION", - "hwlab-cli client workbench reset --profile NAME --confirm", "hwlab-cli client request GET /v1/access/status [--full]", "hwlab-cli client request GET /v1/web-performance/summary [--full]", "hwlab-cli client rpc system.health [--full]", "hwlab-cli client agent session create --provider-profile PROFILE", "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 PROFILE", + "hwlab-cli client agent send --message TEXT|--message-file PATH [--from-trace TRACE] [--session-id ID] [--thread-id ID] [--retry-of TRACE] --provider-profile PROFILE", "hwlab-cli client agent send --message TEXT --wait --timeout-ms 50000", "hwlab-cli client agent result TRACE_ID", "hwlab-cli client agent trace TRACE_ID [--render web]", @@ -1032,7 +1028,7 @@ async function agentCommand(context: any) { if (subcommand === "steer") return agentSteer(context); if (subcommand === "cancel") { const traceId = requiredText(context.rest[1] ?? context.parsed.traceId, "traceId"); - const response = await requestJson({ ...context, method: "POST", path: "/v1/agent/chat/cancel", body: clean({ traceId, conversationId: text(context.parsed.conversationId), sessionId: text(context.parsed.sessionId), threadId: text(context.parsed.threadId) }), extraHeaders: { "x-trace-id": traceId } }); + const response = await requestJson({ ...context, method: "POST", path: "/v1/agent/chat/cancel", body: clean({ traceId, sessionId: text(context.parsed.sessionId), threadId: text(context.parsed.threadId) }), extraHeaders: { "x-trace-id": traceId } }); return responsePayload("client.agent.cancel", response, context, { route: route("POST", "/v1/agent/chat/cancel"), traceId }); } throw cliError("unsupported_agent_command", `unsupported agent command: ${subcommand}`, { subcommand }); @@ -1371,18 +1367,18 @@ function agentHelp() { serviceRuntime: false, imagePublished: false, commands: [ - "session create [--provider-profile PROFILE] [--conversation-id ID]", - "session select SESSION_ID [--workspace-id ID]", + "session create [--provider-profile PROFILE] [--session-id ID]", + "session select SESSION_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] [--provider-profile PROFILE] [default: short return]", + "send --message TEXT|--message-file PATH [--from-trace TRACE] [--session-id ID] [--thread-id ID] [--retry-of TRACE] [--provider-profile PROFILE] [default: short return]", "composer status [--profile NAME] [--full]", "composer submit --message TEXT|--message-file PATH [--profile NAME] [default: auto turn/steer]", "send --message TEXT --wait [--timeout-ms N] [--poll-interval-ms N]", "result TRACE_ID [--full]", "trace TRACE_ID [--render web] [--limit N] [--full]", - "inspect --trace-id TRACE_ID|--conversation-id ID|--session-id ID|--thread-id ID", - "steer TRACE_ID --message TEXT|--message-file PATH [--conversation-id ID] [--session-id ID] [--thread-id ID]", + "inspect --trace-id TRACE_ID|--session-id ID|--thread-id ID", + "steer TRACE_ID --message TEXT|--message-file PATH [--session-id ID] [--thread-id ID]", "cancel TRACE_ID" ] }); @@ -1409,164 +1405,154 @@ function sessionHelp() { commands: [ "list [--limit N]", "current", - "create [--conversation-id ID] [--session-id ID] [--thread-id ID]", - "switch CONVERSATION_ID|--conversation-id ID", - "delete CONVERSATION_ID|--conversation-id ID --confirm", - "inspect CONVERSATION_ID|--conversation-id ID [--trace-id TRACE]", - "final-response CONVERSATION_ID|--conversation-id ID [--trace-id TRACE] [--project-id PROJECT]" + "create [--session-id ID] [--thread-id ID] [--provider-profile PROFILE]", + "switch SESSION_ID|--session-id ID", + "inspect SESSION_ID|--session-id ID [--trace-id TRACE]", + "final-response SESSION_ID|--session-id ID [--trace-id TRACE]" ] }); } async function sessionInspectCommand(context: any) { - const conversationId = text(context.rest[1]) ?? text(context.parsed.conversationId); - if (!conversationId) { - throw cliError("conversation_id_required", "session inspect requires CONVERSATION_ID or --conversation-id", { usage: "hwlab-cli client session inspect " }); - } - const pathName = `/v1/agent/conversations/${encodeURIComponent(conversationId)}`; + const sessionId = requiredSessionId(context.rest[1] ?? context.parsed.sessionId); + const pathName = `/v1/workbench/sessions/${encodeURIComponent(sessionId)}`; const headers = text(context.parsed.traceId) ? { "x-trace-id": text(context.parsed.traceId) } : undefined; const response = await requestJson({ ...context, method: "GET", path: pathName, extraHeaders: headers }); + const session = response.body?.session ?? null; return responsePayload("client.session.inspect", response, context, { route: route("GET", pathName), lookup: clean({ - conversationId, + sessionId, traceId: text(context.parsed.traceId) }), - summary: sessionSummary(response.body?.conversation), - conversation: response.body?.conversation ?? null, + summary: sessionSummary(session), + session, body: responseBodyForCli(response.body, context.parsed) }); } async function sessionListCommand(context: any) { - const projectId = text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID; const limit = numberOption(context.parsed.limit) ?? 100; - const pathName = `/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}&limit=${encodeURIComponent(String(limit))}`; + const includeSessionId = safeOptionalSessionId(context.parsed.sessionId ?? context.parsed.includeSessionId); + const params = new URLSearchParams(); + params.set("limit", String(limit)); + if (includeSessionId) params.set("includeSessionId", includeSessionId); + const pathName = `/v1/workbench/sessions?${params.toString()}`; const response = await requestJson({ ...context, method: "GET", path: pathName }); return responsePayload("client.session.list", response, context, { route: route("GET", pathName), - sessions: sessionSummaries(response.body?.conversations), + sessions: sessionSummaries(response.body?.sessions), body: responseBodyForCli(response.body, context.parsed) }); } async function sessionFinalResponseCommand(context: any) { - const conversationId = requiredConversationId(context.rest[1] ?? context.parsed.conversationId); - const inspectPath = `/v1/agent/conversations/${encodeURIComponent(conversationId)}`; + const sessionId = requiredSessionId(context.rest[1] ?? context.parsed.sessionId); + const inspectPath = `/v1/workbench/sessions/${encodeURIComponent(sessionId)}`; const inspectResponse = await requestJson({ ...context, method: "GET", path: inspectPath }); - const conversation = inspectResponse.body?.conversation ?? null; - if (!responseSucceeded(inspectResponse) || !conversation) { + const session = inspectResponse.body?.session ?? null; + if (!responseSucceeded(inspectResponse) || !session) { const validation = finalResponseEarlyValidation({ - conversationId, + sessionId, routeName: "inspect", response: inspectResponse, - failure: conversation ? "inspect-request-failed" : "inspect-conversation-missing" + failure: session ? "inspect-request-failed" : "inspect-session-missing" }); return { ...responsePayload("client.session.final-response", inspectResponse, context, { route: route("GET", inspectPath), - conversationId, + sessionId, routes: { inspect: route("GET", inspectPath) }, httpStatuses: { inspect: inspectResponse.status }, validation, - inspect: finalResponseConversationSummary(conversation), + inspect: finalResponseSessionSummary(session), body: context.parsed.full === true ? { inspect: inspectResponse.body } : compactApiBody(inspectResponse.body) }), ok: false, status: "failed" }; } - const projectId = text(context.parsed.projectId) || text(conversation?.projectId) || DEFAULT_WORKBENCH_PROJECT_ID; const limit = numberOption(context.parsed.limit) ?? 100; - const listPath = `/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}&limit=${encodeURIComponent(String(limit))}`; - const listResponse = await requestJson({ ...context, method: "GET", path: listPath }); - if (!responseSucceeded(listResponse)) { + const messagesPath = `/v1/workbench/sessions/${encodeURIComponent(sessionId)}/messages?limit=${encodeURIComponent(String(limit))}`; + const messagesResponse = await requestJson({ ...context, method: "GET", path: messagesPath }); + if (!responseSucceeded(messagesResponse)) { const validation = finalResponseEarlyValidation({ - conversationId, - projectId, - routeName: "list", - response: listResponse, - failure: "list-request-failed" + sessionId, + routeName: "messages", + response: messagesResponse, + failure: "messages-request-failed" }); return { - ...responsePayload("client.session.final-response", listResponse, context, { - route: route("GET", listPath), - conversationId, - projectId, - routes: { inspect: route("GET", inspectPath), list: route("GET", listPath) }, - httpStatuses: { inspect: inspectResponse.status, list: listResponse.status }, + ...responsePayload("client.session.final-response", messagesResponse, context, { + route: route("GET", messagesPath), + sessionId, + routes: { inspect: route("GET", inspectPath), messages: route("GET", messagesPath) }, + httpStatuses: { inspect: inspectResponse.status, messages: messagesResponse.status }, validation, - inspect: finalResponseConversationSummary(conversation), - body: context.parsed.full === true ? { inspect: inspectResponse.body, list: listResponse.body } : compactApiBody(listResponse.body) + inspect: finalResponseSessionSummary(session), + body: context.parsed.full === true ? { inspect: inspectResponse.body, messages: messagesResponse.body } : compactApiBody(messagesResponse.body) }), ok: false, status: "failed" }; } - const listedConversation = Array.isArray(listResponse.body?.conversations) - ? listResponse.body.conversations.find((item: any) => text(item?.conversationId) === conversationId) ?? null - : null; - const effectiveConversation = listedConversation ?? conversation; - const resolvedTraceId = text(context.parsed.traceId ?? effectiveConversation?.lastTraceId ?? latestAgentMessage(effectiveConversation)?.traceId); + const messages = Array.isArray(messagesResponse.body?.messages) ? messagesResponse.body.messages : []; + const resolvedTraceId = text(context.parsed.traceId ?? session?.lastTraceId ?? latestAgentMessage({ messages })?.traceId); if (!resolvedTraceId) { const validation = finalResponseEarlyValidation({ - conversationId, - projectId, - routeName: "conversation", - response: listResponse, + sessionId, + routeName: "session", + response: messagesResponse, failure: "trace-id-missing" }); return { - ...responsePayload("client.session.final-response", listResponse, context, { - route: route("GET", listPath), - conversationId, - projectId, - routes: { inspect: route("GET", inspectPath), list: route("GET", listPath) }, - httpStatuses: { inspect: inspectResponse.status, list: listResponse.status }, + ...responsePayload("client.session.final-response", messagesResponse, context, { + route: route("GET", messagesPath), + sessionId, + routes: { inspect: route("GET", inspectPath), messages: route("GET", messagesPath) }, + httpStatuses: { inspect: inspectResponse.status, messages: messagesResponse.status }, validation, - inspect: finalResponseConversationSummary(conversation), - list: finalResponseConversationSummary(listedConversation), - body: context.parsed.full === true ? { inspect: inspectResponse.body, list: listResponse.body } : undefined + inspect: finalResponseSessionSummary(session), + messages: finalResponseMessagesSummary(messages), + body: context.parsed.full === true ? { inspect: inspectResponse.body, messages: messagesResponse.body } : undefined }), ok: false, status: "failed" }; } const traceId = requiredTraceId(resolvedTraceId); - const resultPath = `/v1/agent/turns/${encodeURIComponent(traceId)}`; + const resultPath = `/v1/workbench/turns/${encodeURIComponent(traceId)}`; const resultResponse = await requestJson({ ...context, method: "GET", path: resultPath }); const validation = finalResponseValidation({ - conversationId, - projectId, - inspectConversation: conversation, - listedConversation, + sessionId, + inspectSession: session, + messages, traceId, resultBody: resultResponse.body }); - const success = responseSucceeded(inspectResponse) && responseSucceeded(listResponse) && responseSucceeded(resultResponse) && validation.ok; + const success = responseSucceeded(inspectResponse) && responseSucceeded(messagesResponse) && responseSucceeded(resultResponse) && validation.ok; return { ...responsePayload("client.session.final-response", resultResponse, context, { route: route("GET", resultPath), - conversationId, - projectId, + sessionId, traceId, routes: { inspect: route("GET", inspectPath), - list: route("GET", listPath), + messages: route("GET", messagesPath), result: route("GET", resultPath) }, httpStatuses: { inspect: inspectResponse.status, - list: listResponse.status, + messages: messagesResponse.status, result: resultResponse.status }, validation, - inspect: finalResponseConversationSummary(conversation), - list: finalResponseConversationSummary(listedConversation), + inspect: finalResponseSessionSummary(session), + messages: finalResponseMessagesSummary(messages), result: finalResponseResultSummary(resultResponse.body), body: context.parsed.full === true ? { inspect: inspectResponse.body, - list: listResponse.body, + messages: messagesResponse.body, result: resultResponse.body } : finalResponseResultSummary(resultResponse.body) }), @@ -1576,84 +1562,52 @@ async function sessionFinalResponseCommand(context: any) { } async function sessionCurrentCommand(context: any) { - const workspace = await restoreWorkbenchWorkspace(context, { quiet: false }); - return ok("client.session.current", { - baseUrl: baseUrl(context.parsed, context.env), - runtimeEndpoint: runtimeEndpointVisibility(runtimeEndpoint(context.parsed, context.env, "web")), - route: route("GET", `/v1/workbench/workspace?projectId=${encodeURIComponent(text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID)}`), - workspace, - current: sessionSummary(workspace?.selectedConversation) ?? sessionSummary({ - conversationId: workspace?.selectedConversationId, - lastTraceId: workspace?.activeTraceId, - updatedAt: workspace?.updatedAt - }) + const stored = await loadCliWorkbenchState(context); + const includeSessionId = safeOptionalSessionId(context.parsed.sessionId ?? stored?.activeSessionId); + const params = new URLSearchParams(); + params.set("limit", "1"); + if (includeSessionId) params.set("includeSessionId", includeSessionId); + const pathName = `/v1/workbench/sessions?${params.toString()}`; + const response = await requestJson({ ...context, method: "GET", path: pathName }); + const sessions = Array.isArray(response.body?.sessions) ? response.body.sessions : []; + const selected = includeSessionId ? sessions.find((item: any) => text(item?.sessionId) === includeSessionId) ?? sessions[0] : sessions[0]; + return responsePayload("client.session.current", response, context, { + route: route("GET", pathName), + stored, + current: sessionSummary(selected), + body: responseBodyForCli(response.body, context.parsed) }); } async function sessionCreateCommand(context: any) { - const workspace = await restoreWorkbenchWorkspace(context, { quiet: false }); - const workspaceId = text(context.parsed.workspaceId) || text(workspace?.workspaceId); - if (!workspaceId) throw cliError("workspace_restore_required", "session create requires a restored workspace or --workspace-id", { nextCommand: "client workbench restore" }); - const conversationId = safeOptionalConversationId(context.parsed.conversationId); const sessionId = safeOptionalSessionId(context.parsed.sessionId); - const pathName = `/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`; + const pathName = "/v1/agent/sessions"; const response = await requestJson({ ...context, method: "POST", path: pathName, body: clean({ - projectId: text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID, - create: true, - conversationId, sessionId, threadId: text(context.parsed.threadId), + providerProfile: text(context.parsed.providerProfile) || "deepseek", updatedByClient: "hwlab-cli" }) }); - if (responseSucceeded(response) && response.body?.workspace) await saveWorkspaceState(context, response.body.workspace); - return responsePayload("client.session.create", response, context, { route: route("POST", pathName), workspace: normalizeWorkbenchWorkspace(response.body?.workspace), current: sessionSummary(response.body?.workspace?.selectedConversation), body: responseBodyForCli(response.body, context.parsed) }); + if (responseSucceeded(response)) await saveManualAgentSessionState(context, response.body, { providerProfile: text(context.parsed.providerProfile) || "deepseek" }); + return responsePayload("client.session.create", response, context, { route: route("POST", pathName), current: agentSessionSummary(response.body?.session, { sessionId, providerProfile: text(context.parsed.providerProfile) || "deepseek" }), body: responseBodyForCli(response.body, context.parsed) }); } async function sessionSwitchCommand(context: any) { - const conversationId = requiredConversationId(context.rest[1] ?? context.parsed.conversationId); - const workspace = await restoreWorkbenchWorkspace(context, { quiet: false }); - const workspaceId = text(context.parsed.workspaceId) || text(workspace?.workspaceId); - if (!workspaceId) throw cliError("workspace_restore_required", "session switch requires a restored workspace or --workspace-id", { nextCommand: "client workbench restore" }); - const pathName = `/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`; - const response = await requestJson({ - ...context, - method: "POST", - path: pathName, - body: clean({ - projectId: text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID, - conversationId, - sessionId: safeOptionalSessionId(context.parsed.sessionId), - threadId: text(context.parsed.threadId), - updatedByClient: "hwlab-cli" - }) - }); - if (responseSucceeded(response) && response.body?.workspace) await saveWorkspaceState(context, response.body.workspace); - return responsePayload("client.session.switch", response, context, { route: route("POST", pathName), workspace: normalizeWorkbenchWorkspace(response.body?.workspace), current: sessionSummary(response.body?.workspace?.selectedConversation), selectedConversation: finalResponseConversationSummary(response.body?.workspace?.selectedConversation), body: responseBodyForCli(response.body, context.parsed) }); + const sessionId = requiredSessionId(context.rest[1] ?? context.parsed.sessionId); + const pathName = `/v1/workbench/sessions/${encodeURIComponent(sessionId)}`; + const response = await requestJson({ ...context, method: "GET", path: pathName }); + if (responseSucceeded(response)) await saveCliWorkbenchState(context, { activeSessionId: sessionId, activeTraceId: text(response.body?.session?.lastTraceId), threadId: text(response.body?.session?.threadId), providerProfile: text(response.body?.session?.providerProfile) }); + return responsePayload("client.session.switch", response, context, { route: route("GET", pathName), current: sessionSummary(response.body?.session), session: response.body?.session ?? null, body: responseBodyForCli(response.body, context.parsed) }); } async function sessionDeleteCommand(context: any) { - const conversationId = requiredConversationId(context.rest[1] ?? context.parsed.conversationId); - if (context.parsed.confirm !== true) throw cliError("session_delete_confirmation_required", "session delete requires --confirm", { flag: "--confirm", conversationId }); - const workspace = await restoreWorkbenchWorkspace(context, { quiet: true }); - const workspaceId = text(context.parsed.workspaceId) || text(workspace?.workspaceId); - const pathName = `/v1/agent/conversations/${encodeURIComponent(conversationId)}`; - const response = await requestJson({ - ...context, - method: "DELETE", - path: pathName, - body: clean({ - projectId: text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID, - workspaceId, - updatedByClient: "hwlab-cli" - }) - }); - if (responseSucceeded(response) && response.body?.workspace) await saveWorkspaceState(context, response.body.workspace); - return responsePayload("client.session.delete", response, context, { route: route("DELETE", pathName), workspace: normalizeWorkbenchWorkspace(response.body?.workspace), body: responseBodyForCli(response.body, context.parsed) }); + const sessionId = requiredSessionId(context.rest[1] ?? context.parsed.sessionId); + throw cliError("session_delete_removed", "client session delete was tied to a removed legacy Workbench API; use a session lifecycle API when one is introduced.", { sessionId }); } async function agentSessionCommand(context: any) { @@ -1671,8 +1625,8 @@ function agentSessionHelp() { serviceRuntime: false, imagePublished: false, commands: [ - "create [--provider-profile PROFILE] [--conversation-id ID]", - "select SESSION_ID [--workspace-id ID]", + "create [--provider-profile PROFILE] [--session-id ID]", + "select SESSION_ID", "status SESSION_ID", "list [--limit N]" ] @@ -1680,45 +1634,38 @@ function agentSessionHelp() { } 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) || text(workspaceState?.providerProfile) || "deepseek", - projectId: text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID, - workspaceId: text(context.parsed.workspaceId) || text(workspaceState?.workspaceId), + providerProfile: text(context.parsed.providerProfile) || "deepseek", 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); + if (responseSucceeded(response)) await saveManualAgentSessionState(context, response.body, body); const session = agentSessionSummary(response.body?.session, body); return responsePayload("client.agent.session.create", response, context, { route: route("POST", "/v1/agent/sessions"), ...session, session: response.body?.session ?? null, - workspace: workspaceSummaryFromSession(await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() })), + workbench: await loadCliWorkbenchState(context), 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); + if (responseSucceeded(response)) await saveManualAgentSessionState(context, response.body, { ...body, sessionId }); const session = agentSessionSummary(response.body?.session, { sessionId, providerProfile: body.providerProfile }); return responsePayload("client.agent.session.select", response, context, { route: route("POST", pathName), ...session, session: response.body?.session ?? null, - workspace: workspaceSummaryFromSession(await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() })), + workbench: await loadCliWorkbenchState(context), body: responseBodyForCli(response.body, context.parsed) }); } @@ -1738,24 +1685,12 @@ async function fetchAgentSessionForSend(context: any, sessionId: string) { return { response, session }; } -async function fetchAgentConversationForSend(context: any, conversationId: string) { - const projectId = text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID; - const params = new URLSearchParams(); - if (projectId) params.set("projectId", projectId); - const query = params.toString(); - const pathName = `/v1/agent/conversations/${encodeURIComponent(conversationId)}${query ? `?${query}` : ""}`; - const response = await requestJson({ ...context, method: "GET", path: pathName }); - if (!isHttpSuccess(response)) return { response, conversation: null, pathName }; - const conversation = response.body?.conversation && typeof response.body.conversation === "object" ? response.body.conversation : null; - return { response, conversation, pathName }; -} - 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 query = params.toString(); + const pathName = `/v1/agent/sessions${query ? `?${query}` : ""}`; 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) }); } @@ -1768,17 +1703,15 @@ async function agentComposer(context: any) { } async function agentComposerStatus(context: any) { - const workspaceState = await restoreWorkbenchWorkspace(context, { quiet: true }); const localState = await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }); - const composer = computeCliComposerState(context, workspaceState ?? workspaceSummaryFromSession(localState)); + const composer = computeCliComposerState(context, localState?.workbench ?? null); return ok("client.agent.composer.status", { baseUrl: baseUrl(context.parsed, context.env), runtimeEndpoint: runtimeEndpointVisibility(runtimeEndpoint(context.parsed, context.env, "web")), status: "succeeded", route: route("POST", composer.route), composer, - workspace: workspaceState, - workspaceRestore: workspaceState ? "succeeded" : "unavailable", + workbench: localState?.workbench ?? null, nextCommands: composer.submitMode === "steer" ? [`hwlab-cli client agent composer submit --message TEXT`, `hwlab-cli client agent trace ${composer.targetTraceId} --render web`] : ["hwlab-cli client agent composer submit --message TEXT"] @@ -1788,9 +1721,8 @@ async function agentComposerStatus(context: any) { async function agentComposerSubmit(context: any) { const message = await messageValue(context); if (!message) throw cliError("message_required", "client agent composer submit requires --message or stdin text"); - const workspaceState = context.parsed.noWorkspace === true ? null : await restoreWorkbenchWorkspace(context, { quiet: true }); const localState = await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }); - const composer = computeCliComposerState(context, workspaceState ?? workspaceSummaryFromSession(localState)); + const composer = computeCliComposerState(context, localState?.workbench ?? null); if (composer.submitMode === "steer") { const traceId = requiredTraceId(composer.targetTraceId ?? context.parsed.traceId ?? context.parsed.targetTraceId); const steerTraceId = text(context.parsed.steerTraceId) || makeId("trc_steer"); @@ -1799,7 +1731,6 @@ async function agentComposerSubmit(context: any) { targetTraceId: traceId, steerTraceId, message, - conversationId: text(context.parsed.conversationId) || composer.conversationId, sessionId: text(context.parsed.sessionId) || composer.sessionId, threadId: text(context.parsed.threadId) || composer.threadId }); @@ -1820,7 +1751,7 @@ async function agentComposerSubmit(context: any) { traceId, steerTraceId, composer, - workspace: workspaceSummaryFromSession(await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() })), + workbench: await loadCliWorkbenchState(context), body: responseBodyForCli(response.body, context.parsed), waitPolicy: { defaultWait: false, @@ -1838,11 +1769,8 @@ async function agentComposerSubmit(context: any) { parsed: { ...context.parsed, message, - conversationId: text(context.parsed.conversationId) || composer.conversationId, sessionId: text(context.parsed.sessionId) || composer.sessionId, threadId: text(context.parsed.threadId) || composer.threadId, - workspaceId: text(context.parsed.workspaceId) || composer.workspaceId, - expectedWorkspaceRevision: numberOption(context.parsed.expectedWorkspaceRevision) ?? composer.workspaceRevision } }; const result = await agentSend(sendContext); @@ -1850,19 +1778,14 @@ async function agentComposerSubmit(context: any) { } function computeCliComposerState(context: any, workspaceState: any) { - const state = workspaceState; - const selectedConversation = state?.selectedConversation && typeof state.selectedConversation === "object" ? state.selectedConversation : null; - const selectedSession = selectedConversation?.session && typeof selectedConversation.session === "object" ? selectedConversation.session : null; + const state = workspaceState && typeof workspaceState === "object" ? workspaceState : {}; return computeCodeAgentComposerState({ - workspace: state ? { activeTraceId: state.activeTraceId, workspaceId: state.workspaceId, revision: state.revision } : null, - conversationId: text(context.parsed.conversationId) || text(selectedConversation?.conversationId) || text(state?.selectedConversationId), - sessionId: text(context.parsed.sessionId) || text(selectedConversation?.sessionId ?? selectedSession?.sessionId), - threadId: text(context.parsed.threadId) || text(selectedConversation?.threadId ?? selectedSession?.threadId), - sessionStatus: text(context.parsed.sessionStatus) || text(selectedConversation?.status ?? selectedSession?.status), + workspace: state ? { activeTraceId: state.activeTraceId } : null, + sessionId: text(context.parsed.sessionId) || text(state.activeSessionId), + threadId: text(context.parsed.threadId) || text(state.threadId), + sessionStatus: text(context.parsed.sessionStatus) || text(state.sessionStatus), targetTraceId: text(context.parsed.traceId ?? context.parsed.targetTraceId) || text(state?.activeTraceId), - workspaceId: text(context.parsed.workspaceId) || text(state?.workspaceId), - workspaceRevision: numberOption(context.parsed.expectedWorkspaceRevision) ?? state?.revision, - messages: Array.isArray(selectedConversation?.messages) ? selectedConversation.messages : Array.isArray(state?.messages) ? state.messages : undefined + messages: Array.isArray(state?.messages) ? state.messages : undefined }); } @@ -1876,7 +1799,6 @@ async function agentSteer(context: any) { targetTraceId: traceId, steerTraceId, message, - conversationId: text(context.parsed.conversationId), sessionId: text(context.parsed.sessionId), threadId: text(context.parsed.threadId) }); @@ -1912,15 +1834,13 @@ async function agentSteer(context: any) { function agentInspectPath(context: any) { const params = new URLSearchParams(); const traceId = text(context.rest[1] ?? context.parsed.traceId); - const conversationId = text(context.parsed.conversationId); const sessionId = text(context.parsed.sessionId); const threadId = text(context.parsed.threadId); if (traceId) params.set("traceId", requiredTraceId(traceId)); - if (conversationId) params.set("conversationId", conversationId); if (sessionId) params.set("sessionId", sessionId); if (threadId) params.set("threadId", threadId); if ([...params.keys()].length === 0) { - throw cliError("missing_inspect_query", "client agent inspect requires --trace-id, --conversation-id, --session-id, or --thread-id"); + throw cliError("missing_inspect_query", "client agent inspect requires --trace-id, --session-id, or --thread-id"); } return `/v1/agent/chat/inspect?${params.toString()}`; } @@ -2011,7 +1931,7 @@ function harnessHelp() { aliases: ["harness", "harness-ops", "harness-opt"], commands: [ "health [--base-url URL]", - "submit --message TEXT|--message-file PATH [--conversation-id ID] [--trace-id ID] [--provider-profile PROFILE]", + "submit --message TEXT|--message-file PATH [--session-id ID] [--trace-id ID] [--provider-profile PROFILE]", "result TRACE_ID [--full]", "trace TRACE_ID [--limit N] [--full]", "wait TRACE_ID [--timeout-ms N] [--poll-interval-ms N]", @@ -2178,83 +2098,41 @@ async function agentSend(context: any) { if (!message) throw cliError("message_required", "client agent send requires --message or stdin text"); const replay = await resolveAgentReplayContext(context); const traceId = text(parsed.traceId) || makeId("trc"); - const workspaceState = parsed.noWorkspace === true ? null : await restoreWorkbenchWorkspace(context, { quiet: true }); - const explicitConversationId = text(parsed.conversationId); - const replayConversationId = replay.conversationId; - const requestedConversationId = explicitConversationId || replayConversationId; - const workspaceConversationId = text(workspaceState?.selectedConversationId); - const explicitSessionId = text(parsed.sessionId); + const localWorkbench = await loadCliWorkbenchState(context); + const explicitSessionId = safeOptionalSessionId(parsed.sessionId); + const sessionId = explicitSessionId || replay.sessionId || safeOptionalSessionId(localWorkbench?.activeSessionId); let explicitSessionRecord = null; - if (explicitSessionId && !text(parsed.providerProfile)) { - const lookup = await fetchAgentSessionForSend(context, explicitSessionId); + if (sessionId && !text(parsed.providerProfile)) { + const lookup = await fetchAgentSessionForSend(context, sessionId); if (!isHttpSuccess(lookup.response)) { - return responsePayload("client.agent.send", lookup.response, context, { route: route("GET", `/v1/agent/sessions/${encodeURIComponent(explicitSessionId)}`), traceId, sessionId: explicitSessionId, replay: replay.summary }); + return responsePayload("client.agent.send", lookup.response, context, { route: route("GET", `/v1/agent/sessions/${encodeURIComponent(sessionId)}`), traceId, sessionId, replay: replay.summary }); } explicitSessionRecord = lookup.session; - if (requestedConversationId && text(explicitSessionRecord?.conversationId) && text(explicitSessionRecord.conversationId) !== requestedConversationId) { - throw cliError("session_conversation_mismatch", "client agent send --session-id --conversation-id points at a session whose conversationId does not match; re-create the session for the conversation or pass --provider-profile to keep the user-specified profile without inheriting the session identity", { - traceId, - sessionId: explicitSessionId, - requestedConversationId, - sessionConversationId: text(explicitSessionRecord.conversationId), - nextCommands: [ - `hwlab-cli client agent session create --conversation-id ${requestedConversationId}`, - "hwlab-cli client agent send --session-id --message TEXT" - ] - }); - } } - const explicitSessionConversationId = text(explicitSessionRecord?.conversationId); - const conversationId = requestedConversationId || explicitSessionConversationId || workspaceConversationId; - let conversationRecord = null; - if (conversationId && !explicitSessionRecord && !explicitSessionId && !replay.sessionId) { - const lookup = await fetchAgentConversationForSend(context, conversationId); - if (!isHttpSuccess(lookup.response)) { - return responsePayload("client.agent.send", lookup.response, context, { route: route("GET", lookup.pathName), traceId, conversationId, replay: replay.summary }); - } - conversationRecord = lookup.conversation; - } - const conversationSessionId = text(conversationRecord?.sessionId ?? conversationRecord?.session?.sessionId); - const conversationMatchesExplicitSession = !explicitSessionId || !conversationSessionId || conversationSessionId === explicitSessionId; - const sessionId = explicitSessionId || replay.sessionId || conversationSessionId; 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 PROFILE", "hwlab-cli client agent send --session-id --message TEXT --provider-profile PROFILE" ] }); } - const sessionStatus = explicitSessionRecord - ? text(explicitSessionRecord.status) - : conversationMatchesExplicitSession - ? text(conversationRecord?.status ?? conversationRecord?.session?.status) - : ""; + const sessionStatus = text(explicitSessionRecord?.status ?? localWorkbench?.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 PROFILE"] }); } - 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 PROFILE"] - }); - } const threadId = text(parsed.threadId) || replay.threadId || (explicitSessionRecord ? text(explicitSessionRecord.threadId) : "") - || (conversationMatchesExplicitSession ? text(conversationRecord?.threadId ?? conversationRecord?.session?.threadId) : ""); + || text(localWorkbench?.threadId); const retryOf = text(parsed.retryOf) || replay.retryOf; const continuation = agentContinuationSummary({ - conversationId, sessionId, threadId, retryOf, @@ -2262,11 +2140,10 @@ async function agentSend(context: any) { }); const requestProviderProfile = text(parsed.providerProfile) || text(explicitSessionRecord?.providerProfile) - || (conversationMatchesExplicitSession ? text(conversationRecord?.providerProfile ?? conversationRecord?.session?.providerProfile) : "") + || text(localWorkbench?.providerProfile) || "deepseek"; const requestBody = clean({ message, - conversationId, sessionId, threadId, retryOf, @@ -2274,9 +2151,6 @@ async function agentSend(context: any) { providerProfile: requestProviderProfile, gatewayShellTimeoutMs: numberOption(parsed.gatewayShellTimeoutMs), shortConnection: true, - projectId: text(parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID, - workspaceId: text(parsed.workspaceId) || text(workspaceState?.workspaceId), - expectedWorkspaceRevision: numberOption(parsed.expectedWorkspaceRevision) ?? workspaceState?.revision, updatedByClient: "hwlab-cli" }); const accepted = await requestJson({ @@ -2292,58 +2166,62 @@ async function agentSend(context: any) { } }); if (!isHttpSuccess(accepted)) { - return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation, replay: replay.summary }); + return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, sessionId, continuation, replay: replay.summary }); } - await saveAcceptedAgentWorkspaceState(context, accepted.body, { traceId, conversationId, sessionId, threadId, providerProfile: requestBody.providerProfile, message }); + await saveAcceptedAgentState(context, accepted.body, { traceId, sessionId, threadId, providerProfile: requestBody.providerProfile, message }); if (parsed.wait !== true) { - return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation, replay: replay.summary, workspace: workspaceSummaryFromSession(await loadStoredState({ parsed, env: context.env, cwd: context.cwd ?? process.cwd() })), waited: false, waitPolicy: agentSendWaitPolicy(traceId) }); + return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, sessionId, continuation, replay: replay.summary, workbench: await loadCliWorkbenchState(context), waited: false, waitPolicy: agentSendWaitPolicy(traceId) }); } const result = await pollAgentResult(context, traceId, accepted.body); - const completedWorkspace = await saveCompletedAgentWorkspaceState(context, result.response?.body, { traceId, conversationId, sessionId, threadId, providerProfile: requestBody.providerProfile, message }); + const completedState = await saveCompletedAgentState(context, result.response?.body, { traceId, sessionId, threadId, providerProfile: requestBody.providerProfile, message }); return ok("client.agent.send", { status: result.final ? "succeeded" : "timeout", route: route("POST", "/v1/agent/chat"), traceId, - conversationId, + sessionId, continuation, replay: replay.summary, accepted: compactBody(accepted.body), result: result.response ? compactResponse(result.response) : null, - workspace: completedWorkspace ?? workspaceSummaryFromSession(await loadStoredState({ parsed, env: context.env, cwd: context.cwd ?? process.cwd() })), + workbench: completedState ?? await loadCliWorkbenchState(context), polls: result.polls, timeoutMs: result.timeoutMs, resultUrl: result.resultPath }, 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; - } +async function saveManualAgentSessionState(context: any, body: any, fallback: any = {}) { 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), + return await saveCliWorkbenchState(context, { + activeSessionId: text(sessionRecord.sessionId) || text(fallback.sessionId), + threadId: text(sessionRecord.threadId) || text(fallback.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() + providerProfile: text(fallback.providerProfile) || text(sessionRecord.providerProfile), + sessionStatus: text(sessionRecord.status) + }); +} + +async function saveAcceptedAgentState(context: any, acceptedBody: any, fallback: any) { + return await saveCliWorkbenchState(context, { + activeSessionId: text(acceptedBody?.sessionId) || text(fallback.sessionId), + threadId: text(acceptedBody?.threadId ?? acceptedBody?.session?.threadId ?? acceptedBody?.providerTrace?.threadId) || text(fallback.threadId), + activeTraceId: text(acceptedBody?.traceId) || text(fallback.traceId), + providerProfile: text(fallback.providerProfile), + sessionStatus: "running" + }); +} + +async function saveCompletedAgentState(context: any, resultBody: any, fallback: any) { + const status = text(resultBody?.status); + return await saveCliWorkbenchState(context, { + activeSessionId: text(resultBody?.sessionId) || text(fallback.sessionId), + threadId: text(resultBody?.threadId ?? resultBody?.session?.threadId ?? resultBody?.providerTrace?.threadId) || text(fallback.threadId), + activeTraceId: status && status !== "running" ? null : text(resultBody?.traceId) || text(fallback.traceId), + providerProfile: text(fallback.providerProfile), + sessionStatus: status || undefined, + messages: resultBody ? compactAgentMessagesForWorkbench(resultBody, fallback) : undefined }); - 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) { @@ -2382,9 +2260,8 @@ function agentSendWaitPolicy(traceId: string) { async function workbenchCommand(context: any) { const subcommand = context.rest[0] || "summary"; if (wantsHelp(context)) return workbenchHelp(); - if (subcommand === "restore" || subcommand === "status") return workbenchRestoreCommand(context, subcommand); - if (subcommand === "watch") return workbenchWatchCommand(context); - if (subcommand === "reset") return workbenchResetCommand(context); + if (subcommand === "status") return workbenchStatusCommand(context); + if (["restore", "watch", "reset"].includes(subcommand)) return removedWorkbenchWorkspaceCommand(subcommand); if (subcommand !== "summary") throw cliError("unsupported_workbench_command", `unsupported workbench command: ${subcommand}`, { subcommand }); const paths = [ "/health/live", @@ -2411,45 +2288,33 @@ function workbenchHelp() { imagePublished: false, commands: [ "summary", - "restore [--profile NAME]", - "status [--profile NAME]", - "watch [--after-revision REVISION]", - "reset --confirm" + "status [--profile NAME]" ] }); } -async function workbenchRestoreCommand(context: any, subcommand: string) { - const workspace = await restoreWorkbenchWorkspace(context, { quiet: false }); - return ok(`client.workbench.${subcommand}`, { - baseUrl: baseUrl(context.parsed, context.env), - runtimeEndpoint: runtimeEndpointVisibility(runtimeEndpoint(context.parsed, context.env, "web")), +async function workbenchStatusCommand(context: any) { + const stored = await loadCliWorkbenchState(context); + const includeSessionId = safeOptionalSessionId(context.parsed.sessionId ?? stored?.activeSessionId); + const params = new URLSearchParams(); + params.set("limit", "20"); + if (includeSessionId) params.set("includeSessionId", includeSessionId); + const pathName = `/v1/workbench/sessions?${params.toString()}`; + const response = await requestJson({ ...context, method: "GET", path: pathName }); + return responsePayload("client.workbench.status", response, context, { + route: route("GET", pathName), stateFile: stateFile(context.parsed, context.cwd ?? process.cwd(), context.env), - workspace, - localSession: sessionStateSummary(await readSessionState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() })) + stored, + sessions: sessionSummaries(response.body?.sessions), + body: responseBodyForCli(response.body, context.parsed) }); } -async function workbenchWatchCommand(context: any) { - const session = await loadSession({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }); - const workspaceId = text(context.parsed.workspaceId) || text(session?.workspace?.workspaceId); - if (!workspaceId) throw cliError("workspace_restore_required", "workbench watch requires a restored workspace or --workspace-id", { nextCommand: "client workbench restore" }); - const afterRevision = numberOption(context.parsed.afterRevision) ?? (Number(session?.workspace?.revision ?? 0) || 0); - const pathName = `/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/events?afterRevision=${encodeURIComponent(String(afterRevision))}`; - const response = await requestJson({ ...context, method: "GET", path: pathName }); - if (responseSucceeded(response) && response.body?.workspace) await saveWorkspaceState(context, response.body.workspace); - return responsePayload("client.workbench.watch", response, context, { route: route("GET", pathName), body: responseBodyForCli(response.body, context.parsed) }); -} - -async function workbenchResetCommand(context: any) { - if (context.parsed.confirm !== true) throw cliError("workspace_reset_confirmation_required", "workbench reset requires --confirm", { flag: "--confirm" }); - const session = await loadSession({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }); - const workspaceId = text(context.parsed.workspaceId) || text(session?.workspace?.workspaceId); - if (!workspaceId) throw cliError("workspace_restore_required", "workbench reset requires a restored workspace or --workspace-id", { nextCommand: "client workbench restore" }); - const pathName = `/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/reset`; - const response = await requestJson({ ...context, method: "POST", path: pathName, body: { confirm: true, updatedByClient: "hwlab-cli" } }); - if (responseSucceeded(response) && response.body?.workspace) await saveWorkspaceState(context, response.body.workspace); - return responsePayload("client.workbench.reset", response, context, { route: route("POST", pathName), body: responseBodyForCli(response.body, context.parsed) }); +function removedWorkbenchWorkspaceCommand(subcommand: string) { + throw cliError("workbench_legacy_command_removed", `client workbench ${subcommand} was tied to a removed legacy Workbench API; use client workbench status or client session switch SESSION_ID.`, { + subcommand, + nextCommands: ["hwlab-cli client workbench status", "hwlab-cli client session switch "] + }); } async function requestCommand(context: any) { @@ -3059,183 +2924,85 @@ function authNextCommands(authState: any) { ]; } -async function restoreWorkbenchWorkspace(context: any, { quiet = false } = {}) { - const projectId = text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID; - const pathName = `/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`; - const response = await requestJson({ ...context, method: "GET", path: pathName, timeoutMs: numberOption(context.parsed.restoreTimeoutMs) ?? DEFAULT_TIMEOUT_MS }); - if (!isHttpSuccess(response) || response.body?.ok === false) { - if (quiet) return null; - throw cliError("workspace_restore_failed", "failed to restore workbench workspace", { httpStatus: response.status, body: compactApiBody(response.body) }); - } - const workspace = normalizeWorkbenchWorkspace(response.body?.workspace); - await saveWorkspaceState(context, response.body?.workspace); - return workspace; +async function loadCliWorkbenchState(context: any) { + const session = await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }) ?? {}; + return normalizeCliWorkbenchState(session.workbench); } -async function saveAcceptedAgentWorkspaceState(context: any, acceptedBody: any, fallback: any) { +async function saveCliWorkbenchState(context: any, patch: 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({ + const existing = normalizeCliWorkbenchState(session.workbench) ?? {}; + const next = normalizeCliWorkbenchState({ ...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) || (existingMatchesFallback ? text(existing.threadId) : ""), - activeTraceId: text(acceptedBody?.traceId) || text(fallback.traceId), - providerProfile: text(fallback.providerProfile) || text(existing.providerProfile), + ...patch, updatedByClient: "hwlab-cli", - updatedAt: new Date().toISOString() + updatedAt: context.now() }); - await saveSession(context, { ...session, baseUrl: baseUrl(context.parsed, context.env), workspace }); + await saveSession(context, { ...session, baseUrl: baseUrl(context.parsed, context.env), workbench: next, updatedAt: context.now() }); + return next; } -async function saveCompletedAgentWorkspaceState(context: any, resultBody: 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 resultWorkspace = resultBody ? workspaceWithAgentResult(existing, resultBody, fallback) : null; - if (resultWorkspace) { - await saveSession(context, { ...session, baseUrl: baseUrl(context.parsed, context.env), workspace: resultWorkspace, updatedAt: context.now() }); - } - const workspaceId = text(existing.workspaceId); - if (!workspaceId) return resultWorkspace; - const status = text(resultBody?.status); - const terminal = status && status !== "running"; - const pathName = `/v1/workbench/workspace/${encodeURIComponent(workspaceId)}`; - const body = clean({ - projectId: text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID, - expectedRevision: numberOption(existing.revision), - selectedConversationId: text(resultBody?.conversationId) || text(fallback.conversationId) || text(existing.selectedConversationId), - selectedAgentSessionId: text(resultBody?.sessionId) || text(fallback.sessionId) || text(existing.selectedAgentSessionId), - activeTraceId: terminal ? undefined : text(resultBody?.traceId) || text(fallback.traceId), - providerProfile: text(fallback.providerProfile) || text(existing.providerProfile), - sessionStatus: status || undefined, - threadId: text(resultBody?.threadId ?? resultBody?.session?.threadId ?? resultBody?.providerTrace?.threadId ?? fallback.threadId), - lastTraceId: text(resultBody?.traceId) || text(fallback.traceId), - messages: resultBody ? compactAgentMessagesForWorkspace(resultBody, fallback) : undefined, - updatedByClient: "hwlab-cli" - }); - if (terminal) body.activeTraceId = null; - const response = await requestJson({ - ...context, - method: "PATCH", - path: pathName, - body, - timeoutMs: DEFAULT_TIMEOUT_MS - }); - if (!responseSucceeded(response)) return resultWorkspace; - if (response.body?.workspace) { - const workspace = workspaceWithAgentResult(response.body.workspace, resultBody, fallback); - await saveWorkspaceState(context, workspace); - return workspace; - } - const restored = await restoreWorkbenchWorkspace(context, { quiet: true }); - const workspace = workspaceWithAgentResult(restored, resultBody, fallback); - if (workspace) await saveWorkspaceState(context, workspace); - return workspace ?? resultWorkspace; -} - -function workspaceWithAgentResult(workspace: any, resultBody: any, fallback: any) { - const base = normalizeWorkbenchWorkspace(workspace) ?? {}; - const conversationId = text(resultBody?.conversationId) || text(fallback.conversationId) || text(base.selectedConversationId); - const sessionId = text(resultBody?.sessionId) || text(fallback.sessionId) || text(base.selectedAgentSessionId); - const resultThreadId = text(resultBody?.threadId ?? resultBody?.session?.threadId ?? resultBody?.providerTrace?.threadId ?? fallback.threadId); - const baseConversationMatches = !conversationId || text(base.selectedConversationId) === conversationId; - const baseSessionMatches = !sessionId || text(base.selectedAgentSessionId) === sessionId; - const nestedConversationMatches = selectedConversationMatches(base.selectedConversation, { conversationId, sessionId }); - const baseThreadId = baseConversationMatches && baseSessionMatches ? text(base.threadId) : ""; - const selectedConversation = baseConversationMatches && baseSessionMatches && nestedConversationMatches - ? base.selectedConversation - : clean({ conversationId, sessionId, threadId: resultThreadId || baseThreadId }); +function normalizeCliWorkbenchState(value: any) { + if (!value || typeof value !== "object") return null; return clean({ - ...base, - selectedConversationId: conversationId, - selectedAgentSessionId: sessionId, - providerProfile: text(fallback.providerProfile) || text(base.providerProfile), - sessionStatus: text(resultBody?.status) || text(base.sessionStatus), - threadId: resultThreadId || baseThreadId, - selectedConversation, - updatedByClient: "hwlab-cli" + activeSessionId: safeOptionalSessionId(value.activeSessionId), + activeTraceId: text(value.activeTraceId), + threadId: text(value.threadId), + providerProfile: text(value.providerProfile), + sessionStatus: text(value.sessionStatus), + messages: Array.isArray(value.messages) ? value.messages : undefined, + updatedAt: text(value.updatedAt), + updatedByClient: text(value.updatedByClient), + valuesRedacted: value.valuesRedacted === true ? true : undefined, + secretMaterialStored: value.secretMaterialStored === true ? true : undefined }); } -function selectedConversationMatches(selectedConversation: any, expected: any) { - if (!selectedConversation || typeof selectedConversation !== "object") return false; - const conversationId = text(expected.conversationId); - const sessionId = text(expected.sessionId); - return (!conversationId || text(selectedConversation.conversationId) === conversationId) && (!sessionId || text(selectedConversation.sessionId ?? selectedConversation.session?.sessionId) === sessionId); +function sessionSummaries(sessions: any) { + return Array.isArray(sessions) ? sessions.map(sessionSummary).filter(Boolean) : []; } -async function saveWorkspaceState(context: any, workspaceBody: any) { - const session = await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }) ?? {}; - await saveSession(context, { ...session, baseUrl: baseUrl(context.parsed, context.env), workspace: normalizeWorkbenchWorkspace(workspaceBody), updatedAt: context.now() }); -} - -function normalizeWorkbenchWorkspace(workspace: any) { - if (!workspace || typeof workspace !== "object") return null; - const workspaceJson = workspace.workspace && typeof workspace.workspace === "object" ? workspace.workspace : {}; +function sessionSummary(session: any) { + if (!session || typeof session !== "object") return null; return clean({ - workspaceId: text(workspace.workspaceId), - ownerUserId: text(workspace.ownerUserId), - projectId: text(workspace.projectId), - revision: numberOption(workspace.revision), - selectedConversationId: text(workspace.selectedConversationId ?? workspaceJson.selectedConversationId), - selectedAgentSessionId: text(workspace.selectedAgentSessionId ?? workspaceJson.selectedAgentSessionId), - activeTraceId: text(workspace.activeTraceId ?? workspaceJson.activeTraceId), - providerProfile: text(workspace.providerProfile ?? workspaceJson.providerProfile), - sessionStatus: text(workspaceJson.sessionStatus), - threadId: workbenchThreadId(workspace, workspaceJson), - updatedAt: text(workspace.updatedAt), - updatedByClient: text(workspace.updatedByClient), - messages: Array.isArray(workspaceJson.messages) ? workspaceJson.messages : undefined, - selectedConversation: workspace.selectedConversation ? compactApiBody(workspace.selectedConversation) : undefined, - valuesRedacted: workspace.valuesRedacted === true, - secretMaterialStored: workspace.secretMaterialStored === true + sessionId: text(session.sessionId ?? session.id), + threadId: text(session.threadId ?? session.session?.threadId), + status: text(session.status ?? session.sessionStatus), + lastTraceId: text(session.lastTraceId ?? session.currentTraceId), + providerProfile: text(session.providerProfile), + updatedAt: text(session.updatedAt), + messageCount: numberOrZero(session.messageCount) || (Array.isArray(session.messages) ? session.messages.length : 0) || undefined, + firstUserMessagePreview: text(session.firstUserMessagePreview) || undefined, + valuesRedacted: session.valuesRedacted === true ? true : undefined }); } -function sessionSummaries(conversations: any) { - return Array.isArray(conversations) ? conversations.map(sessionSummary).filter(Boolean) : []; +function latestAgentMessage(source: any) { + const messages = Array.isArray(source?.messages) ? source.messages : []; + return [...messages].reverse().find((message: any) => ["agent", "assistant"].includes(text(message?.role))) ?? null; } -function sessionSummary(conversation: any) { - if (!conversation || typeof conversation !== "object") return null; - return clean({ - conversationId: text(conversation.conversationId), - sessionId: text(conversation.sessionId ?? conversation.session?.sessionId), - threadId: text(conversation.threadId ?? conversation.session?.threadId), - status: text(conversation.status ?? conversation.snapshot?.sessionStatus), - lastTraceId: text(conversation.lastTraceId), - updatedAt: text(conversation.updatedAt), - messageCount: numberOrZero(conversation.messageCount) || (Array.isArray(conversation.messages) ? conversation.messages.length : 0) || undefined, - firstUserMessagePreview: text(conversation.firstUserMessagePreview ?? conversation.snapshot?.firstUserMessagePreview) || undefined, - valuesRedacted: conversation.valuesRedacted === true ? true : undefined - }); -} - -function latestAgentMessage(conversation: any) { - const messages = Array.isArray(conversation?.messages) ? conversation.messages : []; - return [...messages].reverse().find((message: any) => text(message?.role) === "agent") ?? null; -} - -function finalResponseConversationSummary(conversation: any) { - if (!conversation || typeof conversation !== "object") return null; - const latest = latestAgentMessage(conversation); +function finalResponseSessionSummary(session: any) { + if (!session || typeof session !== "object") return null; + const latest = latestAgentMessage(session); return clean({ found: true, - conversationId: text(conversation.conversationId), - projectId: text(conversation.projectId), - sessionId: text(conversation.sessionId ?? conversation.session?.sessionId), - threadId: text(conversation.threadId ?? conversation.session?.threadId), - status: text(conversation.status ?? conversation.snapshot?.sessionStatus), - lastTraceId: text(conversation.lastTraceId), - updatedAt: text(conversation.updatedAt), - snapshotSource: text(conversation.snapshot?.source), + ...sessionSummary(session), + latestAgent: latest ? clean({ + id: text(latest.id), + status: text(latest.status), + traceId: text(latest.traceId), + updatedAt: text(latest.updatedAt), + textPreview: preview(latest.text, 240), + markdown: markdownTextSummary(latest.text) + }) : undefined + }); +} + +function finalResponseMessagesSummary(messages: any[]) { + const latest = latestAgentMessage({ messages }); + return clean({ + count: Array.isArray(messages) ? messages.length : 0, latestAgent: latest ? clean({ id: text(latest.id), status: text(latest.status), @@ -3252,7 +3019,6 @@ function finalResponseResultSummary(body: any) { return clean({ status: text(body?.status), traceId: text(body?.traceId), - conversationId: text(body?.conversationId), sessionId: text(body?.sessionId), threadId: text(body?.threadId ?? body?.session?.threadId ?? body?.providerTrace?.threadId), assistantTextPreview: preview(resultText, 240), @@ -3278,18 +3044,17 @@ function markdownTextSummary(value: unknown) { }); } -function finalResponseEarlyValidation({ conversationId, projectId, routeName, response, failure }: any) { +function finalResponseEarlyValidation({ sessionId, routeName, response, failure }: any) { const failures = [failure].filter(Boolean); return { ok: false, state: "failed", summary: `${routeName} step failed before final response comparison`, - conversationId, - projectId: text(projectId) || undefined, + sessionId, traceId: undefined, checks: { inspectFound: false, - listFound: false, + messagesFound: false, resultHasFinalText: false }, httpStatus: response?.status, @@ -3298,102 +3063,82 @@ function finalResponseEarlyValidation({ conversationId, projectId, routeName, re : compactApiBody(response?.body), failures, nextCommands: [ - `hwlab-cli client session inspect ${conversationId} --full`, - text(projectId) ? `hwlab-cli client session list --project-id ${projectId} --limit 20 --full` : "hwlab-cli client auth status" + `hwlab-cli client session inspect ${sessionId} --full`, + `hwlab-cli client session final-response ${sessionId} --full` ] }; } -function finalResponseValidation({ conversationId, projectId, inspectConversation, listedConversation, traceId, resultBody }: any) { - const inspectedLatest = latestAgentMessage(inspectConversation); - const listedLatest = latestAgentMessage(listedConversation); +function finalResponseValidation({ sessionId, inspectSession, messages, traceId, resultBody }: any) { + const inspectedLatest = latestAgentMessage(inspectSession); + const messageLatest = latestAgentMessage({ messages }); const inspectText = text(inspectedLatest?.text); - const listText = text(listedLatest?.text); + const messageText = text(messageLatest?.text); const resultText = assistantText(resultBody) || ""; const inspectMarkdown = markdownTextSummary(inspectText) ?? {}; - const listMarkdown = markdownTextSummary(listText) ?? {}; + const messageMarkdown = markdownTextSummary(messageText) ?? {}; const resultMarkdown = markdownTextSummary(resultText) ?? {}; const failures = [ - !inspectConversation ? "inspect-conversation-missing" : "", - !listedConversation ? "list-conversation-missing" : "", + !inspectSession ? "inspect-session-missing" : "", + !Array.isArray(messages) ? "messages-page-missing" : "", !traceId ? "trace-id-missing" : "", !resultText ? "result-final-response-missing" : "", - text(inspectConversation?.lastTraceId) !== traceId ? "inspect-last-trace-mismatch" : "", - listedConversation && text(listedConversation?.lastTraceId) !== traceId ? "list-last-trace-mismatch" : "", + text(inspectSession?.lastTraceId) && text(inspectSession?.lastTraceId) !== traceId ? "inspect-last-trace-mismatch" : "", inspectedLatest && text(inspectedLatest.traceId) !== traceId ? "inspect-latest-agent-trace-mismatch" : "", - listedLatest && text(listedLatest.traceId) !== traceId ? "list-latest-agent-trace-mismatch" : "", + messageLatest && text(messageLatest.traceId) !== traceId ? "message-latest-agent-trace-mismatch" : "", inspectText && resultText && inspectText !== resultText ? "inspect-text-result-mismatch" : "", - listText && resultText && listText !== resultText ? "list-text-result-mismatch" : "", + messageText && resultText && messageText !== resultText ? "message-text-result-mismatch" : "", inspectMarkdown.likelyFlattened === true && resultMarkdown.newlineCount > 0 ? "inspect-markdown-flattened" : "", - listMarkdown.likelyFlattened === true && resultMarkdown.newlineCount > 0 ? "list-markdown-flattened" : "" + messageMarkdown.likelyFlattened === true && resultMarkdown.newlineCount > 0 ? "message-markdown-flattened" : "" ].filter(Boolean); return { ok: failures.length === 0, state: failures.length === 0 ? "passed" : "failed", summary: failures.length === 0 - ? "list, inspect, and agent result agree on the final assistant response" + ? "session detail, message page, and Workbench turn agree on the final assistant response" : `final response validation failed with ${failures.length} issue(s)`, - conversationId, - projectId, + sessionId, traceId, checks: { - inspectFound: Boolean(inspectConversation), - listFound: Boolean(listedConversation), + inspectFound: Boolean(inspectSession), + messagesFound: Array.isArray(messages), resultHasFinalText: Boolean(resultText), - inspectLastTraceMatches: text(inspectConversation?.lastTraceId) === traceId, - listLastTraceMatches: listedConversation ? text(listedConversation?.lastTraceId) === traceId : false, + inspectLastTraceMatches: !text(inspectSession?.lastTraceId) || text(inspectSession?.lastTraceId) === traceId, inspectLatestAgentTraceMatches: inspectedLatest ? text(inspectedLatest.traceId) === traceId : false, - listLatestAgentTraceMatches: listedLatest ? text(listedLatest.traceId) === traceId : false, + messageLatestAgentTraceMatches: messageLatest ? text(messageLatest.traceId) === traceId : false, inspectTextMatchesResult: Boolean(inspectText && resultText && inspectText === resultText), - listTextMatchesResult: Boolean(listText && resultText && listText === resultText), + messageTextMatchesResult: Boolean(messageText && resultText && messageText === resultText), inspectMarkdownNotFlattened: inspectMarkdown.likelyFlattened !== true, - listMarkdownNotFlattened: listMarkdown.likelyFlattened !== true, + messageMarkdownNotFlattened: messageMarkdown.likelyFlattened !== true, resultMarkdownHasStructure: resultMarkdown.newlineCount > 0 || resultMarkdown.hasTableSeparator === true || resultMarkdown.hasFence === true }, previews: { inspectText: preview(inspectText, 200), - listText: preview(listText, 200), + messageText: preview(messageText, 200), resultText: preview(resultText, 200) }, markdown: { inspect: inspectMarkdown, - list: listMarkdown, + messages: messageMarkdown, result: resultMarkdown }, failures, nextCommands: [ - `hwlab-cli client session inspect ${conversationId} --full`, - `hwlab-cli client session list --project-id ${projectId} --limit 20 --full`, + `hwlab-cli client session inspect ${sessionId} --full`, + `hwlab-cli client session final-response ${sessionId} --full`, `hwlab-cli client agent result ${traceId} --full` ] }; } + function numberOrZero(value: any) { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; } -function workbenchThreadId(workspace: any, workspaceJson: any) { - const selectedConversation = workspace?.selectedConversation && typeof workspace.selectedConversation === "object" ? workspace.selectedConversation : {}; - const selectedConversationJson = workspaceJson?.selectedConversation && typeof workspaceJson.selectedConversation === "object" ? workspaceJson.selectedConversation : {}; - const topConversationId = text(workspace?.selectedConversationId ?? workspaceJson?.selectedConversationId); - const selectedConversationThreadId = selectedConversationMatches(selectedConversation, { conversationId: topConversationId }) - ? text(selectedConversation.threadId ?? selectedConversation.session?.threadId) - : ""; - const selectedConversationJsonThreadId = selectedConversationMatches(selectedConversationJson, { conversationId: topConversationId }) - ? text(selectedConversationJson.threadId ?? selectedConversationJson.session?.threadId) - : ""; - return selectedConversationThreadId || selectedConversationJsonThreadId; -} - -function workspaceSummaryFromSession(session: any) { - return normalizeWorkbenchWorkspace(session?.workspace); -} - function agentSessionSummary(session: any, fallback: any = {}) { return pruneUndefined({ - sessionId: text(session?.sessionId) || text(fallback.sessionId), - conversationId: text(session?.conversationId) || text(fallback.conversationId), + sessionId: text(session?.sessionId) || text(session?.id) || text(fallback.sessionId), threadId: text(session?.threadId) || text(fallback.threadId), providerProfile: text(session?.providerProfile) || text(fallback.providerProfile), sessionStatus: text(session?.status) || text(fallback.status), @@ -3401,9 +3146,8 @@ function agentSessionSummary(session: any, fallback: any = {}) { }); } -function compactAgentMessagesForWorkspace(resultBody: any, fallback: any) { +function compactAgentMessagesForWorkbench(resultBody: any, fallback: any) { const traceId = text(resultBody?.traceId) || text(fallback.traceId); - const conversationId = text(resultBody?.conversationId) || text(fallback.conversationId); const sessionId = text(resultBody?.sessionId) || text(fallback.sessionId); const threadId = text(resultBody?.threadId ?? resultBody?.session?.threadId ?? resultBody?.providerTrace?.threadId ?? fallback.threadId); const now = new Date().toISOString(); @@ -3417,7 +3161,6 @@ function compactAgentMessagesForWorkspace(resultBody: any, fallback: any) { text: userText, status: "sent", traceId, - conversationId, sessionId, threadId, updatedAt: now @@ -3430,7 +3173,6 @@ function compactAgentMessagesForWorkspace(resultBody: any, fallback: any) { text: messageTextValue(assistantText(resultBody) || resultBody?.error?.message || resultBody?.status), status: text(resultBody?.status) || "unknown", traceId, - conversationId, sessionId, threadId, updatedAt: now @@ -3446,12 +3188,11 @@ function compactResponse(response: any) { return { httpStatus: response.status, route: route(response.method, response.path), body: compactApiBody(response.body) }; } -function agentContinuationSummary(value: { conversationId?: string | null; sessionId?: string | null; threadId?: string | null; retryOf?: string | null; fromTrace?: string | null }) { +function agentContinuationSummary(value: { sessionId?: string | null; threadId?: string | null; retryOf?: string | null; fromTrace?: string | null }) { return pruneUndefined({ webEquivalent: true, shortConnection: true, replayedFromTrace: text(value.fromTrace), - conversationId: text(value.conversationId), sessionId: text(value.sessionId), threadId: text(value.threadId), retryOf: text(value.retryOf), @@ -4218,8 +3959,6 @@ function compactBody(body: any) { conversationId: body.conversationId, sessionId: body.sessionId, threadId: body.threadId, - workspaceId: body.workspaceId, - workspaceRevision: body.workspaceRevision, accepted: body.accepted, shortConnection: body.shortConnection, resultUrl: body.resultUrl, diff --git a/tools/src/hwlab-cli/composer-policy.ts b/tools/src/hwlab-cli/composer-policy.ts index db6d16bc..33ac20a8 100644 --- a/tools/src/hwlab-cli/composer-policy.ts +++ b/tools/src/hwlab-cli/composer-policy.ts @@ -25,7 +25,6 @@ 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); const sessionUsable = Boolean(sessionId) && !unusableSessionStatus(explicitStatus); const disabledReason = active @@ -48,11 +47,8 @@ export function computeCodeAgentComposerState(input = {}) { route: mode === "steer" ? "/v1/agent/chat/steer" : "/v1/agent/chat", method: "POST", targetTraceId: active ? traceId : null, - conversationId, sessionId, threadId: firstNonEmptyString(input.threadId, current?.threadId, latest?.threadId), - workspaceId: firstNonEmptyString(input.workspaceId, workspace?.workspaceId), - workspaceRevision: integerOrNull(input.workspaceRevision ?? workspace?.revision), reason: active ? "active-turn-steer" : disabledReason ?? (terminalStatus(effectiveStatus) ? "terminal-turn" : "idle-turn") }); } @@ -108,10 +104,6 @@ function firstNonEmptyString(...values) { return null; } -function integerOrNull(value) { - return Number.isInteger(value) ? value : null; -} - function pruneUndefined(value) { const out = {}; for (const [key, item] of Object.entries(value)) { diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-capture-fixtures.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-capture-fixtures.ts index f0e2db6c..c1348537 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-capture-fixtures.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-capture-fixtures.ts @@ -2,24 +2,23 @@ // Responsibility: Read-only helper for capturing and redacting Workbench fixture seeds from a controlled runtime. const baseUrl = (process.env.HWLAB_WORKBENCH_CAPTURE_BASE_URL ?? "https://hwlab.pikapython.com").replace(/\/+$/u, ""); -const projectId = process.env.HWLAB_WORKBENCH_CAPTURE_PROJECT_ID ?? "prj_hwpod_workbench"; -const conversationId = process.env.HWLAB_WORKBENCH_CAPTURE_CONVERSATION_ID ?? ""; +const sessionId = process.env.HWLAB_WORKBENCH_CAPTURE_SESSION_ID ?? ""; const authorization = process.env.HWLAB_API_KEY ? `Bearer ${process.env.HWLAB_API_KEY}` : ""; const headers: Record = { accept: "application/json" }; if (authorization) headers.authorization = authorization; const paths = [ - `/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, - `/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}${conversationId ? `&includeConversationId=${encodeURIComponent(conversationId)}` : ""}`, - conversationId ? `/v1/agent/conversations/${encodeURIComponent(conversationId)}?projectId=${encodeURIComponent(projectId)}` : "" + `/v1/workbench/sessions${sessionId ? `?includeSessionId=${encodeURIComponent(sessionId)}` : ""}`, + sessionId ? `/v1/workbench/sessions/${encodeURIComponent(sessionId)}` : "", + sessionId ? `/v1/workbench/sessions/${encodeURIComponent(sessionId)}/messages?limit=100` : "" ].filter(Boolean); const captured: Record = { metadata: { - capturedFrom: { origin: baseUrl, projectId, conversationId: conversationId || null }, + capturedFrom: { origin: baseUrl, sessionId: sessionId || null }, capturedAt: new Date().toISOString(), - schemaVersion: "workbench-e2e-capture.v1", + schemaVersion: "workbench-e2e-capture.v2", redactionVersion: "redact-stable-pseudo-ids.v1", valuesPrinted: false }, diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 4e3e3528..04acc6f9 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -8,34 +8,29 @@ import { extname, join, resolve, sep } from "node:path"; type JsonRecord = Record; -interface ConversationRecord extends JsonRecord { - conversationId: string; - sessionId?: string | null; +interface SessionRecord extends JsonRecord { + sessionId: string; threadId?: string | null; status?: string | null; lastTraceId?: string | null; messages?: JsonRecord[]; + hidden?: boolean; } interface ScenarioState { scenarioId: string; - projectId: string; - workspaceId: string; providerProfile: string; - selectedConversationId: string; - workspaceJson: JsonRecord; - conversations: ConversationRecord[]; + selectedSessionId: string; + sessions: SessionRecord[]; traces: Record; requestLedger: JsonRecord[]; legacyRequestLedger: JsonRecord[]; - selectRequests: JsonRecord[]; - workspacePatchRequests: JsonRecord[]; chatRequests: JsonRecord[]; listOmitSelected: boolean; - conversationDelayMs: number; + sessionDelayMs: number; terminalScript: boolean; terminalFailureScript: boolean; - staleNestedTraceId: string | null; + staleTraceId: string | null; } const cwd = process.cwd(); @@ -52,7 +47,7 @@ for (let index = 2; index < process.argv.length; index += 1) { const port = Number(args.get("port") ?? process.env.HWLAB_WORKBENCH_E2E_PORT ?? 4173); const distDir = resolve(cwd, args.get("dist") ?? "dist"); const capturePath = resolve(cwd, "tests/workbench-e2e/fixtures/real-captures/d601-v03-redacted.json"); -const capture = JSON.parse(await readFile(capturePath, "utf8")) as { scenario: { projectId: string; workspaceId: string; selectedConversationId: string; providerProfile: string; conversations: ConversationRecord[]; traces: Record } }; +const capture = JSON.parse(await readFile(capturePath, "utf8")) as { scenario: { selectedSessionId: string; providerProfile: string; sessions: SessionRecord[]; traces: Record } }; let state = createScenarioState("baseline"); @@ -84,43 +79,40 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) if (path === "/auth/login" && method === "POST") return authLoginResponse(response); if (path === "/v1/workbench/events" && method === "GET") return sse(response, url); if (path === "/v1/workbench/sessions" && method === "GET") { - await delay(state.conversationDelayMs); + await delay(state.sessionDelayMs); return json(response, 200, workbenchSessionListPayload(url)); } - const workbenchTurnMatch = path.match(/^\/v1\/workbench\/turns\/([^/]+)$/u); - if (workbenchTurnMatch && method === "GET") { - const traceId = decodeURIComponent(workbenchTurnMatch[1] ?? ""); + + const turnMatch = path.match(/^\/v1\/workbench\/turns\/([^/]+)$/u); + if (turnMatch && method === "GET") { + const traceId = decodeURIComponent(turnMatch[1] ?? ""); if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "turn_replay_unavailable" } }); - if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } }); + if (traceId === state.staleTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } }); return json(response, 200, { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", turn: turnPayload(traceId) }); } - const workbenchTraceMatch = path.match(/^\/v1\/workbench\/traces\/([^/]+)\/events$/u); - if (workbenchTraceMatch && method === "GET") { - const traceId = decodeURIComponent(workbenchTraceMatch[1] ?? ""); + + const traceMatch = path.match(/^\/v1\/workbench\/traces\/([^/]+)\/events$/u); + if (traceMatch && method === "GET") { + const traceId = decodeURIComponent(traceMatch[1] ?? ""); if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "trace_replay_unavailable" } }); - if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } }); + if (traceId === state.staleTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } }); return json(response, 200, workbenchTracePayload(traceId, url)); } + const sessionMessagesMatch = path.match(/^\/v1\/workbench\/sessions\/([^/]+)\/messages$/u); if (sessionMessagesMatch && method === "GET") { const sessionId = decodeURIComponent(sessionMessagesMatch[1] ?? ""); - const conversation = workbenchConversationBySessionId(sessionId); - return conversation ? json(response, 200, workbenchSessionMessagesPayload(conversation, url)) : json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } }); + const session = visibleSessionById(sessionId); + return session ? json(response, 200, workbenchSessionMessagesPayload(session, url)) : json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } }); } + const workbenchSessionMatch = path.match(/^\/v1\/workbench\/sessions\/([^/]+)$/u); if (workbenchSessionMatch && method === "GET") { const sessionId = decodeURIComponent(workbenchSessionMatch[1] ?? ""); if (state.scenarioId === "session-switch-detail-404-isolated" && sessionId === "ses_stale_404") return json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } }); if (state.scenarioId === "completed-replay-detail-404" && sessionId === "ses_completed") return json(response, 404, { ok: false, status: 404, error: { code: "session_replay_unavailable" } }); - const conversation = workbenchConversationBySessionId(sessionId); - return conversation ? json(response, 200, { session: conversation }) : json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } }); - } - if (path === "/v1/workbench/workspace") return legacyApiResponse(response, method, path); - if (/^\/v1\/workbench\/workspace\/[^/]+$/u.test(path) && method === "PATCH") { - return legacyApiResponse(response, method, path); - } - if (/^\/v1\/workbench\/workspace\/[^/]+\/select-conversation$/u.test(path) && method === "POST") { - return legacyApiResponse(response, method, path); + const session = visibleSessionById(sessionId, { includeArchived: true }); + return session ? json(response, 200, { ok: true, status: "found", session }) : json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } }); } if (path === "/v1/agent/chat" && method === "POST") { @@ -131,36 +123,14 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) if (path === "/v1/agent/sessions" && method === "POST") { const body = await readJson(request); - const conversation = createManualSessionConversation(body); - state.selectedConversationId = conversation.conversationId; - state.conversations.unshift(conversation); - return json(response, 200, { ok: true, status: "ok", session: conversation }); + const session = createManualSession(body); + state.selectedSessionId = session.sessionId; + state.sessions.unshift(session); + return json(response, 200, { ok: true, status: "ok", session }); } - if (path === "/v1/agent/conversations" && method === "GET") { - return legacyApiResponse(response, method, path); - } - const conversationMatch = path.match(/^\/v1\/agent\/conversations\/([^/]+)$/u); - if (conversationMatch && method === "GET") { - return legacyApiResponse(response, method, path); - } - if (conversationMatch && method === "PUT") return legacyApiResponse(response, method, path); - if (conversationMatch && method === "DELETE") { - return legacyApiResponse(response, method, path); - } - - const sessionMatch = path.match(/^\/v1\/agent\/sessions\/([^/]+)$/u); - if (sessionMatch && method === "GET") return json(response, 200, { session: sessionPayload(decodeURIComponent(sessionMatch[1] ?? "")) }); - - const turnMatch = path.match(/^\/v1\/agent\/turns\/([^/]+)$/u); - if (turnMatch && method === "GET") { - return legacyApiResponse(response, method, path); - } - - const traceMatch = path.match(/^\/v1\/agent\/traces\/([^/]+)$/u); - if (traceMatch && method === "GET") { - return legacyApiResponse(response, method, path); - } + const agentSessionMatch = path.match(/^\/v1\/agent\/sessions\/([^/]+)$/u); + if (agentSessionMatch && method === "GET") return json(response, 200, { session: sessionPayload(decodeURIComponent(agentSessionMatch[1] ?? "")) }); if (path === "/v1/provider-profiles") return json(response, 200, { profiles: [{ profile: "codex-api", name: "Codex API", configured: true }, { profile: "deepseek", name: "DeepSeek", configured: true }] }); if (path === "/health/live" || path === "/health" || path === "/v1") return json(response, 200, { status: "ok", serviceId: "workbench-e2e", codeAgent: { ready: true, status: "ready" } }); @@ -175,184 +145,60 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) function createScenarioState(scenarioId: string): ScenarioState { const base = structuredClone(capture.scenario); const id = scenarioId || "baseline"; - const conversations = base.conversations; + const sessions = base.sessions; const traces = base.traces; - if (id === "cross-project-detail-boundary") { - conversations.push(crossProjectConversation()); - traces.trc_cross_project = crossProjectTrace(); - } + if (id === "cross-project-detail-boundary") sessions.push(hiddenBoundarySession()); if (id === "session-switch-detail-404-isolated") { - conversations.unshift(staleDetailConversation()); - conversations.push(emptyConversation()); + sessions.unshift(staleDetailSession()); + sessions.push(emptySession()); } if (id === "terminal-empty-trace") { - conversations.push(terminalEmptyTraceConversation()); + sessions.push(terminalEmptyTraceSession()); traces.trc_terminal_empty = terminalEmptyTrace(); } - if (id === "deleted-session-deeplink") conversations.unshift(archivedDeletedConversation()); - if (id === "session-switch-empty-reload") conversations.push(emptyConversation()); - const selectedConversationId = id === "deep-link" || id === "stale-nested-trace" - ? "cnv_failed" - : id === "session-switch-detail-404-isolated" || id === "completed-replay-detail-404" - ? "cnv_completed" - : id === "deleted-session-deeplink" - ? "cnv_completed" - : id === "terminal-turn-stale-session-active" - ? "cnv_completed" + if (id === "deleted-session-deeplink") sessions.unshift(archivedDeletedSession()); + if (id === "session-switch-empty-reload") sessions.push(emptySession()); + const selectedSessionId = id === "deep-link" || id === "stale-nested-trace" + ? "ses_failed" + : id === "session-switch-detail-404-isolated" || id === "completed-replay-detail-404" || id === "deleted-session-deeplink" || id === "terminal-turn-stale-session-active" + ? "ses_completed" : id === "terminal-empty-trace" - ? "cnv_terminal_empty" - : id === "deep-link-stale-workspace-authority" || id === "stale-submit-restore" - ? "cnv_running" - : base.selectedConversationId; - const staleNestedTraceId = id === "stale-nested-trace" || id === "stale-submit-restore" ? "trc_stale_502" : null; + ? "ses_terminal_empty" + : base.selectedSessionId; + const staleTraceId = id === "stale-nested-trace" || id === "stale-submit-restore" ? "trc_stale_502" : null; return { scenarioId: id, - projectId: base.projectId, - workspaceId: base.workspaceId, providerProfile: base.providerProfile, - selectedConversationId, - workspaceJson: initialWorkspaceJson(base.projectId, base.providerProfile, selectedConversationId, conversations, staleNestedTraceId), - conversations, + selectedSessionId, + sessions, traces, requestLedger: [], legacyRequestLedger: [], - selectRequests: [], - workspacePatchRequests: [], chatRequests: [], listOmitSelected: id === "selected-missing-from-list", - conversationDelayMs: id === "loading" ? 2_500 : 0, + sessionDelayMs: id === "loading" ? 2_500 : 0, terminalScript: id === "event-replay" || id === "running-to-terminal" || id === "stale-submit-restore", terminalFailureScript: id === "stale-submit-restore", - staleNestedTraceId + staleTraceId }; } -function workspacePayload(): JsonRecord { - const selected = conversationById(state.selectedConversationId); - const nested = { - projectId: state.projectId, - ...state.workspaceJson, - selectedConversationId: state.selectedConversationId, - selectedAgentSessionId: selected?.sessionId ?? null, - threadId: selected?.threadId ?? null, - providerProfile: state.providerProfile, - updatedAt: new Date().toISOString() - } as JsonRecord; - if (!Object.hasOwn(state.workspaceJson, "activeTraceId")) nested.activeTraceId = selected?.status === "running" ? selected.lastTraceId ?? null : null; - if (!Object.hasOwn(state.workspaceJson, "sessionStatus")) nested.sessionStatus = selected?.status ?? null; - if (!Object.hasOwn(state.workspaceJson, "lastTraceId")) nested.lastTraceId = selected?.lastTraceId ?? null; - return { - workspaceId: state.workspaceId, - projectId: state.projectId, - revision: state.selectRequests.length + state.workspacePatchRequests.length + 1, - updatedAt: new Date().toISOString(), - selectedConversationId: state.selectedConversationId, - selectedAgentSessionId: selected?.sessionId ?? null, - activeTraceId: Object.hasOwn(nested, "activeTraceId") ? nested.activeTraceId : selected?.status === "running" ? selected.lastTraceId ?? null : null, - providerProfile: state.providerProfile, - selectedConversation: selected ?? null, - workspace: nested - }; -} - -function initialWorkspaceJson(projectId: string, providerProfile: string, selectedConversationId: string, conversations: ConversationRecord[], staleLastTraceId: string | null): JsonRecord { - const selected = conversations.find((conversation) => conversation.conversationId === selectedConversationId) ?? null; - return { - projectId, - selectedConversationId, - selectedAgentSessionId: selected?.sessionId ?? null, - threadId: selected?.threadId ?? null, - activeTraceId: selected?.status === "running" ? selected.lastTraceId ?? null : null, - sessionStatus: selected?.status ?? null, - providerProfile, - lastTraceId: staleLastTraceId ?? selected?.lastTraceId ?? null, - updatedAt: new Date().toISOString() - }; -} - -function applyWorkspacePatch(body: JsonRecord): void { - const bodyWorkspace = body.workspace && typeof body.workspace === "object" ? body.workspace as JsonRecord : {}; - const next = { ...state.workspaceJson, ...bodyWorkspace } as JsonRecord; - for (const key of ["selectedConversationId", "selectedAgentSessionId", "activeTraceId", "providerProfile", "sessionStatus", "lastTraceId", "staleActiveTraceId", "staleActiveTraceReason", "updatedByClient"]) { - if (Object.hasOwn(body, key)) next[key] = body[key]; - } - state.workspaceJson = next; - if (typeof body.providerProfile === "string") state.providerProfile = body.providerProfile; - if (typeof next.selectedConversationId === "string" && conversationById(next.selectedConversationId)) state.selectedConversationId = next.selectedConversationId; - if ((Object.hasOwn(body, "activeTraceId") && body.activeTraceId === null) || (Object.hasOwn(bodyWorkspace, "activeTraceId") && bodyWorkspace.activeTraceId === null)) clearActiveTraceForSelection(); -} - -function createConversationFromSelect(body: JsonRecord, conversationId: string): ConversationRecord { - const token = conversationId.slice(4); - const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() ? body.sessionId : `ses_${token}`; - const threadId = typeof body.threadId === "string" && body.threadId.trim() ? body.threadId : null; - const now = new Date().toISOString(); - return { - conversationId, - projectId: state.projectId, - sessionId, - threadId, - status: "active", - startedAt: now, - updatedAt: now, - messageCount: 0, - firstUserMessagePreview: null, - session: { sessionId, threadId, status: "active" }, - messages: [] - }; -} - -function createManualSessionConversation(body: JsonRecord): ConversationRecord { +function createManualSession(body: JsonRecord): SessionRecord { const now = new Date().toISOString(); const token = state.scenarioId === "server-authoritative-create" ? "server_created" : Date.now().toString(36); const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() ? body.sessionId : `ses_${token}`; - const conversationId = typeof body.conversationId === "string" && body.conversationId.trim() ? body.conversationId : `cnv_${token}`; const threadId = typeof body.threadId === "string" && body.threadId.trim() ? body.threadId : null; - return { - conversationId, - projectId: state.projectId, - sessionId, - threadId, - status: "active", - startedAt: now, - updatedAt: now, - messageCount: 0, - firstUserMessagePreview: null, - session: { sessionId, threadId, status: "active" }, - messages: [] - }; + return { sessionId, threadId, status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: null, messages: [] }; } -function crossProjectConversation(): ConversationRecord { +function hiddenBoundarySession(): SessionRecord { + const now = new Date().toISOString(); + return { sessionId: "ses_cross_project", threadId: "thr_cross_project", hidden: true, status: "completed", lastTraceId: "trc_cross_project", startedAt: now, updatedAt: now, messageCount: 2, firstUserMessagePreview: "边界外 session 不应泄露", messages: [] }; +} + +function terminalEmptyTraceSession(): SessionRecord { const now = new Date().toISOString(); return { - conversationId: "cnv_cross_project", - projectId: "prj_v02_code_agent", - sessionId: "ses_cross_project", - threadId: "thr_cross_project", - status: "completed", - lastTraceId: "trc_cross_project", - startedAt: now, - updatedAt: now, - messageCount: 2, - firstUserMessagePreview: "跨项目会话不应泄露", - session: { sessionId: "ses_cross_project", threadId: "thr_cross_project", status: "completed" }, - messages: [ - { id: "msg_cross_project_user", role: "user", title: "用户", text: "读取另一个项目", status: "sent", createdAt: now, conversationId: "cnv_cross_project", sessionId: "ses_cross_project", threadId: "thr_cross_project", traceId: "trc_cross_project" }, - { id: "msg_cross_project_agent", role: "agent", title: "Code Agent", text: "另一个项目的结果", status: "completed", createdAt: now, conversationId: "cnv_cross_project", sessionId: "ses_cross_project", threadId: "thr_cross_project", traceId: "trc_cross_project", runnerTrace: crossProjectTrace() } - ] - }; -} - -function crossProjectTrace(): JsonRecord { - return { traceId: "trc_cross_project", projectId: "prj_v02_code_agent", conversationId: "cnv_cross_project", sessionId: "ses_cross_project", threadId: "thr_cross_project", status: "completed", events: [], eventCount: 0, fullTraceLoaded: true, hasMore: false }; -} - -function terminalEmptyTraceConversation(): ConversationRecord { - const now = new Date().toISOString(); - return { - conversationId: "cnv_terminal_empty", - projectId: capture.scenario.projectId, sessionId: "ses_terminal_empty", threadId: "thr_terminal_empty", status: "completed", @@ -361,164 +207,97 @@ function terminalEmptyTraceConversation(): ConversationRecord { updatedAt: now, messageCount: 2, firstUserMessagePreview: "完成态空 Trace 文案验证", - session: { sessionId: "ses_terminal_empty", threadId: "thr_terminal_empty", status: "completed" }, messages: [ - { id: "msg_terminal_empty_user", role: "user", title: "用户", text: "完成态空 Trace 文案验证", status: "sent", createdAt: now, conversationId: "cnv_terminal_empty", sessionId: "ses_terminal_empty", threadId: "thr_terminal_empty", turnId: "turn_terminal_empty" }, - { id: "msg_terminal_empty_agent", role: "agent", title: "Code Agent", text: "终态空 Trace 已完成。", status: "completed", createdAt: now, conversationId: "cnv_terminal_empty", sessionId: "ses_terminal_empty", threadId: "thr_terminal_empty", traceId: "trc_terminal_empty", turnId: "turn_terminal_empty", runnerTrace: terminalEmptyTrace() } + { id: "msg_terminal_empty_user", role: "user", title: "用户", text: "完成态空 Trace 文案验证", status: "sent", createdAt: now, sessionId: "ses_terminal_empty", threadId: "thr_terminal_empty", turnId: "turn_terminal_empty" }, + { id: "msg_terminal_empty_agent", role: "agent", title: "Code Agent", text: "终态空 Trace 已完成。", status: "completed", createdAt: now, sessionId: "ses_terminal_empty", threadId: "thr_terminal_empty", traceId: "trc_terminal_empty", turnId: "turn_terminal_empty", runnerTrace: terminalEmptyTrace() } ] }; } function terminalEmptyTrace(): JsonRecord { - return { traceId: "trc_terminal_empty", status: "completed", conversationId: "cnv_terminal_empty", sessionId: "ses_terminal_empty", threadId: "thr_terminal_empty", events: [], eventCount: 0, fullTraceLoaded: true, hasMore: false, finalResponse: { text: "终态空 Trace 已完成。" } }; + return { traceId: "trc_terminal_empty", status: "completed", sessionId: "ses_terminal_empty", threadId: "thr_terminal_empty", events: [], eventCount: 0, fullTraceLoaded: true, hasMore: false, finalResponse: { text: "终态空 Trace 已完成。" } }; } -function staleDetailConversation(): ConversationRecord { +function staleDetailSession(): SessionRecord { const now = new Date().toISOString(); - return { - conversationId: "cnv_stale_404", - projectId: state?.projectId ?? capture.scenario.projectId, - sessionId: "ses_stale_404", - threadId: "thr_stale_404", - status: "active", - startedAt: now, - updatedAt: now, - messageCount: 0, - firstUserMessagePreview: "已失效的列表项", - session: { sessionId: "ses_stale_404", threadId: "thr_stale_404", status: "active" }, - messages: [] - }; + return { sessionId: "ses_stale_404", threadId: "thr_stale_404", status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "已失效的列表项", messages: [] }; } -function emptyConversation(): ConversationRecord { +function emptySession(): SessionRecord { const now = new Date().toISOString(); - return { conversationId: "cnv_empty", projectId: state?.projectId ?? capture.scenario.projectId, sessionId: "ses_empty", threadId: "thr_empty", status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "空白会话", session: { sessionId: "ses_empty", threadId: "thr_empty", status: "active" }, messages: [] }; + return { sessionId: "ses_empty", threadId: "thr_empty", status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "空白会话", messages: [] }; } -function archivedDeletedConversation(): ConversationRecord { +function archivedDeletedSession(): SessionRecord { const now = new Date().toISOString(); - return { - conversationId: "cnv_deleted", - projectId: capture.scenario.projectId, - sessionId: "ses_deleted", - threadId: "thr_deleted", - status: "archived", - startedAt: now, - updatedAt: now, - messageCount: 0, - firstUserMessagePreview: "已删除 session", - session: { sessionId: "ses_deleted", threadId: "thr_deleted", status: "archived" }, - messages: [] - }; + return { sessionId: "ses_deleted", threadId: "thr_deleted", status: "archived", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "已删除 session", messages: [] }; } function acceptChatTurn(body: JsonRecord): JsonRecord { - const requestedSession = typeof body.sessionId === "string" ? conversationBySessionId(body.sessionId) : null; - const conversationId = typeof body.conversationId === "string" ? body.conversationId : requestedSession?.conversationId ?? state.selectedConversationId; - const sessionId = typeof body.sessionId === "string" ? body.sessionId : conversationById(conversationId)?.sessionId ?? `ses_${conversationId.slice(4)}`; + const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() ? body.sessionId : state.selectedSessionId; + const session = sessionById(sessionId) ?? createManualSession({ sessionId }); + if (!state.sessions.includes(session)) state.sessions.unshift(session); const threadId = typeof body.threadId === "string" && body.threadId.trim() ? body.threadId : null; const traceId = typeof body.traceId === "string" && body.traceId.trim() ? body.traceId : `trc_${Date.now().toString(16)}`; const message = typeof body.message === "string" ? body.message : ""; - const conversation = conversationById(conversationId); const now = new Date().toISOString(); - if (conversation) { - conversation.status = "running"; - conversation.lastTraceId = traceId; - conversation.updatedAt = now; - conversation.firstUserMessagePreview = conversation.firstUserMessagePreview ?? message; - conversation.messageCount = (conversation.messages?.length ?? 0) + 2; - conversation.messages = [ - ...(conversation.messages ?? []), - { id: `msg_${traceId}_user`, role: "user", title: "用户", text: message, status: "sent", createdAt: now, conversationId, sessionId, threadId, traceId }, - { id: `msg_${traceId}_agent`, role: "agent", title: "Code Agent", text: "", status: "running", createdAt: now, conversationId, sessionId, threadId, traceId, runnerTrace: { traceId, status: "running", sessionId, threadId, events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false } } - ]; - } - state.traces[traceId] = { traceId, status: "running", conversationId, sessionId, threadId, events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false }; - return { status: "running", accepted: true, running: true, terminal: false, traceId, conversationId, sessionId, threadId, turnId: traceId }; + session.status = "running"; + session.lastTraceId = traceId; + session.updatedAt = now; + session.firstUserMessagePreview = session.firstUserMessagePreview ?? message; + session.messageCount = (session.messages?.length ?? 0) + 2; + session.messages = [ + ...(session.messages ?? []), + { id: `msg_${traceId}_user`, role: "user", title: "用户", text: message, status: "sent", createdAt: now, sessionId, threadId, traceId }, + { id: `msg_${traceId}_agent`, role: "agent", title: "Code Agent", text: "", status: "running", createdAt: now, sessionId, threadId, traceId, runnerTrace: { traceId, status: "running", sessionId, threadId, events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false } } + ]; + state.traces[traceId] = { traceId, status: "running", sessionId, threadId, events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false }; + return { status: "running", accepted: true, running: true, terminal: false, traceId, sessionId, threadId, turnId: traceId }; } -function conversationSummary(conversation: ConversationRecord): ConversationRecord { - const { messages: _messages, ...rest } = conversation; - return rest as ConversationRecord; +function sessionSummary(session: SessionRecord): SessionRecord { + const { messages: _messages, hidden: _hidden, ...rest } = session; + return rest as SessionRecord; } function workbenchSessionListPayload(url: URL): JsonRecord { + if (url.searchParams.has("projectId") || url.searchParams.has("workspaceId")) return { ok: false, status: 400, error: { code: "workbench_authority_removed" } }; const includeSessionId = url.searchParams.get("includeSessionId") || ""; - const visibleConversations = state.conversations.filter((item) => conversationProjectId(item) === state.projectId); - const conversations = state.listOmitSelected && includeSessionId ? visibleConversations.filter((item) => item.sessionId !== includeSessionId) : visibleConversations; - const summaries = conversations.map((conversation) => conversationSummary(conversation)); - return { projectId: null, sessions: summaries, conversations: summaries, count: summaries.length }; + const visible = state.sessions.filter((item) => !item.hidden && (item.status !== "archived" || item.sessionId === includeSessionId)); + const listed = state.listOmitSelected && includeSessionId ? visible.filter((item) => item.sessionId !== includeSessionId) : visible; + const summaries = listed.map((session) => sessionSummary(session)); + return { ok: true, status: "succeeded", sessions: summaries, count: summaries.length }; } -function workbenchSessionMessagesPayload(conversation: ConversationRecord, url: URL): JsonRecord { - const messages = Array.isArray(conversation.messages) ? conversation.messages : []; +function workbenchSessionMessagesPayload(session: SessionRecord, url: URL): JsonRecord { + const messages = Array.isArray(session.messages) ? session.messages : []; const requestedLimit = Number(url.searchParams.get("limit") ?? messages.length); const limit = Math.max(1, Number.isFinite(requestedLimit) ? Math.trunc(requestedLimit) : messages.length || 100); const page = messages.slice(0, limit); - return { sessionId: conversation.sessionId ?? null, conversationId: conversation.conversationId, messages: page, count: page.length, total: messages.length, hasMore: messages.length > page.length }; + return { ok: true, status: "succeeded", sessionId: session.sessionId, messages: page, count: page.length, total: messages.length, hasMore: messages.length > page.length }; } -function conversationById(id: string): ConversationRecord | null { - return state.conversations.find((conversation) => conversation.conversationId === id) ?? null; +function sessionById(id: string): SessionRecord | null { + return state.sessions.find((session) => session.sessionId === id) ?? null; } -function conversationBySessionId(id: string): ConversationRecord | null { - return state.conversations.find((conversation) => conversation.sessionId === id) ?? null; -} - -function workbenchConversationBySessionId(id: string): ConversationRecord | null { - const conversation = conversationBySessionId(id); - return conversation && conversationProjectId(conversation) === state.projectId ? conversation : null; -} - -function deleteConversationById(id: string): void { - const conversation = conversationById(id); - if (!conversation) return; - conversation.status = "archived"; - conversation.session = { ...(conversation.session ?? {}), status: "archived" }; - conversation.updatedAt = new Date().toISOString(); - if (state.selectedConversationId === id) { - state.selectedConversationId = state.conversations.find((item) => item.conversationId !== id && item.status !== "archived" && conversationProjectId(item) === state.projectId)?.conversationId ?? ""; - } -} - -function requestedProjectId(url: URL): string { - return url.searchParams.get("projectId") || state.projectId; -} - -function conversationProjectId(conversation: ConversationRecord): string { - return typeof conversation.projectId === "string" && conversation.projectId.trim() ? conversation.projectId : state.projectId; -} - -function conversationProjectMismatch(conversation: ConversationRecord, url: URL): boolean { - const projectId = url.searchParams.get("projectId"); - return Boolean(projectId && conversationProjectId(conversation) !== projectId); -} - -function traceProjectMismatch(traceId: string, url: URL): boolean { - const projectId = url.searchParams.get("projectId"); - if (!projectId) return false; - const trace = state.traces[traceId]; - const traceProjectId = typeof trace?.projectId === "string" && trace.projectId.trim() ? trace.projectId : state.conversations.find((conversation) => conversation.lastTraceId === traceId)?.projectId; - return Boolean(traceProjectId && traceProjectId !== projectId); +function visibleSessionById(id: string, options: { includeArchived?: boolean } = {}): SessionRecord | null { + const session = sessionById(id); + if (!session || session.hidden) return null; + if (session.status === "archived" && !options.includeArchived) return null; + return session; } function sessionPayload(sessionId: string): JsonRecord { - const conversation = state.conversations.find((item) => item.sessionId === sessionId); + const session = sessionById(sessionId); if (state.scenarioId === "terminal-turn-stale-session-active" && sessionId === "ses_completed") return { sessionId, status: "active", lastTraceId: "trc_completed", updatedAt: new Date().toISOString() }; - return { sessionId, status: conversation?.status ?? "unknown", lastTraceId: conversation?.lastTraceId ?? null, updatedAt: new Date().toISOString() }; + return { sessionId, status: session?.status ?? "unknown", lastTraceId: session?.lastTraceId ?? null, updatedAt: new Date().toISOString() }; } function turnPayload(traceId: string): JsonRecord { const trace = state.traces[traceId] ?? { traceId, status: "unknown", events: [] }; const status = String(trace.status ?? "unknown"); - return { - ...trace, - traceId, - status, - running: ["running", "pending", "accepted"].includes(status), - terminal: ["completed", "failed", "blocked", "timeout", "canceled"].includes(status) - }; + return { ...trace, traceId, status, running: ["running", "pending", "accepted"].includes(status), terminal: ["completed", "failed", "blocked", "timeout", "canceled"].includes(status) }; } function tracePayload(traceId: string, url: URL): JsonRecord { @@ -536,44 +315,23 @@ function tracePayload(traceId: string, url: URL): JsonRecord { function workbenchTracePayload(traceId: string, url: URL): JsonRecord { const payload = tracePayload(traceId, url); - return { - ok: true, - status: "ok", - contractVersion: "workbench-read-model-v1", - traceId, - traceStatus: payload.status, - events: payload.events, - eventCount: payload.eventCount, - hasMore: payload.hasMore, - nextSeq: payload.nextSinceSeq, - range: payload.range, - fullTraceLoaded: payload.fullTraceLoaded, - terminalEvidence: payload.terminalEvidence, - finalResponse: payload.finalResponse, - traceSummary: payload.traceSummary, - retention: payload.retention - }; + return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, traceStatus: payload.status, events: payload.events, eventCount: payload.eventCount, hasMore: payload.hasMore, nextSeq: payload.nextSinceSeq, range: payload.range, fullTraceLoaded: payload.fullTraceLoaded, terminalEvidence: payload.terminalEvidence, finalResponse: payload.finalResponse, traceSummary: payload.traceSummary, retention: payload.retention }; } function sse(response: ServerResponse, url: URL): void { - response.writeHead(200, { - "content-type": "text/event-stream; charset=utf-8", - "cache-control": "no-cache", - connection: "keep-alive" - }); - writeSse(response, "workbench.connected", { type: "connected", sessionId: url.searchParams.get("sessionId"), workspaceId: state.workspaceId }); - if (state.terminalScript) { - const scenarioId = state.scenarioId; - setTimeout(() => { - if (state.scenarioId !== scenarioId) return; - const terminalStatus = state.terminalFailureScript ? "failed" : "completed"; - const terminalText = state.terminalFailureScript ? "恢复后失败:缺少受控依赖。" : "事件重放后完成。"; - const event = { seq: 3, createdAt: new Date().toISOString(), label: "agentrun:assistant:message", type: "assistant_message", status: terminalStatus, replyAuthority: true, final: true, message: terminalText, terminal: true }; - writeSse(response, "workbench.trace.event", { type: "trace.event", traceId: "trc_running", event, snapshot: { traceId: "trc_running", status: terminalStatus, events: [event], eventCount: 3, fullTraceLoaded: true, finalResponse: { text: terminalText, status: terminalStatus } } }); - finishRunningConversation(terminalStatus, terminalText); - writeSse(response, "workbench.turn.snapshot", { type: "turn.snapshot", traceId: "trc_running", turn: turnPayload("trc_running") }); - }, 350); - } + response.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", connection: "keep-alive" }); + writeSse(response, "workbench.connected", { type: "connected", sessionId: url.searchParams.get("sessionId") }); + if (!state.terminalScript) return; + const scenarioId = state.scenarioId; + setTimeout(() => { + if (state.scenarioId !== scenarioId) return; + const terminalStatus = state.terminalFailureScript ? "failed" : "completed"; + const terminalText = state.terminalFailureScript ? "恢复后失败:缺少受控依赖。" : "事件重放后完成。"; + const event = { seq: 3, createdAt: new Date().toISOString(), label: "agentrun:assistant:message", type: "assistant_message", status: terminalStatus, replyAuthority: true, final: true, message: terminalText, terminal: true }; + writeSse(response, "workbench.trace.event", { type: "trace.event", traceId: "trc_running", event, snapshot: { traceId: "trc_running", status: terminalStatus, events: [event], eventCount: 3, fullTraceLoaded: true, finalResponse: { text: terminalText, status: terminalStatus } } }); + finishRunningSession(terminalStatus, terminalText); + writeSse(response, "workbench.turn.snapshot", { type: "turn.snapshot", traceId: "trc_running", turn: turnPayload("trc_running") }); + }, 350); } function writeSse(response: ServerResponse, eventName: string, payload: JsonRecord): void { @@ -581,25 +339,20 @@ function writeSse(response: ServerResponse, eventName: string, payload: JsonReco response.write(`data: ${JSON.stringify(payload)}\n\n`); } -function finishRunningConversation(status: "completed" | "failed", text: string): void { - const conversation = conversationById("cnv_running"); - if (!conversation) return; - conversation.status = status; - conversation.updatedAt = new Date().toISOString(); +function finishRunningSession(status: "completed" | "failed", text: string): void { + const session = sessionById("ses_running"); + if (!session) return; + session.status = status; + session.updatedAt = new Date().toISOString(); const trace = state.traces.trc_running ?? { traceId: "trc_running", status: "running", events: [] }; const finalEvent = { seq: 4, createdAt: new Date().toISOString(), label: `agentrun:terminal:${status}`, status, terminal: true }; const events = [...(Array.isArray(trace.events) ? trace.events as JsonRecord[] : []), finalEvent]; state.traces.trc_running = { ...trace, status, events, eventCount: events.length, fullTraceLoaded: true, hasMore: false, assistantText: text, finalResponse: { text, status } }; - conversation.messages = (conversation.messages ?? []).map((message) => message.role === "agent" ? { ...message, text, status, runnerTrace: state.traces.trc_running } : message); -} - -function clearActiveTraceForSelection(): void { - const conversation = conversationById(state.selectedConversationId); - if (conversation?.status === "running") conversation.status = "failed"; + session.messages = (session.messages ?? []).map((message) => message.role === "agent" ? { ...message, text, status, runnerTrace: state.traces.trc_running } : message); } function stateSummary(): JsonRecord { - return { scenarioId: state.scenarioId, selectedConversationId: state.selectedConversationId, requestLedger: state.requestLedger, legacyRequestLedger: state.legacyRequestLedger, selectRequests: state.selectRequests, workspacePatchRequests: state.workspacePatchRequests, chatRequests: state.chatRequests, staleNestedTraceId: state.staleNestedTraceId, workspace: workspacePayload(), conversations: state.conversations.map((item) => ({ conversationId: item.conversationId, sessionId: item.sessionId, threadId: item.threadId, status: item.status, lastTraceId: item.lastTraceId })) }; + return { scenarioId: state.scenarioId, selectedSessionId: state.selectedSessionId, requestLedger: state.requestLedger, legacyRequestLedger: state.legacyRequestLedger, chatRequests: state.chatRequests, staleTraceId: state.staleTraceId, sessions: state.sessions.map((item) => ({ sessionId: item.sessionId, threadId: item.threadId, status: item.status, lastTraceId: item.lastTraceId, hidden: item.hidden === true })) }; } function recordLedger(method: string, url: URL): void { @@ -610,16 +363,9 @@ function recordLedger(method: string, url: URL): void { } function isLegacyWorkbenchPath(path: string): boolean { - return path === "/v1/workbench/workspace" - || /^\/v1\/workbench\/workspace\/[^/]+(?:\/select-conversation)?$/u.test(path) - || path === "/v1/agent/conversations" - || /^\/v1\/agent\/conversations\/[^/]+$/u.test(path) - || /^\/v1\/agent\/turns\/[^/]+$/u.test(path) - || /^\/v1\/agent\/traces\/[^/]+$/u.test(path); -} - -function legacyApiResponse(response: ServerResponse, method: string, path: string): void { - return json(response, 410, { ok: false, status: 410, error: { code: "legacy_workbench_endpoint_forbidden", message: `${method} ${path} is not part of the sessionId Workbench contract.` } }); + if (/^\/v1\/workbench\/(sessions(?:\/[^/]+(?:\/messages)?)?|events|turns\/[^/]+|traces\/[^/]+\/events)$/u.test(path)) return false; + if (path === "/v1/agent/chat" || /^\/v1\/agent\/sessions(?:\/[^/]+(?:\/select)?)?$/u.test(path)) return false; + return path.startsWith("/v1/workbench/") || path.startsWith("/v1/agent/"); } function authPayload(): JsonRecord { diff --git a/web/hwlab-cloud-web/scripts/workbench-performance.test.ts b/web/hwlab-cloud-web/scripts/workbench-performance.test.ts index fff0b41c..30e1b91f 100644 --- a/web/hwlab-cloud-web/scripts/workbench-performance.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-performance.test.ts @@ -22,23 +22,23 @@ import { test("Workbench performance probe records journeys and strips high-cardinality identifiers", () => { resetWorkbenchPerformanceForTest(); - startWorkbenchOpenJourney({ route: "/workbench/sessions/cnv_secret", cache: "cold", authState: "warm" }); - acknowledgeWorkbenchVisible({ messages: [], activeConversationId: "cnv_secret", detailLoading: false }); + startWorkbenchOpenJourney({ route: "/workbench/sessions/ses_secret", cache: "cold", authState: "warm" }); + acknowledgeWorkbenchVisible({ messages: [], activeSessionId: "ses_secret", detailLoading: false }); finishWorkbenchOpenFullLoad("ok"); - startWorkbenchSessionSwitch({ conversationId: "cnv_secret", source: "rail", targetState: "running", cache: "warm" }); - acknowledgeWorkbenchVisible({ messages: [userMessage("cnv_secret")], activeConversationId: "cnv_secret", detailLoading: false }); - finishWorkbenchSessionSwitchFullLoad("cnv_secret", "ok"); + startWorkbenchSessionSwitch({ sessionId: "ses_secret", source: "rail", targetState: "running", cache: "warm" }); + acknowledgeWorkbenchVisible({ messages: [userMessage("ses_secret")], activeSessionId: "ses_secret", detailLoading: false }); + finishWorkbenchSessionSwitchFullLoad("ses_secret", "ok"); const wallBase = Date.now(); const createdAt = new Date(wallBase - 300).toISOString(); const appendedAt = new Date(wallBase - 220).toISOString(); const serverSentAt = new Date(wallBase - 120).toISOString(); - startWorkbenchSubmitJourney({ traceId: "trc_secret", conversationId: "cnv_secret", entry: "existing", backend: "codex", transport: "sse" }); + startWorkbenchSubmitJourney({ traceId: "trc_secret", sessionId: "ses_secret", entry: "existing", backend: "codex", transport: "sse" }); markWorkbenchSubmitApiAccepted("trc_secret"); markWorkbenchTraceEventsReceived({ traceId: "trc_secret", transport: "sse", serverSentAt, traceSeq: 42, events: [{ type: "assistant_message", eventType: "assistant", backend: "agentrun-v01/codex", createdAt, appendedAt, ts: createdAt, sourceSeq: 42 } as TraceEvent] }); markWorkbenchTraceProjected("trc_secret"); - acknowledgeWorkbenchVisible({ messages: [agentMessage("cnv_secret", "trc_secret")], activeConversationId: "cnv_secret", detailLoading: false }); + acknowledgeWorkbenchVisible({ messages: [agentMessage("ses_secret", "trc_secret")], activeSessionId: "ses_secret", detailLoading: false }); const events = drainWorkbenchPerformanceEventsForTest(); const payload = JSON.stringify(events); @@ -51,12 +51,12 @@ test("Workbench performance probe records journeys and strips high-cardinality i assert.ok(events.some((event) => event.kind === "workbench_event_phase" && event.phase === "receive_to_project" && event.eventType === "assistant")); assert.ok(events.some((event) => event.kind === "workbench_event_phase" && event.phase === "project_to_paint" && event.eventType === "assistant")); assert.ok(events.some((event) => event.kind === "workbench_backend_event_visible" && event.eventType === "assistant")); - assert.doesNotMatch(payload, /trc_secret|cnv_secret|sessionId|conversationId|runId|commandId/u); + assert.doesNotMatch(payload, /trc_secret|ses_secret|sessionId|runId|commandId/u); }); test("Workbench submit failures do not pollute first visible output SLI", () => { resetWorkbenchPerformanceForTest(); - startWorkbenchSubmitJourney({ traceId: "trc_network", conversationId: "cnv_secret", entry: "new", backend: "codex", transport: "sse" }); + startWorkbenchSubmitJourney({ traceId: "trc_network", sessionId: "ses_secret", entry: "new", backend: "codex", transport: "sse" }); failWorkbenchSubmitJourney("trc_network", "network"); const events = drainWorkbenchPerformanceEventsForTest(); @@ -70,26 +70,26 @@ test("Workbench submit first visible waits for assistant or tool output", () => const createdAt = new Date(wallBase - 12_000).toISOString(); const appendedAt = new Date(wallBase - 11_500).toISOString(); const backendEvent = { label: "agentrun:run:createdAgentRun", backend: "agentrun-v01/codex", createdAt, appendedAt, message: "AgentRun created." } as TraceEvent; - startWorkbenchSubmitJourney({ traceId: "trc_secret", conversationId: "cnv_secret", entry: "existing", backend: "codex", transport: "sse" }); + startWorkbenchSubmitJourney({ traceId: "trc_secret", sessionId: "ses_secret", entry: "existing", backend: "codex", transport: "sse" }); markWorkbenchTraceEventsReceived({ traceId: "trc_secret", transport: "rest_gap", events: [backendEvent] }); markWorkbenchTraceProjected("trc_secret"); - acknowledgeWorkbenchVisible({ messages: [traceOnlyAgentMessage("cnv_secret", "trc_secret", [backendEvent])], activeConversationId: "cnv_secret", detailLoading: false }); + acknowledgeWorkbenchVisible({ messages: [traceOnlyAgentMessage("ses_secret", "trc_secret", [backendEvent])], activeSessionId: "ses_secret", detailLoading: false }); const backendOnlyEvents = drainWorkbenchPerformanceEventsForTest(); assert.equal(backendOnlyEvents.some((event) => event.kind === "workbench_journey" && event.journey === "submit_to_first_visible"), false); assert.equal(backendOnlyEvents.some((event) => event.kind === "workbench_event_phase" && event.phase === "sse_to_receive"), false); assert.ok(backendOnlyEvents.some((event) => event.kind === "workbench_backend_event_visible" && event.eventType === "backend" && event.outcome === "stale")); - acknowledgeWorkbenchVisible({ messages: [traceOnlyAgentMessage("cnv_secret", "trc_secret", [{ type: "tool_call", command: "pwd" } as TraceEvent])], activeConversationId: "cnv_secret", detailLoading: false }); + acknowledgeWorkbenchVisible({ messages: [traceOnlyAgentMessage("ses_secret", "trc_secret", [{ type: "tool_call", command: "pwd" } as TraceEvent])], activeSessionId: "ses_secret", detailLoading: false }); const toolEvents = drainWorkbenchPerformanceEventsForTest(); assert.ok(toolEvents.some((event) => event.kind === "workbench_journey" && event.journey === "submit_to_first_visible" && event.outcome === "ok")); }); -function userMessage(conversationId: string): ChatMessage { - return { id: "msg_user", role: "user", title: "User", text: "hello", status: "sent", createdAt: new Date().toISOString(), conversationId }; +function userMessage(sessionId: string): ChatMessage { + return { id: "msg_user", role: "user", title: "User", text: "hello", status: "sent", createdAt: new Date().toISOString(), sessionId }; } -function agentMessage(conversationId: string, traceId: string): ChatMessage { +function agentMessage(sessionId: string, traceId: string): ChatMessage { return { id: "msg_agent", role: "agent", @@ -97,13 +97,13 @@ function agentMessage(conversationId: string, traceId: string): ChatMessage { text: "visible", status: "running", createdAt: new Date().toISOString(), - conversationId, + sessionId, traceId, - runnerTrace: { traceId, eventCount: 1, events: [{ type: "assistant_message" }] } + runnerTrace: { traceId, sessionId, eventCount: 1, events: [{ type: "assistant_message" }] } }; } -function traceOnlyAgentMessage(conversationId: string, traceId: string, events: TraceEvent[]): ChatMessage { +function traceOnlyAgentMessage(sessionId: string, traceId: string, events: TraceEvent[]): ChatMessage { return { id: "msg_agent_trace", role: "agent", @@ -111,8 +111,8 @@ function traceOnlyAgentMessage(conversationId: string, traceId: string, events: text: "", status: "running", createdAt: new Date().toISOString(), - conversationId, + sessionId, traceId, - runnerTrace: { traceId, eventCount: events.length, events } + runnerTrace: { traceId, sessionId, eventCount: events.length, events } }; } diff --git a/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts b/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts deleted file mode 100644 index 0d3ba700..00000000 --- a/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import type { ChatMessage, ConversationRecord, WorkspaceRecord } from "../src/types/index.ts"; -import { defaultProviderProfileOptions, mergeConversationIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldShowSessionListLoading, sortSessionTabs, stableConversationList } from "../src/stores/workbench-session.ts"; - -test("R2 composer ignores stale workspace activeTraceId and workspace session without verified active projection", () => { - const workspace = workspaceRecord({ activeTraceId: "trc_stale", sessionStatus: "running" }); - const composer = resolveComposerState({ workspace, messages: [], conversations: [], activeConversationId: "cnv_r2", chatPending: false }); - assert.equal(composer.disabled, true); - assert.equal(composer.disabledReason, "session_required"); - assert.equal(composer.submitMode, "turn"); - assert.equal(composer.route, "/v1/agent/chat"); - assert.equal(composer.targetTraceId, null); - assert.equal(composer.sessionId, null); -}); - -test("R2 composer steers only when an active agent message or status exists", () => { - const messages: ChatMessage[] = [agentMessage({ status: "running", traceId: "trc_running", conversationId: "cnv_r2", sessionId: "ses_r2" })]; - const composer = resolveComposerState({ workspace: workspaceRecord({ activeTraceId: "trc_running", sessionStatus: "running" }), messages, activeConversationId: "cnv_r2", chatPending: true, turnStatusAuthority: { trc_running: { traceId: "trc_running", status: "running", running: true, terminal: false, sessionId: "ses_r2" } } }); - assert.equal(composer.submitMode, "steer"); - assert.equal(composer.route, "/v1/agent/chat/steer"); - assert.equal(composer.targetTraceId, "trc_running"); -}); - -test("R2 composer cancel targets only the active conversation trace", () => { - const messages: ChatMessage[] = [ - agentMessage({ id: "msg_other", status: "running", traceId: "trc_other", conversationId: "cnv_other", sessionId: "ses_other", threadId: "thr_other" }), - agentMessage({ id: "msg_active", status: "running", traceId: "trc_active", conversationId: "cnv_active", sessionId: "ses_active", threadId: "thr_active" }) - ]; - assert.equal(resolveCancelableAgentMessage({ - messages, - activeConversationId: "cnv_active", - targetTraceId: "trc_active", - targetSessionId: "ses_active", - targetThreadId: "thr_active", - turnStatusAuthority: { trc_active: { traceId: "trc_active", status: "running", running: true, terminal: false, sessionId: "ses_active", threadId: "thr_active" } } - })?.traceId, "trc_active"); - assert.equal(resolveCancelableAgentMessage({ - messages, - activeConversationId: "cnv_active", - targetTraceId: "trc_other", - targetSessionId: "ses_active", - targetThreadId: "thr_active", - turnStatusAuthority: { trc_other: { traceId: "trc_other", status: "running", running: true, terminal: false, sessionId: "ses_other", threadId: "thr_other" } } - }), null); -}); - -test("R2 composer never reuses a stale workspace session during routed selection", () => { - const workspace = workspaceRecord({ activeTraceId: null, sessionStatus: "idle" }); - const withoutTarget = resolveComposerState({ workspace, messages: [], conversations: [], activeConversationId: "cnv_route", chatPending: false }); - assert.equal(withoutTarget.disabled, true); - assert.equal(withoutTarget.disabledReason, "session_required"); - assert.equal(withoutTarget.conversationId, "cnv_route"); - assert.equal(withoutTarget.sessionId, null); - - const withTarget = resolveComposerState({ - workspace, - messages: [], - conversations: [{ conversationId: "cnv_route", sessionId: "ses_route", threadId: "thr_route" }], - activeConversationId: "cnv_route", - chatPending: false - }); - assert.equal(withTarget.disabled, false); - assert.equal(withTarget.conversationId, "cnv_route"); - assert.equal(withTarget.sessionId, "ses_route"); - assert.equal(withTarget.threadId, "thr_route"); -}); - -test("R2 session list shows loading until conversations are ready", () => { - assert.equal(shouldShowSessionListLoading({ loading: true, conversationsReady: false }), true); - assert.equal(shouldShowSessionListLoading({ loading: true, conversationsReady: true }), false); - assert.equal(shouldShowSessionListLoading({ loading: false, conversationsReady: false }), false); -}); - -test("R2 session tabs keep selected conversation even when the list window misses it", () => { - const selected: ConversationRecord = { conversationId: "cnv_selected_only", sessionId: "ses_selected_only", firstUserMessagePreview: "当前 deep link 会话" }; - const conversations = stableConversationList([], [], "cnv_selected_only", selected); - const tabs = sortSessionTabs(conversations, "cnv_selected_only"); - assert.equal(tabs.length, 1); - assert.equal(tabs[0]?.active, true); - assert.equal(tabs[0]?.conversationId, "cnv_selected_only"); - assert.equal(tabs[0]?.label, "当前 deep link 会话"); -}); - -test("R2 session list keeps current tabs when include response misses selected conversation", () => { - const current: ConversationRecord[] = [{ conversationId: "cnv_selected", sessionId: "ses_selected", firstUserMessagePreview: "当前会话" }]; - assert.deepEqual(stableConversationList(current, [], "cnv_selected"), current); - assert.equal(stableConversationList(current, [{ conversationId: "cnv_selected", sessionId: "ses_selected" }], "cnv_selected").length, 1); - assert.deepEqual(stableConversationList(current, [], null), []); -}); - -test("R2 merging selected conversation preserves the active tab while filling details", () => { - const merged = mergeConversationIntoList([{ conversationId: "cnv_selected", sessionId: "ses_old", firstUserMessagePreview: "旧标题" }], { conversationId: "cnv_selected", sessionId: "ses_new", threadId: "thr_new", messages: [userMessage({ text: "新标题" })] }); - assert.equal(merged.length, 1); - assert.equal(merged[0]?.sessionId, "ses_new"); - assert.equal(merged[0]?.threadId, "thr_new"); - assert.equal(merged[0]?.messages?.length, 1); -}); - -test("R2 drafts keep recent unique values and normalize corrupt storage", () => { - const next = recordRecentDraft([{ text: "旧输入", ts: "2026-01-01T00:00:00.000Z" }], "新输入", "2026-01-02T00:00:00.000Z"); - assert.deepEqual(next.map((item) => item.text), ["新输入", "旧输入"]); - const deduped = recordRecentDraft(next, "旧输入", "2026-01-03T00:00:00.000Z"); - assert.deepEqual(deduped.map((item) => item.text), ["旧输入", "新输入"]); - assert.deepEqual(normalizeRecentDrafts([{ text: "ok", ts: "now" }, { text: 1 }]).map((item) => item.text), ["ok"]); -}); - -test("R2 session tabs expose label, model metadata source, count and active state", () => { - const tabs = sortSessionTabs([{ conversationId: "cnv_r2", sessionId: "ses_r2", lastTraceId: "trc_done", messageCount: 2, firstUserMessagePreview: "请执行一次长任务", status: "completed", updatedAt: "2026-01-02T00:00:00.000Z" }], "cnv_r2"); - assert.equal(tabs.length, 1); - assert.equal(tabs[0]?.active, true); - assert.equal(tabs[0]?.label, "请执行一次长任务"); - assert.match(tabs[0]?.subtitle ?? "", /trace trc_done/u); -}); - -test("R2 session tabs use last user message time instead of agent or trace activity", () => { - const tabs = sortSessionTabs([ - { - conversationId: "cnv_old_user_recent_agent", - sessionId: "ses_old_user_recent_agent", - firstUserMessagePreview: "用户消息较早但 agent 刚刚更新", - updatedAt: "2026-01-01T00:00:00.000Z", - startedAt: "2026-01-01T00:00:00.000Z", - messages: [ - userMessage({ conversationId: "cnv_old_user_recent_agent", createdAt: "2026-01-01T00:02:00.000Z" }), - agentMessage({ traceId: "trc_recent", conversationId: "cnv_old_user_recent_agent", updatedAt: "2026-01-04T00:01:00.000Z" }) - ] - }, - { - conversationId: "cnv_newer_user", - sessionId: "ses_newer_user", - firstUserMessagePreview: "用户消息较新", - updatedAt: "2026-01-03T00:00:00.000Z", - messages: [userMessage({ conversationId: "cnv_newer_user", createdAt: "2026-01-03T00:00:00.000Z" })] - } - ], null); - assert.equal(tabs[0]?.conversationId, "cnv_newer_user"); - assert.equal(tabs.find((tab) => tab.conversationId === "cnv_old_user_recent_agent")?.updatedAt, "2026-01-01T00:02:00.000Z"); -}); - -test("R2 running session tabs expose animation state without rewriting label", () => { - const tabs = sortSessionTabs([ - { - conversationId: "cnv_running", - sessionId: "ses_running", - firstUserMessagePreview: "保持原始标题", - status: "busy", - messages: [ - userMessage({ conversationId: "cnv_running", createdAt: "2026-01-04T00:00:00.000Z" }), - agentMessage({ conversationId: "cnv_running", status: "running", updatedAt: "2026-01-04T00:01:00.000Z" }) - ] - } - ], null, { ses_running: { sessionId: "ses_running", status: "running" } }); - assert.equal(tabs[0]?.label, "保持原始标题"); - assert.equal(tabs[0]?.running, true); -}); - -test("R2 session tabs do not infer running state from workspace stubs", () => { - const conversations: ConversationRecord[] = [{ - conversationId: "cnv_r2", - sessionId: "ses_r2", - firstUserMessagePreview: "保持原始标题", - status: "running", - messageCount: 2, - messages: [ - userMessage({ conversationId: "cnv_r2", createdAt: "2026-01-04T00:00:00.000Z" }), - agentMessage({ conversationId: "cnv_r2", status: "running", updatedAt: "2026-01-04T00:01:00.000Z" }) - ] - }]; - const tabs = sortSessionTabs(conversations, "cnv_r2"); - assert.equal(tabs[0]?.status, "unknown"); - assert.equal(tabs[0]?.running, false); - assert.equal(tabs[0]?.messageCount, 2); - assert.equal(tabs[0]?.label, "保持原始标题"); - const authorityTabs = sortSessionTabs(conversations, "cnv_r2", { ses_r2: { sessionId: "ses_r2", status: "running" } }); - assert.equal(authorityTabs[0]?.status, "running"); - assert.equal(authorityTabs[0]?.running, true); -}); - -test("R2 session tabs keep trace metadata but status comes only from session authority", () => { - const conversation: ConversationRecord = { - conversationId: "cnv_stale_snapshot", - sessionId: "ses_stale_snapshot", - status: "idle", - snapshot: { sessionStatus: "running" }, - messages: [agentMessage({ conversationId: "cnv_stale_snapshot", status: "running", traceId: "trc_stale" })] - }; - const tabs = sortSessionTabs([conversation], "cnv_stale_snapshot"); - assert.equal(tabs[0]?.status, "unknown"); - assert.equal(tabs[0]?.running, false); - assert.equal(tabs[0]?.lastTraceId, "trc_stale"); - const authorityTabs = sortSessionTabs([conversation], "cnv_stale_snapshot", { ses_stale_snapshot: { sessionId: "ses_stale_snapshot", status: "idle" } }); - assert.equal(authorityTabs[0]?.status, "idle"); - assert.equal(authorityTabs[0]?.running, false); -}); - -test("R2 terminal conversation evidence overrides stale active session authority", () => { - const conversation: ConversationRecord = { - conversationId: "cnv_terminal", - sessionId: "ses_terminal", - status: "completed", - lastTraceId: "trc_terminal", - firstUserMessagePreview: "终端状态会话", - messages: [ - userMessage({ conversationId: "cnv_terminal", createdAt: "2026-01-04T00:00:00.000Z" }), - agentMessage({ conversationId: "cnv_terminal", sessionId: "ses_terminal", traceId: "trc_terminal", status: "completed", updatedAt: "2026-01-04T00:01:00.000Z" }) - ] - }; - const tabs = sortSessionTabs([conversation], "cnv_terminal", { ses_terminal: { sessionId: "ses_terminal", status: "active" } }); - assert.equal(tabs[0]?.status, "completed"); - assert.equal(tabs[0]?.running, false); - assert.match(tabs[0]?.subtitle ?? "", /trace trc_term/u); -}); - -test("R2 agent message titles drop transient running wording", () => { - assert.equal(normalizeWorkbenchMessageTitle("agent", "Code Agent 处理中"), "Code Agent"); - assert.equal(normalizeWorkbenchMessageTitle("agent", "Code Agent running"), "Code Agent"); - assert.equal(normalizeWorkbenchMessageTitle("agent", "Code Agent result"), "Code Agent result"); - assert.equal(normalizeWorkbenchMessageTitle("user", "用户"), "用户"); -}); - -test("R2 agent message status normalization keeps active lifecycle states", () => { - assert.equal(normalizeChatMessageStatus("running"), "running"); - assert.equal(normalizeChatMessageStatus("accepted"), "running"); - assert.equal(normalizeChatMessageStatus("processing"), "running"); - assert.equal(normalizeChatMessageStatus("unknown"), "pending"); - assert.equal(normalizeChatMessageStatus(undefined), "pending"); -}); - -test("R2 session tab labels normalize object previews and reject object placeholders", () => { - const tabs = sortSessionTabs([ - { conversationId: "cnv_object", sessionId: "ses_object", firstUserMessagePreview: { text: "对象预览标题" } as unknown as string, updatedAt: "2026-01-03T00:00:00.000Z" }, - { conversationId: "cnv_literal", sessionId: "ses_literal", firstUserMessagePreview: "[object Object]", updatedAt: "2026-01-02T00:00:00.000Z" } - ], null); - assert.equal(tabs[0]?.label, "对象预览标题"); - assert.equal(tabs[1]?.label, "Session ses_lite..."); - assert.ok(tabs.every((tab) => !tab.label.includes("[object Object]"))); -}); - -test("R2 provider options follow catalog and keep selected fallback", () => { - const catalog = providerProfileOptionsFromPayload({ profiles: [{ profile: "codex-api", configured: true }, { profile: "deepseek", configured: false }] }, "minimax-m3"); - assert.deepEqual(catalog.map((item) => item.value), ["deepseek", "codex-api", "minimax-m3"]); - assert.equal(catalog.find((item) => item.value === "codex-api")?.configured, true); - assert.ok(defaultProviderProfileOptions("custom-profile").some((item) => item.value === "custom-profile")); -}); - -function workspaceRecord(input: { activeTraceId: string | null; sessionStatus: string | null }): WorkspaceRecord { - return { - workspaceId: "wsp_r2", - projectId: "prj_hwpod_workbench", - selectedConversationId: "cnv_r2", - selectedAgentSessionId: "ses_r2", - activeTraceId: input.activeTraceId, - workspace: { - selectedConversationId: "cnv_r2", - selectedAgentSessionId: "ses_r2", - activeTraceId: input.activeTraceId, - sessionStatus: input.sessionStatus - } - }; -} - -function agentMessage(patch: Partial): ChatMessage { - return { id: "msg_r2", role: "agent", title: "Code Agent", text: "running", status: "running", createdAt: "2026-01-01T00:00:00.000Z", ...patch }; -} - -function userMessage(patch: Partial): ChatMessage { - return { id: `msg_user_${patch.conversationId ?? "r2"}`, role: "user", title: "用户", text: "prompt", status: "sent", createdAt: "2026-01-01T00:00:00.000Z", ...patch }; -} diff --git a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts index 85a01290..bcef3a6b 100644 --- a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts @@ -1,38 +1,28 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { ConversationRecord, WorkspaceRecord } from "../src/types/index.ts"; -import { initialWorkbenchSessionIdFromLocation, resolveWorkbenchActiveConversationId } from "../src/stores/workbench-projection.ts"; -import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveConversation, selectActiveMessages, selectTraceAuthorityById } from "../src/stores/workbench-server-state.ts"; +import type { WorkbenchSessionRecord } from "../src/types/index.ts"; +import { initialWorkbenchSessionIdFromLocation } from "../src/stores/workbench-projection.ts"; +import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectActiveSession, selectTraceAuthorityById } from "../src/stores/workbench-server-state.ts"; -test("R5 active conversation comes only from route or explicit UI selection", () => { - assert.equal(resolveWorkbenchActiveConversationId({ switchingConversationId: null, explicitConversationId: "cnv_route" }), "cnv_route"); - assert.equal(resolveWorkbenchActiveConversationId({ switchingConversationId: "cnv_switching", explicitConversationId: "cnv_route" }), "cnv_switching"); - assert.equal(resolveWorkbenchActiveConversationId({ switchingConversationId: null, explicitConversationId: null }), null); -}); - -test("R5 initial route parsing ignores workspace fallback", () => { +test("Workbench route parsing accepts only session routes", () => { assert.equal(initialWorkbenchSessionIdFromLocation({ pathname: "/workbench/sessions/ses_route" }), "ses_route"); assert.equal(initialWorkbenchSessionIdFromLocation({ pathname: "/workbench/sessions/cnv_route" }), null); + assert.equal(initialWorkbenchSessionIdFromLocation({ pathname: "/workspace/sessions/ses_route" }), null); assert.equal(initialWorkbenchSessionIdFromLocation({ pathname: "/workbench" }), null); }); -test("R5 late workspace and trace snapshots update cache without changing active projection", () => { - const workspace: WorkspaceRecord = { workspaceId: "wsp_r5", projectId: "prj_hwpod_workbench", selectedConversationId: "cnv_late" }; - const routeConversation: ConversationRecord = { conversationId: "cnv_route", sessionId: "ses_route", messages: [{ id: "msg_route", messageId: "msg_route", role: "user", title: "用户", text: "route prompt", status: "sent", createdAt: "2026-06-17T00:00:00.000Z", conversationId: "cnv_route" }] }; - const lateConversation: ConversationRecord = { conversationId: "cnv_late", sessionId: "ses_late", messages: [{ id: "msg_late", messageId: "msg_late", role: "agent", title: "Code Agent", text: "late reply", status: "completed", createdAt: "2026-06-17T00:00:01.000Z", conversationId: "cnv_late", traceId: "trc_late" }] }; +test("Workbench server-state is keyed only by session and trace", () => { + const session: WorkbenchSessionRecord = { + sessionId: "ses_route", + threadId: "thr_route", + messages: [{ id: "msg_route", messageId: "msg_route", role: "user", title: "用户", text: "route prompt", status: "sent", createdAt: "2026-06-18T00:00:00.000Z", sessionId: "ses_route" }] + }; let state = createWorkbenchServerState(); - const active = "cnv_route"; + state = reduceWorkbenchServerState(state, { type: "session.detail", session }); + state = reduceWorkbenchServerState(state, { type: "trace.snapshot", traceId: "trc_route", trace: { traceId: "trc_route", status: "completed", events: [{ seq: 1, type: "done" }] } }); - state = reduceWorkbenchServerState(state, { type: "workspace.snapshot", projectId: "prj_hwpod_workbench", workspace }); - state = reduceWorkbenchServerState(state, { type: "conversation.detail", conversation: lateConversation }); - state = reduceWorkbenchServerState(state, { type: "trace.snapshot", traceId: "trc_late", trace: { traceId: "trc_late", status: "completed", events: [{ seq: 1, type: "late" }] } }); - - assert.equal(selectActiveConversation(state, active), null); - assert.deepEqual(selectActiveMessages(state, active), []); - assert.equal(selectTraceAuthorityById(state).trc_late?.traceId, "trc_late"); - - state = reduceWorkbenchServerState(state, { type: "conversation.detail", conversation: routeConversation }); - assert.equal(selectActiveConversation(state, active)?.conversationId, "cnv_route"); - assert.deepEqual(selectActiveMessages(state, active).map((message) => message.id), ["msg_route"]); + assert.equal(selectActiveSession(state, "ses_route")?.sessionId, "ses_route"); + assert.deepEqual(selectActiveMessages(state, "ses_route").map((message) => message.id), ["msg_route"]); + assert.equal(selectTraceAuthorityById(state).trc_route?.traceId, "trc_route"); }); diff --git a/web/hwlab-cloud-web/src/api/agent.ts b/web/hwlab-cloud-web/src/api/agent.ts index 7bfb6275..47fa6c3a 100644 --- a/web/hwlab-cloud-web/src/api/agent.ts +++ b/web/hwlab-cloud-web/src/api/agent.ts @@ -40,7 +40,6 @@ function normalizeWorkbenchTurnResult(result: ApiResult> terminal: turn.terminal === true, sessionId: textValue(turn.sessionId) ?? undefined, threadId: textValue(turn.threadId) ?? undefined, - conversationId: textValue(turn.conversationId) ?? undefined, agentRun: recordValue(turn.agentRun) ?? undefined, updatedAt: textValue(turn.updatedAt) ?? textValue(result.data.observedAt) ?? undefined } as AgentChatResultResponse diff --git a/web/hwlab-cloud-web/src/api/auth.ts b/web/hwlab-cloud-web/src/api/auth.ts index 692732c7..ca00b4d3 100644 --- a/web/hwlab-cloud-web/src/api/auth.ts +++ b/web/hwlab-cloud-web/src/api/auth.ts @@ -1,12 +1,5 @@ import { fetchJson } from "./client"; -import type { ApiResult, AuthSession, WorkspaceRecord } from "@/types"; - -export interface WorkspaceBootstrapResponse extends AuthSession { - projectId?: string; - workspace?: WorkspaceRecord | null; - workspaceStatus?: number | null; - workspaceError?: unknown; -} +import type { ApiResult, AuthSession } from "@/types"; export const authAPI = { session: (): Promise> => fetchJson("/auth/session", { timeoutName: "auth session" }), diff --git a/web/hwlab-cloud-web/src/api/index.ts b/web/hwlab-cloud-web/src/api/index.ts index 8237c0ae..3c1a656e 100644 --- a/web/hwlab-cloud-web/src/api/index.ts +++ b/web/hwlab-cloud-web/src/api/index.ts @@ -1,5 +1,5 @@ export { fetchJson, fetchText, type ActivityRef, type ActivityRefSource, type ApiRequestOptions } from "./client"; -export { authAPI, HWLAB_WEB_SESSION_COOKIE, type WorkspaceBootstrapResponse } from "./auth"; +export { authAPI, HWLAB_WEB_SESSION_COOKIE } from "./auth"; export { workbenchAPI } from "./workbench"; export { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent } from "./workbench-events"; export { agentAPI } from "./agent"; diff --git a/web/hwlab-cloud-web/src/api/workbench-events.ts b/web/hwlab-cloud-web/src/api/workbench-events.ts index a1a906c6..7f8a7813 100644 --- a/web/hwlab-cloud-web/src/api/workbench-events.ts +++ b/web/hwlab-cloud-web/src/api/workbench-events.ts @@ -1,7 +1,7 @@ // SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. // Responsibility: Workbench SSE client. Realtime events accelerate UI projection; REST snapshots remain gap-fill authority. -import type { TraceEvent, WorkspaceRecord } from "@/types"; +import type { TraceEvent } from "@/types"; export interface WorkbenchRealtimeTraceSnapshot { traceId?: string | null; @@ -18,10 +18,8 @@ export interface WorkbenchRealtimeEvent { status?: string; serverSentAt?: string | null; eventCreatedAt?: string | null; - projectId?: string | null; - workspace?: WorkspaceRecord | null; - workspaceId?: string | null; - workspaceRevision?: number | null; + sessionId?: string | null; + threadId?: string | null; traceId?: string | null; event?: TraceEvent | null; snapshot?: WorkbenchRealtimeTraceSnapshot | null; @@ -29,7 +27,7 @@ export interface WorkbenchRealtimeEvent { reason?: string | null; error?: { code?: string; message?: string } | null; traceSeq?: number | null; - cursor?: { workspaceRevision?: number | null; traceSeq?: number | null; [key: string]: unknown }; + cursor?: { traceSeq?: number | null; [key: string]: unknown }; [key: string]: unknown; } @@ -47,7 +45,6 @@ export interface WorkbenchEventStream { const WORKBENCH_EVENT_NAMES = [ "workbench.connected", - "workbench.workspace.snapshot", "workbench.trace.snapshot", "workbench.trace.event", "workbench.trace.unavailable", diff --git a/web/hwlab-cloud-web/src/api/workbench.ts b/web/hwlab-cloud-web/src/api/workbench.ts index 1637af99..b95b4565 100644 --- a/web/hwlab-cloud-web/src/api/workbench.ts +++ b/web/hwlab-cloud-web/src/api/workbench.ts @@ -1,20 +1,17 @@ import { fetchJson } from "./client"; -import type { ApiResult, ChatMessage, ConversationRecord } from "@/types"; +import type { ApiResult, ChatMessage, WorkbenchSessionRecord } from "@/types"; export interface WorkbenchSessionListResponse { - projectId?: string | null; - sessions?: ConversationRecord[]; - conversations?: ConversationRecord[]; + sessions?: WorkbenchSessionRecord[]; count?: number; } export interface WorkbenchSessionDetailResponse { - session?: ConversationRecord; + session?: WorkbenchSessionRecord; } export interface WorkbenchMessagePageResponse { sessionId?: string | null; - conversationId?: string | null; messages?: ChatMessage[]; count?: number; total?: number; diff --git a/web/hwlab-cloud-web/src/components/workbench/CodeAgentStatusSummary.vue b/web/hwlab-cloud-web/src/components/workbench/CodeAgentStatusSummary.vue index ff6bfa7b..87d1167b 100644 --- a/web/hwlab-cloud-web/src/components/workbench/CodeAgentStatusSummary.vue +++ b/web/hwlab-cloud-web/src/components/workbench/CodeAgentStatusSummary.vue @@ -19,7 +19,6 @@ const rows = computed(() => [ summaryRow("turnStatus", "Turn 状态", props.message.status), summaryRow("traceDetailStatus", "Trace 明细", firstNonEmptyString(trace.value?.status, trace.value?.traceStatus)), summaryRow("traceId", "Trace", props.message.traceId ?? trace.value?.traceId), - summaryRow("conversationId", "Conversation", props.message.conversationId), summaryRow("sessionId", "Session", props.message.sessionId ?? trace.value?.sessionId), summaryRow("threadId", "Thread", props.message.threadId ?? trace.value?.threadId), summaryRow("runnerKind", "Runner", trace.value?.runnerKind ?? agentRun.value?.adapter), diff --git a/web/hwlab-cloud-web/src/components/workbench/CommandComposer.vue b/web/hwlab-cloud-web/src/components/workbench/CommandComposer.vue index 97cd6c8c..abdb7a3a 100644 --- a/web/hwlab-cloud-web/src/components/workbench/CommandComposer.vue +++ b/web/hwlab-cloud-web/src/components/workbench/CommandComposer.vue @@ -58,7 +58,7 @@ function pickDraft(text: string): void { - +
diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index a0df1189..a91bdfea 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -45,7 +45,7 @@ onMounted(async () => { acknowledgeVisibleMessages(); }); -watch(() => workbench.conversationDetailLoading, async () => { +watch(() => workbench.sessionDetailLoading, async () => { await nextTick(); acknowledgeVisibleMessages(); }); @@ -83,7 +83,7 @@ function scrollToBottom(): void { } function acknowledgeVisibleMessages(): void { - acknowledgeWorkbenchVisibleAfterPaint({ messages: workbench.messages, activeConversationId: workbench.activeConversationId, detailLoading: workbench.conversationDetailLoading }); + acknowledgeWorkbenchVisibleAfterPaint({ messages: workbench.messages, activeSessionId: workbench.activeSessionId, detailLoading: workbench.sessionDetailLoading }); } function traceStorageKey(message: ChatMessage): string { @@ -93,8 +93,8 @@ function traceStorageKey(message: ChatMessage): string {