diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 7809ce41..f45f2c0f 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -865,6 +865,122 @@ test("workbench workspace terminal status sync preserves a newer selected conver } }); +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_device_pod_workbench", aliceLogin.cookie); + assert.equal(workspace.status, 200); + + await accessController.recordAgentSessionOwner({ + ownerUserId: alice.body.user.id, + ownerRole: "user", + sessionId: "ses_issue810_running", + projectId: "prj_device_pod_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_device_pod_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_device_pod_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_device_pod_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_device_pod_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 clears stale continuation after AgentRun thread resume failure", async () => { const staleThreadId = "019e0000-0000-7000-8000-000000000195"; const agentRunCalls = []; diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index f428886b..97b135d1 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -333,6 +333,8 @@ async function updateManualSessionWorkspace({ params = {}, options = {}, session ? 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, @@ -343,13 +345,14 @@ async function updateManualSessionWorkspace({ params = {}, options = {}, session selectedConversationId: session.conversationId, selectedAgentSessionId: session.id, selectedDevicePodId: current.selectedDevicePodId, - activeTraceId: null, + activeTraceId: selectedActiveTraceId, providerProfile, patch: { ...(current.workspace && typeof current.workspace === "object" ? current.workspace : {}), selectedConversationId: session.conversationId, selectedAgentSessionId: session.id, - activeTraceId: null, + activeTraceId: selectedActiveTraceId, + ...(selectedLastTraceId ? { lastTraceId: selectedLastTraceId } : {}), sessionStatus: session.status, threadId: safeOpaqueId(session.threadId) || null, providerProfile, @@ -368,6 +371,31 @@ async function updateManualSessionWorkspace({ params = {}, options = {}, session 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 {