diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 5440edbd..7809ce41 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -689,6 +689,182 @@ test("workbench workspace status clears completed AgentRun active trace on read" } }); +test("workbench workspace terminal status sync preserves a newer selected conversation", 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/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_device_pod_workbench", aliceLogin.cookie); + assert.equal(workspace.status, 200); + + const oldConversation = await putJson(port, "/v1/agent/conversations/cnv_issue808_old", { + projectId: "prj_device_pod_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_device_pod_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_device_pod_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_device_pod_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, "trc_issue808_old_done"); + assert.equal(agentRunCalls.some((call) => call.path === "/api/v1/runs/run_issue808_old/commands/cmd_issue808_old/result"), false); + + const selectedOld = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, { + projectId: "prj_device_pod_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_device_pod_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, "idle"); + assert.equal(repaired.body.workspace.selectedConversation.lastTraceId, "trc_issue808_old_done"); + assert.equal(repaired.body.workspace.selectedConversation.messages[0].status, "idle"); + assert.equal(repaired.body.workspace.selectedConversation.messages[0].text, "old turn completed"); + assert.ok(agentRunCalls.some((call) => call.path === "/api/v1/runs/run_issue808_old/commands/cmd_issue808_old/result")); + + const repairedOld = await getJson(port, "/v1/agent/conversations/cnv_issue808_old", aliceLogin.cookie); + assert.equal(repairedOld.status, 200); + assert.equal(repairedOld.body.conversation.status, "idle"); + assert.equal(repairedOld.body.conversation.lastTraceId, "trc_issue808_old_done"); + assert.equal(repairedOld.body.conversation.messages[0].status, "idle"); + assert.equal(repairedOld.body.conversation.messages[0].text, "old turn completed"); + } 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 clears stale continuation after AgentRun thread resume failure", async () => { const staleThreadId = "019e0000-0000-7000-8000-000000000195"; const agentRunCalls = []; diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 7b5a0a7c..c51349a3 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -936,8 +936,21 @@ class AccessController { const staleContinuation = isThreadResumeFailedResult(result); const resultConversationId = safeConversationIdLocal(result.conversationId) ? result.conversationId : workspace.selectedConversationId; const resultAgentSessionId = safeAgentSessionId(result.sessionId ?? result.session?.sessionId ?? result.sessionReuse?.sessionId) || workspace.selectedAgentSessionId; - const selectedConversationId = staleContinuation ? null : resultConversationId; - const selectedAgentSessionId = staleContinuation ? null : resultAgentSessionId; + const currentSelectedConversationId = safeConversationIdLocal(workspace.selectedConversationId) ? workspace.selectedConversationId : null; + const currentSelectedAgentSessionId = safeAgentSessionId(workspace.selectedAgentSessionId) || null; + const resultMatchesCurrentSelection = Boolean( + resultConversationId && + (!currentSelectedConversationId || currentSelectedConversationId === resultConversationId) && + (!currentSelectedAgentSessionId || currentSelectedAgentSessionId === resultAgentSessionId) + ); + const preserveCurrentSelection = Boolean(currentSelectedConversationId && !resultMatchesCurrentSelection); + const selectedConversationId = staleContinuation + ? (preserveCurrentSelection ? currentSelectedConversationId : null) + : (preserveCurrentSelection ? currentSelectedConversationId : resultConversationId); + const selectedAgentSessionId = staleContinuation + ? (preserveCurrentSelection ? currentSelectedAgentSessionId : null) + : (preserveCurrentSelection ? currentSelectedAgentSessionId : resultAgentSessionId); + const nextSessionStatus = preserveCurrentSelection ? workspaceJson.sessionStatus : terminalWorkbenchSessionStatus(result); await this.syncTerminalWorkbenchConversation({ workspace, actor, @@ -962,7 +975,7 @@ class AccessController { selectedConversationId, selectedAgentSessionId, activeTraceId: null, - sessionStatus: terminalWorkbenchSessionStatus(result), + sessionStatus: nextSessionStatus, lastTraceId: traceId, syncedTraceStatus: result.status ?? result.agentRun?.terminalStatus ?? null, ...(staleContinuation ? { @@ -2050,7 +2063,7 @@ function conversationNeedsTerminalRepair(conversation, traceId) { if (["running", "busy", "pending", "active"].includes(status)) return true; const messages = Array.isArray(conversation.messages) ? conversation.messages : []; const traceMessage = messages.find((message) => message?.traceId === traceId && message?.role === "agent") ?? null; - if (!traceMessage) return true; + if (!traceMessage) return false; const messageStatus = textOr(traceMessage.status, "").toLowerCase(); return ["", "running", "busy", "pending", "active"].includes(messageStatus); }