fix: prevent workbench steer duplicate agent cards (#2075)

This commit is contained in:
Lyon
2026-06-25 02:56:36 +08:00
committed by GitHub
parent ee3a3572bf
commit abfd742bee
4 changed files with 48 additions and 7 deletions
+1 -2
View File
@@ -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");
@@ -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"]);
});
@@ -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];
+19 -5
View File
@@ -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;
}