fix(workbench): expose turn lifecycle ids

This commit is contained in:
lyon
2026-06-17 12:34:22 +08:00
parent 9e460346b9
commit 8a4a3f1248
4 changed files with 94 additions and 4 deletions
+26
View File
@@ -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");
+41 -3
View File
@@ -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) }
+19 -1
View File
@@ -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<WorkspaceRecord | null> {
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<string, unknown>).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, TurnStatusAuthority>): string | null {
+8
View File
@@ -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;