Merge pull request #1514 from pikasTech/fix/issue-1505-p4-session-summary

fix: project Workbench status from current turn
This commit is contained in:
Lyon
2026-06-18 20:16:26 +08:00
committed by GitHub
6 changed files with 150 additions and 63 deletions
+79 -4
View File
@@ -293,7 +293,8 @@ test("workbench read model does not expose trace-only memory without visible ses
}
});
test("workbench read model treats active session state as running", async () => {
test("workbench read model projects current turn running state from trace projection", async () => {
const traceStore = createCodeAgentTraceStore();
const traceId = "trc_workbench_active_state";
const session = {
id: "ses_workbench_active_state",
@@ -307,6 +308,7 @@ test("workbench read model treats active session state as running", async () =>
updatedAt: "2026-06-17T00:01:00.000Z",
session: { sessionStatus: "active", lastTraceId: traceId, messages: [{ role: "user", text: "still running", traceId }] }
};
traceStore.append(traceId, { type: "backend", status: "running", label: "runner:created", terminal: false });
const accessController = {
store: {
async listAgentSessionsForUser() { return [session]; }
@@ -314,14 +316,14 @@ test("workbench read model treats active session state as running", async () =>
async ensureBootstrap() {},
async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; }
};
const server = createCloudApiServer({ accessController, codeAgentChatResults: createCodeAgentChatResultStore() });
const server = createCloudApiServer({ accessController, traceStore, codeAgentChatResults: createCodeAgentChatResultStore() });
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const sessions = await getJson(port, "/v1/workbench/sessions");
assert.equal(sessions.status, 200);
assert.equal(sessions.body.sessions[0].status, "active");
assert.equal(sessions.body.sessions[0].status, "running");
assert.equal(sessions.body.sessions[0].running, true);
assert.equal(sessions.body.sessions[0].terminal, false);
} finally {
@@ -329,7 +331,7 @@ test("workbench read model treats active session state as running", async () =>
}
});
test("workbench read model lets running trace override idle session summary", async () => {
test("workbench read model projects current turn running state for idle session summary", async () => {
const traceStore = createCodeAgentTraceStore();
const traceId = "trc_workbench_idle_with_running_trace";
const session = {
@@ -368,6 +370,79 @@ test("workbench read model lets running trace override idle session summary", as
}
});
test("workbench read model projects completed current turn for idle session summary", async () => {
const traceStore = createCodeAgentTraceStore();
const results = createCodeAgentChatResultStore();
const traceId = "trc_workbench_idle_with_completed_trace";
const finalText = "OK";
const session = {
id: "ses_workbench_idle_with_completed_trace",
projectId: "prj_hwpod_workbench",
agentId: "hwlab-code-agent",
status: "idle",
ownerUserId: ACTOR.id,
conversationId: "cnv_workbench_idle_with_completed_trace",
threadId: "thread-workbench-idle-with-completed-trace",
lastTraceId: traceId,
updatedAt: "2026-06-17T00:03:00.000Z",
session: {
sessionStatus: "idle",
lastTraceId: traceId,
messages: [
{ role: "user", text: "reply OK", traceId, status: "sent" },
{ role: "agent", text: "", traceId, status: "running" }
]
}
};
traceStore.append(traceId, { type: "request", status: "accepted", label: "request:accepted" });
traceStore.append(traceId, { type: "result", status: "completed", label: "result:completed", terminal: true });
results.set(traceId, {
status: "completed",
traceId,
ownerUserId: ACTOR.id,
conversationId: session.conversationId,
sessionId: session.id,
threadId: session.threadId,
finalResponse: finalText,
agentRun: { runId: "run_workbench_idle_completed", commandId: "cmd_workbench_idle_completed", status: "completed" }
});
const accessController = {
store: {
async listAgentSessionsForUser() { return [session]; },
async getAgentSession(sessionId) { return sessionId === session.id ? session : null; },
async getAgentSessionByTraceId(requestTraceId) { return requestTraceId === traceId ? session : null; }
},
async ensureBootstrap() {},
async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; }
};
const server = createCloudApiServer({ accessController, traceStore, codeAgentChatResults: results });
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const sessions = await getJson(port, `/v1/workbench/sessions?includeSessionId=${encodeURIComponent(session.id)}`);
assert.equal(sessions.status, 200);
assert.equal(sessions.body.sessions[0].status, "completed");
assert.equal(sessions.body.sessions[0].running, false);
assert.equal(sessions.body.sessions[0].terminal, true);
assert.equal(sessions.body.sessions[0].turnSummary.status, "completed");
const messages = await getJson(port, `/v1/workbench/sessions/${encodeURIComponent(session.id)}/messages?limit=10`);
assert.equal(messages.status, 200);
assert.equal(messages.body.messages[1].status, "completed");
assert.equal(messages.body.messages[1].text, finalText);
const turn = await getJson(port, `/v1/workbench/turns/${encodeURIComponent(traceId)}`);
assert.equal(turn.status, 200);
assert.equal(turn.body.turn.status, "completed");
assert.equal(turn.body.turn.running, false);
assert.equal(turn.body.turn.terminal, true);
assert.equal(turn.body.turn.assistantText, finalText);
} finally {
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
test("workbench realtime stream accepts session authority without project or workspace", async () => {
const traceStore = createCodeAgentTraceStore();
const results = createCodeAgentChatResultStore();
+45 -39
View File
@@ -277,7 +277,7 @@ async function handleWorkbenchTurnSnapshot(response, url, options, actor, rawTur
}
const resultVisible = result && (session || actor.role === "admin" || Boolean(result.ownerUserId));
const trace = await traceSnapshot(options, traceId);
const status = turnStatus(result, session, trace);
const status = workbenchTurnProjectionStatus(trace);
const found = Boolean(resultVisible || session);
if (!found) return sendJson(response, 404, workbenchError("workbench_turn_not_found", "Workbench turn is not visible to the current actor.", { turnId, traceId }));
sendJson(response, 200, {
@@ -345,10 +345,9 @@ async function visibleSessionByTrace(store, traceId, actor) {
function sessionSummary(session, options) {
if (!session?.id) return null;
const snapshot = objectValue(session.session);
const traceId = safeTraceId(session.lastTraceId ?? snapshot.lastTraceId ?? snapshot.currentTraceId) ?? null;
const trace = traceId ? traceSnapshotSync(options, traceId) : null;
const messages = sessionMessages(session, options);
const status = canonicalWorkbenchStatus(snapshot.sessionStatus ?? session.status, trace?.status);
const currentTurn = currentWorkbenchTurnProjection(session, options);
const messages = sessionMessages(session, { ...options, currentTurn });
const status = currentTurn ? currentTurn.status : sessionLifecycleProjectionStatus(session);
return {
sessionId: session.id,
threadId: safeOpaqueId(session.threadId) ?? (textValue(session.threadId) || null),
@@ -356,19 +355,19 @@ function sessionSummary(session, options) {
status,
running: RUNNING_STATUSES.has(status),
terminal: TERMINAL_STATUSES.has(status) && !RUNNING_STATUSES.has(status),
lastTraceId: traceId,
lastTraceId: currentTurn ? currentTurn.traceId : null,
providerProfile: textValue(snapshot.providerProfile) || null,
messageCount: messages.length,
firstUserMessagePreview: firstUserPreview(messages),
updatedAt: session.updatedAt ?? snapshot.updatedAt ?? null,
turnSummary: traceId ? {
turnId: traceId,
traceId,
turnSummary: currentTurn ? {
turnId: currentTurn.turnId,
traceId: currentTurn.traceId,
status,
running: RUNNING_STATUSES.has(status),
terminal: TERMINAL_STATUSES.has(status) && !RUNNING_STATUSES.has(status),
eventCount: trace?.eventCount ?? null,
updatedAt: trace?.updatedAt ?? session.updatedAt ?? null
eventCount: currentTurn.eventCount,
updatedAt: currentTurn.updatedAt
} : null,
valuesRedacted: true
};
@@ -391,8 +390,9 @@ function sessionDetail(session, options) {
function sessionMessages(session, options = {}) {
const snapshot = objectValue(session.session);
const raw = Array.isArray(snapshot.messages) ? snapshot.messages : Array.isArray(snapshot.chatMessages) ? snapshot.chatMessages : [];
const finalTraceId = safeTraceId(session.lastTraceId ?? snapshot.lastTraceId ?? snapshot.currentTraceId) ?? null;
const terminalProjection = terminalMessageProjection(session, snapshot, options, finalTraceId);
const currentTurn = options.currentTurn ? options.currentTurn : currentWorkbenchTurnProjection(session, options);
const finalTraceId = currentTurn ? currentTurn.traceId : null;
const terminalProjection = terminalMessageProjection(snapshot, options, currentTurn);
const messages = raw
.map((message, index) => messageFact(message, index, session, snapshot))
.filter(Boolean)
@@ -400,22 +400,22 @@ function sessionMessages(session, options = {}) {
const hasAssistantLikeFinal = messages.some((message) => isAssistantLikeRole(message.role) && (!finalTraceId || message.traceId === finalTraceId));
const finalText = terminalProjection?.text ?? projectionText(snapshot.finalResponse);
if (!hasAssistantLikeFinal && finalText) {
messages.push(messageFact({ role: "assistant", text: finalText, status: terminalProjection?.status ?? session.status, traceId: finalTraceId, source: terminalProjection?.source ?? "finalResponse" }, messages.length, session, snapshot));
const finalMessageStatus = terminalProjection ? terminalProjection.status : sessionLifecycleProjectionStatus(session);
const finalMessageSource = terminalProjection ? terminalProjection.source : "finalResponse";
messages.push(messageFact({ role: "assistant", text: finalText, status: finalMessageStatus, traceId: finalTraceId, source: finalMessageSource }, messages.length, session, snapshot));
}
return messages;
}
function terminalMessageProjection(session, snapshot, options, traceId) {
if (!traceId) return null;
function terminalMessageProjection(snapshot, options, currentTurn) {
if (!currentTurn) return null;
const traceId = currentTurn.traceId;
const result = options.result ?? options.codeAgentChatResults?.get?.(traceId) ?? null;
const trace = options.trace ?? traceSnapshotSync(options, traceId);
const status = canonicalWorkbenchStatus(
result?.status ?? result?.agentRun?.terminalStatus ?? result?.agentRun?.commandState ?? result?.agentRun?.status ?? session?.status ?? snapshot.sessionStatus,
trace?.status
);
const trace = currentTurn.trace;
const status = currentTurn.status;
if (!TERMINAL_STATUSES.has(status) || RUNNING_STATUSES.has(status)) return null;
const text = projectionText(result?.finalResponse, result?.assistantText, result?.reply, result?.text, result?.summary, trace?.finalResponse, trace?.terminalEvidence?.finalResponse, snapshot.finalResponse);
return { traceId, status, text, source: result ? "turn-result" : trace?.status && trace.status !== "missing" ? "trace-projection" : "session-snapshot" };
return { traceId, status, text, source: "turn-projection" };
}
function applyTerminalMessageProjection(message, projection) {
@@ -656,7 +656,7 @@ async function visibleTraceContext(options, actor, traceId) {
const resultVisible = result && (session || actor.role === "admin" || Boolean(result.ownerUserId));
if (!resultVisible && !session) return { visible: false };
const trace = await traceSnapshot(options, traceId);
const status = turnStatus(result, session, trace);
const status = workbenchTurnProjectionStatus(trace);
return { visible: true, turnId: traceId, traceId, status, result, session, trace };
}
@@ -689,24 +689,30 @@ function traceSnapshotLastSeq(snapshot) {
return events.reduce((max, event, index) => Math.max(max, eventSeq(event, index)), 0);
}
function turnStatus(result, session, trace) {
return canonicalWorkbenchStatus(
result?.status ??
result?.agentRun?.terminalStatus ??
result?.agentRun?.commandState ??
result?.agentRun?.status ??
session?.status ??
objectValue(session?.session).sessionStatus,
trace?.status
);
function currentWorkbenchTurnProjection(session, options = {}) {
const traceId = safeTraceId(session?.lastTraceId) ?? null;
if (!traceId) return null;
const trace = options.trace?.traceId === traceId ? options.trace : traceSnapshotSync(options, traceId);
const status = workbenchTurnProjectionStatus(trace);
return {
turnId: traceId,
traceId,
status,
trace,
eventCount: Number.isFinite(Number(trace?.eventCount)) ? Number(trace.eventCount) : null,
updatedAt: trace?.updatedAt ?? session?.updatedAt ?? null
};
}
function canonicalWorkbenchStatus(primary, traceStatus) {
const primaryStatus = normalizeStatus(primary);
const trace = normalizeStatus(traceStatus);
if (RUNNING_STATUSES.has(trace) && (primaryStatus === "idle" || primaryStatus === "unknown" || !TERMINAL_STATUSES.has(primaryStatus))) return trace;
if (TERMINAL_STATUSES.has(trace) && (!TERMINAL_STATUSES.has(primaryStatus) || RUNNING_STATUSES.has(primaryStatus))) return trace;
return primaryStatus || trace || "unknown";
function workbenchTurnProjectionStatus(trace) {
const status = normalizeStatus(trace?.status);
if (status === "missing") return "unknown";
if (RUNNING_STATUSES.has(status) || TERMINAL_STATUSES.has(status)) return status;
return "unknown";
}
function sessionLifecycleProjectionStatus(session) {
return normalizeStatus(session?.status);
}
function canActorReadSession(session, actor) {
@@ -10,7 +10,7 @@ test("workbench navigation context recognizes workbench routes only while the co
assert.equal(isWorkbenchNavigationContext({ componentActive: false, routeSection: "workbench", routeName: "CodeWorkbenchSession", routePath: "/workbench/sessions/ses_1" }), false);
});
test("workbench URL reflection never overrides a user navigation outside Workbench", () => {
test("workbench URL reflection never rewrites a user navigation outside Workbench", () => {
assert.equal(shouldReflectWorkbenchSessionUrl({ componentActive: true, routeSection: "user", routeName: "Dashboard", routePath: "/dashboard", routeSessionId: null, sessionId: "ses_async" }), false);
assert.equal(shouldReflectWorkbenchSessionUrl({ componentActive: false, routeSection: "workbench", routeName: "CodeWorkbench", routePath: "/workbench", routeSessionId: null, sessionId: "ses_async" }), false);
});
@@ -4,7 +4,7 @@ import test from "node:test";
import type { WorkbenchSessionRecord } from "../src/types/index.ts";
import { initialWorkbenchSessionIdFromLocation } from "../src/stores/workbench-projection.ts";
import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectActiveSession, selectTraceAuthorityById } from "../src/stores/workbench-server-state.ts";
import { stableSessionList } from "../src/stores/workbench-session.ts";
import { sessionToSessionTab, stableSessionList } from "../src/stores/workbench-session.ts";
test("Workbench route parsing accepts only session routes", () => {
assert.equal(initialWorkbenchSessionIdFromLocation({ pathname: "/workbench/sessions/ses_route" }), "ses_route");
@@ -28,7 +28,7 @@ test("Workbench server-state is keyed only by session and trace", () => {
assert.equal(selectTraceAuthorityById(state).trc_route?.traceId, "trc_route");
});
test("Workbench session list keeps selected detail but lets server summary override stale running status", () => {
test("Workbench session list accepts server projection while keeping selected messages", () => {
const selected: WorkbenchSessionRecord = {
sessionId: "ses_live",
threadId: "thr_live",
@@ -56,3 +56,22 @@ test("Workbench session list keeps selected detail but lets server summary overr
assert.equal(next[0]?.updatedAt, "2026-06-18T00:00:03.000Z");
assert.equal(next[0]?.messages?.length, 2);
});
test("Workbench session tab status is read from server projection", () => {
const session: WorkbenchSessionRecord = {
sessionId: "ses_tab_projection",
threadId: "thr_tab_projection",
status: "running",
lastTraceId: "trc_tab_projection",
messageCount: 2,
messages: [
{ id: "msg_tab_user", messageId: "msg_tab_user", role: "user", title: "用户", text: "ping", status: "sent", createdAt: "2026-06-18T00:00:00.000Z", sessionId: "ses_tab_projection", traceId: "trc_tab_projection" },
{ id: "msg_tab_agent", messageId: "msg_tab_agent", role: "agent", title: "Code Agent", text: "OK", status: "completed", createdAt: "2026-06-18T00:00:01.000Z", sessionId: "ses_tab_projection", traceId: "trc_tab_projection" }
]
};
const tab = sessionToSessionTab(session, "ses_tab_projection");
assert.equal(tab.status, "running");
assert.equal(tab.running, true);
});
@@ -252,21 +252,8 @@ function latestAgentMessage(messages: ChatMessage[] | undefined): ChatMessage |
function resolveSessionTabStatus(session: WorkbenchSessionRecord, authority: SessionStatusAuthority | null | undefined): string {
const authorityStatus = normalizeSessionStatus(authority?.status);
const terminalStatus = terminalStatusFromSession(session);
const sessionStatus = normalizeSessionStatus(session.status);
if (authorityStatus && isActiveStatus(authorityStatus) && terminalStatus) return terminalStatus;
if (sessionStatus && isActiveStatus(sessionStatus) && terminalStatus) return terminalStatus;
return authorityStatus ?? terminalStatus ?? sessionStatus ?? "unknown";
}
function terminalStatusFromSession(session: WorkbenchSessionRecord): string | null {
const agentMessage = latestAgentMessage(session.messages);
const candidates = [session.status, agentMessage?.status, agentMessage?.runnerTrace?.status];
for (const candidate of candidates) {
const status = normalizeSessionStatus(candidate);
if (status && isTerminalStatus(status)) return status;
}
return null;
if (authorityStatus) return authorityStatus;
return normalizeSessionStatus(session.status) ?? "unknown";
}
function messageBelongsToCancelTarget(message: ChatMessage, input: { targetSessionId?: string | null; targetThreadId?: string | null }): boolean {
+2 -2
View File
@@ -1015,9 +1015,9 @@ function activeTraceIdFromMessages(messages: ChatMessage[], turnStatusAuthority:
return null;
}
function realtimeSnapshotToTraceSnapshot(traceId: string, snapshot: WorkbenchRealtimeEvent["snapshot"], overrideEvents?: TraceEvent[]): TraceSnapshot {
function realtimeSnapshotToTraceSnapshot(traceId: string, snapshot: WorkbenchRealtimeEvent["snapshot"], eventPageEvents?: TraceEvent[]): TraceSnapshot {
const source = snapshot ?? { traceId };
const events = Array.isArray(overrideEvents) ? overrideEvents : Array.isArray(source.events) ? source.events : [];
const events = Array.isArray(eventPageEvents) ? eventPageEvents : Array.isArray(source.events) ? source.events : [];
return {
...source,
traceId: firstNonEmptyString(source.traceId, traceId) ?? traceId,