fix: seal Workbench admission failure projection
This commit is contained in:
@@ -131,6 +131,17 @@ test("Workbench realtime agent snapshots cannot create duplicate assistant messa
|
||||
assert.deepEqual(selectActiveMessages(state, "ses_steer").map((message) => message.messageId), ["msg_user", "msg_agent"]);
|
||||
});
|
||||
|
||||
test("Workbench realtime agent snapshots can seed an empty session projection", () => {
|
||||
let state = createWorkbenchServerState();
|
||||
state = reduceWorkbenchServerState(state, {
|
||||
type: "message.snapshot",
|
||||
sessionId: "ses_empty_snapshot",
|
||||
message: { id: "msg_agent_seed", messageId: "msg_agent_seed", role: "agent", title: "Code Agent", text: "Failed to fetch", status: "failed", createdAt: "2026-06-24T00:00:01.000Z", sessionId: "ses_empty_snapshot", traceId: "trc_empty_snapshot" }
|
||||
});
|
||||
|
||||
assert.deepEqual(selectActiveMessages(state, "ses_empty_snapshot").map((message) => `${message.role}:${message.messageId}:${message.text}`), ["agent:msg_agent_seed:Failed to fetch"]);
|
||||
});
|
||||
|
||||
test("Workbench session messages keep sealed terminal timing on bulk refresh", () => {
|
||||
let state = createWorkbenchServerState();
|
||||
state = reduceWorkbenchServerState(state, {
|
||||
|
||||
@@ -151,7 +151,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;
|
||||
if (index < 0 && message.role === "agent" && existing.length > 0) return state;
|
||||
const merged = index >= 0
|
||||
? existing.map((item, itemIndex) => itemIndex === index ? mergeMessageSnapshot(item, message) : item)
|
||||
: [...existing, message];
|
||||
|
||||
@@ -831,9 +831,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
|
||||
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 });
|
||||
projectLocalAdmissionFailure({ traceId, sessionId, threadId, message: failureMessage, error: failureError, projection, submittedAt });
|
||||
failWorkbenchSubmitJourney(traceId, response.status === 0 ? "network" : "error");
|
||||
await clearActiveTrace(traceId, steerMode && response.status === 404 ? "steer-trace-not-found" : "submit-not-admitted");
|
||||
chatPending.value = false;
|
||||
@@ -1591,6 +1589,97 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
|
||||
scheduleSessionListRefresh(selectedSessionId.value, SESSION_LIST_TERMINAL_REFRESH_DELAY_MS);
|
||||
}
|
||||
|
||||
function projectLocalAdmissionFailure(input: { traceId: string; sessionId: string; threadId: string | null; message: string; error: ChatMessage["error"]; projection: ProjectionDiagnostic; submittedAt: string }): void {
|
||||
const finishedAt = new Date().toISOString();
|
||||
const startedAt = firstNonEmptyString(input.submittedAt, finishedAt) ?? finishedAt;
|
||||
const durationMs = positiveDurationBetween(startedAt, finishedAt) ?? 1000;
|
||||
const timing = { startedAt, lastEventAt: finishedAt, finishedAt, durationMs, valuesRedacted: true } as WorkbenchTurnTimingProjection;
|
||||
const event = {
|
||||
projectedSeq: 1,
|
||||
ts: finishedAt,
|
||||
createdAt: finishedAt,
|
||||
label: "workbench:admission:failed",
|
||||
type: "event",
|
||||
status: "failed",
|
||||
message: input.message,
|
||||
elapsedMs: durationMs,
|
||||
terminal: true,
|
||||
traceId: input.traceId,
|
||||
sessionId: input.sessionId,
|
||||
threadId: input.threadId,
|
||||
source: "workbench-web",
|
||||
projection: input.projection,
|
||||
projectionStatus: input.projection.projectionStatus ?? null,
|
||||
projectionHealth: input.projection.projectionHealth ?? null,
|
||||
error: input.error,
|
||||
valuesRedacted: true
|
||||
} as TraceEvent;
|
||||
const runnerTrace = {
|
||||
traceId: input.traceId,
|
||||
sessionId: input.sessionId,
|
||||
threadId: input.threadId ?? undefined,
|
||||
status: "failed",
|
||||
traceStatus: "failed",
|
||||
events: [event],
|
||||
eventCount: 1,
|
||||
eventsCompacted: false,
|
||||
fullTraceLoaded: true,
|
||||
hasMore: false,
|
||||
truncated: false,
|
||||
nextProjectedSeq: 1,
|
||||
range: { afterProjectedSeq: 0, fromProjectedSeq: 1, toProjectedSeq: 1, limit: TRACE_HYDRATION_PAGE_LIMIT, returned: 1, total: 1 },
|
||||
lastEventLabel: "workbench:admission:failed",
|
||||
error: input.error ?? undefined,
|
||||
projection: input.projection,
|
||||
projectionStatus: input.projection.projectionStatus ?? null,
|
||||
projectionHealth: input.projection.projectionHealth ?? null,
|
||||
blocker: input.projection.blocker ?? null,
|
||||
timing,
|
||||
startedAt,
|
||||
lastEventAt: finishedAt,
|
||||
finishedAt,
|
||||
durationMs,
|
||||
eventSource: "workbench-local",
|
||||
updatedAt: finishedAt
|
||||
} as NonNullable<ChatMessage["runnerTrace"]>;
|
||||
const timingPatch = messageTimingPatchFromProjection(timing);
|
||||
markMessage(input.traceId, {
|
||||
status: "failed",
|
||||
text: input.message,
|
||||
error: input.error,
|
||||
projection: input.projection,
|
||||
projectionStatus: input.projection.projectionStatus ?? null,
|
||||
projectionHealth: input.projection.projectionHealth ?? null,
|
||||
blocker: input.projection.blocker ?? null,
|
||||
runnerTrace,
|
||||
traceAutoLifecycle: "terminal",
|
||||
...timingPatch
|
||||
});
|
||||
rememberTraceAuthority(runnerTrace);
|
||||
reduceServerState({
|
||||
type: "turn.status",
|
||||
turn: { traceId: input.traceId, status: "failed", running: false, terminal: true, sessionId: input.sessionId, threadId: input.threadId, updatedAt: finishedAt, projection: input.projection, loadedAt: finishedAt }
|
||||
});
|
||||
reduceServerState({
|
||||
type: "session.status",
|
||||
session: { sessionId: input.sessionId, status: "failed", updatedAt: finishedAt, lastTraceId: input.traceId, projection: input.projection, loadedAt: finishedAt }
|
||||
});
|
||||
const existing = sessions.value.find((session) => session.sessionId === input.sessionId) ?? null;
|
||||
const projectedMessages = serverState.value.messagesBySessionId[input.sessionId] ?? messages.value.filter((message) => firstNonEmptyString(message.sessionId, message.runnerTrace?.sessionId) === input.sessionId);
|
||||
rememberSessionList(mergeSessionIntoList(sessions.value, {
|
||||
...(existing ?? {}),
|
||||
sessionId: input.sessionId,
|
||||
threadId: firstNonEmptyString(input.threadId, existing?.threadId),
|
||||
status: "failed",
|
||||
updatedAt: finishedAt,
|
||||
lastTraceId: input.traceId,
|
||||
messageCount: Math.max(existing?.messageCount ?? 0, projectedMessages.length),
|
||||
messages: projectedMessages
|
||||
} as WorkbenchSessionRecord));
|
||||
publishWorkbenchProjectionSignal(input.sessionId, input.traceId, "submit-admission-failed");
|
||||
scheduleSessionListRefresh(input.sessionId, SESSION_LIST_TERMINAL_REFRESH_DELAY_MS);
|
||||
}
|
||||
|
||||
function projectOptimisticRunningTurn(input: { sessionId: string; threadId: string | null; traceId: string; userText: string }): void {
|
||||
const now = new Date().toISOString();
|
||||
reduceServerState({ type: "turn.status", turn: { traceId: input.traceId, status: "running", running: true, terminal: false, sessionId: input.sessionId, threadId: input.threadId, updatedAt: now, loadedAt: now } });
|
||||
|
||||
Reference in New Issue
Block a user