From da6be55512d93e71b8dd861746abe4f38cf68512 Mon Sep 17 00:00:00 2001 From: lyon Date: Wed, 17 Jun 2026 15:20:56 +0800 Subject: [PATCH] fix: remove workbench legacy read repair paths --- internal/cloud/access-control.test.ts | 35 +- internal/cloud/access-control.ts | 420 +------------------- tools/hwlab-cli/client.test.ts | 3 +- tools/src/hwlab-cli-lib.ts | 8 +- web/hwlab-cloud-web/src/api/agent.ts | 4 +- web/hwlab-cloud-web/src/stores/workbench.ts | 23 +- 6 files changed, 41 insertions(+), 452 deletions(-) diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 7d53e42f..c84aa42b 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -740,12 +740,13 @@ test("workbench workspace read preserves a newer selected conversation without t assert.equal(repaired.body.workspace.selectedConversation.messages[0].text, "old turn running"); assert.equal(agentRunCalls.length, 0); - 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"); + 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()))); @@ -1219,7 +1220,7 @@ test("cloud api exposes terminal conversation status when stored session status } }); -test("cloud api repairs conversation snapshot when persisted result advances last trace", async () => { +test("cloud api preserves conversation snapshot when persisted result advances last trace", async () => { const codeAgentChatResults = new Map(); const server = createCloudApiServer({ env: { @@ -1285,15 +1286,15 @@ test("cloud api repairs conversation snapshot when persisted result advances las 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_persisted_final"); - assert.equal(listed.messages.at(-1).text, finalText); - assert.match(listed.messages.at(-1).text, /\n\|---\|---\|\n/u); + 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_persisted_final"); - assert.equal(direct.body.conversation.messages.at(-1).text, finalText); + 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); @@ -1304,9 +1305,9 @@ test("cloud api repairs conversation snapshot when persisted result advances las updatedByClient: "test-suite" }, aliceLogin.cookie); assert.equal(selected.status, 200); - assert.equal(selected.body.workspace.selectedConversation.messages.at(-1).traceId, "trc_issue842_persisted_final"); - assert.equal(selected.body.workspace.selectedConversation.messages.at(-1).text, finalText); - assert.match(selected.body.workspace.selectedConversation.messages.at(-1).text, /\n\|---\|---\|\n/u); + 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())); @@ -1474,7 +1475,7 @@ test("cloud api workspace read preserves preview-only conversation without termi threadId, traceId, session: { - source: "workbench-status-sync", + source: "code-agent-result", sessionStatus: "failed", lastTraceId: traceId, messages: [ @@ -1561,7 +1562,7 @@ test("access controller preserves user message when owner evidence updates with threadId, traceId, session: { - source: "workbench-status-sync", + source: "code-agent-result", sessionStatus: "running", lastTraceId: traceId, messages: [ diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 3cea5557..90cb0c23 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -5,12 +5,6 @@ import { createHash, createPublicKey, randomBytes, randomUUID, verify as verifySignature } from "node:crypto"; import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs"; -import { - codeAgentAgentRunAdapterEnabled, - loadPersistedAgentRunResult, - syncAgentRunChatResult -} from "./code-agent-agentrun-adapter.ts"; -import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; import { HWLAB_TOOL_IDS, createOpenFgaAuthorizer, @@ -1051,12 +1045,7 @@ class AccessController { includeArchived: false, now: this.now() }) ?? []; - let conversations = await this.repairVisibleConversationsIfNeeded( - auth.actor, - conversationsFromAgentSessions(sessions), - projectId, - { deadlineMs: 1500 } - ); + let conversations = conversationsFromAgentSessions(sessions); conversations = includeConversation(conversations, includeConversationId ? await this.visibleConversationForActor(auth.actor, includeConversationId, projectId) : null); return sendJson(response, 200, { ok: true, @@ -1088,9 +1077,8 @@ class AccessController { includeArchived: true, now: this.now() }) ?? []; - let conversation = conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null; + const conversation = conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null; if (!conversation) return sendJson(response, 404, errorPayload("agent_conversation_not_found", "Agent conversation is not visible to the current actor", 404)); - conversation = await this.repairVisibleConversationIfNeeded(auth.actor, conversation, projectId) ?? conversation; return sendJson(response, 200, { ok: true, status: "found", contractVersion: "agent-conversations-v1", actor: publicActor(auth.actor), conversation }); } @@ -1209,7 +1197,7 @@ class AccessController { } const selectedConversationId = textOr(body.selectedConversationId ?? body.conversationId, current.selectedConversationId ?? ""); if (selectedConversationId) { - const visible = await this.visibleConversationForActor(auth.actor, selectedConversationId, body.projectId ?? current.projectId, { skipTerminalRepair: true }); + const visible = await this.visibleConversationForActor(auth.actor, selectedConversationId, body.projectId ?? current.projectId); if (!visible) return sendJson(response, 403, errorPayload("workspace_conversation_forbidden", "Selected conversation is not visible to the current actor", 403)); } const workspace = await this.store.updateWorkspace?.({ @@ -1241,7 +1229,7 @@ class AccessController { const body = await jsonBody(request); 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.visibleConversationForActor(auth.actor, conversationId, body.projectId ?? current.projectId, { skipTerminalRepair: true }); + const existing = await this.visibleConversationForActor(auth.actor, conversationId, body.projectId ?? current.projectId); 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) { @@ -1329,251 +1317,13 @@ class AccessController { }; } - async syncTerminalWorkbenchWorkspace(workspace, actor) { - const activeTraceId = safeTraceIdLocal(workspace?.activeTraceId); - const codeAgentEnv = this.codeAgentEnv ?? this.env; - if (!workspace || !codeAgentAgentRunAdapterEnabled(codeAgentEnv)) return workspace; - const workspaceJson = normalizeObject(workspace.workspace); - const repairTraceId = activeTraceId ? "" : await this.terminalWorkbenchRepairTraceId(workspace, actor, workspaceJson); - const traceId = activeTraceId || repairTraceId; - if (!traceId) return workspace; - try { - const result = await this.terminalCodeAgentResultForActor(traceId, actor); - if (!result) return workspace; - - const now = this.now(); - 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 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, - result, - activeTraceId: traceId, - selectedConversationId: resultConversationId, - selectedAgentSessionId: resultAgentSessionId, - now - }); - const staleThreadId = threadResumeFailureThreadId(result); - const updated = await this.store.updateWorkspace?.({ - workspaceId: workspace.id, - ownerUserId: workspace.ownerUserId, - actorRole: actor?.role ?? "user", - selectedConversationId, - selectedAgentSessionId, - activeTraceId: null, - providerProfile: workspace.providerProfile, - patch: { - ...workspaceJson, - selectedConversationId, - selectedAgentSessionId, - activeTraceId: null, - sessionStatus: nextSessionStatus, - lastTraceId: traceId, - syncedTraceStatus: result.status ?? result.agentRun?.terminalStatus ?? null, - ...(staleContinuation ? { - staleContinuationCleared: true, - staleContinuationReason: "thread-resume-failed", - staleConversationId: resultConversationId, - staleAgentSessionId: resultAgentSessionId, - staleThreadId, - recoveryAction: "new-session-on-next-turn" - } : {}), - updatedAt: now, - source: "workbench-status-sync", - valuesRedacted: true, - secretMaterialStored: false - }, - updatedBySessionId: workspace.updatedBySessionId, - updatedByClient: "workbench-status-sync", - now - }); - return updated ?? workspace; - } catch { - return workspace; - } - } - - async terminalWorkbenchRepairTraceId(workspace, actor, workspaceJson) { - try { - const traceId = safeTraceIdLocal(workspaceJson?.lastTraceId); - if (!traceId || !safeConversationIdLocal(workspace?.selectedConversationId)) return ""; - const conversation = await this.visibleConversationForActor(actor, workspace.selectedConversationId, workspace.projectId, { skipTerminalRepair: true }); - return conversationNeedsTerminalRepair(conversation, traceId) ? traceId : ""; - } catch { - return ""; - } - } - async publicWorkbenchWorkspace(workspace, actor) { const conversation = workspace?.selectedConversationId - ? await this.visibleConversationForActor(actor, workspace.selectedConversationId, workspace.projectId, { skipTerminalRepair: true }) + ? await this.visibleConversationForActor(actor, workspace.selectedConversationId, workspace.projectId) : null; return publicWorkbenchWorkspace(workspace, { conversation }); } - 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, { skipTerminalRepair: true }); - const sessionStatus = terminalWorkbenchSessionStatus(result); - const threadId = textOr(result.threadId ?? result.session?.threadId ?? result.sessionReuse?.threadId ?? result.providerTrace?.threadId, existing?.threadId ?? null); - const workspaceJson = normalizeObject(workspace?.workspace); - const workspaceMessages = workspace?.selectedConversationId === selectedConversationId && workspace?.selectedAgentSessionId === selectedAgentSessionId && Array.isArray(workspaceJson.messages) - ? workspaceJson.messages - : []; - const existingMessages = Array.isArray(existing?.messages) && existing.messages.length > 0 ? existing.messages : workspaceMessages; - const messages = mergeTerminalConversationMessages(existingMessages, result, { - traceId: activeTraceId, - conversationId: selectedConversationId, - sessionId: selectedAgentSessionId, - threadId, - now, - existingMessageCount: existing?.messageCount ?? existing?.snapshot?.messageCount ?? (workspaceMessages.length || null), - firstUserMessagePreview: existing?.firstUserMessagePreview ?? existing?.snapshot?.firstUserMessagePreview ?? firstUserPreviewFromMessages(workspaceMessages) ?? null - }); - const mergedMessages = Array.isArray(messages) ? messages : messages?.messages ?? []; - const mergedCount = Array.isArray(messages) ? null : messages?.messageCount ?? null; - const mergedPreview = Array.isArray(messages) ? null : messages?.firstUserMessagePreview ?? null; - 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: mergedMessages, - messageCount: mergedCount, - firstUserMessagePreview: mergedPreview - }, actor), - now - }); - } - - async terminalCodeAgentResultForActor(traceId, actor) { - const safeTraceId = safeTraceIdLocal(traceId); - const codeAgentEnv = this.codeAgentEnv ?? this.env; - if (!safeTraceId || !codeAgentAgentRunAdapterEnabled(codeAgentEnv)) return null; - const options = { - env: codeAgentEnv, - fetchImpl: this.fetchImpl, - accessController: this, - codeAgentChatResults: this.codeAgentChatResults, - traceStore: this.traceStore ?? defaultCodeAgentTraceStore - }; - try { - const cached = this.codeAgentChatResults?.get?.(safeTraceId) ?? null; - const persisted = cached ? null : await loadPersistedAgentRunResult(safeTraceId, options); - const current = cached ?? persisted ?? null; - if (!current || !canActorReadWorkspaceTrace(current, actor)) return null; - const synced = current?.agentRun?.runId - ? await syncAgentRunChatResult({ traceId: safeTraceId, currentResult: current, options, traceStore: options.traceStore }) - : { result: current }; - const result = synced.result ?? current; - return isTerminalCodeAgentResult(result) ? result : null; - } catch { - return null; - } - } - - async repairVisibleConversationsIfNeeded(actor, conversations = [], projectId = "", options = {}) { - if (!Array.isArray(conversations) || conversations.length === 0) return []; - const deadlineMs = positiveInteger(options.deadlineMs, 0); - const deadline = deadlineMs > 0 ? Date.now() + deadlineMs : 0; - const repaired = []; - for (const conversation of conversations) { - if (!terminalConversationRepairTraceId(conversation)) { - repaired.push(conversation); - continue; - } - const remainingMs = deadline > 0 ? deadline - Date.now() : 0; - if (deadline > 0 && remainingMs <= 0) { - repaired.push(conversation); - continue; - } - const repair = this.repairVisibleConversationIfNeeded(actor, conversation, projectId); - const next = deadline > 0 ? await resolveBeforeDeadline(repair, remainingMs) : await repair; - repaired.push(next ?? conversation); - } - return repaired; - } - - async repairVisibleConversationIfNeeded(actor, conversation, projectId = "") { - if (textOr(conversation?.status, "").toLowerCase() === "archived") return conversation; - const traceId = terminalConversationRepairTraceId(conversation); - if (!traceId) return conversation; - const result = await this.terminalCodeAgentResultForActor(traceId, actor); - if (!result) return conversation; - const sessionId = safeAgentSessionId( - result.sessionId - ?? result.session?.sessionId - ?? result.sessionReuse?.sessionId - ?? conversation.sessionId - ?? conversation.session?.sessionId - ); - const conversationId = safeConversationIdLocal(result.conversationId) ? result.conversationId : conversation.conversationId; - if (!safeConversationIdLocal(conversationId) || !sessionId) return conversation; - const now = this.now(); - const threadId = textOr(result.threadId ?? result.session?.threadId ?? result.sessionReuse?.threadId ?? result.providerTrace?.threadId, conversation.threadId ?? null); - const messages = mergeTerminalConversationMessages(conversation.messages, result, { - traceId, - conversationId, - sessionId, - threadId, - now, - existingMessageCount: conversation.messageCount ?? conversation.snapshot?.messageCount ?? null, - firstUserMessagePreview: conversation.firstUserMessagePreview ?? conversation.snapshot?.firstUserMessagePreview ?? null - }); - const mergedMessages = Array.isArray(messages) ? messages : messages?.messages ?? []; - const mergedCount = Array.isArray(messages) ? null : messages?.messageCount ?? null; - const mergedPreview = Array.isArray(messages) ? null : messages?.firstUserMessagePreview ?? null; - const sessionStatus = terminalWorkbenchSessionStatus(result); - const repaired = await this.recordAgentSessionOwner({ - ownerUserId: actor.id, - sessionId, - projectId: textOr(conversation.projectId ?? projectId, "prj_hwpod_workbench"), - agentId: conversation.agentId ?? "hwlab-code-agent", - status: sessionStatus, - conversationId, - threadId, - traceId, - session: normalizeConversationSnapshot({ - ...(conversation.snapshot ?? {}), - source: "conversation-terminal-repair", - sessionStatus, - updatedAt: now, - lastTraceId: traceId, - messages: mergedMessages, - messageCount: mergedCount, - firstUserMessagePreview: mergedPreview - }, actor), - now - }); - return conversationsFromAgentSessions(repaired ? [repaired] : []).find((item) => item.conversationId === conversationId) ?? conversation; - } - async visibleConversationForActor(actor, conversationId, projectId = "", options = {}) { if (!safeConversationIdLocal(conversationId)) return null; const sessions = await this.store.listAgentSessionsForUser?.({ @@ -1586,9 +1336,7 @@ class AccessController { includeArchived: options.includeArchived === true, now: this.now() }) ?? []; - const conversation = conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null; - if (!conversation || options.skipTerminalRepair === true) return conversation; - return await this.repairVisibleConversationIfNeeded(actor, conversation, projectId) ?? conversation; + return conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null; } async nextVisibleConversationForActor(actor, projectId = "", excludedConversationId = "") { @@ -2451,130 +2199,6 @@ function numberOrNull(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } -function isTerminalCodeAgentResult(result = {}) { - const status = textOr(result.status, "").toLowerCase(); - const terminalStatus = textOr(result.agentRun?.terminalStatus, "").toLowerCase(); - return [status, terminalStatus].some((value) => ["completed", "failed", "blocked", "canceled", "cancelled", "timeout", "error"].includes(value)); -} -function isThreadResumeFailedResult(result = {}) { - const values = [ - result.error?.code, - result.error?.category, - result.blocker?.code, - result.blocker?.category, - result.providerTrace?.failureKind, - result.agentRun?.providerTrace?.failureKind, - result.agentRun?.failureKind - ].map((value) => textOr(value, "").toLowerCase().replace(/_/gu, "-")); - return values.some((value) => value === "thread-resume-failed" || value === "session-store-evicted") - || /session stor(?:e|age).*evicted|pvc-backed session|no rollout found for thread id|thread\/resume failed/iu.test(String(result.error?.message ?? result.blocker?.message ?? result.providerTrace?.failureMessage ?? result.agentRun?.providerTrace?.failureMessage ?? "")); -} -function threadResumeFailureThreadId(result = {}) { - return boundedText(textOr( - result.providerTrace?.threadId - ?? result.agentRun?.providerTrace?.threadId - ?? result.session?.threadId - ?? result.sessionReuse?.threadId - ?? result.threadId, - "" - ), 240) || null; -} -function terminalWorkbenchSessionStatus(result = {}) { - if (isThreadResumeFailedResult(result)) return "thread-resume-failed"; - const sessionStatus = textOr(result.session?.status ?? result.sessionSummary?.status ?? result.sessionLifecycleStatus ?? result.runnerTrace?.sessionStatus, "").toLowerCase(); - if (sessionStatus && sessionStatus !== "running" && sessionStatus !== "busy" && sessionStatus !== "pending") return sessionStatus === "cancelled" ? "canceled" : sessionStatus; - const status = textOr(result.status ?? result.agentRun?.terminalStatus, "").toLowerCase(); - if (status === "completed") return "idle"; - if (status === "cancelled") return "canceled"; - return status || "idle"; -} -function canActorReadWorkspaceTrace(result = {}, actor = null) { - const ownerUserId = textOr(result.ownerUserId, ""); - if (!ownerUserId || actor?.role === "admin") return true; - return ownerUserId === actor?.id; -} -function conversationNeedsTerminalRepair(conversation, traceId) { - if (!conversation || !safeTraceIdLocal(traceId)) return false; - const status = textOr(conversation.status, "").toLowerCase(); - 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; - const latestAgent = latestAgentConversationMessage(messages); - if (!traceMessage) return Boolean(latestAgent && traceId === safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId)); - if (latestAgent?.traceId && latestAgent.traceId !== traceId && traceId === safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId)) return true; - if (markdownTextLooksFlattened(traceMessage.text ?? traceMessage.content ?? traceMessage.message) && traceId === safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId)) return true; - const messageStatus = textOr(traceMessage.status, "").toLowerCase(); - return ["", "running", "busy", "pending", "active"].includes(messageStatus); -} -function markdownTextLooksFlattened(value) { - const text = conversationTextRaw(value).replace(/\r\n?/gu, "\n").trim(); - if (!text || text.includes("\n")) return false; - return /\|\s*:?-{3,}:?\s*\|/u.test(text) || /```[\s\S]+```/u.test(text); -} -function terminalConversationRepairTraceId(conversation) { - if (!conversation) return ""; - const lastTraceId = safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId); - if (lastTraceId && conversationNeedsTerminalRepair(conversation, lastTraceId)) return lastTraceId; - const messages = Array.isArray(conversation.messages) ? conversation.messages : []; - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - const traceId = safeTraceIdLocal(message?.traceId); - if (message?.role === "agent" && traceId && conversationNeedsTerminalRepair(conversation, traceId)) return traceId; - } - return ""; -} -function mergeTerminalConversationMessages(existingMessages = [], result = {}, context = {}) { - const baseMessages = dedupeConversationMessages(existingMessages); - const traceId = safeTraceIdLocal(result.traceId ?? context.traceId); - const terminalStatus = terminalWorkbenchSessionStatus(result); - const assistantText = firstConversationText([result.reply?.content, result.assistantText, result.message?.content, result.error?.message, result.userMessage], 12000); - const now = textOr(context.now, new Date().toISOString()); - const firstUserPreview = firstUserPreviewFromMessages(baseMessages) ?? (conversationText(context.firstUserMessagePreview, 240) || null); - const messages = dedupeConversationMessages(ensureTerminalUserMessage(baseMessages, { ...context, traceId, now, firstUserPreview })); - const runnerTrace = terminalRunnerTraceForConversationMessage(result, { ...context, traceId, terminalStatus }); - 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, - runnerTrace: runnerTrace ?? message.runnerTrace, - updatedAt: now - }); - } - return message; - }).filter(Boolean); - let addedAgentMessage = false; - if (traceId && !next.some((message) => message.traceId === traceId && message.role === "agent")) { - const messageId = result.reply?.messageId ?? result.messageId ?? `msg_${traceId.slice(4)}_agent`; - next.push(redactConversationMessage({ - id: messageId, - messageId, - role: "agent", - title: "Code Agent result", - text: assistantText, - status: terminalStatus, - traceId, - turnId: traceId, - conversationId: context.conversationId, - sessionId: context.sessionId, - threadId: context.threadId, - runnerTrace, - createdAt: result.reply?.createdAt ?? result.createdAt ?? now, - updatedAt: now - })); - addedAgentMessage = true; - } - const finalMessages = dedupeConversationMessages(next).slice(-50); - return { - messages: finalMessages, - messageCount: finalMessages.length, - firstUserMessagePreview: firstUserPreviewFromMessages(finalMessages) ?? firstUserPreview - }; -} function ensureTerminalUserMessage(messages = [], context = {}) { if (messages.some((message) => message?.role === "user")) return messages; const userText = conversationText(context.firstUserPreview, 12000); @@ -2598,25 +2222,6 @@ function ensureTerminalUserMessage(messages = [], context = {}) { }); return userMessage ? [userMessage, ...messages] : messages; } -function terminalRunnerTraceForConversationMessage(result = {}, context = {}) { - const source = result.runnerTrace && typeof result.runnerTrace === "object" ? result.runnerTrace : null; - if (!source) return null; - const events = Array.isArray(source.events) ? source.events : []; - const lastEvent = source.lastEvent && typeof source.lastEvent === "object" ? source.lastEvent : events.at(-1); - return pruneEmpty({ - traceId: safeTraceIdLocal(source.traceId ?? result.traceId ?? context.traceId) || undefined, - status: textOr(source.status ?? result.status ?? result.agentRun?.terminalStatus ?? context.terminalStatus, ""), - sessionId: safeAgentSessionId(source.sessionId ?? result.sessionId ?? result.session?.sessionId ?? result.sessionReuse?.sessionId ?? context.sessionId) || undefined, - threadId: textOr(source.threadId ?? result.threadId ?? result.session?.threadId ?? result.sessionReuse?.threadId ?? context.threadId, ""), - eventCount: numberOrNull(source.eventCount ?? result.traceSummary?.sourceEventCount ?? result.traceSummary?.eventCount) ?? events.length, - eventsCompacted: source.eventsCompacted === true || events.length === 0, - fullTraceLoaded: false, - lastEvent, - traceStatus: textOr(result.traceStatus, ""), - finalResponse: redactTraceFinalResponse(result.finalResponse), - traceSummary: redactTraceSummary(result.traceSummary) - }); -} function redactConversationMessage(message) { if (!message || typeof message !== "object") return null; const runnerTrace = message.runnerTrace && typeof message.runnerTrace === "object" ? message.runnerTrace : null; @@ -2978,19 +2583,6 @@ function nonNegativeResultInteger(value) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0; } -function positiveInteger(value, fallback = 0) { const number = Number(value); return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback; } -async function resolveBeforeDeadline(promise, timeoutMs) { - /** @type {ReturnType | null} */ - let timer = null; - const boundedTimeoutMs = Math.max(1, positiveInteger(timeoutMs, 1)); - const guarded = Promise.resolve(promise).catch(() => null); - const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(null), boundedTimeoutMs); }); - try { - return await Promise.race([guarded, timeout]); - } finally { - if (timer) clearTimeout(timer); - } -} function accessWriteErrorPayload(result = {}) { const error = result.error ?? {}; const code = textOr(error.code, "openfga_write_failed"); diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 69140a77..6217c165 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -417,7 +417,7 @@ test("hwlab-cli client session final-response verifies original conversation acr threadId: "thread_issue834", status: "idle", lastTraceId: "trc_issue834_final", - snapshot: { source: "conversation-terminal-repair" }, + snapshot: { source: "code-agent-result" }, messages: [{ role: "agent", traceId: "trc_issue834_final", status: "idle", text: finalText }] }; const fetchImpl = async (url: string | URL) => { @@ -443,7 +443,6 @@ test("hwlab-cli client session final-response verifies original conversation acr 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(result.payload.validation.repair.appearsRepaired, true); assert.equal(calls[1], "http://web.test/v1/agent/conversations?projectId=prj_v02_code_agent&limit=100"); assert.deepEqual(result.payload.validation.failures, []); }); diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index b0ebd2a2..08c7401e 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -1,3 +1,6 @@ +// SPEC: PJ2026-010402 HWLAB CLI draft-2026-06-17-r0; PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0. +// Responsibility: HWLAB CLI Web-equivalent client commands, including session, trace, result and workbench diagnostics. + import { createHash } from "node:crypto"; import { chmod, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; @@ -3322,11 +3325,6 @@ function finalResponseValidation({ conversationId, projectId, inspectConversatio listMarkdownNotFlattened: listMarkdown.likelyFlattened !== true, resultMarkdownHasStructure: resultMarkdown.newlineCount > 0 || resultMarkdown.hasTableSeparator === true || resultMarkdown.hasFence === true }, - repair: clean({ - inspectSnapshotSource: text(inspectConversation?.snapshot?.source), - listSnapshotSource: text(listedConversation?.snapshot?.source), - appearsRepaired: text(inspectConversation?.snapshot?.source).includes("repair") || text(listedConversation?.snapshot?.source).includes("repair") - }), previews: { inspectText: preview(inspectText, 200), listText: preview(listText, 200), diff --git a/web/hwlab-cloud-web/src/api/agent.ts b/web/hwlab-cloud-web/src/api/agent.ts index 1deb9603..9e7901f7 100644 --- a/web/hwlab-cloud-web/src/api/agent.ts +++ b/web/hwlab-cloud-web/src/api/agent.ts @@ -1,3 +1,6 @@ +// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0. +// Responsibility: Code Agent HTTP client for send, steer, turn, trace, session and cancel APIs. + import { fetchJson, type ApiRequestOptions } from "./client"; import type { AgentChatResponse, AgentChatResultResponse, ApiResult } from "@/types"; @@ -16,7 +19,6 @@ function agentTracePath(traceId: string, projectId: string, options: AgentTraceR export const agentAPI = { sendAgentMessage: (payload: Record, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise> => fetchJson("/v1/agent/chat", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent", activityRef }), steerAgentMessage: (payload: Record, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise> => fetchJson("/v1/agent/chat/steer", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent steer", activityRef }), - getAgentChatResult: (resultUrl: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise> => fetchJson(resultUrl, { timeoutMs, timeoutName: "Code Agent result", activityRef }), getAgentTurn: (traceId: string, projectId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise> => fetchJson(`/v1/agent/turns/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(projectId)}`, { timeoutMs, timeoutName: "Code Agent turn", activityRef }), getAgentTrace: (traceId: string, projectId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"], options?: AgentTraceRequestOptions): Promise> => fetchJson(agentTracePath(traceId, projectId, options), { timeoutMs, timeoutName: "Code Agent trace", activityRef }), getAgentSession: (sessionId: string): Promise }>> => fetchJson(`/v1/agent/sessions/${encodeURIComponent(sessionId)}`, { timeoutMs: 8000, timeoutName: "Code Agent session" }), diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index c2c6cf39..f0eef238 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -160,9 +160,11 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (response.ok && isCurrentWorkspaceSelection(requestEpoch)) { workspace.value = response.data?.workspace ?? current; rememberWorkspaceSnapshot(activeProjectId.value, workspace.value); - setActiveConversationSelection(selectedConversationIdFromWorkspace(workspace.value)); - rememberConversationDetail(workspace.value?.selectedConversation); - messages.value = restoreMessagesTraceAuthority(messagesFromWorkspace(workspace.value), messages.value); + const selectedConversationId = selectedConversationIdFromWorkspace(workspace.value); + const selectedConversation = workspace.value?.selectedConversation ?? null; + setActiveConversationSelection(selectedConversationId); + rememberConversationDetail(selectedConversation); + messages.value = restoreMessagesTraceAuthority(messagesFromSelectedConversation(selectedConversation, selectedConversationId, null, []), messages.value); currentRequest.value = null; void hydrateTurnStatusAuthority(messages.value); void hydrateTraceEvents(messages.value); @@ -253,9 +255,11 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (response.ok) { workspace.value = response.data?.workspace ?? workspace.value; rememberWorkspaceSnapshot(activeProjectId.value, workspace.value); - replaceActiveConversationSelection(selectedConversationIdFromWorkspace(workspace.value)); - rememberConversationDetail(workspace.value?.selectedConversation); - messages.value = restoreMessagesTraceAuthority(messagesFromWorkspace(workspace.value), messages.value); + const selectedConversationId = selectedConversationIdFromWorkspace(workspace.value); + const selectedConversation = workspace.value?.selectedConversation ?? null; + replaceActiveConversationSelection(selectedConversationId); + rememberConversationDetail(selectedConversation); + messages.value = restoreMessagesTraceAuthority(messagesFromSelectedConversation(selectedConversation, selectedConversationId, null, []), messages.value); currentRequest.value = null; void hydrateTurnStatusAuthority(messages.value); await refreshConversations(selectedConversationIdFromWorkspace(workspace.value)); @@ -1102,13 +1106,6 @@ function messagesFromSelectedConversation(conversation: ConversationRecord | nul return []; } -function messagesFromWorkspace(workspace: WorkspaceRecord | null): ChatMessage[] { - const selected = workspace?.selectedConversation?.messages; - const embedded = workspace?.workspace?.messages; - const source = Array.isArray(selected) ? selected : Array.isArray(embedded) ? embedded : []; - return source.map((message) => normalizeChatMessage(message)); -} - function normalizeChatMessage(message: ChatMessage): ChatMessage { const runnerTrace = normalizeMessageRunnerTrace(message); const error = normalizeAgentError(message.error ?? runnerTrace?.error);