fix: prevent workbench steer duplicate agent cards (#2075)
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user