fix: unify code agent turn status authority
This commit is contained in:
@@ -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")),
|
||||
|
||||
@@ -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/")
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user