fix: unify code agent turn status authority

This commit is contained in:
lyon
2026-06-16 19:21:13 +08:00
parent ccc2cacfe2
commit ce1f1031f5
15 changed files with 382 additions and 185 deletions
+176
View File
@@ -104,6 +104,7 @@ export async function handleCodeAgentChatHttp(request, response, options) {
traceId
});
const traceUrl = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`;
const turnUrl = `/v1/agent/turns/${encodeURIComponent(traceId)}`;
sendJson(response, 202, {
accepted: true,
status: "running",
@@ -116,6 +117,7 @@ export async function handleCodeAgentChatHttp(request, response, options) {
sessionId: safeSessionId(nativeSessionChatParams.sessionId) || null,
traceUrl,
resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}`,
turnUrl,
streamUrl: `${traceUrl}/stream`,
cancelUrl: "/v1/agent/chat/cancel",
polling: {
@@ -1156,6 +1158,180 @@ export async function handleCodeAgentChatResultHttp(request, response, url, opti
});
}
export async function handleCodeAgentTurnHttp(request, response, url, options) {
const parts = url.pathname.split("/").filter(Boolean);
const traceId = decodeURIComponent(parts[3] ?? "");
if (!safeTraceId(traceId)) {
sendJson(response, 400, {
ok: false,
status: "unknown",
running: false,
terminal: false,
error: {
code: "invalid_trace_id",
message: "traceId must start with trc_ and contain only safe identifier characters"
}
});
return;
}
const resolved = await resolveCodeAgentTurnStatusSnapshot(traceId, options);
sendJson(response, resolved.statusCode, resolved.body);
}
async function resolveCodeAgentTurnStatusSnapshot(traceId, options) {
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
let result = options.codeAgentChatResults?.get(traceId) ?? null;
if (result && !canAccessOwnedResult(result, options.actor)) return forbiddenTurnSnapshot(traceId);
const adapterEnabled = codeAgentAgentRunAdapterEnabled(options.env ?? process.env);
let resultPollError = null;
if (adapterEnabled && (result?.agentRun?.runId || !result)) {
try {
const synced = await syncAgentRunChatResult({ traceId, currentResult: result, options, traceStore });
result = synced.result ?? result;
if (result && !canAccessOwnedResult(result, options.actor)) return forbiddenTurnSnapshot(traceId);
if (result && isTraceCommandTerminalStatus(result.status)) {
await finalizeCodeAgentBillingUsage({ payload: result, params: result, options });
recordCodeAgentConversationFact(result, options);
await recordCodeAgentSessionOwner({ payload: result, params: result, options, status: codeAgentOwnerStatusForResult(result) });
}
} catch (error) {
resultPollError = error;
traceStore.append(traceId, {
type: "turn-status",
status: "degraded",
label: "turn-status:result-sync-failed",
errorCode: error?.code ?? "agentrun_result_poll_failed",
message: error?.message ?? "AgentRun result polling failed",
waitingFor: "agentrun-result",
valuesPrinted: false
});
}
}
let agentRunResult = result?.agentRun ? result : null;
if (!agentRunResult && adapterEnabled) {
try {
agentRunResult = await loadPersistedAgentRunResult(traceId, options);
} catch {
agentRunResult = null;
}
}
if (agentRunResult && !canAccessOwnedResult(agentRunResult, options.actor)) return forbiddenTurnSnapshot(traceId);
let refreshError = null;
if (agentRunResult?.agentRun) {
try {
const refreshedTrace = await refreshAgentRunTrace({ traceId, result: agentRunResult, options, traceStore });
agentRunResult = options.codeAgentChatResults?.get?.(traceId) ?? agentRunResult;
if (traceNeedsCommandResultSync(agentRunResult, refreshedTrace)) {
const synced = await syncAgentRunChatResult({ traceId, currentResult: agentRunResult, options, traceStore, appendResultEvent: false, refreshEvents: false });
agentRunResult = synced.result ?? agentRunResult;
}
if (isTraceCommandTerminalStatus(agentRunResult?.status)) {
await finalizeCodeAgentBillingUsage({ payload: agentRunResult, params: agentRunResult, options });
recordCodeAgentConversationFact(agentRunResult, options);
await recordCodeAgentSessionOwner({ payload: agentRunResult, params: agentRunResult, options, status: codeAgentOwnerStatusForResult(agentRunResult), preserveLastTraceId: true });
}
} catch (error) {
refreshError = error;
}
}
const snapshot = traceSnapshotWithTerminalEvidence(traceStore.snapshot(traceId), agentRunResult ?? result, traceId, refreshError);
const body = codeAgentTurnStatusPayload({ traceId, result: agentRunResult ?? result, snapshot, resultPollError, refreshError, options });
return { statusCode: body.ok ? 200 : 404, body };
}
function forbiddenTurnSnapshot(traceId) {
return {
statusCode: 403,
body: {
ok: false,
status: "unknown",
running: false,
terminal: false,
traceId,
error: {
code: "agent_session_owner_required",
message: "Only the session owner or admin can read this Code Agent turn status"
}
}
};
}
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 events = Array.isArray(snapshotObject?.events) ? snapshotObject.events : Array.isArray(resultObject?.runnerTrace?.events) ? resultObject.runnerTrace.events : [];
const lastEvent = events.at(-1) ?? null;
const status = normalizeTurnStatus(
resultObject?.status,
resultObject?.agentRun?.terminalStatus,
resultObject?.agentRun?.commandState,
resultObject?.agentRun?.status,
snapshotObject?.terminalEvidence?.traceSummary?.terminalStatus,
snapshotObject?.traceStatus,
snapshotObject?.status,
snapshotObject?.runnerTrace?.status
);
const found = Boolean(resultObject || (snapshotObject && snapshotObject.status !== "missing") || snapshotObject?.persisted === true);
const running = isTurnRunningStatus(status);
const terminal = isTurnTerminalStatus(status);
const runnerTrace = snapshotObject && snapshotObject.status !== "missing" ? snapshotObject : resultObject?.runnerTrace ?? null;
return {
ok: found,
action: "code-agent.turn.status",
contractVersion: "code-agent-turn-status-v1",
status: found ? status ?? "unknown" : "unknown",
running: found ? running : false,
terminal: found ? terminal : false,
traceId,
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,
updatedAt: textValue(resultObject?.updatedAt ?? resultObject?.agentRun?.updatedAt ?? snapshotObject?.updatedAt) || null,
lastEventLabel: textValue(snapshotObject?.lastEventLabel ?? runnerTrace?.lastEventLabel ?? lastEvent?.label ?? lastEvent?.type) || null,
waitingFor: textValue(snapshotObject?.waitingFor ?? runnerTrace?.waitingFor) || null,
resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}`,
traceUrl: `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`,
turnUrl: `/v1/agent/turns/${encodeURIComponent(traceId)}`,
runnerTrace: runnerTrace ? compactRunnerTraceForResult(runnerTrace, resultTraceEventLimit(options)) : null,
agentRun: resultObject?.agentRun ?? snapshotObject?.agentRun ?? null,
terminalEvidence: snapshotObject?.terminalEvidence ?? null,
finalResponse: resultObject?.finalResponse ?? snapshotObject?.finalResponse ?? snapshotObject?.terminalEvidence?.finalResponse ?? null,
traceSummary: resultObject?.traceSummary ?? snapshotObject?.traceSummary ?? snapshotObject?.terminalEvidence?.traceSummary ?? null,
retention: snapshotObject?.retention ?? null,
eventCount: numberOrNull(snapshotObject?.eventCount ?? runnerTrace?.eventCount ?? events.length),
error: resultPollError || refreshError
? { code: resultPollError?.code ?? refreshError?.code ?? "turn_status_degraded", message: resultPollError?.message ?? refreshError?.message ?? "Code Agent turn status refresh degraded", valuesPrinted: false }
: resultObject?.error ?? snapshotObject?.error ?? null,
valuesRedacted: true,
secretMaterialStored: false
};
}
function normalizeTurnStatus(...values) {
for (const value of values) {
const text = textValue(value).toLowerCase().replace(/_/gu, "-");
if (!text) continue;
if (["accepted", "pending", "processing", "running", "busy", "creating", "queued", "in-flight"].includes(text)) return "running";
if (["completed", "done", "succeeded", "success"].includes(text)) return "completed";
if (["failed", "failure", "error", "stale", "thread-resume-failed", "aborted", "interrupted", "expired"].includes(text)) return "failed";
if (["cancelled", "canceled"].includes(text)) return "canceled";
if (["blocked", "timeout"].includes(text)) return text;
}
return null;
}
function isTurnRunningStatus(status) {
return status === "running";
}
function isTurnTerminalStatus(status) {
return CODE_AGENT_TERMINAL_STATUSES.has(String(status ?? "").trim().toLowerCase().replace(/_/gu, "-"));
}
export async function handleCodeAgentInspectHttp(request, response, url, options) {
const query = {
conversationId: safeConversationId(url.searchParams.get("conversationId")),
+8
View File
@@ -59,6 +59,7 @@ import {
handleCodeAgentInspectHttp,
handleCodeAgentSessionsHttp,
handleCodeAgentSteerHttp,
handleCodeAgentTurnHttp,
handleCodeAgentTraceHttp
} from "./server-code-agent-http.ts";
import { handleM3IoControlHttp } from "./server-m3-http.ts";
@@ -627,6 +628,13 @@ async function handleRestAdapter(request, response, url, options) {
return;
}
if (request.method === "GET" && url.pathname.startsWith("/v1/agent/turns/")) {
const nextOptions = await codeAgentOptions(request, response, options);
if (!nextOptions) return;
await handleCodeAgentTurnHttp(request, response, url, nextOptions);
return;
}
if (request.method === "POST" && url.pathname === "/v1/agent/chat/cancel") {
const nextOptions = await codeAgentOptions(request, response, options);
if (!nextOptions) return;
@@ -133,6 +133,7 @@ function isAgentConversationWriteProxyRoute(method, pathname) {
function isPublicCodeAgentPollRoute(method, pathname) {
return method === "GET" && (
pathname.startsWith("/v1/agent/turns/") ||
pathname.startsWith("/v1/agent/chat/result/") ||
pathname.startsWith("/v1/agent/chat/trace/")
);
+6
View File
@@ -49,6 +49,12 @@ test("cloud web route policy proxies public Code Agent chat without gating other
publicRoute: true,
routeKey: "GET /v1/agent/chat/result/trc_live"
});
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/agent/turns/trc_live"), {
proxy: true,
authRequired: false,
publicRoute: true,
routeKey: "GET /v1/agent/turns/trc_live"
});
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1"), {
proxy: true,
authRequired: true,
+10 -6
View File
@@ -997,7 +997,7 @@ async function agentCommand(context: any) {
if (subcommand === "composer") return agentComposer(context);
if (subcommand === "result") {
const traceId = requiredTraceId(context.rest[1] ?? context.parsed.traceId);
const pathName = `/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
const pathName = `/v1/agent/turns/${encodeURIComponent(traceId)}`;
const response = await requestJson({ ...context, method: "GET", path: pathName });
return responsePayload("client.agent.result", response, context, {
route: route("GET", pathName),
@@ -1009,13 +1009,17 @@ async function agentCommand(context: any) {
if (subcommand === "trace") {
const traceId = requiredTraceId(context.rest[1] ?? context.parsed.traceId);
const pathName = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`;
const turnPath = `/v1/agent/turns/${encodeURIComponent(traceId)}`;
const turnResponse = await requestJson({ ...context, method: "GET", path: turnPath });
const response = await requestJson({ ...context, method: "GET", path: pathName });
const traceBody = traceBodyForCli(response.body, context.parsed);
return responsePayload("client.agent.trace", response, context, {
route: route("GET", pathName),
turnRoute: route("GET", turnPath),
traceId,
body: traceBody,
traceResultSummary: traceResultSummaryForCli(response.body, { traceId, command: "trace", renderedTrace: traceBody }),
turnStatus: responseBodyForCli(turnResponse.body, context.parsed),
traceResultSummary: traceResultSummaryForCli(turnResponse.body ?? response.body, { traceId, command: "trace", renderedTrace: traceBody }),
...traceResponseAliases(traceBody, context.parsed)
});
}
@@ -1526,7 +1530,7 @@ async function sessionFinalResponseCommand(context: any) {
};
}
const traceId = requiredTraceId(resolvedTraceId);
const resultPath = `/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
const resultPath = `/v1/agent/turns/${encodeURIComponent(traceId)}`;
const resultResponse = await requestJson({ ...context, method: "GET", path: resultPath });
const validation = finalResponseValidation({
conversationId,
@@ -2318,14 +2322,14 @@ async function pollAgentResult(context: any, traceId: string, acceptedBody: any)
const timeoutMs = numberOption(context.parsed.timeoutMs) ?? DEFAULT_AGENT_TIMEOUT_MS;
const pollIntervalMs = numberOption(context.parsed.pollIntervalMs) ?? DEFAULT_POLL_INTERVAL_MS;
const startedAt = Date.now();
const resultPath = text(acceptedBody?.resultUrl) || `/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
const resultPath = text(acceptedBody?.turnUrl) || `/v1/agent/turns/${encodeURIComponent(traceId)}`;
let polls = 0;
let lastResponse = null;
while (Date.now() - startedAt < timeoutMs) {
polls += 1;
const response = await requestJson({ ...context, method: "GET", path: resultPath, timeoutMs: Math.min(DEFAULT_TIMEOUT_MS, pollIntervalMs + 2000) });
lastResponse = response;
if (response.status === 200 && response.body?.status && response.body.status !== "running") {
if (response.status === 200 && response.body?.terminal === true) {
return { final: true, response, polls, timeoutMs, resultPath };
}
await context.sleep(pollIntervalMs);
@@ -2337,7 +2341,7 @@ function agentSendWaitPolicy(traceId: string) {
return {
defaultWait: false,
webEquivalent: true,
reason: "Cloud Web submits /v1/agent/chat as a short request and observes progress by polling result/trace; CLI follows the same path by default to avoid UniDesk ssh/tran 60s runtime disconnects.",
reason: "Cloud Web submits /v1/agent/chat as a short request and observes progress by polling /v1/agent/turns/:traceId; trace is only used for event details.",
waitCommand: `hwlab-cli client agent send --from-trace ${traceId} --message TEXT --wait`,
nextCommands: [
`hwlab-cli client agent result ${traceId}`,
+1 -1
View File
@@ -95,7 +95,7 @@ assertIncludes(appSource, "useForm", "form composable must remain available for
assertIncludes(appSource, "hwlab_session", "auth comments/code must preserve Web session cookie boundary");
assertIncludes(appSource, "activityRef", "Code Agent inactivity-timeout activityRef must be preserved");
assertIncludes(appSource, "activityRef: () => activityRef.value", "Workbench must pass activityRef into trace subscription");
assertIncludes(appSource, "getAgentChatResult(resultUrl, inactivityTimeoutMs, activityRef)", "Trace result polling must use activityRef-backed inactivity timeout");
assertIncludes(appSource, "getAgentTurn(traceId, projectId, inactivityTimeoutMs, activityRef)", "Turn status polling must use activityRef-backed inactivity timeout");
assertIncludes(appSource, "for (;;) ", "trace subscription must keep unbounded polling without total timeout");
assertIncludes(appSource, "/v1/agent/chat/trace/", "trace replay must use Cloud Web same-origin trace API");
assertIncludes(appSource, "/v1/workbench/workspace", "workspace bootstrap must use Cloud Web same-origin workbench API");
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { ChatMessage, ConversationRecord, WorkspaceRecord } from "../src/types/index.ts";
import { defaultProviderProfileOptions, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, resolveConversationSessionStatus, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts";
import { defaultProviderProfileOptions, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts";
test("R2 composer ignores stale workspace activeTraceId without verified active message", () => {
const workspace = workspaceRecord({ activeTraceId: "trc_stale", sessionStatus: "running" });
@@ -15,7 +15,7 @@ test("R2 composer ignores stale workspace activeTraceId without verified active
test("R2 composer steers only when an active agent message or status exists", () => {
const messages: ChatMessage[] = [agentMessage({ status: "running", traceId: "trc_running", conversationId: "cnv_r2", sessionId: "ses_r2" })];
const composer = resolveComposerState({ workspace: workspaceRecord({ activeTraceId: "trc_running", sessionStatus: "running" }), messages, activeConversationId: "cnv_r2", chatPending: true });
const composer = resolveComposerState({ workspace: workspaceRecord({ activeTraceId: "trc_running", sessionStatus: "running" }), messages, activeConversationId: "cnv_r2", chatPending: true, turnStatusAuthority: { trc_running: { traceId: "trc_running", status: "running", running: true, terminal: false, sessionId: "ses_r2" } } });
assert.equal(composer.submitMode, "steer");
assert.equal(composer.route, "/v1/agent/chat/steer");
assert.equal(composer.targetTraceId, "trc_running");
@@ -31,14 +31,16 @@ test("R2 composer cancel targets only the active conversation trace", () => {
activeConversationId: "cnv_active",
targetTraceId: "trc_active",
targetSessionId: "ses_active",
targetThreadId: "thr_active"
targetThreadId: "thr_active",
turnStatusAuthority: { trc_active: { traceId: "trc_active", status: "running", running: true, terminal: false, sessionId: "ses_active", threadId: "thr_active" } }
})?.traceId, "trc_active");
assert.equal(resolveCancelableAgentMessage({
messages,
activeConversationId: "cnv_active",
targetTraceId: "trc_other",
targetSessionId: "ses_active",
targetThreadId: "thr_active"
targetThreadId: "thr_active",
turnStatusAuthority: { trc_other: { traceId: "trc_other", status: "running", running: true, terminal: false, sessionId: "ses_other", threadId: "thr_other" } }
}), null);
});
@@ -98,6 +100,13 @@ test("R2 session tabs render only conversations supplied by the list API", () =>
assert.equal(tabs.length, 0);
});
test("R2 session list keeps current tabs when include response misses selected conversation", () => {
const current: ConversationRecord[] = [{ conversationId: "cnv_selected", sessionId: "ses_selected", firstUserMessagePreview: "当前会话" }];
assert.deepEqual(stableConversationList(current, [], "cnv_selected"), current);
assert.equal(stableConversationList(current, [{ conversationId: "cnv_selected", sessionId: "ses_selected" }], "cnv_selected").length, 1);
assert.deepEqual(stableConversationList(current, [], null), []);
});
test("R2 drafts keep recent unique values and normalize corrupt storage", () => {
const next = recordRecentDraft([{ text: "旧输入", ts: "2026-01-01T00:00:00.000Z" }], "新输入", "2026-01-02T00:00:00.000Z");
assert.deepEqual(next.map((item) => item.text), ["新输入", "旧输入"]);
@@ -186,7 +195,6 @@ test("R2 session tabs keep trace metadata but status comes only from session aut
snapshot: { sessionStatus: "running" },
messages: [agentMessage({ conversationId: "cnv_stale_snapshot", status: "running", traceId: "trc_stale" })]
};
assert.equal(resolveConversationSessionStatus(conversation), "running");
const tabs = sortSessionTabs([conversation], "cnv_stale_snapshot");
assert.equal(tabs[0]?.status, "unknown");
assert.equal(tabs[0]?.running, false);
@@ -8,7 +8,7 @@ test("Trace lifecycle expands only active agent turns", () => {
assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "running" } })), true);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "pending" } })), true);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "completed", runnerTrace: { status: "running" } })), false);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "completed" } })), false);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "completed" } })), true);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "running", finalResponse: "ok" } })), false);
assert.equal(traceLifecycleExpanded(agentMessage({ status: "failed", runnerTrace: { status: "failed" } })), false);
assert.equal(traceLifecycleExpanded({ ...agentMessage({ status: "running" }), role: "user", title: "用户" }), false);
+1
View File
@@ -5,6 +5,7 @@ export const agentAPI = {
sendAgentMessage: (payload: Record<string, unknown>, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent", activityRef }),
steerAgentMessage: (payload: Record<string, unknown>, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResponse>> => 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<ApiResult<AgentChatResultResponse>> => fetchJson(resultUrl, { timeoutMs, timeoutName: "Code Agent result", activityRef }),
getAgentTurn: (traceId: string, projectId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResultResponse>> => fetchJson(`/v1/agent/turns/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(projectId)}`, { timeoutMs, timeoutName: "Code Agent turn", activityRef }),
getAgentSession: (sessionId: string): Promise<ApiResult<{ session?: Record<string, unknown> }>> => fetchJson(`/v1/agent/sessions/${encodeURIComponent(sessionId)}`, { timeoutMs: 8000, timeoutName: "Code Agent session" }),
cancelAgentMessage: (payload: Record<string, unknown>): Promise<ApiResult<{ ok?: boolean; canceled?: boolean; error?: { code?: string; message?: string; userMessage?: string } }>> => fetchJson("/v1/agent/chat/cancel", { method: "POST", body: JSON.stringify(payload), timeoutMs: 30000, timeoutName: "Code Agent cancel" })
};
@@ -16,8 +16,8 @@ const errorTitle = computed(() => firstNonEmptyString(error.value?.code, error.v
const errorMessage = computed(() => firstNonEmptyString(error.value?.message, error.value?.userMessage, error.value?.reason));
const rows = computed(() => [
summaryRow("messageStatus", "消息状态", props.message.status),
summaryRow("traceStatus", "Trace 状态", firstNonEmptyString(trace.value?.status, trace.value?.traceStatus)),
summaryRow("turnStatus", "Turn 状态", props.message.status),
summaryRow("traceDetailStatus", "Trace 明细", firstNonEmptyString(trace.value?.status, trace.value?.traceStatus)),
summaryRow("traceId", "Trace", props.message.traceId ?? trace.value?.traceId),
summaryRow("conversationId", "Conversation", props.message.conversationId),
summaryRow("sessionId", "Session", props.message.sessionId ?? trace.value?.sessionId),
@@ -3,7 +3,7 @@ import type { ChatMessage } from "@/types";
export function traceLifecycleExpanded(message: ChatMessage): boolean {
if (message.role !== "agent") return false;
if (hasTraceTerminalSignal(message)) return false;
return traceStatusValues(message).some(isTraceActiveStatus);
return isTraceActiveStatus(message.status);
}
function hasTraceTerminalSignal(message: ChatMessage): boolean {
@@ -14,8 +14,6 @@ function hasTraceTerminalSignal(message: ChatMessage): boolean {
function traceStatusValues(message: ChatMessage): string[] {
return [
message.status,
message.runnerTrace?.status,
message.runnerTrace?.traceStatus
].map(normalizeTraceStatus).filter((value): value is string => Boolean(value));
}
@@ -152,48 +152,30 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise
const { traceId, initial, onActivity, onSnapshot, onComplete, onInfrastructureError, signal, inactivityTimeoutMs, activityRef } = config;
const projectId = normalizeWorkbenchProjectId(config.projectId) ?? DEFAULT_WORKBENCH_PROJECT_ID;
if (isTerminalStatus(initial.status)) {
const traceSnapshot = await fetchTraceSnapshot(traceId, traceId, inactivityTimeoutMs, signal, projectId, activityRef);
if (signal.aborted) return;
if (traceSnapshot) onSnapshot(traceSnapshot);
onComplete(mergeTraceResults(initial as AgentChatResultResponse, traceSnapshot));
const snapshot = resultToTraceSnapshot(traceId, initial as AgentChatResultResponse);
onSnapshot(snapshot);
onComplete(mergeTraceResults(initial as AgentChatResultResponse, snapshot));
return;
}
if (!initial.resultUrl) {
onInfrastructureError("Code Agent initial response is missing resultUrl");
if (!initial.turnUrl && !traceId) {
onInfrastructureError("Code Agent initial response is missing traceId for turn status polling");
return;
}
const resultUrl = initial.resultUrl;
const traceUrl = traceUrlFromResultUrl(resultUrl, traceId, projectId);
let lastEventCount = -1;
let lastStatus: string | null = null;
let terminalResult: AgentChatResultResponse | null = null;
for (;;) {
if (signal.aborted) return;
await sleep(TRACE_POLL_INTERVAL_MS);
if (signal.aborted) return;
const [resultPolled, tracePolled] = await Promise.all([
agentAPI.getAgentChatResult(resultUrl, inactivityTimeoutMs, activityRef),
agentAPI.getAgentChatResult(traceUrl, inactivityTimeoutMs, activityRef)
]);
const turnPolled = await agentAPI.getAgentTurn(traceId, projectId, inactivityTimeoutMs, activityRef);
if (signal.aborted) return;
if (resultPolled.ok && resultPolled.data) {
if (turnPolled.ok && turnPolled.data) {
onActivity();
if (isTerminalStatus(resultPolled.data.status)) terminalResult = resultPolled.data;
} else if (!resultPolled.ok && resultPolled.status >= 500) {
// Backend hiccup: keep polling and let inactivity-timeout classify a real outage.
} else if (!resultPolled.ok) {
onInfrastructureError(resultPolled.error ?? "Code Agent result poll failed (non-5xx)");
return;
}
let snapshot: TraceSnapshot | null = null;
if (tracePolled.ok && tracePolled.data) {
onActivity();
snapshot = resultToTraceSnapshot(traceId, tracePolled.data);
const snapshot = resultToTraceSnapshot(traceId, turnPolled.data);
const eventCount = snapshot.eventCount ?? snapshot.events?.length ?? 0;
const status = snapshot.status ?? "";
if (eventCount !== lastEventCount || status !== lastStatus) {
@@ -201,28 +183,19 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise
lastStatus = status;
onSnapshot(snapshot);
}
} else if (!tracePolled.ok && tracePolled.status < 500) {
onInfrastructureError(tracePolled.error ?? "Code Agent trace poll failed (non-5xx)");
return;
}
if (terminalResult) {
const finalTrace = snapshot ?? await fetchTraceSnapshot(traceId, traceUrl, inactivityTimeoutMs, signal, projectId, activityRef);
if (signal.aborted) return;
if (finalTrace) onSnapshot(finalTrace);
onComplete(mergeTraceResults(terminalResult, finalTrace));
if (turnPolled.data.terminal === true || isTerminalStatus(turnPolled.data.status)) {
onComplete(mergeTraceResults(turnPolled.data, snapshot));
return;
}
} else if (!turnPolled.ok && turnPolled.status >= 500) {
// Backend hiccup: keep polling and let inactivity-timeout classify a real outage.
} else {
onInfrastructureError(turnPolled.error ?? "Code Agent turn status poll failed (non-5xx)");
return;
}
}
}
async function fetchTraceSnapshot(traceId: string, traceUrlOrResultUrl: string, timeoutMs: number, signal: AbortSignal, projectId: string, activityRef: ActivityRefSource): Promise<TraceSnapshot | null> {
const traceUrl = traceUrlOrResultUrl.includes("/trace/") ? traceUrlOrResultUrl : traceUrlFromResultUrl(traceUrlOrResultUrl, traceId, projectId);
const response = await agentAPI.getAgentChatResult(traceUrl, timeoutMs, activityRef);
if (signal.aborted || !response.ok || !response.data) return null;
return resultToTraceSnapshot(traceId, response.data);
}
function resultToTraceSnapshot(traceId: string, result: AgentChatResultResponse): TraceSnapshot {
const events = Array.isArray(result.events) ? result.events : Array.isArray(result.traceEvents) ? result.traceEvents : [];
const lastEvent = events.at(-1);
@@ -247,12 +220,6 @@ function resultToTraceSnapshot(traceId: string, result: AgentChatResultResponse)
};
}
function traceUrlFromResultUrl(resultUrl: string, traceId: string, projectId: string): string {
const url = resultUrl.replace(/\/v1\/agent\/chat\/result\/[^/?#]+/u, `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`);
if (/[?&]projectId=/u.test(url)) return url;
return `${url}${url.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}`;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
@@ -30,6 +30,20 @@ export interface SessionStatusAuthority {
export type SessionStatusAuthorityMap = Readonly<Record<string, SessionStatusAuthority | undefined>>;
export interface TurnStatusAuthority {
traceId: string;
status: string | null;
running: boolean;
terminal: boolean;
conversationId?: string | null;
sessionId?: string | null;
threadId?: string | null;
updatedAt?: string | null;
loadedAt?: string | null;
}
export type TurnStatusAuthorityMap = Readonly<Record<string, TurnStatusAuthority | undefined>>;
export interface DraftEntry {
text: string;
ts: string;
@@ -54,35 +68,36 @@ const BUILTIN_PROVIDER_PROFILE_LABELS: Readonly<Record<string, string>> = Object
"minimax-m3": "MiniMax-M3"
});
export function resolveComposerState(input: { workspace: WorkspaceRecord | null; messages: ChatMessage[]; conversations?: ConversationRecord[]; activeConversationId: string | null; chatPending: boolean; currentRequest?: { traceId?: string | null; conversationId?: string | null; sessionId?: string | null; threadId?: string | null; status?: string | null } | null }): ComposerState {
export function resolveComposerState(input: { workspace: WorkspaceRecord | null; messages: ChatMessage[]; conversations?: ConversationRecord[]; activeConversationId: string | null; chatPending: boolean; currentRequest?: { traceId?: string | null; conversationId?: string | null; sessionId?: string | null; threadId?: string | null; status?: string | null } | null; turnStatusAuthority?: TurnStatusAuthorityMap }): ComposerState {
const latestMessage = latestConversationMessage(input.messages, input.activeConversationId);
const runningMessage = findActiveAgentMessage(input.messages, input.activeConversationId);
const currentRequest = input.currentRequest && input.currentRequest.conversationId === input.activeConversationId ? input.currentRequest : null;
const workspaceStatus = firstNonEmptyString(input.workspace?.workspace?.sessionStatus);
const workspaceTraceId = firstNonEmptyString(input.workspace?.activeTraceId, input.workspace?.workspace?.activeTraceId);
const activeTraceId = firstNonEmptyString(currentRequest?.traceId, runningMessage?.traceId, runningMessage?.runnerTrace?.traceId, runningMessage ? workspaceTraceId : null);
const activeByStatus = isActiveStatus(runningMessage?.status) || isActiveStatus(runningMessage?.runnerTrace?.status) || isActiveStatus(currentRequest?.status) || input.chatPending;
const terminal = isTerminalStatus(latestMessage?.status) || isTerminalStatus(currentRequest?.status) || (!latestMessage && isTerminalStatus(workspaceStatus));
const latestTraceId = firstNonEmptyString(latestMessage?.traceId, latestMessage?.runnerTrace?.traceId);
const activeTraceId = firstNonEmptyString(currentRequest?.traceId, workspaceTraceId, latestTraceId);
const turn = activeTraceId ? input.turnStatusAuthority?.[activeTraceId] : null;
const activeByStatus = turn?.running === true || isActiveStatus(turn?.status);
const terminal = turn?.terminal === true || isTerminalStatus(turn?.status);
const conversationId = input.activeConversationId;
const active = activeConversation(input.conversations ?? [], conversationId);
const workspaceConversationId = selectedConversationIdFromWorkspace(input.workspace);
const workspaceMatchesConversation = !conversationId || !workspaceConversationId || workspaceConversationId === conversationId;
const workspaceSessionId = workspaceMatchesConversation ? firstNonEmptyString(input.workspace?.selectedAgentSessionId, input.workspace?.workspace?.selectedAgentSessionId) : null;
const workspaceThreadId = workspaceMatchesConversation ? firstNonEmptyString(input.workspace?.workspace?.threadId) : null;
const sessionId = firstNonEmptyString(runningMessage?.sessionId, currentRequest?.sessionId, active?.sessionId, workspaceSessionId);
const threadId = firstNonEmptyString(runningMessage?.threadId, currentRequest?.threadId, active?.threadId, workspaceThreadId);
const sessionId = firstNonEmptyString(turn?.sessionId, currentRequest?.sessionId, active?.sessionId, workspaceSessionId);
const threadId = firstNonEmptyString(turn?.threadId, currentRequest?.threadId, active?.threadId, workspaceThreadId);
const canSteer = Boolean(conversationId && sessionId && activeTraceId && activeByStatus && !terminal);
if (canSteer) return { disabled: false, disabledReason: null, submitMode: "steer", route: "/v1/agent/chat/steer", targetTraceId: activeTraceId, conversationId, sessionId, threadId };
if (!sessionId) return { disabled: true, disabledReason: "session_required", submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null, conversationId, sessionId: null, threadId };
return { disabled: false, disabledReason: null, submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null, conversationId, sessionId, threadId };
}
export function resolveCancelableAgentMessage(input: { messages: ChatMessage[]; activeConversationId: string | null; targetTraceId?: string | null; targetSessionId?: string | null; targetThreadId?: string | null }): ChatMessage | null {
export function resolveCancelableAgentMessage(input: { messages: ChatMessage[]; activeConversationId: string | null; targetTraceId?: string | null; targetSessionId?: string | null; targetThreadId?: string | null; turnStatusAuthority?: TurnStatusAuthorityMap }): ChatMessage | null {
const targetTraceId = firstNonEmptyString(input.targetTraceId);
if (!targetTraceId) return null;
const turn = input.turnStatusAuthority?.[targetTraceId];
if (turn?.running !== true && !isActiveStatus(turn?.status)) return null;
for (const message of [...input.messages].reverse()) {
if (message.role !== "agent") continue;
if (!isActiveStatus(message.status) && !isActiveStatus(message.runnerTrace?.status)) continue;
if (firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) !== targetTraceId) continue;
if (!messageBelongsToCancelTarget(message, input)) continue;
return message;
@@ -164,48 +179,6 @@ export function conversationToSessionTab(conversation: ConversationRecord, activ
};
}
export function resolveConversationSessionStatus(conversation: ConversationRecord, extraStatuses: unknown[] = []): string {
const latestAgent = latestAgentMessage(conversation.messages);
const latestMessage = conversation.messages?.at(-1);
const stored = normalizeSessionStatus(conversation.status);
const terminalEvidence = firstNonActiveStatus(
...extraStatuses,
conversation.session?.status,
conversation.snapshot?.sessionStatus,
conversation.snapshot?.status,
latestAgent?.status,
latestAgent?.runnerTrace?.status,
latestAgent?.runnerTrace?.traceStatus,
runnerTraceAgentRunStatus(latestAgent?.runnerTrace, "terminalStatus"),
runnerTraceAgentRunStatus(latestAgent?.runnerTrace, "commandState"),
latestMessage?.status
);
if (stored && isActiveStatus(stored) && terminalEvidence) return terminalEvidence;
const activeEvidence = firstActiveStatus(
latestAgent?.status,
latestAgent?.runnerTrace?.status,
latestAgent?.runnerTrace?.traceStatus,
latestMessage?.status,
...extraStatuses,
conversation.session?.status,
conversation.snapshot?.sessionStatus,
conversation.snapshot?.status
);
if (activeEvidence && !isTerminalStatus(stored)) return activeEvidence;
return stored ?? firstNormalizedSessionStatus(
...extraStatuses,
conversation.session?.status,
conversation.snapshot?.sessionStatus,
conversation.snapshot?.status,
latestAgent?.status,
latestAgent?.runnerTrace?.status,
latestAgent?.runnerTrace?.traceStatus,
runnerTraceAgentRunStatus(latestAgent?.runnerTrace, "terminalStatus"),
runnerTraceAgentRunStatus(latestAgent?.runnerTrace, "commandState"),
latestMessage?.status
) ?? "source";
}
export function normalizeWorkbenchMessageTitle(role: ChatMessage["role"], title: unknown): string {
const fallback = role === "user" ? "用户" : "Code Agent";
const value = firstNonEmptyString(title) ?? fallback;
@@ -218,6 +191,14 @@ export function sortSessionTabs(conversations: ConversationRecord[], activeConve
return conversations.map((conversation) => conversationToSessionTab(conversation, activeConversationId, sessionStatusAuthority)).sort((left, right) => timestampMs(right.updatedAt) - timestampMs(left.updatedAt));
}
export function stableConversationList(current: ConversationRecord[], candidate: unknown, includeConversationId: string | null | undefined): ConversationRecord[] {
if (!Array.isArray(candidate)) return current;
const includeId = firstNonEmptyString(includeConversationId);
if (!includeId) return candidate as ConversationRecord[];
if ((candidate as ConversationRecord[]).some((conversation) => conversation.conversationId === includeId)) return candidate as ConversationRecord[];
return current.length > 0 ? current : candidate as ConversationRecord[];
}
export function recordRecentDraft(existing: DraftEntry[], text: string, now = new Date().toISOString()): DraftEntry[] {
const trimmed = text.trim();
if (!trimmed) return existing;
@@ -252,16 +233,6 @@ function latestAgentMessage(messages: ChatMessage[] | undefined): ChatMessage |
return [...(messages ?? [])].reverse().find((message) => message.role === "agent") ?? null;
}
function findActiveAgentMessage(messages: ChatMessage[], activeConversationId: string | null): ChatMessage | null {
for (const message of [...messages].reverse()) {
if (message.role !== "agent") continue;
if (!isActiveStatus(message.status) && !isActiveStatus(message.runnerTrace?.status)) continue;
if (activeConversationId && message.conversationId && message.conversationId !== activeConversationId) continue;
return message;
}
return null;
}
function messageBelongsToCancelTarget(message: ChatMessage, input: { activeConversationId: string | null; targetSessionId?: string | null; targetThreadId?: string | null }): boolean {
const activeConversationId = firstNonEmptyString(input.activeConversationId);
const messageConversationId = firstNonEmptyString(message.conversationId);
@@ -293,30 +264,6 @@ function isTerminalStatus(value: unknown): boolean {
return ["completed", "failed", "blocked", "timeout", "canceled", "stale", "thread-resume-failed", "interrupted", "expired"].includes(normalizeSessionStatus(value) ?? "");
}
function firstNonActiveStatus(...values: unknown[]): string | null {
for (const value of values) {
const status = normalizeSessionStatus(value);
if (status && !isActiveStatus(status) && isTerminalStatus(status)) return status;
}
return null;
}
function firstActiveStatus(...values: unknown[]): string | null {
for (const value of values) {
const status = normalizeSessionStatus(value);
if (status && isActiveStatus(status)) return status;
}
return null;
}
function firstNormalizedSessionStatus(...values: unknown[]): string | null {
for (const value of values) {
const status = normalizeSessionStatus(value);
if (status) return status;
}
return null;
}
function normalizeSessionStatus(value: unknown): string | null {
const text = firstNonEmptyString(value);
if (!text) return null;
@@ -327,11 +274,6 @@ function normalizeSessionStatus(value: unknown): string | null {
return normalized;
}
function runnerTraceAgentRunStatus(runnerTrace: ChatMessage["runnerTrace"], key: string): unknown {
const agentRun = runnerTrace && typeof runnerTrace === "object" ? (runnerTrace as Record<string, unknown>).agentRun : null;
return agentRun && typeof agentRun === "object" ? (agentRun as Record<string, unknown>)[key] : null;
}
function profileOptionFromUnknown(item: unknown): ProviderProfileOption | null {
if (!item || typeof item !== "object") return null;
const record = item as Record<string, unknown>;
+107 -27
View File
@@ -4,7 +4,7 @@ import { api } from "@/api";
import { mergeRunnerTrace, snapshotToRunnerTrace, subscribeToTrace, type TraceSnapshot } from "@/composables/useTraceSubscription";
import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, ConversationRecord, LiveSurface, ProviderProfile, TraceEvent, WorkspaceRecord } from "@/types";
import { DEFAULT_WORKBENCH_PROJECT_ID, firstNonEmptyString, nextProtocolId, normalizeWorkbenchConversationId, rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "@/utils";
import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, defaultProviderProfileOptions, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption, type SessionStatusAuthority } from "./workbench-session";
import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, defaultProviderProfileOptions, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption, type SessionStatusAuthority, type TurnStatusAuthority } from "./workbench-session";
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000;
const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000;
@@ -30,6 +30,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const currentRequest = ref<{ traceId: string; conversationId: string | null; sessionId: string | null; threadId: string | null; status?: string | null } | null>(null);
const workspaceSelectionEpoch = ref(0);
const sessionStatusAuthority = ref<Record<string, SessionStatusAuthority>>({});
const turnStatusAuthority = ref<Record<string, TurnStatusAuthority>>({});
const visibleConversations = computed(() => conversations.value);
const activeConversationId = computed(() => firstNonEmptyString(switchingConversationId.value, workspace.value?.selectedConversationId, workspace.value?.workspace?.selectedConversationId, messages.value.find((message) => message.conversationId)?.conversationId));
@@ -39,7 +40,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const sessionTabs = computed(() => sortSessionTabs(visibleConversations.value, activeConversationId.value, sessionStatusAuthority.value));
const sessionListLoading = computed(() => shouldShowSessionListLoading({ loading: loading.value, conversationsReady: conversationsReady.value }));
const activeProjectId = computed(() => workspaceProjectId(workspace.value, projectId.value));
const composer = computed(() => resolveComposerState({ workspace: workspace.value, messages: messages.value, conversations: visibleConversations.value, activeConversationId: activeConversationId.value, chatPending: chatPending.value, currentRequest: currentRequest.value }));
const composer = computed(() => resolveComposerState({ workspace: workspace.value, messages: messages.value, conversations: visibleConversations.value, activeConversationId: activeConversationId.value, chatPending: chatPending.value, currentRequest: currentRequest.value, turnStatusAuthority: turnStatusAuthority.value }));
function recordActivity(label = "user-activity"): void {
const now = Date.now();
@@ -60,8 +61,9 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const nextWorkspace = workspaceResult.data?.workspace ?? null;
const conversationsResult = await api.workbench.conversations(projectId.value, { includeConversationId: selectedConversationIdFromWorkspace(nextWorkspace) });
loading.value = false;
const nextConversations = conversationsResult.ok ? conversationsResult.data?.conversations ?? [] : conversations.value;
let nextConversations = conversations.value;
if (conversationsResult.ok) {
nextConversations = stableConversationList(conversations.value, conversationsResult.data?.conversations, selectedConversationIdFromWorkspace(nextWorkspace));
conversations.value = nextConversations;
conversationsReady.value = true;
void refreshSessionStatusAuthority(nextConversations);
@@ -74,6 +76,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
providerProfile.value = firstNonEmptyString(workspace.value?.providerProfile, workspace.value?.workspace?.providerProfile, providerProfile.value) ?? providerProfile.value;
rememberWorkbenchProjectId(workspaceProjectId(workspace.value, projectId.value));
messages.value = messagesFromWorkspaceSelection(workspace.value, nextConversations);
void hydrateTurnStatusAuthority(messages.value);
void hydrateTerminalMessageDiagnostics();
reattachRestoredActiveTrace();
}
@@ -104,6 +107,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
workspace.value = response.data?.workspace ?? current;
messages.value = messagesFromWorkspace(workspace.value);
currentRequest.value = null;
void hydrateTurnStatusAuthority(messages.value);
await refreshSelectedSessionStatus();
await refreshConversations(selectedConversationIdFromWorkspace(workspace.value));
}
@@ -117,6 +121,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
switchingConversationId.value = conversation.conversationId;
workspace.value = optimisticWorkspaceSelection(current, conversation, tabProjectId);
messages.value = messagesFromConversation(conversation);
void hydrateTurnStatusAuthority(messages.value);
void refreshSessionStatusById(sessionIdFromConversation(conversation));
loading.value = true;
const response = await api.workbench.selectConversation(current.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue" });
@@ -126,6 +131,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (response.ok) {
workspace.value = response.data?.workspace ?? current;
messages.value = messagesFromWorkspaceSelection(workspace.value, [conversation, ...conversations.value]);
void hydrateTurnStatusAuthority(messages.value);
void hydrateTerminalMessageDiagnostics();
reattachRestoredActiveTrace();
currentRequest.value = null;
@@ -173,6 +179,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
workspace.value = response.data?.workspace ?? workspace.value;
messages.value = messagesFromWorkspace(workspace.value);
currentRequest.value = null;
void hydrateTurnStatusAuthority(messages.value);
await refreshConversations(selectedConversationIdFromWorkspace(workspace.value));
}
}
@@ -180,7 +187,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
async function refreshConversations(includeConversationId: string | null = currentListIncludeConversationId()): Promise<void> {
const response = await api.workbench.conversations(activeProjectId.value, { includeConversationId });
if (response.ok) {
conversations.value = response.data?.conversations ?? [];
conversations.value = stableConversationList(conversations.value, response.data?.conversations, includeConversationId);
conversationsReady.value = true;
await refreshSessionStatusAuthority(conversations.value);
return;
@@ -227,6 +234,66 @@ export const useWorkbenchStore = defineStore("workbench", () => {
};
}
async function hydrateTurnStatusAuthority(source: ChatMessage[] = messages.value): Promise<void> {
await Promise.all(uniqueTraceIds(source).slice(-12).map((traceId) => refreshTurnStatusByTraceId(traceId)));
}
async function refreshTurnStatusByTraceId(traceId: string | null | undefined): Promise<void> {
const id = firstNonEmptyString(traceId);
if (!id) return;
const response = await api.agent.getAgentTurn(id, activeProjectId.value, 8000, () => activityRef.value);
if (response.ok && response.data) {
applyTurnStatusSnapshot(id, response.data);
return;
}
const next = { ...turnStatusAuthority.value };
delete next[id];
turnStatusAuthority.value = next;
}
function applyTurnStatusSnapshot(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void {
rememberTurnStatus(traceId, result);
syncTurnStatusToMessage(traceId, result);
}
function rememberTurnStatus(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void {
const id = firstNonEmptyString(result.traceId, traceId);
if (!id) return;
const status = normalizedStatusText(result.status) ?? null;
const running = (result as AgentChatResultResponse).running === true || isTraceActiveStatus(status);
const terminal = (result as AgentChatResultResponse).terminal === true || isTerminalMessageStatus(status);
turnStatusAuthority.value = {
...turnStatusAuthority.value,
[id]: {
traceId: id,
status,
running,
terminal,
conversationId: firstNonEmptyString((result as AgentChatResultResponse).conversationId) ?? null,
sessionId: firstNonEmptyString(result.sessionId) ?? null,
threadId: firstNonEmptyString(result.threadId) ?? null,
updatedAt: firstNonEmptyString((result as AgentChatResultResponse).updatedAt) ?? null,
loadedAt: new Date().toISOString()
}
};
}
function syncTurnStatusToMessage(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void {
const status = statusFromResult(result.status);
const terminal = (result as AgentChatResultResponse).terminal === true || isTerminalMessageStatus(status);
messages.value = messages.value.map((message) => {
if (message.traceId !== traceId || message.role !== "agent") return message;
const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result as AgentChatResultResponse);
const error = normalizeAgentError((result as AgentChatResultResponse).error ?? runnerTrace?.error ?? message.error);
const agentRun = agentRunFromResult(result as AgentChatResultResponse, runnerTrace) ?? agentRunFromMessage(message);
const replyText = agentReplyText((result as AgentChatResultResponse).reply);
const text = terminal
? firstNonEmptyString((result as AgentChatResultResponse).assistantText, finalResponseText((result as AgentChatResultResponse).finalResponse), replyText, (result as AgentChatResultResponse).text, (result as AgentChatResultResponse).summary, message.text, "Code Agent 已完成,但没有返回可展示的 final response。") ?? message.text
: message.text;
return { ...message, status, text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
});
}
async function submitMessage(text: string): Promise<void> {
const value = text.trim();
if (!value) return;
@@ -261,6 +328,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
return;
}
void refreshSessionStatusById(sessionId);
applyTurnStatusSnapshot(traceId, response.data);
void refreshConversations(conversationId);
subscribe(traceId, response.data);
}
@@ -270,6 +338,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (!traceId) return;
const response = await api.agent.cancelAgentMessage({ traceId, projectId: activeProjectId.value, sessionId: message.sessionId ?? selectedSessionId.value, threadId: message.threadId ?? selectedThreadId.value, conversationId: message.conversationId ?? activeConversationId.value });
const canceledStatus = workspaceSessionStatusFromChatStatus(firstNonEmptyString((response.data as Record<string, unknown> | null)?.status, "canceled"));
applyTurnStatusSnapshot(traceId, { traceId, status: firstNonEmptyString((response.data as Record<string, unknown> | null)?.status, "canceled"), running: false, terminal: true, sessionId: message.sessionId ?? selectedSessionId.value ?? undefined, threadId: message.threadId ?? selectedThreadId.value ?? undefined, conversationId: message.conversationId ?? activeConversationId.value ?? undefined } as AgentChatResultResponse);
markMessage(traceId, { status: "canceled", text: "用户已取消该 turn。" });
void clearActiveTrace(traceId, "cancel-agent-message", canceledStatus);
if (message.status === "running") chatPending.value = false;
@@ -284,7 +353,8 @@ export const useWorkbenchStore = defineStore("workbench", () => {
activeConversationId: activeConversationId.value,
targetTraceId: target.targetTraceId,
targetSessionId: target.sessionId,
targetThreadId: target.threadId
targetThreadId: target.threadId,
turnStatusAuthority: turnStatusAuthority.value
});
if (message) await cancelAgentMessage(message);
}
@@ -372,18 +442,19 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
function reattachTrace(traceId: string): void {
const initial: AgentChatResponse = { status: "running", traceId, resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(activeProjectId.value)}` };
const initial: AgentChatResponse = { status: "running", traceId, turnUrl: `/v1/agent/turns/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(activeProjectId.value)}` };
if (!messages.value.some((message) => message.traceId === traceId)) messages.value.push(makeMessage("agent", "", "running", { traceId, title: "Code Agent" }));
subscribe(traceId, initial);
}
async function validateAndReattachTrace(traceId: string): Promise<void> {
const result = await api.agent.getAgentChatResult(`/v1/agent/chat/result/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(activeProjectId.value)}`, 8000, () => activityRef.value);
const result = await api.agent.getAgentTurn(traceId, activeProjectId.value, 8000, () => activityRef.value);
if (!result.ok || !result.data) {
await clearActiveTrace(traceId, result.status === 404 ? "reattach-result-not-found" : "reattach-result-unavailable");
return;
}
if (isTraceActiveStatus(result.data.status)) {
applyTurnStatusSnapshot(traceId, result.data);
if (result.data.running === true || isTraceActiveStatus(result.data.status)) {
reattachTrace(traceId);
return;
}
@@ -392,7 +463,9 @@ export const useWorkbenchStore = defineStore("workbench", () => {
function applyTraceSnapshot(traceId: string, snapshot: TraceSnapshot): void {
const trace = snapshotToRunnerTrace(snapshot);
messages.value = messages.value.map((message) => message.traceId === traceId ? { ...message, runnerTrace: mergeRunnerTrace(message.runnerTrace, trace), updatedAt: new Date().toISOString() } : message);
rememberTurnStatus(traceId, snapshot);
const status = statusFromResult(snapshot.status);
messages.value = messages.value.map((message) => message.traceId === traceId ? { ...message, status, runnerTrace: mergeRunnerTrace(message.runnerTrace, trace), updatedAt: new Date().toISOString() } : message);
void refreshSessionStatusById(trace.sessionId ?? selectedSessionId.value);
}
@@ -406,6 +479,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message);
return { ...message, status: terminalStatus, text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
});
rememberTurnStatus(traceId, result);
chatPending.value = false;
currentRequest.value = null;
void clearActiveTrace(traceId, "trace-terminal", workspaceSessionStatusFromChatStatus(terminalStatus));
@@ -418,9 +492,9 @@ export const useWorkbenchStore = defineStore("workbench", () => {
await Promise.all(targets.map(async (message) => {
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
if (!traceId) return;
const result = await api.agent.getAgentChatResult(`/v1/agent/chat/result/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(activeProjectId.value)}`, 8000, () => activityRef.value);
const result = await api.agent.getAgentTurn(traceId, activeProjectId.value, 8000, () => activityRef.value);
if (!result.ok || !result.data) return;
applyTerminalResultDiagnostics(traceId, result.data);
applyTurnStatusSnapshot(traceId, result.data);
}));
}
@@ -503,6 +577,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
workspace.value = retried.data?.workspace ?? workspace.value;
messages.value = messagesFromWorkspaceSelection(workspace.value, [conversation, ...conversations.value]);
void hydrateTurnStatusAuthority(messages.value);
void hydrateTerminalMessageDiagnostics();
reattachRestoredActiveTrace();
currentRequest.value = null;
@@ -516,7 +591,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
function reattachRestoredActiveTrace(): void {
const traceId = firstNonEmptyString(activeTraceIdFromMessages(messages.value), activeTraceIdFromWorkspace(workspace.value));
const traceId = firstNonEmptyString(activeTraceIdFromMessages(messages.value, turnStatusAuthority.value), activeTraceIdFromWorkspace(workspace.value));
if (traceId) void validateAndReattachTrace(traceId);
}
@@ -531,6 +606,10 @@ function uniqueSessionIds(conversations: ConversationRecord[]): string[] {
return [...new Set(conversations.map((conversation) => sessionIdFromConversation(conversation)).filter((sessionId): sessionId is string => Boolean(sessionId)))];
}
function uniqueTraceIds(messages: ChatMessage[]): string[] {
return [...new Set(messages.map((message) => firstNonEmptyString(message.traceId, message.runnerTrace?.traceId)).filter((traceId): traceId is string => Boolean(traceId)))];
}
function optimisticWorkspaceSelection(current: WorkspaceRecord, conversation: ConversationRecord, projectId: string): WorkspaceRecord {
return {
...current,
@@ -550,15 +629,14 @@ function optimisticWorkspaceSelection(current: WorkspaceRecord, conversation: Co
}
function messagesFromConversation(conversation: ConversationRecord): ChatMessage[] {
return (conversation.messages ?? []).map((message) => normalizeChatMessage(message, firstNonEmptyString(conversation.status, conversation.snapshot?.sessionStatus)));
return (conversation.messages ?? []).map((message) => normalizeChatMessage(message));
}
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 : [];
const sessionStatus = firstNonEmptyString(workspace?.selectedConversation?.status, workspace?.selectedConversation?.snapshot?.sessionStatus, workspace?.workspace?.sessionStatus);
return source.map((message) => normalizeChatMessage(message, sessionStatus));
return source.map((message) => normalizeChatMessage(message));
}
function messagesFromWorkspaceSelection(workspace: WorkspaceRecord | null, fallbackConversations: ConversationRecord[]): ChatMessage[] {
@@ -569,27 +647,20 @@ function messagesFromWorkspaceSelection(workspace: WorkspaceRecord | null, fallb
return fallback ? messagesFromConversation(fallback) : [];
}
function normalizeChatMessage(message: ChatMessage, sessionStatus?: string | null): ChatMessage {
function normalizeChatMessage(message: ChatMessage): ChatMessage {
const text = firstNonEmptyString(message.text, messageText((message as Record<string, unknown>).content), messageText((message as Record<string, unknown>).message), finalResponseText((message as Record<string, unknown>).finalResponse)) ?? "";
const runnerTrace = normalizeMessageRunnerTrace(message);
const error = normalizeAgentError(message.error ?? runnerTrace?.error);
const agentRun = agentRunFromMessage(message) ?? asAgentRun(runnerTrace?.agentRun);
const status = normalizeChatMessageStatus(message.status, runnerTrace, sessionStatus);
const status = normalizeChatMessageStatus(message.status);
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 };
}
function normalizeChatMessageStatus(value: unknown, runnerTrace: ChatMessage["runnerTrace"], sessionStatus?: string | null): ChatMessage["status"] {
const session = normalizedStatusText(sessionStatus);
function normalizeChatMessageStatus(value: unknown): ChatMessage["status"] {
const explicit = normalizedStatusText(value);
const trace = normalizedStatusText(firstNonEmptyString(runnerTrace?.status, runnerTrace?.traceStatus));
const sessionTerminal = terminalChatStatus(session);
if (sessionTerminal && !terminalChatStatus(explicit)) return sessionTerminal;
const traceTerminal = terminalChatStatus(trace);
if (traceTerminal && (!explicit || explicit === "idle" || explicit === "source" || isTraceActiveStatus(explicit))) return traceTerminal;
const explicitTerminal = terminalChatStatus(explicit);
if (explicitTerminal) return explicitTerminal;
if (explicit === "sent") return "sent";
if (isTraceActiveStatus(explicit) || isTraceActiveStatus(trace)) return "running";
if (explicit === "blocked") return "blocked";
if (explicit === "timeout") return "timeout";
if (explicit === "failed" || explicit === "error") return "failed";
@@ -606,11 +677,13 @@ function terminalChatStatus(value: string | null): ChatMessage["status"] | null
return null;
}
function activeTraceIdFromMessages(messages: ChatMessage[]): string | null {
function activeTraceIdFromMessages(messages: ChatMessage[], turnStatusAuthority: Record<string, TurnStatusAuthority>): string | null {
for (const message of [...messages].reverse()) {
if (message.role !== "agent") continue;
if (!isTraceActiveStatus(message.status) && !isTraceActiveStatus(message.runnerTrace?.status) && !isTraceActiveStatus(message.runnerTrace?.traceStatus)) continue;
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
if (!traceId) continue;
const turn = turnStatusAuthority[traceId];
if (turn?.running !== true && !isTraceActiveStatus(turn?.status)) continue;
if (traceId) return traceId;
}
return null;
@@ -737,6 +810,11 @@ function finalResponseText(value: unknown): string | null {
return firstNonEmptyString(messageText(record.text), messageText(record.content), messageText(record.message));
}
function agentReplyText(value: AgentChatResultResponse["reply"]): string | null {
if (typeof value === "string") return value;
return value && typeof value === "object" ? firstNonEmptyString(value.content) : null;
}
function messageText(value: unknown): string | null {
if (typeof value === "string") return value.trim() || null;
if (typeof value === "number" || typeof value === "boolean") return String(value);
@@ -761,10 +839,12 @@ function makeMessage(role: ChatMessage["role"], text: string, status: ChatMessag
}
function statusFromResult(status: string | undefined): ChatMessage["status"] {
if (isTraceActiveStatus(status)) return "running";
if (status === "blocked") return "blocked";
if (status === "timeout") return "timeout";
if (status === "canceled" || status === "cancelled") return "canceled";
if (status === "completed") return "completed";
if (!status || status === "unknown") return "source";
return "failed";
}
+6
View File
@@ -210,8 +210,14 @@ export interface AgentChatResponse {
agentRun?: AgentRunProvenance;
resultUrl?: string;
traceUrl?: string;
turnUrl?: string;
accepted?: boolean;
shortConnection?: boolean;
running?: boolean;
terminal?: boolean;
updatedAt?: string;
lastEventLabel?: string;
waitingFor?: string;
error?: string | { code?: string; message?: string; providerStatus?: number; [key: string]: unknown };
[key: string]: unknown;
}