diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index ec50e00f..4285c2c6 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -100,8 +100,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(workbenchStoreSource, "connectWorkbenchEvents", "Workbench must use the unified SSE realtime entry"); -assertIncludes(workbenchStoreSource, "scheduleRealtimeGapHydration", "Workbench realtime disconnect/reconnect must trigger REST gap fill"); -assertIncludes(workbenchStoreSource, "hydrateRealtimeGap", "Workbench realtime recovery must use REST snapshot/page gap fill"); +assert.doesNotMatch(workbenchStoreSource, /scheduleRealtimeGapHydration|hydrateRealtimeGap/u, "Workbench realtime consumer must not repair projection through REST gap fill"); assert.doesNotMatch(workbenchStoreSource, /subscribeToTrace|TRACE_POLL_INTERVAL_MS/u, "Workbench store must not reintroduce active trace polling"); assertIncludes(appSource, "/v1/workbench/events", "Workbench realtime client must use the RESTful same-origin events endpoint"); assertIncludes(appSource, "/v1/workbench/traces/", "trace hydration must use Workbench read-model trace API"); diff --git a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts index 508e4d1f..f45d22c1 100644 --- a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts @@ -103,3 +103,30 @@ test("Workbench server-state session detail updates session status authority", ( assert.equal(authority?.status, "completed"); assert.equal(authority?.lastTraceId, "trc_backfill"); }); + +test("Workbench realtime agent snapshots cannot create duplicate assistant messages", () => { + let state = createWorkbenchServerState(); + state = reduceWorkbenchServerState(state, { + type: "session.messages", + sessionId: "ses_steer", + messages: [ + { id: "msg_user", messageId: "msg_user", role: "user", title: "用户", text: "ping", status: "sent", createdAt: "2026-06-24T00:00:00.000Z", sessionId: "ses_steer", traceId: "trc_steer_target" }, + { id: "msg_agent", messageId: "msg_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: "2026-06-24T00:00:01.000Z", sessionId: "ses_steer", traceId: "trc_steer_target" } + ] + }); + + state = reduceWorkbenchServerState(state, { + type: "message.snapshot", + sessionId: "ses_steer", + message: { id: "msg_agent", messageId: "msg_agent", role: "agent", title: "Code Agent", text: "done", status: "completed", createdAt: "2026-06-24T00:00:01.000Z", sessionId: "ses_steer", traceId: "trc_steer_target" } + }); + assert.deepEqual(selectActiveMessages(state, "ses_steer").map((message) => `${message.role}:${message.messageId}:${message.text}`), ["user:msg_user:ping", "agent:msg_agent:done"]); + + state = reduceWorkbenchServerState(state, { + type: "message.snapshot", + sessionId: "ses_steer", + message: { id: "msg_duplicate_agent", messageId: "msg_duplicate_agent", role: "agent", title: "Code Agent", text: "projection-resume:non-terminal-backoff", status: "completed", createdAt: "2026-06-24T00:00:02.000Z", sessionId: "ses_steer", traceId: "trc_duplicate" } + }); + + assert.deepEqual(selectActiveMessages(state, "ses_steer").map((message) => message.messageId), ["msg_user", "msg_agent"]); +}); diff --git a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts index 4e585690..626dc9fc 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts @@ -148,6 +148,7 @@ function reduceMessageSnapshot(state: WorkbenchServerState, sessionId: string | if (!id || !message) return state; const existing = state.messagesBySessionId[id] ?? state.sessionsById[id]?.messages ?? []; const index = existing.findIndex((item) => messageMatchesSnapshot(item, message)); + if (index < 0 && message.role === "agent") return state; const merged = index >= 0 ? existing.map((item, itemIndex) => itemIndex === index ? mergeMessageSnapshot(item, message) : item) : [...existing, message]; diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 33b62cc0..0fbb983b 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -502,6 +502,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { return { ok: false, status: 503, + data: null, error: message, apiError: { code: "workbench_read_hydration_cooldown", @@ -528,6 +529,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { return { ok: false, status: 0, + data: null, error: message, apiError: { code: "workbench_read_hydration_throttled", @@ -770,9 +772,13 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project const threadId = composer.value.threadId; const providerThreadId = providerThreadIdForRequest(threadId); const submittedAt = new Date().toISOString(); - const user = makeMessage("user", value, "sent", { traceId: steerTraceId ?? traceId, sessionId, threadId, title: steerMode ? "用户引导" : "用户", createdAt: submittedAt, updatedAt: submittedAt }); - const pending = makeMessage("agent", "", "running", { traceId, sessionId, threadId, title: "Code Agent", retryInput: value, traceAutoLifecycle: "running", createdAt: submittedAt, updatedAt: submittedAt, ...optimisticRunningTimingPatch(submittedAt) }); - appendActiveMessages(user, pending); + const previousChatPending = chatPending.value; + if (!steerMode) { + const user = makeMessage("user", value, "sent", { traceId, sessionId, threadId, title: "用户", createdAt: submittedAt, updatedAt: submittedAt }); + const pending = makeMessage("agent", "", "running", { traceId, sessionId, threadId, title: "Code Agent", retryInput: value, traceAutoLifecycle: "running", createdAt: submittedAt, updatedAt: submittedAt, ...optimisticRunningTimingPatch(submittedAt) }); + appendActiveMessages(user, pending); + projectOptimisticRunningTurn({ sessionId, threadId, traceId, userText: value }); + } startWorkbenchSubmitJourney({ traceId, sessionId, entry: submitEntry, backend: providerProfile.value, transport: "sse" }); chatPending.value = true; rememberRecentDraft(value); @@ -784,6 +790,14 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project const projection = projectionDiagnosticFromApiFailure(response, { code: "code_agent_admission_failed", message: response.error ?? "Code Agent 请求失败", health: response.status === 0 ? "unavailable" : "degraded" }); const failureError = agentErrorFromApiFailure(response, projection, "Code Agent 请求失败"); const failureMessage = agentErrorDisplayText(failureError) ?? "Code Agent 请求失败"; + if (steerMode) { + error.value = failureMessage; + applyProjectionDiagnostic(traceId, projection); + failWorkbenchSubmitJourney(traceId, response.status === 0 ? "network" : "error"); + chatPending.value = previousChatPending; + restartRealtime("steer-not-admitted"); + return false; + } markMessage(traceId, { status: "failed", text: failureMessage, error: failureError, projection, projectionStatus: projection.projectionStatus ?? null, projectionHealth: projection.projectionHealth ?? null, blocker: projection.blocker ?? null }); applyProjectionDiagnostic(traceId, projection); reduceServerState({ type: "turn.forget", traceId }); @@ -1385,7 +1399,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project const status = existing?.status ?? normalizedStatusText(message?.status) ?? null; const running = existing?.running === true || (!isTerminalMessageStatus(message?.status) && isTraceActiveStatus(message?.status)); const now = new Date().toISOString(); - reduceServerState({ type: "turn.status", turn: { traceId, status, running, terminal: existing?.terminal === true, sessionId, threadId: firstNonEmptyString(message?.threadId, message?.runnerTrace?.threadId) ?? existing?.threadId ?? null, updatedAt: now, projection, loadedAt: now } }); + reduceServerState({ type: "turn.status", turn: { traceId, status, running, terminal: false, sessionId, threadId: firstNonEmptyString(message?.threadId, message?.runnerTrace?.threadId) ?? existing?.threadId ?? null, updatedAt: now, projection, loadedAt: now } }); if (sessionId) reduceServerState({ type: "session.status", session: { sessionId, status: activeSession.value?.status ?? status, updatedAt: now, lastTraceId: traceId, projection, loadedAt: now } }); const projectionError = agentErrorFromProjection(projection); updateActiveMessages((source) => source.map((item) => { @@ -1855,7 +1869,7 @@ function messageStatusPatchForTerminalMerge(message: ChatMessage, resultStatus: function firstPositiveFiniteNumber(...values: unknown[]): number | null { for (const value of values) { const number = firstFiniteNumber(value); - if (number !== null && number > 0) return number; + if (number !== undefined && number > 0) return number; } return null; }