diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index ee76e28c..bab6febc 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -1770,6 +1770,7 @@ function mapAgentRunEvent(event, mapping = {}) { createdAt: event.createdAt ?? null, source: "agentrun", sourceSeq: event.seq ?? null, + backend: backendForBackendProfile(mapping.backendProfile ?? payload.backendProfile ?? payload.providerProfile ?? "deepseek"), runId: event.runId ?? mapping.runId ?? null, commandId: payload.commandId ?? mapping.commandId ?? null, attemptId: payload.attemptId ?? mapping.attemptId ?? null, @@ -1783,6 +1784,7 @@ function mapAgentRunEvent(event, mapping = {}) { return { ...base, type: "backend", + eventType: "backend", status: "running", label: `agentrun:backend:${phase}`, message: textPayload(payload, phase), @@ -1795,6 +1797,7 @@ function mapAgentRunEvent(event, mapping = {}) { return { ...base, type: "assistant", + eventType: "assistant", status: terminal ? "completed" : "running", label: "agentrun:assistant:message", message: textPayload(payload, "assistant message"), @@ -1827,6 +1830,7 @@ function mapAgentRunEvent(event, mapping = {}) { return { ...base, type: "tool_call", + eventType: "tool_call", status: String(payload.status ?? preview.status ?? (payload.method === "item/completed" ? "completed" : "running")), label: `agentrun:tool:${String(toolName)}`, itemId: payload.item?.id ?? payload.itemId ?? preview.itemId ?? null, @@ -1843,19 +1847,19 @@ function mapAgentRunEvent(event, mapping = {}) { } if (event.type === "command_output") { const stream = payload.stream === "stderr" ? "stderr" : "stdout"; - return { ...base, type: "output", status: "running", label: `agentrun:output:${stream}`, stream, message: textPayload(payload, ""), outputTruncated: payload.outputTruncated === true }; + return { ...base, type: "output", eventType: "status", status: "running", label: `agentrun:output:${stream}`, stream, message: textPayload(payload, ""), outputTruncated: payload.outputTruncated === true }; } if (event.type === "diff") { - return { ...base, type: "diff", status: "running", label: "agentrun:diff", message: textPayload(payload, "diff") }; + return { ...base, type: "diff", eventType: "status", status: "running", label: "agentrun:diff", message: textPayload(payload, "diff") }; } if (event.type === "error") { - return { ...base, type: "error", status: "failed", label: `agentrun:error:${String(payload.failureKind ?? "backend")}`, errorCode: payload.failureKind ?? "agentrun_error", message: textPayload(payload, "AgentRun error") }; + return { ...base, type: "error", eventType: "error", status: "failed", label: `agentrun:error:${String(payload.failureKind ?? "backend")}`, errorCode: payload.failureKind ?? "agentrun_error", message: textPayload(payload, "AgentRun error") }; } if (event.type === "terminal_status") { const terminalStatus = String(payload.terminalStatus ?? "failed"); - return { ...base, type: "result", status: terminalStatus === "cancelled" ? "canceled" : terminalStatus, label: `agentrun:terminal:${terminalStatus}`, errorCode: payload.failureKind ?? null, message: textPayload(payload, `AgentRun terminal status ${terminalStatus}`), terminal: true }; + return { ...base, type: "result", eventType: "terminal", status: terminalStatus === "cancelled" ? "canceled" : terminalStatus, label: `agentrun:terminal:${terminalStatus}`, errorCode: payload.failureKind ?? null, message: textPayload(payload, `AgentRun terminal status ${terminalStatus}`), terminal: true }; } - return { ...base, type: "backend", status: "running", label: `agentrun:event:${String(event.type ?? "unknown")}`, message: textPayload(payload, "AgentRun event") }; + return { ...base, type: "backend", eventType: "backend", status: "running", label: `agentrun:event:${String(event.type ?? "unknown")}`, message: textPayload(payload, "AgentRun event") }; } function isProtocolToolLifecycleMethod(value) { diff --git a/internal/cloud/code-agent-trace-store.test.ts b/internal/cloud/code-agent-trace-store.test.ts index 0a0e2578..c1a7c3ca 100644 --- a/internal/cloud/code-agent-trace-store.test.ts +++ b/internal/cloud/code-agent-trace-store.test.ts @@ -1,3 +1,6 @@ +// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. +// Responsibility: Code Agent trace timing and durable projection tests. + import assert from "node:assert/strict"; import { test } from "bun:test"; @@ -88,9 +91,13 @@ test("code agent trace store preserves upstream event timestamps", () => { type: "backend", status: "running", label: "agentrun:backend:turn/completed", + backend: "agentrun-v01/codex", createdAt: "2026-06-01T16:35:40.939Z" }, { now: "2026-06-01T16:35:56.171Z" }); const snapshot = traceStore.snapshot(traceId); assert.equal(snapshot.events[0].createdAt, "2026-06-01T16:35:40.939Z"); + assert.equal(snapshot.events[0].appendedAt, "2026-06-01T16:35:56.171Z"); + assert.equal(snapshot.events[0].eventType, "backend"); + assert.equal(snapshot.events[0].backend, "agentrun-v01/codex"); assert.equal(snapshot.elapsedMs, 0); }); diff --git a/internal/cloud/code-agent-trace-store.ts b/internal/cloud/code-agent-trace-store.ts index 51b4fa01..4a96322e 100644 --- a/internal/cloud/code-agent-trace-store.ts +++ b/internal/cloud/code-agent-trace-store.ts @@ -1,5 +1,5 @@ /* - * SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-0102 Agent编排 draft-2026-06-17-r0 + * SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-0102 Agent编排 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0 * 职责: Code Agent trace capture。实时内存 trace 可加速 SSE/详情面板,但 append 事件必须同步投影到 durable store。 */ import { randomUUID } from "node:crypto"; @@ -351,6 +351,7 @@ function normalizeTraceEvent(event, { traceId, seq, now, fallbackRunnerKind } = const status = safeToken(event.status ?? "observed"); const toolName = safeText(event.toolName ?? event.name, 120); const label = safeText(event.label, 180) || labelFor({ type, status, toolName }); + const appendedAt = timestampFor(now); const eventCreatedAt = safeTimestamp(event.createdAt) ?? timestampFor(now); return dropUndefined({ seq, @@ -360,6 +361,9 @@ function normalizeTraceEvent(event, { traceId, seq, now, fallbackRunnerKind } = status, label, createdAt: eventCreatedAt, + appendedAt, + eventType: safeToken(event.eventType ?? type), + backend: safeText(event.backend, 140), elapsedMs: typeof event.elapsedMs === "number" ? Math.max(0, Math.trunc(event.elapsedMs)) : null, runnerKind: safeText(event.runnerKind ?? fallbackRunnerKind, 140), source: safeText(event.source, 120), diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index e71ec0cb..d0a042e3 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -1,3 +1,6 @@ +// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. +// Responsibility: Cloud API AgentRun adapter and trace observability regression tests. + import assert from "node:assert/strict"; import { createServer as createHttpServer } from "node:http"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; @@ -680,6 +683,9 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.ok(traceBody.events.some((event) => event.label === "agentrun:assistant:message")); assert.equal(traceBody.events.some((event) => event.details?.initialPromptInjected === true), true); const commandTraceEvent = traceBody.events.find((event) => event.label === "item/commandExecution:completed"); + assert.equal(commandTraceEvent.eventType, "tool_call"); + assert.equal(commandTraceEvent.backend, "agentrun-v01/deepseek"); + assert.ok(Date.parse(commandTraceEvent.appendedAt) >= Date.parse(commandTraceEvent.createdAt)); assert.equal(commandTraceEvent.toolName, "commandExecution"); assert.equal(commandTraceEvent.createdAt, "2026-06-01T00:00:00.500Z"); assert.match(commandTraceEvent.command, /hwpod inspect --dry-run/u); diff --git a/web/hwlab-cloud-web/package.json b/web/hwlab-cloud-web/package.json index 5f1d30e3..d02974e9 100644 --- a/web/hwlab-cloud-web/package.json +++ b/web/hwlab-cloud-web/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "check": "bun run scripts/check.ts && bun run scripts/tsc-check.ts && bun test", + "check": "bun run scripts/check.ts && bun run scripts/tsc-check.ts && bun test scripts", "check:tsc": "bun run scripts/tsc-check.ts", "check:tsc-strict": "bun run scripts/tsc-check.ts --strict", "typecheck": "vue-tsc --noEmit", diff --git a/web/hwlab-cloud-web/scripts/workbench-performance.test.ts b/web/hwlab-cloud-web/scripts/workbench-performance.test.ts index 9a51b4e7..1a08a5ba 100644 --- a/web/hwlab-cloud-web/scripts/workbench-performance.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-performance.test.ts @@ -29,10 +29,13 @@ test("Workbench performance probe records journeys and strips high-cardinality i acknowledgeWorkbenchVisible({ messages: [userMessage("cnv_secret")], activeConversationId: "cnv_secret", detailLoading: false }); finishWorkbenchSessionSwitchFullLoad("cnv_secret", "ok"); - const createdAt = new Date(Date.now() - 250).toISOString(); + const wallBase = Date.now(); + const createdAt = new Date(wallBase - 300).toISOString(); + const appendedAt = new Date(wallBase - 220).toISOString(); + const serverSentAt = new Date(wallBase - 120).toISOString(); startWorkbenchSubmitJourney({ traceId: "trc_secret", conversationId: "cnv_secret", entry: "existing", backend: "codex", transport: "sse" }); markWorkbenchSubmitApiAccepted("trc_secret"); - markWorkbenchTraceEventsReceived({ traceId: "trc_secret", transport: "sse", events: [{ type: "assistant_message", createdAt, ts: createdAt } as TraceEvent] }); + markWorkbenchTraceEventsReceived({ traceId: "trc_secret", transport: "sse", serverSentAt, traceSeq: 42, events: [{ type: "assistant_message", eventType: "assistant", backend: "agentrun-v01/codex", createdAt, appendedAt, ts: createdAt, sourceSeq: 42 } as TraceEvent] }); markWorkbenchTraceProjected("trc_secret"); acknowledgeWorkbenchVisible({ messages: [agentMessage("cnv_secret", "trc_secret")], activeConversationId: "cnv_secret", detailLoading: false }); @@ -41,7 +44,11 @@ test("Workbench performance probe records journeys and strips high-cardinality i assert.ok(events.some((event) => event.kind === "workbench_journey" && event.journey === "workbench_open_first_visible")); assert.ok(events.some((event) => event.kind === "workbench_journey" && event.journey === "session_switch_first_visible")); assert.ok(events.some((event) => event.kind === "workbench_journey" && event.journey === "submit_to_first_visible" && event.backend === "agentrun-v01/codex")); - assert.ok(events.some((event) => event.kind === "workbench_event_phase" && event.phase === "project_to_paint")); + assert.ok(events.some((event) => event.kind === "workbench_event_phase" && event.phase === "created_to_append" && event.eventType === "assistant")); + assert.ok(events.some((event) => event.kind === "workbench_event_phase" && event.phase === "append_to_sse" && event.eventType === "assistant")); + assert.ok(events.some((event) => event.kind === "workbench_event_phase" && event.phase === "sse_to_receive" && event.eventType === "assistant")); + assert.ok(events.some((event) => event.kind === "workbench_event_phase" && event.phase === "receive_to_project" && event.eventType === "assistant")); + assert.ok(events.some((event) => event.kind === "workbench_event_phase" && event.phase === "project_to_paint" && event.eventType === "assistant")); assert.ok(events.some((event) => event.kind === "workbench_backend_event_visible" && event.eventType === "assistant")); assert.doesNotMatch(payload, /trc_secret|cnv_secret|sessionId|conversationId|runId|commandId/u); }); diff --git a/web/hwlab-cloud-web/src/api/workbench-events.ts b/web/hwlab-cloud-web/src/api/workbench-events.ts index 90451888..e3f7448d 100644 --- a/web/hwlab-cloud-web/src/api/workbench-events.ts +++ b/web/hwlab-cloud-web/src/api/workbench-events.ts @@ -1,4 +1,4 @@ -// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0. +// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. // Responsibility: Workbench SSE client. Realtime events accelerate UI projection; REST snapshots remain gap-fill authority. import type { TraceEvent, WorkspaceRecord } from "@/types"; @@ -16,6 +16,8 @@ export interface WorkbenchRealtimeTraceSnapshot { export interface WorkbenchRealtimeEvent { type?: string; status?: string; + serverSentAt?: string | null; + eventCreatedAt?: string | null; projectId?: string; workspace?: WorkspaceRecord | null; workspaceId?: string | null; @@ -26,6 +28,7 @@ export interface WorkbenchRealtimeEvent { turn?: Record | null; reason?: string | null; error?: { code?: string; message?: string } | null; + traceSeq?: number | null; cursor?: { workspaceRevision?: number | null; traceSeq?: number | null; [key: string]: unknown }; [key: string]: unknown; } diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 97e70d2e..662914ed 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -764,7 +764,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { return; } if (event.type === "trace.event") { - applyRealtimeTraceEvent(event.traceId, event.event, event.snapshot); + applyRealtimeTraceEvent(event.traceId, event.event, event.snapshot, event); return; } if (event.type === "turn.snapshot" && event.turn) { @@ -798,11 +798,11 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (isTerminalMessageStatus(snapshot.status)) void refreshTerminalTraceFromRest(id, "realtime-trace-snapshot"); } - function applyRealtimeTraceEvent(traceId: string | null | undefined, event: WorkbenchRealtimeEvent["event"], snapshot: WorkbenchRealtimeEvent["snapshot"]): void { + function applyRealtimeTraceEvent(traceId: string | null | undefined, event: WorkbenchRealtimeEvent["event"], snapshot: WorkbenchRealtimeEvent["snapshot"], realtimeEvent?: WorkbenchRealtimeEvent | null): void { const id = firstNonEmptyString(traceId, event?.traceId, snapshot?.traceId); if (!id) return; const events = event ? [event] : Array.isArray(snapshot?.events) ? snapshot.events : []; - markWorkbenchTraceEventsReceived({ traceId: id, events, transport: "sse" }); + markWorkbenchTraceEventsReceived({ traceId: id, events, transport: "sse", serverSentAt: realtimeEvent?.serverSentAt, eventCreatedAt: realtimeEvent?.eventCreatedAt, traceSeq: realtimeEvent?.traceSeq ?? realtimeEvent?.cursor?.traceSeq }); applyTraceSnapshot(id, realtimeSnapshotToTraceSnapshot(id, snapshot ?? { traceId: id, status: event?.status, events }, events)); if (event?.terminal === true || isTerminalMessageStatus(event?.status) || isTerminalMessageStatus(snapshot?.status)) void refreshTerminalTraceFromRest(id, "realtime-trace-event"); } diff --git a/web/hwlab-cloud-web/src/utils/workbench-performance.ts b/web/hwlab-cloud-web/src/utils/workbench-performance.ts index afdd3694..548e31b0 100644 --- a/web/hwlab-cloud-web/src/utils/workbench-performance.ts +++ b/web/hwlab-cloud-web/src/utils/workbench-performance.ts @@ -24,6 +24,15 @@ interface WorkbenchPerformanceEvent { outcome?: WorkbenchOutcome; } +interface TraceEventTimingInput { + traceId: string | null | undefined; + events: TraceEvent[]; + transport: "sse" | "rest_gap" | "poll"; + serverSentAt?: string | null | undefined; + eventCreatedAt?: string | null | undefined; + traceSeq?: number | string | null | undefined; +} + interface OpenJourneyState { startAt: number; route: string; @@ -61,6 +70,9 @@ interface TraceEventState { receivedAt: number; projectedAt: number | null; eventCreatedAt: number | null; + appendedAt: number | null; + serverSentAt: number | null; + traceSeq: number | null; eventType: string; backend: string; transport: string; @@ -191,19 +203,24 @@ export function failWorkbenchSubmitJourney(traceId: string | null | undefined, o submitJourneys.delete(state.traceId); } -export function markWorkbenchTraceEventsReceived(input: { traceId: string | null | undefined; events: TraceEvent[]; transport: "sse" | "rest_gap" | "poll" }): void { +export function markWorkbenchTraceEventsReceived(input: TraceEventTimingInput): void { const traceId = safeText(input.traceId); if (!traceId) return; const event = input.events.find(isUserVisibleTraceEvent) ?? input.events[0]; if (!event) return; const eventType = eventTypeFromTraceEvent(event); const backend = backendFromTraceEvent(event); - const eventCreatedAt = traceEventCreatedAt(event); + const eventCreatedAt = traceEventCreatedAt(event, input.eventCreatedAt); + const appendedAt = timestampMs(event.appendedAt); + const serverSentAt = timestampMs(input.serverSentAt); const receivedAt = monotonicNow(); - traceEvents.set(traceId, { traceId, receivedAt, projectedAt: null, eventCreatedAt, eventType, backend, transport: input.transport, visibleReported: false }); - if (eventCreatedAt !== null) { - enqueue({ kind: "workbench_event_phase", phase: "sse_to_receive", eventType, backend, transport: input.transport, outcome: "ok", valueMs: Math.max(0, wallNow() - eventCreatedAt) }); - } + const receivedWallAt = wallNow(); + const traceSeq = Number(input.traceSeq ?? event.seq ?? event.sourceSeq ?? NaN); + traceEvents.set(traceId, { traceId, receivedAt, projectedAt: null, eventCreatedAt, appendedAt, serverSentAt, traceSeq: Number.isFinite(traceSeq) ? Math.trunc(traceSeq) : null, eventType, backend, transport: input.transport, visibleReported: false }); + if (eventCreatedAt !== null && appendedAt !== null) enqueue({ kind: "workbench_event_phase", phase: "created_to_append", eventType, backend, transport: input.transport, outcome: "ok", valueMs: Math.max(0, appendedAt - eventCreatedAt) }); + if (appendedAt !== null && serverSentAt !== null) enqueue({ kind: "workbench_event_phase", phase: "append_to_sse", eventType, backend, transport: input.transport, outcome: "ok", valueMs: Math.max(0, serverSentAt - appendedAt) }); + if (serverSentAt !== null) enqueue({ kind: "workbench_event_phase", phase: "sse_to_receive", eventType, backend, transport: input.transport, outcome: "ok", valueMs: Math.max(0, receivedWallAt - serverSentAt) }); + else if (eventCreatedAt !== null) enqueue({ kind: "workbench_event_phase", phase: "sse_to_receive", eventType, backend, transport: input.transport, outcome: "ok", valueMs: Math.max(0, receivedWallAt - eventCreatedAt) }); } export function markWorkbenchTraceProjected(traceId: string | null | undefined): void { @@ -322,7 +339,7 @@ function isUserVisibleTraceEvent(event: TraceEvent): boolean { } function eventTypeFromTraceEvent(event: TraceEvent | null | undefined): string { - const raw = safeText(event?.type ?? event?.kind ?? event?.label ?? event?.status).toLowerCase(); + const raw = safeText(event?.eventType ?? event?.type ?? event?.kind ?? event?.label ?? event?.status).toLowerCase(); if (/tool/u.test(raw)) return "tool_call"; if (/assistant|message|delta|reply/u.test(raw)) return "assistant"; if (/terminal|complete|final/u.test(raw)) return "terminal"; @@ -357,8 +374,14 @@ function normalizeTargetState(value: unknown): string { return "unknown"; } -function traceEventCreatedAt(event: TraceEvent): number | null { - const raw = safeText(event.createdAt ?? event.ts ?? event.timestamp); +function traceEventCreatedAt(event: TraceEvent, fallback?: unknown): number | null { + const raw = safeText(event.createdAt ?? fallback ?? event.ts ?? event.timestamp); + const parsed = raw ? Date.parse(raw) : NaN; + return Number.isFinite(parsed) ? parsed : null; +} + +function timestampMs(value: unknown): number | null { + const raw = safeText(value); const parsed = raw ? Date.parse(raw) : NaN; return Number.isFinite(parsed) ? parsed : null; }