diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index d5023898..e71ec0cb 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -299,12 +299,25 @@ test("cloud api /v1/agent/chat supports short submit and result polling", async assert.equal(accepted.accepted, true); assert.equal(accepted.shortConnection, true); assert.equal(accepted.traceId, traceId); + assert.equal(accepted.turnId, traceId); + assert.equal(accepted.userMessageId, "msg_server-test-short-submit_user"); + assert.equal(accepted.assistantMessageId, "msg_server-test-short-submit_agent"); assert.equal(accepted.resultUrl, `/v1/agent/chat/result/${traceId}`); + const acceptedTurn = await fetch(`http://127.0.0.1:${port}/v1/agent/turns/${encodeURIComponent(traceId)}`, { headers: { cookie: manualSession.cookie } }); + assert.equal(acceptedTurn.status, 200); + const acceptedTurnPayload = await acceptedTurn.json(); + assert.equal(acceptedTurnPayload.turnId, traceId); + assert.equal(acceptedTurnPayload.userMessageId, accepted.userMessageId); + assert.equal(acceptedTurnPayload.assistantMessageId, accepted.assistantMessageId); + const payload = await pollAgentResult(port, traceId); validateCodeAgentChatSchema(payload); assert.equal(payload.status, "completed"); assert.equal(payload.traceId, traceId); + assert.equal(payload.turnId, traceId); + assert.equal(payload.userMessageId, accepted.userMessageId); + assert.equal(payload.assistantMessageId, accepted.assistantMessageId); assert.equal(payload.sandbox, "danger-full-access"); assert.match(payload.reply.content, new RegExp(workspace.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))); } finally { @@ -588,10 +601,23 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.equal(submit.status, 202); const accepted = await submit.json(); assert.equal(accepted.shortConnection, true); + assert.equal(accepted.turnId, traceId); + assert.equal(accepted.userMessageId, "msg_server-test-agentrun-adapter_user"); + assert.equal(accepted.assistantMessageId, "msg_server-test-agentrun-adapter_agent"); + + const acceptedTurn = await fetch(`http://127.0.0.1:${port}/v1/agent/turns/${encodeURIComponent(traceId)}`, { headers: { cookie: "hwlab_session=test-stub-session" } }); + assert.equal(acceptedTurn.status, 200); + const acceptedTurnPayload = await acceptedTurn.json(); + assert.equal(acceptedTurnPayload.turnId, traceId); + assert.equal(acceptedTurnPayload.userMessageId, accepted.userMessageId); + assert.equal(acceptedTurnPayload.assistantMessageId, accepted.assistantMessageId); const payload = await pollAgentResult(port, traceId); validateCodeAgentChatSchema(payload); assert.equal(payload.status, "completed"); + assert.equal(payload.turnId, traceId); + assert.equal(payload.userMessageId, accepted.userMessageId); + assert.equal(payload.assistantMessageId, accepted.assistantMessageId); assert.equal(payload.provider, "deepseek"); assert.equal(payload.backend, "agentrun-v01/deepseek"); assert.equal(payload.infrastructureBackend, "agentrun-v01/deepseek"); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index a4cd7aea..bfd6897b 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -1,3 +1,5 @@ +// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-0102 Agent编排 draft-2026-06-17-r0. +// Responsibility: Code Agent HTTP turn admission, lifecycle compatibility wrappers, and session evidence projection. import { createHash, randomUUID } from "node:crypto"; @@ -85,9 +87,11 @@ export async function handleCodeAgentChatHttp(request, response, options) { } const traceId = safeTraceId(getHeader(request, "x-trace-id") || params.traceId) || `trc_${randomUUID()}`; + const lifecycle = codeAgentTurnLifecycleFields(traceId, params); const chatParams = { ...params, traceId, + ...lifecycle, ownerUserId: options.actor?.id ?? params.ownerUserId, ownerRole: options.actor?.role ?? params.ownerRole }; @@ -113,6 +117,7 @@ export async function handleCodeAgentChatHttp(request, response, options) { shortConnection: true, controlSemantics: "submit-and-poll", traceId, + ...lifecycle, workspaceId: workspaceClaim?.workspace?.id ?? null, workspaceRevision: workspaceClaim?.workspace?.revision ?? null, conversationId: safeConversationId(nativeSessionChatParams.conversationId) || null, @@ -702,6 +707,7 @@ function submitCodeAgentChatTurn({ params, options, traceId }) { accepted: true, status: "running", traceId, + ...codeAgentTurnLifecycleFields(traceId, params), conversationId: safeConversationId(params.conversationId) || null, sessionId: safeSessionId(params.sessionId) || null, threadId: safeOpaqueId(params.threadId) || null, @@ -1276,6 +1282,7 @@ function forbiddenTurnSnapshot(traceId) { function codeAgentTurnStatusPayload({ traceId, result, snapshot, resultPollError, refreshError, options }) { const resultObject = result && typeof result === "object" ? result : null; const snapshotObject = snapshot && typeof snapshot === "object" ? snapshot : null; + const lifecycle = codeAgentTurnLifecycleFields(traceId, resultObject ?? snapshotObject ?? {}); const events = Array.isArray(snapshotObject?.events) ? snapshotObject.events : Array.isArray(resultObject?.runnerTrace?.events) ? resultObject.runnerTrace.events : []; const lastEvent = events.at(-1) ?? null; const status = normalizeTurnStatus( @@ -1300,6 +1307,7 @@ function codeAgentTurnStatusPayload({ traceId, result, snapshot, resultPollError running: found ? running : false, terminal: found ? terminal : false, traceId, + ...lifecycle, conversationId: safeConversationId(resultObject?.conversationId ?? snapshotObject?.conversationId) || null, sessionId: safeSessionId(resultObject?.sessionId ?? resultObject?.session?.sessionId ?? snapshotObject?.sessionId) || null, threadId: safeOpaqueId(resultObject?.threadId ?? resultObject?.session?.threadId ?? snapshotObject?.threadId) || null, @@ -2219,6 +2227,7 @@ function codeAgentConversationMessagesEvidence(payload = {}, params = {}, traceI const userText = boundedConversationMessageText(params.message ?? params.prompt ?? payload.userMessage ?? payload.prompt); if (!userText) return []; const resolvedTraceId = safeTraceId(traceId) || null; + const lifecycle = codeAgentTurnLifecycleFields(resolvedTraceId, payload); const session = payload.session && typeof payload.session === "object" ? payload.session : null; const sessionReuse = payload.sessionReuse && typeof payload.sessionReuse === "object" ? payload.sessionReuse : null; const conversationId = safeConversationId(payload.conversationId ?? session?.conversationId ?? sessionReuse?.conversationId ?? params.conversationId) || null; @@ -2226,14 +2235,15 @@ function codeAgentConversationMessagesEvidence(payload = {}, params = {}, traceI const threadId = safeOpaqueId(session?.threadId ?? sessionReuse?.threadId ?? payload.threadId ?? params.threadId) || null; const createdAt = payload.createdAt ?? payload.reply?.createdAt ?? new Date().toISOString(); const updatedAt = payload.updatedAt ?? createdAt; - const traceSuffix = codeAgentMessageTraceSuffix(resolvedTraceId); const userMessage = { - id: `msg_${traceSuffix}_user`, + id: lifecycle.userMessageId, + messageId: lifecycle.userMessageId, role: "user", title: "用户", text: userText, status: "sent", traceId: resolvedTraceId, + turnId: lifecycle.turnId, conversationId, sessionId, threadId, @@ -2256,12 +2266,14 @@ function codeAgentConversationMessagesEvidence(payload = {}, params = {}, traceI ); const agentStatus = codeAgentConversationAgentMessageStatus(payload); const agentMessage = { - id: `msg_${traceSuffix}_agent`, + id: lifecycle.assistantMessageId, + messageId: lifecycle.assistantMessageId, role: "agent", title: agentStatus === "failed" ? "Code Agent 返回阻塞" : agentStatus === "running" ? "Code Agent 处理中" : "Code Agent 回复", text: agentText || (agentStatus === "running" ? "" : "Code Agent 请求已结束,请查看 Trace 详情。"), status: agentStatus, traceId: resolvedTraceId, + turnId: lifecycle.turnId, conversationId, sessionId, threadId, @@ -2306,6 +2318,31 @@ function codeAgentMessageTraceSuffix(traceId) { return text.replace(/^trc_/u, "").replace(/[^A-Za-z0-9_.:-]/gu, "_").slice(0, 48) || "trace"; } +function codeAgentTurnLifecycleFields(traceId, source = {}) { + const resolvedTraceId = safeTraceId(source?.traceId ?? traceId) || safeTraceId(traceId) || null; + const turnId = safeTurnId(source?.turnId) || resolvedTraceId; + const traceSuffix = codeAgentMessageTraceSuffix(resolvedTraceId); + return { + turnId, + userMessageId: safeMessageId(source?.userMessageId ?? source?.userMessage?.messageId) || codeAgentLifecycleMessageId(traceSuffix, "user"), + assistantMessageId: safeMessageId(source?.assistantMessageId ?? source?.assistantMessage?.messageId) || codeAgentLifecycleMessageId(traceSuffix, "agent") + }; +} + +function codeAgentLifecycleMessageId(traceSuffix, role) { + return `msg_${traceSuffix}_${role}`; +} + +function safeTurnId(value) { + const text = textValue(value); + return /^turn_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null; +} + +function safeMessageId(value) { + const text = textValue(value); + return /^msg_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null; +} + function codeAgentConversationAgentMessageStatus(payload = {}) { const status = textValue(payload.status ?? payload.agentRun?.terminalStatus ?? payload.commandState ?? payload.runStatus).toLowerCase(); if (["completed", "done", "success", "active"].includes(status)) return "completed"; @@ -2831,6 +2868,7 @@ function compactCodeAgentChatResultPayload(payload, options = {}) { const { userBillingReservation, ...publicPayload } = payload; return { ...publicPayload, + ...codeAgentTurnLifecycleFields(publicPayload.traceId, publicPayload), ...(terminalEvidence ? { terminalEvidence: terminalEvidencePayload(terminalEvidence) } : {}), ...(publicPayload.runnerTrace && typeof publicPayload.runnerTrace === "object" ? { runnerTrace: compactRunnerTraceForResult(publicPayload.runnerTrace, limit) } diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index cbec4a71..7c677dcf 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -1,3 +1,6 @@ +// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0. +// Responsibility: Workbench client state orchestration for session selection, turn admission, and trace lifecycle rendering. + import { computed, ref } from "vue"; import { defineStore } from "pinia"; import { api } from "@/api"; @@ -377,6 +380,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { return; } void refreshSessionStatusById(sessionId); + alignOptimisticTurnMessages(traceId, response.data); applyTurnStatusSnapshot(traceId, response.data); void refreshConversations(conversationId); subscribe(traceId, response.data); @@ -656,6 +660,19 @@ export const useWorkbenchStore = defineStore("workbench", () => { messages.value = messages.value.map((message) => message.traceId === traceId && message.role === "agent" ? { ...message, ...patch, updatedAt: new Date().toISOString() } : message); } + function alignOptimisticTurnMessages(traceId: string, lifecycle: AgentChatResponse): void { + const turnId = firstNonEmptyString(lifecycle.turnId, traceId); + const userMessageId = firstNonEmptyString(lifecycle.userMessageId); + const assistantMessageId = firstNonEmptyString(lifecycle.assistantMessageId); + if (!turnId && !userMessageId && !assistantMessageId) return; + messages.value = messages.value.map((message) => { + if (message.traceId !== traceId) return message; + if (message.role === "user" && userMessageId) return { ...message, id: userMessageId, messageId: userMessageId, turnId, updatedAt: new Date().toISOString() }; + if (message.role === "agent" && assistantMessageId) return { ...message, id: assistantMessageId, messageId: assistantMessageId, turnId, updatedAt: new Date().toISOString() }; + return turnId ? { ...message, turnId, updatedAt: new Date().toISOString() } : message; + }); + } + async function ensureWorkspace(): Promise { if (workspace.value) return workspace.value; await hydrate(); @@ -879,7 +896,8 @@ function normalizeChatMessage(message: ChatMessage): ChatMessage { const text = message.role === "agent" && isTerminalMessageStatus(status) ? firstNonEmptyString(finalText, errorText, baseText) ?? "" : firstNonEmptyString(baseText, finalText, errorText) ?? ""; - return { ...message, text, id: message.id ?? nextProtocolId("msg"), title: normalizeWorkbenchMessageTitle(message.role, message.title), createdAt: message.createdAt ?? new Date().toISOString(), status, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined }; + const messageId = firstNonEmptyString((message as Record).messageId, message.id) ?? nextProtocolId("msg"); + return { ...message, text, id: messageId, messageId, title: normalizeWorkbenchMessageTitle(message.role, message.title), createdAt: message.createdAt ?? new Date().toISOString(), status, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined }; } function activeTraceIdFromMessages(messages: ChatMessage[], turnStatusAuthority: Record): string | null { diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index e0e7cb5d..429e148d 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -1,3 +1,6 @@ +// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0. +// Responsibility: Cloud Web API and Workbench state type contracts shared by stores and components. + export type Tone = "ok" | "pending" | "blocked" | "error" | "warn" | "dev-live" | "dry-run"; export type AuthState = "checking" | "login" | "authenticated"; export type ProviderProfile = string; @@ -167,6 +170,8 @@ export interface ChatMessage { createdAt: string; updatedAt?: string; traceId?: string | null; + turnId?: string | null; + messageId?: string | null; conversationId?: string | null; sessionId?: string | null; threadId?: string | null; @@ -200,6 +205,9 @@ export interface AgentChatResponse { text?: string; summary?: string; traceId?: string; + turnId?: string | null; + userMessageId?: string | null; + assistantMessageId?: string | null; conversationId?: string; sessionId?: string; threadId?: string;