diff --git a/internal/cloud/server-workbench-http.test.ts b/internal/cloud/server-workbench-http.test.ts index 34691cf9..27ffbb74 100644 --- a/internal/cloud/server-workbench-http.test.ts +++ b/internal/cloud/server-workbench-http.test.ts @@ -1522,7 +1522,7 @@ test("workbench trace events API blocks historical projectedSeq collisions inste } }); -test("workbench realtime stream accepts session authority without project or workspace", async () => { +test("workbench realtime stream surfaces facts blocker instead of legacy trace fallback", async () => { const traceStore = createCodeAgentTraceStore(); const results = createCodeAgentChatResultStore(); const traceId = "trc_workbench_realtime"; @@ -1538,8 +1538,8 @@ test("workbench realtime stream accepts session authority without project or wor updatedAt: "2026-06-17T02:00:00.000Z", session: { sessionStatus: "running", lastTraceId: traceId, messages: [{ role: "user", text: "stream", traceId }] } }; - traceStore.append(traceId, { type: "request", status: "accepted", label: "request:accepted" }); - results.set(traceId, { status: "running", traceId, ownerUserId: ACTOR.id, sessionId: session.id, threadId: session.threadId }); + traceStore.append(traceId, { type: "assistant", status: "completed", label: "assistant:completed", terminal: true, message: "legacy trace answer" }); + results.set(traceId, { status: "completed", traceId, ownerUserId: ACTOR.id, sessionId: session.id, threadId: session.threadId, finalResponse: { text: "legacy result answer" } }); const accessController = { store: { async getAgentSession(sessionId) { return sessionId === session.id ? session : null; }, @@ -1554,18 +1554,24 @@ test("workbench realtime stream accepts session authority without project or wor try { const { port } = server.address(); const eventsPromise = getSseEvents(port, `/v1/workbench/events?sessionId=${encodeURIComponent(session.id)}`, 4); - setTimeout(() => traceStore.append(traceId, { type: "backend", status: "running", label: "backend:live" }), 50); const events = await eventsPromise; - assert.deepEqual(events.slice(0, 3).map((event) => event.event), [ + assert.deepEqual(events.map((event) => event.event), [ "workbench.connected", + "workbench.error", "workbench.trace.snapshot", "workbench.turn.snapshot" ]); assert.equal(events[0].data.filters.sessionId, session.id); - assert.equal(events[1].data.traceId, traceId); - assert.equal(events[2].data.turn.traceId, traceId); - assert.equal(events[3].event, "workbench.trace.event"); - assert.equal(events[3].data.event.label, "backend:live"); + assert.equal(events[1].data.error.code, "workbench_facts_session_missing"); + assert.equal(events[2].data.traceId, traceId); + assert.equal(events[2].data.snapshot.status, "unknown"); + assert.equal(events[2].data.snapshot.eventCount, 0); + assert.equal(events[3].data.turn.traceId, traceId); + assert.equal(events[3].data.turn.status, "unknown"); + assert.equal(events[3].data.turn.terminal, false); + assert.equal(events[3].data.turn.finalResponse, null); + assert.equal(JSON.stringify(events).includes("legacy result answer"), false); + assert.equal(JSON.stringify(events).includes("legacy trace answer"), false); } finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); } diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index a8150893..b845017f 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -2221,14 +2221,16 @@ async function writeTraceRealtimeSnapshot({ writeEvent, options, actor, traceId, } const sessionId = safeSessionId(context.result?.sessionId ?? context.result?.session?.sessionId ?? context.session?.id) ?? null; const threadId = safeOpaqueId(context.result?.threadId ?? context.result?.session?.threadId ?? context.session?.threadId) ?? (textValue(context.session?.threadId) || null); + const realtimeTraceSnapshot = context.factsReadBlocker ? blockedRealtimeTraceSnapshot(context) : traceSnapshotForRealtime(context.trace); + const traceSeq = context.factsReadBlocker ? 0 : traceSnapshotLastSeq(context.trace); writeEvent("workbench.trace.snapshot", { type: "trace.snapshot", reason, sessionId, threadId, traceId, - snapshot: { ...traceSnapshotForRealtime(context.trace), sessionId, threadId }, - cursor: { traceSeq: traceSnapshotLastSeq(context.trace) } + snapshot: { ...realtimeTraceSnapshot, sessionId, threadId }, + cursor: { traceSeq } }); writeEvent("workbench.turn.snapshot", { type: "turn.snapshot", @@ -2237,21 +2239,12 @@ async function writeTraceRealtimeSnapshot({ writeEvent, options, actor, traceId, threadId, traceId, turn: realtimeTurnSnapshot(context), - cursor: { traceSeq: traceSnapshotLastSeq(context.trace) } + cursor: { traceSeq } }); } function realtimeTurnSnapshot(context) { - if (context?.factsReadBlocker) { - return { - ...turnSnapshot(context), - projectionStatus: "blocked", - projectionHealth: "degraded", - blocker: context.factsReadBlocker, - projection: blockedTraceEventProjection(context.projection ?? {}, context.factsReadBlocker), - valuesRedacted: true - }; - } + if (context?.factsReadBlocker) return blockedRealtimeTurnSnapshot(context, context.factsReadBlocker); const facts = context?.facts; const traceId = safeTraceId(context?.traceId); if (traceId && facts && context?.factSession) { @@ -2259,7 +2252,76 @@ function realtimeTurnSnapshot(context) { const projected = factTurnSnapshot({ turn: factTurn, session: context.factSession, facts, traceId, turnId: context.turnId }); if (projected) return projected; } - return turnSnapshot(context); + const blocker = traceEventsReadModelBlocker("workbench_facts_turn_missing", "Workbench durable facts did not contain a turn snapshot; realtime must surface the read-model gap instead of falling back to legacy trace projection.", { traceId, session: context?.factSession, route: "/v1/workbench/events" }); + return blockedRealtimeTurnSnapshot(context, blocker); +} + +function blockedRealtimeTraceSnapshot(context) { + const traceId = safeTraceId(context?.traceId ?? context?.factsReadBlocker?.traceId) ?? null; + const blocker = context?.factsReadBlocker ?? traceEventsReadModelBlocker("workbench_facts_unavailable", "Workbench durable facts are unavailable for realtime trace snapshot.", { traceId, route: "/v1/workbench/events" }); + const projection = blockedTraceEventProjection({}, blocker); + return { + traceId, + status: "unknown", + traceStatus: "unknown", + events: [], + eventCount: 0, + fullTraceLoaded: true, + hasMore: false, + nextProjectedSeq: 0, + updatedAt: null, + projection, + projectionStatus: projection.projectionStatus, + projectionHealth: projection.projectionHealth, + staleMs: projection.staleMs ?? null, + blocker, + valuesRedacted: true, + secretMaterialStored: false + }; +} + +function blockedRealtimeTurnSnapshot(context, blocker) { + const traceId = safeTraceId(context?.traceId ?? blocker?.traceId) ?? null; + const turnId = safeTurnId(context?.turnId ?? blocker?.turnId) || traceId; + const sessionId = safeSessionId(blocker?.sessionId ?? factSessionId(context?.factSession) ?? context?.session?.id) ?? null; + const threadId = safeOpaqueId(context?.factSession?.threadId ?? context?.session?.threadId) ?? (textValue(context?.session?.threadId) || null); + const projection = blockedTraceEventProjection({}, blocker); + return { + turnId, + traceId, + status: "unknown", + running: false, + terminal: false, + sessionId, + threadId, + userMessageId: null, + assistantMessageId: null, + assistantText: null, + finalResponse: null, + timing: null, + startedAt: null, + lastEventAt: null, + finishedAt: null, + durationMs: null, + agentRun: null, + trace: { + traceId, + status: "unknown", + eventCount: 0, + updatedAt: null + }, + urls: { + self: turnId ? `/v1/workbench/turns/${encodeURIComponent(turnId)}` : null, + traceEvents: traceId ? `/v1/workbench/traces/${encodeURIComponent(traceId)}/events` : null + }, + projectionStatus: projection.projectionStatus, + projectionHealth: projection.projectionHealth, + staleMs: projection.staleMs ?? null, + blocker, + projection, + valuesRedacted: true, + secretMaterialStored: false + }; } async function writeTraceRealtimeSnapshotSafe(input) { @@ -2305,6 +2367,15 @@ async function visibleTraceContext(options, actor, traceId) { } } const factSession = factArray(facts.sessions).find((item) => factSessionId(item) === factSessionIdValue) ?? null; + if (!factsReadBlocker) { + if (!factSessionIdValue) { + factsReadBlocker = traceEventsReadModelBlocker("workbench_facts_session_id_missing", "Workbench realtime could not resolve a durable session id; realtime must surface the read-model gap instead of using legacy trace projection.", { traceId, projection, session, route: "/v1/workbench/events" }); + } else if (typeof readModel.queryFacts !== "function") { + factsReadBlocker = traceEventsReadModelBlocker("workbench_facts_query_unavailable", "Workbench durable facts query is unavailable; realtime must surface the read-model gap instead of using legacy trace projection.", { traceId, projection, session, route: "/v1/workbench/events" }); + } else if (!factSession) { + factsReadBlocker = traceEventsReadModelBlocker("workbench_facts_session_missing", "Workbench durable facts did not contain the trace session; realtime must surface the read-model gap instead of using legacy trace projection.", { traceId, projection, session, route: "/v1/workbench/events" }); + } + } return { visible: true, turnId: projection.turnId, traceId, status: projection.status, projection, result, session, trace, facts, factSession, factsReadBlocker }; } diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 4285c2c6..c005e1b0 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -64,6 +64,8 @@ const appSource = readCloudWebAppSource(rootDir); const workbenchStoreSource = readWeb("src/stores/workbench.ts"); const traceTimelineSource = readWeb("src/components/agent/TraceTimeline.vue"); const messageTraceDebugSource = readWeb("src/components/workbench/MessageTraceDebugPanel.vue"); +const conversationPanelSource = readWeb("src/components/workbench/ConversationPanel.vue"); +const serverWorkbenchHttpSource = fs.readFileSync(path.resolve(rootDir, "..", "..", "internal/cloud/server-workbench-http.ts"), "utf8"); assert.match(html, /