Merge pull request #1514 from pikasTech/fix/issue-1505-p4-session-summary
fix: project Workbench status from current turn
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user