diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 1405d80b..b43ac5b1 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -397,7 +397,7 @@ test("workbench workspace status clears completed AgentRun active trace on read" threadId: "thread-issue-664", sessionStatus: "active", lastTraceId: "trc_issue664_previous", - messages: [{ role: "agent", text: "previous turn", traceId: "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); @@ -441,6 +441,11 @@ test("workbench workspace status clears completed AgentRun active trace on read" assert.equal(restored.body.workspace.activeTraceId, null); assert.equal(restored.body.workspace.workspace.lastTraceId, "trc_issue664_status_done"); assert.equal(restored.body.workspace.workspace.sessionStatus, "idle"); + assert.equal(restored.body.workspace.selectedConversation.status, "idle"); + assert.equal(restored.body.workspace.selectedConversation.lastTraceId, "trc_issue664_status_done"); + assert.equal(restored.body.workspace.selectedConversation.messages.length, 1); + assert.equal(restored.body.workspace.selectedConversation.messages[0].status, "idle"); + assert.equal(restored.body.workspace.selectedConversation.messages[0].text, "previous turn completed"); assert.equal(restored.body.workspace.revision, 3); assert.ok(agentRunCalls.some((call) => call.path === "/api/v1/runs/run_workspace_status/commands/cmd_workspace_status/result")); } finally { diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 5135793b..e5cff7c0 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -871,6 +871,15 @@ class AccessController { const workspaceJson = normalizeObject(workspace.workspace); const selectedConversationId = safeConversationIdLocal(result.conversationId) ? result.conversationId : workspace.selectedConversationId; const selectedAgentSessionId = safeAgentSessionId(result.sessionId ?? result.session?.sessionId ?? result.sessionReuse?.sessionId) || workspace.selectedAgentSessionId; + await this.syncTerminalWorkbenchConversation({ + workspace, + actor, + result, + activeTraceId, + selectedConversationId, + selectedAgentSessionId, + now + }); const updated = await this.store.updateWorkspace?.({ workspaceId: workspace.id, ownerUserId: workspace.ownerUserId, @@ -913,6 +922,39 @@ class AccessController { return publicWorkbenchWorkspace(workspace, { conversation, selectedDevicePod }); } + async syncTerminalWorkbenchConversation({ workspace, actor, result, activeTraceId, selectedConversationId, selectedAgentSessionId, now }) { + if (!safeConversationIdLocal(selectedConversationId) || !safeAgentSessionId(selectedAgentSessionId)) return null; + const existing = await this.visibleConversationForActor(actor, selectedConversationId, workspace.projectId); + const sessionStatus = terminalWorkbenchSessionStatus(result); + const threadId = textOr(result.threadId ?? result.session?.threadId ?? result.sessionReuse?.threadId ?? result.providerTrace?.threadId, existing?.threadId ?? null); + const messages = mergeTerminalConversationMessages(existing?.messages, result, { + traceId: activeTraceId, + conversationId: selectedConversationId, + sessionId: selectedAgentSessionId, + threadId, + now + }); + return await this.recordAgentSessionOwner({ + ownerUserId: actor.id, + sessionId: selectedAgentSessionId, + projectId: workspace.projectId, + agentId: existing?.agentId ?? "hwlab-code-agent", + status: sessionStatus, + conversationId: selectedConversationId, + threadId, + traceId: activeTraceId, + session: normalizeConversationSnapshot({ + ...(existing?.snapshot ?? {}), + source: "workbench-status-sync", + sessionStatus, + updatedAt: now, + lastTraceId: activeTraceId, + messages + }, actor), + now + }); + } + async visibleConversationForActor(actor, conversationId, projectId = "") { if (!safeConversationIdLocal(conversationId)) return null; const sessions = await this.store.listAgentSessionsForUser?.({ @@ -1943,6 +1985,43 @@ function canActorReadWorkspaceTrace(result = {}, actor = null) { if (!ownerUserId || actor?.role === "admin") return true; return ownerUserId === actor?.id; } +function mergeTerminalConversationMessages(existingMessages = [], result = {}, context = {}) { + const messages = Array.isArray(existingMessages) ? existingMessages.map(redactConversationMessage).filter(Boolean) : []; + const traceId = safeTraceIdLocal(result.traceId ?? context.traceId); + const terminalStatus = terminalWorkbenchSessionStatus(result); + const assistantText = textOr(result.reply?.content ?? result.assistantText ?? result.message?.content ?? result.error?.message ?? result.userMessage, ""); + const now = textOr(context.now, new Date().toISOString()); + const next = messages.map((message) => { + if (traceId && message.traceId === traceId && message.role === "agent") { + return redactConversationMessage({ + ...message, + text: assistantText || message.text, + status: terminalStatus, + conversationId: context.conversationId ?? message.conversationId, + sessionId: context.sessionId ?? message.sessionId, + threadId: context.threadId ?? message.threadId, + updatedAt: now + }); + } + return message; + }).filter(Boolean); + if (traceId && !next.some((message) => message.traceId === traceId && message.role === "agent")) { + next.push(redactConversationMessage({ + id: result.reply?.messageId ?? result.messageId ?? `msg_${traceId.slice(4)}`, + role: "agent", + title: "Code Agent result", + text: assistantText, + status: terminalStatus, + traceId, + conversationId: context.conversationId, + sessionId: context.sessionId, + threadId: context.threadId, + createdAt: result.reply?.createdAt ?? result.createdAt ?? now, + updatedAt: now + })); + } + return next.slice(-50); +} function redactConversationMessage(message) { if (!message || typeof message !== "object") return null; const runnerTrace = message.runnerTrace && typeof message.runnerTrace === "object" ? message.runnerTrace : null;