Merge pull request #2341 from pikasTech/fix/2338-session-status-authority-v2
fix: keep workbench session status upstream
This commit is contained in:
@@ -104,7 +104,7 @@ test("Workbench server-state session detail updates session status authority", (
|
||||
assert.equal(authority?.lastTraceId, "trc_backfill");
|
||||
});
|
||||
|
||||
test("Workbench session status action preserves sealed completed authority for the same trace", () => {
|
||||
test("Workbench turn status action seals completed authority for the same trace", () => {
|
||||
let state = createWorkbenchServerState();
|
||||
state = reduceWorkbenchServerState(state, {
|
||||
type: "session.messages",
|
||||
@@ -115,8 +115,8 @@ test("Workbench session status action preserves sealed completed authority for t
|
||||
]
|
||||
});
|
||||
state = reduceWorkbenchServerState(state, {
|
||||
type: "session.status",
|
||||
session: { sessionId: "ses_status_seal", status: "running", lastTraceId: "trc_status_seal" }
|
||||
type: "turn.status",
|
||||
turn: { traceId: "trc_status_seal", sessionId: "ses_status_seal", status: "completed", running: false, terminal: true }
|
||||
});
|
||||
|
||||
let authority = selectSessionStatusAuthority(state).ses_status_seal;
|
||||
@@ -124,8 +124,8 @@ test("Workbench session status action preserves sealed completed authority for t
|
||||
assert.equal(authority?.lastTraceId, "trc_status_seal");
|
||||
|
||||
state = reduceWorkbenchServerState(state, {
|
||||
type: "session.status",
|
||||
session: { sessionId: "ses_status_seal", status: "running", lastTraceId: "trc_status_next" }
|
||||
type: "turn.status",
|
||||
turn: { traceId: "trc_status_next", sessionId: "ses_status_seal", status: "running", running: true, terminal: false }
|
||||
});
|
||||
|
||||
authority = selectSessionStatusAuthority(state).ses_status_seal;
|
||||
@@ -252,6 +252,13 @@ test("Workbench sealed completed final body survives stale running session messa
|
||||
assert.equal(agent?.status, "completed");
|
||||
assert.equal(agent?.text, "final answer");
|
||||
assert.equal((agent?.finalResponse as { text?: string } | undefined)?.text, "final answer");
|
||||
assert.equal(selectSessionStatusAuthority(state).ses_final_seal?.status, "running");
|
||||
|
||||
state = reduceWorkbenchServerState(state, {
|
||||
type: "turn.status",
|
||||
turn: { traceId: "trc_final_seal", sessionId: "ses_final_seal", status: "completed", running: false, terminal: true, updatedAt: "2026-06-24T00:00:06.000Z" }
|
||||
});
|
||||
|
||||
assert.equal(selectSessionStatusAuthority(state).ses_final_seal?.status, "completed");
|
||||
|
||||
state = reduceWorkbenchServerState(state, {
|
||||
|
||||
@@ -1,15 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import type { ChatMessage, WorkbenchSessionRecord } from "../types";
|
||||
import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectSessionStatusAuthority } from "./workbench-server-state";
|
||||
|
||||
function sessionRecord(input: WorkbenchSessionRecord): WorkbenchSessionRecord {
|
||||
return input;
|
||||
}
|
||||
|
||||
function agentMessage(input: Partial<ChatMessage> & Pick<ChatMessage, "id">): ChatMessage {
|
||||
return {
|
||||
role: "agent",
|
||||
title: "Code Agent",
|
||||
text: "",
|
||||
status: "running",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
...input
|
||||
};
|
||||
}
|
||||
|
||||
function userMessage(input: Partial<ChatMessage> & Pick<ChatMessage, "id">): ChatMessage {
|
||||
return {
|
||||
role: "user",
|
||||
title: "用户",
|
||||
text: "",
|
||||
status: "sent",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
...input
|
||||
};
|
||||
}
|
||||
|
||||
test("server state reducer keeps completed message without final response unsealed", () => {
|
||||
const sessionId = "ses_state_completed_without_final";
|
||||
const traceId = "trc_state_completed_without_final";
|
||||
let state = createWorkbenchServerState();
|
||||
state = reduceWorkbenchServerState(state, { type: "session.detail", session: { sessionId, status: "running", messages: [] } as any });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [{ id: "msg_state_completed_without_final", role: "agent", status: "running", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z" } as any] });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [{ id: "msg_state_completed_without_final", role: "agent", status: "completed", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z", finishedAt: "2026-07-01T00:00:02.000Z", durationMs: 2000 } as any] });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.detail", session: sessionRecord({ sessionId, status: "running", messages: [] }) });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [agentMessage({ id: "msg_state_completed_without_final", status: "running", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z" })] });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [agentMessage({ id: "msg_state_completed_without_final", status: "completed", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z", finishedAt: "2026-07-01T00:00:02.000Z", durationMs: 2000 })] });
|
||||
|
||||
const [message] = selectActiveMessages(state, sessionId);
|
||||
assert.equal(message.status, "running");
|
||||
@@ -22,9 +49,9 @@ test("server state reducer keeps failed message without terminal body unsealed",
|
||||
const sessionId = "ses_state_failed_without_body";
|
||||
const traceId = "trc_state_failed_without_body";
|
||||
let state = createWorkbenchServerState();
|
||||
state = reduceWorkbenchServerState(state, { type: "session.detail", session: { sessionId, status: "running", messages: [] } as any });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [{ id: "msg_state_failed_without_body", role: "agent", status: "running", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z" } as any] });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [{ id: "msg_state_failed_without_body", role: "agent", status: "failed", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z", finishedAt: "2026-07-01T00:00:02.000Z", durationMs: 2000 } as any] });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.detail", session: sessionRecord({ sessionId, status: "running", messages: [] }) });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [agentMessage({ id: "msg_state_failed_without_body", status: "running", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z" })] });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [agentMessage({ id: "msg_state_failed_without_body", status: "failed", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z", finishedAt: "2026-07-01T00:00:02.000Z", durationMs: 2000 })] });
|
||||
|
||||
const [message] = selectActiveMessages(state, sessionId);
|
||||
assert.equal(message.status, "running");
|
||||
@@ -37,8 +64,10 @@ test("server state reducer seals completed message when final response exists",
|
||||
const sessionId = "ses_state_completed_with_final";
|
||||
const traceId = "trc_state_completed_with_final";
|
||||
let state = createWorkbenchServerState();
|
||||
state = reduceWorkbenchServerState(state, { type: "session.detail", session: { sessionId, status: "running", messages: [] } as any });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [{ id: "msg_state_completed_with_final", role: "agent", status: "completed", traceId, sessionId, text: "final answer", finishedAt: "2026-07-01T00:00:02.000Z", durationMs: 2000 } as any] });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.detail", session: sessionRecord({ sessionId, status: "running", messages: [] }) });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [agentMessage({ id: "msg_state_completed_with_final", status: "completed", traceId, sessionId, text: "final answer", finishedAt: "2026-07-01T00:00:02.000Z", durationMs: 2000 })] });
|
||||
assert.equal(selectSessionStatusAuthority(state)[sessionId]?.status, "running");
|
||||
state = reduceWorkbenchServerState(state, { type: "turn.status", turn: { traceId, status: "completed", running: false, terminal: true, sessionId, updatedAt: "2026-07-01T00:00:02.000Z" } });
|
||||
|
||||
const [message] = selectActiveMessages(state, sessionId);
|
||||
assert.equal(message.status, "completed");
|
||||
@@ -50,24 +79,26 @@ test("server state reducer keeps sealed terminal messages when a stale partial s
|
||||
const sessionId = "ses_state_partial_snapshot";
|
||||
const traceId = "trc_state_partial_snapshot";
|
||||
let state = createWorkbenchServerState();
|
||||
state = reduceWorkbenchServerState(state, { type: "session.detail", session: { sessionId, status: "running", messages: [] } as any });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.detail", session: sessionRecord({ sessionId, status: "running", messages: [] }) });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [
|
||||
{ id: "msg_state_partial_user_1", role: "user", status: "sent", text: "one", sessionId, traceId: "trc_state_partial_1" } as any,
|
||||
{ id: "msg_state_partial_agent_1", role: "agent", status: "completed", text: "one done", sessionId, traceId: "trc_state_partial_1" } as any,
|
||||
{ id: "msg_state_partial_user_2", role: "user", status: "sent", text: "two", sessionId, traceId } as any,
|
||||
{ id: "msg_state_partial_agent_2", role: "agent", status: "completed", text: "final answer", finalResponse: { text: "final answer" }, sessionId, traceId, finishedAt: "2026-07-01T00:00:04.000Z", durationMs: 4000 } as any
|
||||
userMessage({ id: "msg_state_partial_user_1", text: "one", sessionId, traceId: "trc_state_partial_1" }),
|
||||
agentMessage({ id: "msg_state_partial_agent_1", status: "completed", text: "one done", sessionId, traceId: "trc_state_partial_1" }),
|
||||
userMessage({ id: "msg_state_partial_user_2", text: "two", sessionId, traceId }),
|
||||
agentMessage({ id: "msg_state_partial_agent_2", status: "completed", text: "final answer", finalResponse: { text: "final answer" }, sessionId, traceId, finishedAt: "2026-07-01T00:00:04.000Z", durationMs: 4000 })
|
||||
] });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [
|
||||
{ id: "msg_state_partial_user_1", role: "user", status: "sent", text: "one", sessionId, traceId: "trc_state_partial_1" } as any,
|
||||
{ id: "msg_state_partial_agent_1", role: "agent", status: "completed", text: "one done", sessionId, traceId: "trc_state_partial_1" } as any
|
||||
userMessage({ id: "msg_state_partial_user_1", text: "one", sessionId, traceId: "trc_state_partial_1" }),
|
||||
agentMessage({ id: "msg_state_partial_agent_1", status: "completed", text: "one done", sessionId, traceId: "trc_state_partial_1" })
|
||||
] });
|
||||
|
||||
const messages = selectActiveMessages(state, sessionId);
|
||||
assert.equal(messages.length, 4);
|
||||
const terminal = messages.find((message) => message.id === "msg_state_partial_agent_2") as any;
|
||||
const terminal = messages.find((message) => message.id === "msg_state_partial_agent_2");
|
||||
assert.equal(terminal.status, "completed");
|
||||
assert.equal(terminal.text, "final answer");
|
||||
assert.equal(terminal.finalResponse.text, "final answer");
|
||||
assert.equal((terminal.finalResponse as { text?: string } | undefined)?.text, "final answer");
|
||||
assert.equal(selectSessionStatusAuthority(state)[sessionId]?.status, "running");
|
||||
state = reduceWorkbenchServerState(state, { type: "turn.status", turn: { traceId, status: "completed", running: false, terminal: true, sessionId, updatedAt: "2026-07-01T00:00:04.000Z" } });
|
||||
assert.equal(selectSessionStatusAuthority(state)[sessionId]?.status, "completed");
|
||||
});
|
||||
|
||||
@@ -75,15 +106,56 @@ test("server state reducer promotes failed terminal message authority and ignore
|
||||
const sessionId = "ses_state_failed_terminal_authority";
|
||||
const traceId = "trc_state_failed_terminal_authority";
|
||||
let state = createWorkbenchServerState();
|
||||
state = reduceWorkbenchServerState(state, { type: "session.detail", session: { sessionId, status: "running", lastTraceId: traceId, messages: [] } as any });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.detail", session: sessionRecord({ sessionId, status: "running", lastTraceId: traceId, messages: [] }) });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [
|
||||
{ id: "msg_state_failed_user", role: "user", status: "sent", text: "one", sessionId, traceId } as any,
|
||||
{ id: "msg_state_failed_agent", role: "agent", status: "failed", text: "Workbench terminal failed: provider-stream-disconnected", finalResponse: { text: "Workbench terminal failed: provider-stream-disconnected" }, sessionId, traceId, finishedAt: "2026-07-01T00:00:05.000Z", durationMs: 5000 } as any
|
||||
userMessage({ id: "msg_state_failed_user", text: "one", sessionId, traceId }),
|
||||
agentMessage({ id: "msg_state_failed_agent", status: "failed", text: "Workbench terminal failed: provider-stream-disconnected", finalResponse: { text: "Workbench terminal failed: provider-stream-disconnected" }, sessionId, traceId, finishedAt: "2026-07-01T00:00:05.000Z", durationMs: 5000 })
|
||||
] });
|
||||
|
||||
assert.equal(selectSessionStatusAuthority(state)[sessionId]?.status, "running");
|
||||
state = reduceWorkbenchServerState(state, { type: "turn.status", turn: { traceId, status: "failed", running: false, terminal: true, sessionId, updatedAt: "2026-07-01T00:00:05.000Z" } });
|
||||
assert.equal(selectSessionStatusAuthority(state)[sessionId]?.status, "failed");
|
||||
state = reduceWorkbenchServerState(state, { type: "session.list", sessions: [{ sessionId, status: "running", lastTraceId: traceId, messages: [] } as any] });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.list", sessions: [sessionRecord({ sessionId, status: "running", lastTraceId: traceId, messages: [] })] });
|
||||
|
||||
assert.equal(selectSessionStatusAuthority(state)[sessionId]?.status, "failed");
|
||||
assert.equal(selectActiveMessages(state, sessionId).find((message) => message.id === "msg_state_failed_agent")?.status, "failed");
|
||||
});
|
||||
|
||||
test("server state reducer keeps newer running turn authority over older terminal message", () => {
|
||||
const sessionId = "ses_state_new_turn_running";
|
||||
const oldTraceId = "trc_state_old_completed";
|
||||
const newTraceId = "trc_state_new_running";
|
||||
let state = createWorkbenchServerState();
|
||||
state = reduceWorkbenchServerState(state, {
|
||||
type: "session.detail",
|
||||
session: {
|
||||
sessionId,
|
||||
status: "completed",
|
||||
lastTraceId: oldTraceId,
|
||||
messages: [
|
||||
userMessage({ id: "msg_state_old_user", text: "one", sessionId, traceId: oldTraceId }),
|
||||
agentMessage({ id: "msg_state_old_agent", status: "completed", text: "one done", finalResponse: { text: "one done" }, sessionId, traceId: oldTraceId }),
|
||||
userMessage({ id: "msg_state_new_user", text: "two", sessionId, traceId: newTraceId }),
|
||||
agentMessage({ id: "msg_state_new_agent", status: "running", text: "", sessionId, traceId: newTraceId })
|
||||
]
|
||||
}
|
||||
});
|
||||
state = reduceWorkbenchServerState(state, { type: "turn.status", turn: { traceId: newTraceId, status: "running", running: true, terminal: false, sessionId, updatedAt: "2026-07-02T00:00:04.000Z" } });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.status", session: { sessionId, status: "running", lastTraceId: newTraceId, updatedAt: "2026-07-02T00:00:04.000Z" } });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.list", sessions: [sessionRecord({ sessionId, status: "completed", lastTraceId: oldTraceId, messages: [] })] });
|
||||
state = reduceWorkbenchServerState(state, { type: "session.detail", session: sessionRecord({ sessionId, status: "completed", lastTraceId: oldTraceId, messages: [] }) });
|
||||
state = reduceWorkbenchServerState(state, {
|
||||
type: "session.messages",
|
||||
sessionId,
|
||||
messages: [
|
||||
userMessage({ id: "msg_state_old_user", text: "one", sessionId, traceId: oldTraceId }),
|
||||
agentMessage({ id: "msg_state_old_agent", status: "completed", text: "one done", finalResponse: { text: "one done" }, sessionId, traceId: oldTraceId }),
|
||||
userMessage({ id: "msg_state_new_user", text: "two", sessionId, traceId: newTraceId }),
|
||||
agentMessage({ id: "msg_state_new_agent", status: "running", text: "", sessionId, traceId: newTraceId })
|
||||
]
|
||||
});
|
||||
|
||||
const authority = selectSessionStatusAuthority(state)[sessionId];
|
||||
assert.equal(authority?.status, "running");
|
||||
assert.equal(authority?.lastTraceId, newTraceId);
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ export function reduceWorkbenchServerState(state: WorkbenchServerState, action:
|
||||
case "session.cleanupDropped":
|
||||
return cleanupWorkbenchServerStateSessions(state, droppedSessionIds(state, new Set(action.keepSessionIds)));
|
||||
case "turn.status":
|
||||
return { ...state, turnStatusByTraceId: { ...state.turnStatusByTraceId, [action.turn.traceId]: mergeTurnStatusAuthority(state.turnStatusByTraceId[action.turn.traceId], action.turn) } };
|
||||
return reduceTurnStatus(state, action.turn);
|
||||
case "turn.forget":
|
||||
return { ...state, turnStatusByTraceId: withoutKey(state.turnStatusByTraceId, action.traceId) };
|
||||
case "trace.snapshot":
|
||||
@@ -93,11 +93,12 @@ export function selectTraceAuthorityById(state: WorkbenchServerState): Readonly<
|
||||
}
|
||||
|
||||
export function selectSessionList(state: WorkbenchServerState): WorkbenchSessionRecord[] {
|
||||
return state.sessionOrder.map((sessionId) => state.sessionsById[sessionId]).filter((session): session is WorkbenchSessionRecord => Boolean(session));
|
||||
return state.sessionOrder.map((sessionId) => projectSessionRecord(state.sessionsById[sessionId], state.sessionStatusById[sessionId])).filter((session): session is WorkbenchSessionRecord => Boolean(session));
|
||||
}
|
||||
|
||||
export function selectActiveSession(state: WorkbenchServerState, sessionId: string | null): WorkbenchSessionRecord | null {
|
||||
return sessionId ? state.sessionsById[sessionId] ?? null : null;
|
||||
if (!sessionId) return null;
|
||||
return projectSessionRecord(state.sessionsById[sessionId], state.sessionStatusById[sessionId]);
|
||||
}
|
||||
|
||||
export function selectActiveMessages(state: WorkbenchServerState, sessionId: string | null): ChatMessage[] {
|
||||
@@ -110,9 +111,8 @@ function reduceSessionDetail(state: WorkbenchServerState, session: WorkbenchSess
|
||||
const existing = state.sessionsById[sessionId];
|
||||
const existingMessages = state.messagesBySessionId[sessionId] ?? existing?.messages ?? [];
|
||||
const messages = Array.isArray(session.messages) ? mergeMessageList(existingMessages, session.messages) : existingMessages;
|
||||
const messageStatus = sessionStatusAuthorityFromMessages(sessionId, messages);
|
||||
const merged = mergeSessionRecord(existing, { ...session, status: messageStatus?.status ?? session.status, messages });
|
||||
const sessionStatus = messageStatus ?? sessionStatusAuthorityFromDetail(session);
|
||||
const merged = mergeSessionRecord(existing, { ...session, messages });
|
||||
const sessionStatus = sessionStatusAuthorityFromDetail(session);
|
||||
return {
|
||||
...state,
|
||||
sessionOrder: state.sessionOrder.includes(sessionId) ? state.sessionOrder : [sessionId, ...state.sessionOrder],
|
||||
@@ -139,9 +139,8 @@ function reduceSessionListItem(state: WorkbenchServerState, session: WorkbenchSe
|
||||
const existing = state.sessionsById[sessionId];
|
||||
const existingMessages = state.messagesBySessionId[sessionId] ?? existing?.messages;
|
||||
const messages = Array.isArray(existingMessages) ? existingMessages : Array.isArray(session.messages) ? session.messages : [];
|
||||
const messageStatus = sessionStatusAuthorityFromMessages(sessionId, messages);
|
||||
const merged = mergeSessionRecord(existing, { ...session, status: messageStatus?.status ?? session.status, messages });
|
||||
const sessionStatus = messageStatus ?? sessionStatusAuthorityFromDetail(session);
|
||||
const merged = mergeSessionRecord(existing, { ...session, messages });
|
||||
const sessionStatus = sessionStatusAuthorityFromDetail(session);
|
||||
return {
|
||||
...state,
|
||||
sessionsById: { ...state.sessionsById, [sessionId]: merged },
|
||||
@@ -156,18 +155,27 @@ function reduceSessionMessages(state: WorkbenchServerState, sessionId: string |
|
||||
const existing = state.sessionsById[sessionId];
|
||||
const existingMessages = state.messagesBySessionId[sessionId] ?? existing?.messages ?? [];
|
||||
const mergedMessages = mergeMessageList(existingMessages, messages);
|
||||
const reconciledStatus = sessionStatusAuthorityFromMessages(sessionId, mergedMessages);
|
||||
const session = existing ? { ...existing, status: reconciledStatus?.status ?? existing.status, messages: mergedMessages, messageCount: mergedMessages.length } : null;
|
||||
const session = existing ? { ...existing, messages: mergedMessages, messageCount: mergedMessages.length } : null;
|
||||
return {
|
||||
...state,
|
||||
sessionsById: session ? { ...state.sessionsById, [sessionId]: session } : state.sessionsById,
|
||||
sessionStatusById: reconciledStatus
|
||||
? { ...state.sessionStatusById, [sessionId]: mergeSessionStatusAuthority(state.sessionStatusById[sessionId], reconciledStatus) }
|
||||
: state.sessionStatusById,
|
||||
messagesBySessionId: { ...state.messagesBySessionId, [sessionId]: mergedMessages }
|
||||
};
|
||||
}
|
||||
|
||||
function reduceTurnStatus(state: WorkbenchServerState, turn: TurnStatusAuthority): WorkbenchServerState {
|
||||
const nextTurn = mergeTurnStatusAuthority(state.turnStatusByTraceId[turn.traceId], turn);
|
||||
const nextTurns = { ...state.turnStatusByTraceId, [turn.traceId]: nextTurn };
|
||||
const sessionStatus = sessionStatusAuthorityFromTurn(nextTurn);
|
||||
return {
|
||||
...state,
|
||||
turnStatusByTraceId: nextTurns,
|
||||
sessionStatusById: sessionStatus
|
||||
? { ...state.sessionStatusById, [sessionStatus.sessionId]: mergeSessionStatusAuthority(state.sessionStatusById[sessionStatus.sessionId], sessionStatus) }
|
||||
: state.sessionStatusById
|
||||
};
|
||||
}
|
||||
|
||||
function reduceMessageSnapshot(state: WorkbenchServerState, sessionId: string | null | undefined, message: ChatMessage | null | undefined): WorkbenchServerState {
|
||||
const id = sessionId || message?.sessionId;
|
||||
if (!id || !message) return state;
|
||||
@@ -386,6 +394,21 @@ function mergeSessionRecord(existing: WorkbenchSessionRecord | undefined, incomi
|
||||
};
|
||||
}
|
||||
|
||||
function projectSessionRecord(session: WorkbenchSessionRecord | null | undefined, authority: SessionStatusAuthority | null | undefined): WorkbenchSessionRecord | null {
|
||||
if (!session) return null;
|
||||
if (!authority?.status || normalizedMessageStatus(session.status) === "archived") return session;
|
||||
const projection = authority.projection ?? session.projection;
|
||||
return {
|
||||
...session,
|
||||
status: authority.status,
|
||||
updatedAt: textValue(authority.updatedAt) ?? session.updatedAt,
|
||||
lastTraceId: textValue(authority.lastTraceId) ?? session.lastTraceId,
|
||||
projection,
|
||||
projectionHealth: authority.projection?.projectionHealth ?? session.projectionHealth,
|
||||
projectionStatus: authority.projection?.projectionStatus ?? session.projectionStatus
|
||||
};
|
||||
}
|
||||
|
||||
function sessionStatusAuthorityFromDetail(session: WorkbenchSessionRecord): SessionStatusAuthority | null {
|
||||
const sessionId = session.sessionId;
|
||||
if (!sessionId || typeof session.status !== "string") return null;
|
||||
@@ -399,22 +422,24 @@ function sessionStatusAuthorityFromDetail(session: WorkbenchSessionRecord): Sess
|
||||
};
|
||||
}
|
||||
|
||||
function sessionStatusAuthorityFromMessages(sessionId: string, messages: ChatMessage[]): SessionStatusAuthority | null {
|
||||
const message = [...messages].reverse().find((item) => messageHasTerminalResponse(item));
|
||||
if (!message) return null;
|
||||
const status = canonicalTerminalMessageStatus(message.status);
|
||||
function sessionStatusAuthorityFromTurn(turn: TurnStatusAuthority): SessionStatusAuthority | null {
|
||||
const sessionId = textValue(turn.sessionId);
|
||||
if (!sessionId) return null;
|
||||
const status = textValue(turn.status) ?? (turn.running === true ? "running" : null);
|
||||
if (!status) return null;
|
||||
return {
|
||||
sessionId,
|
||||
status,
|
||||
updatedAt: textValue(message.updatedAt) ?? textValue(message.finishedAt) ?? textValue(message.lastEventAt),
|
||||
lastTraceId: textValue(message.traceId) ?? textValue(message.runnerTrace?.traceId),
|
||||
projection: projectionFromMessageRecord(message),
|
||||
loadedAt: new Date().toISOString()
|
||||
updatedAt: textValue(turn.updatedAt),
|
||||
lastTraceId: textValue(turn.traceId),
|
||||
projection: turn.projection ?? null,
|
||||
loadedAt: turn.loadedAt ?? new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function mergeSessionStatusAuthority(existing: SessionStatusAuthority | undefined, incoming: SessionStatusAuthority): SessionStatusAuthority {
|
||||
if (isTerminalSessionStatusAuthority(existing) && !isTerminalSessionStatusAuthority(incoming) && isSameTraceAuthority(existing, incoming) && isRunningSessionStatus(incoming.status)) return existing;
|
||||
if (isRunningSessionStatus(existing?.status) && isTerminalSessionStatusAuthority(incoming) && isDifferentTraceAuthority(existing, incoming)) return existing;
|
||||
return {
|
||||
...(existing ?? {}),
|
||||
...incoming,
|
||||
@@ -452,6 +477,12 @@ function isSameTraceAuthority(left: SessionStatusAuthority, right: SessionStatus
|
||||
return Boolean(leftTrace && rightTrace && leftTrace === rightTrace);
|
||||
}
|
||||
|
||||
function isDifferentTraceAuthority(left: SessionStatusAuthority | undefined, right: SessionStatusAuthority | undefined): boolean {
|
||||
const leftTrace = textValue(left?.lastTraceId);
|
||||
const rightTrace = textValue(right?.lastTraceId);
|
||||
return Boolean(leftTrace && rightTrace && leftTrace !== rightTrace);
|
||||
}
|
||||
|
||||
function isRunningSessionStatus(value: unknown): boolean {
|
||||
return ["", "pending", "running", "accepted", "queued", "dispatching", "streaming", "processing", "retrying", "busy", "creating"].includes(normalizedMessageStatus(value));
|
||||
}
|
||||
@@ -463,23 +494,10 @@ function messageHasTerminalResponse(message: ChatMessage): boolean {
|
||||
return Boolean(messageFinalResponseText(message));
|
||||
}
|
||||
|
||||
function canonicalTerminalMessageStatus(value: unknown): string {
|
||||
const status = normalizedMessageStatus(value);
|
||||
return status === "cancelled" ? "canceled" : status;
|
||||
}
|
||||
|
||||
function messageFinalResponseText(message: ChatMessage): string | null {
|
||||
return textValue(message.text) ?? textValue(message.content) ?? nestedTextValue((message as Record<string, unknown>).finalResponse);
|
||||
}
|
||||
|
||||
function projectionFromMessageRecord(message: ChatMessage): ProjectionDiagnostic | null {
|
||||
const direct = message.projection && typeof message.projection === "object" ? message.projection : null;
|
||||
if (direct) return direct;
|
||||
const trace = message.runnerTrace && typeof message.runnerTrace === "object" ? message.runnerTrace as Record<string, unknown> : null;
|
||||
const traceProjection = trace?.projection && typeof trace.projection === "object" ? trace.projection as ProjectionDiagnostic : null;
|
||||
return traceProjection ?? null;
|
||||
}
|
||||
|
||||
function nestedTextValue(value: unknown): string | null {
|
||||
const direct = textValue(value);
|
||||
if (direct) return direct;
|
||||
|
||||
@@ -1462,7 +1462,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
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: 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) => {
|
||||
if (!shouldApplyTraceToMessage(item, traceId, sessionId)) return item;
|
||||
@@ -1556,10 +1555,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
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, {
|
||||
@@ -1579,7 +1574,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
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 } });
|
||||
reduceServerState({ type: "session.status", session: { sessionId: input.sessionId, status: "running", updatedAt: now, lastTraceId: input.traceId, loadedAt: now } });
|
||||
const existing = sessions.value.find((session) => session.sessionId === input.sessionId) ?? null;
|
||||
rememberSessionList(mergeSessionIntoList(sessions.value, {
|
||||
...(existing ?? {}),
|
||||
|
||||
Reference in New Issue
Block a user