fix(cloud): preserve selected conversation during terminal sync (#809)
Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user