feat(compound-engineering): manage and work across multiple CE sessions
The store/orchestrator were already multi-session (independent rows + live
handles per session); this surfaces it end to end:
- Sessions panel in the dashboard view: lists every session with stage,
status badge ("needs your input" for awaiting_input), and last activity;
stays visible while a flow is open so switching is one click. Closing a
flow returns to the overview without stopping the session.
- useCeSession.open(): adopt an existing session (pins its projectId for
answer/resume/poll); useCeSessions list hook with push-event refresh and
poll fallback while any session is mid-turn.
- DELETE /sessions/:id + orchestrator.discard(): dispose the live handle
before deleting the row (pipeline-link rows kept for task provenance);
Discard affordance on settled sessions.
- Tests: cross-session independence through one orchestrator, store delete,
route list/delete, hook open/list/remove/push/poll, view panel
open/switch/discard. 116 tests green; plugin + dashboard tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,25 @@ progress and emits an observable event — never silent loss** — and an
|
||||
`interrupted`/`error` session can be resumed/retried back to its current
|
||||
question.
|
||||
|
||||
### Multiple sessions
|
||||
|
||||
Sessions are independent pipeline runs — the store, routes, and orchestrator
|
||||
all hold many at once (each with its own live agent handle). The dashboard's
|
||||
**Sessions panel** lists every session with its stage, status, and last
|
||||
activity; from there you can:
|
||||
|
||||
- **open** any session and keep working on it (an `awaiting_input` session is
|
||||
flagged "needs your input"),
|
||||
- **switch** between sessions — the panel stays visible while a flow is open,
|
||||
and a session you switch away from keeps running server-side,
|
||||
- **resume** an `interrupted`/`error` session from where it stopped,
|
||||
- **discard** a settled (completed/error/interrupted) session via
|
||||
`DELETE /sessions/:id`, which disposes any live handle before deleting the
|
||||
row (pipeline-link rows are kept — board-task provenance survives).
|
||||
|
||||
The list refreshes on any CE push event and falls back to polling
|
||||
`GET /sessions` while any session has a turn in flight.
|
||||
|
||||
### Transport
|
||||
|
||||
Session updates are **pushed** over the shared `/api/events` SSE stream. The
|
||||
|
||||
@@ -76,6 +76,61 @@ describe("orchestrator happy path", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple concurrent sessions", () => {
|
||||
it("drives two independent sessions through the SAME orchestrator without cross-talk", async () => {
|
||||
// Two scripted live sessions; the factory hands them out in creation order.
|
||||
const liveA = makeScriptedSession([
|
||||
{ type: "question", data: QUESTION },
|
||||
{ type: "complete", data: { artifact: "# A\n" } },
|
||||
]);
|
||||
const liveB = makeScriptedSession([
|
||||
{ type: "question", data: { ...QUESTION, id: "q-b" } },
|
||||
{ type: "complete", data: { artifact: "# B\n" } },
|
||||
]);
|
||||
const handles = [liveA, liveB];
|
||||
const orch = new CeOrchestrator({
|
||||
ctx: h.ctx,
|
||||
createInteractiveAiSession: vi.fn(async () => ({ session: handles.shift()! })),
|
||||
projectRoot: h.projectRoot,
|
||||
turnTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const a = await orch.start("brainstorm", { openingMessage: "topic A" });
|
||||
const b = await orch.start("brainstorm", { openingMessage: "topic B" });
|
||||
expect(a.session.id).not.toBe(b.session.id);
|
||||
expect(a.session.status).toBe("awaiting_input");
|
||||
expect(b.session.status).toBe("awaiting_input");
|
||||
|
||||
// Answer B first — A must stay awaiting, untouched.
|
||||
const doneB = await orch.answer(b.session.id, "q-b", "bee");
|
||||
expect(doneB.session.status).toBe("completed");
|
||||
expect(orch.getState(a.session.id)?.status).toBe("awaiting_input");
|
||||
|
||||
// A is still answerable on ITS live handle (not B's).
|
||||
const doneA = await orch.answer(a.session.id, "q1", "ay");
|
||||
expect(doneA.session.status).toBe("completed");
|
||||
expect(liveA.answer).toHaveBeenCalledTimes(1);
|
||||
expect(liveB.answer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("discard disposes the live handle and deletes only that session", async () => {
|
||||
const live = makeScriptedSession([{ type: "question", data: QUESTION }]);
|
||||
const orch = new CeOrchestrator({
|
||||
ctx: h.ctx,
|
||||
createInteractiveAiSession: vi.fn(async () => ({ session: live })),
|
||||
projectRoot: h.projectRoot,
|
||||
turnTimeoutMs: 5000,
|
||||
});
|
||||
const started = await orch.start("brainstorm", { openingMessage: "topic" });
|
||||
|
||||
expect(orch.discard(started.session.id)).toBe(true);
|
||||
expect(live.dispose).toHaveBeenCalled();
|
||||
expect(orch.getState(started.session.id)).toBeUndefined();
|
||||
// Idempotent-ish: a second discard reports false, no throw.
|
||||
expect(orch.discard(started.session.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("orchestrator error + retry", () => {
|
||||
it("agent error → status error, progress preserved, observable event; retry resumes to the question", async () => {
|
||||
const orch = makeOrch([
|
||||
|
||||
@@ -40,10 +40,38 @@ describe("session routes (polling transport)", () => {
|
||||
"POST /sessions/:id/resume",
|
||||
"GET /sessions/:id",
|
||||
"GET /sessions",
|
||||
"DELETE /sessions/:id",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("DELETE /sessions/:id discards a session (404 for unknown, gone afterwards, others kept)", async () => {
|
||||
const { getCeSessionStore } = await import("../session/session-store.js");
|
||||
const store = getCeSessionStore(h.ctx);
|
||||
const keep = store.create({ stage: "brainstorm" });
|
||||
const drop = store.create({ stage: "plan" });
|
||||
|
||||
const missing = await call("DELETE", "/sessions/:id", { params: { id: "nope" } }, h.ctx);
|
||||
expect(missing.status).toBe(404);
|
||||
|
||||
const deleted = await call("DELETE", "/sessions/:id", { params: { id: drop.id } }, h.ctx);
|
||||
expect(deleted.status).toBe(200);
|
||||
expect(store.get(drop.id)).toBeUndefined();
|
||||
expect(store.get(keep.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it("GET /sessions lists every session so a client can manage multiple concurrently", async () => {
|
||||
const { getCeSessionStore } = await import("../session/session-store.js");
|
||||
const store = getCeSessionStore(h.ctx);
|
||||
store.create({ stage: "brainstorm" });
|
||||
store.create({ stage: "plan" });
|
||||
|
||||
const res = await call("GET", "/sessions", { params: {}, query: {} }, h.ctx);
|
||||
expect(res.status).toBe(200);
|
||||
const sessions = (res.body as { sessions: Array<{ stage: string }> }).sessions;
|
||||
expect(sessions.map((s) => s.stage).sort()).toEqual(["brainstorm", "plan"]);
|
||||
});
|
||||
|
||||
it("POST /sessions requires a stage", async () => {
|
||||
const res = await call("POST", "/sessions", { body: {} }, h.ctx);
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
@@ -51,6 +51,23 @@ describe("CeSessionStore CRUD + JSON round-trip", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi-session independence + delete", () => {
|
||||
it("holds many independent sessions; deleting one leaves the others untouched", () => {
|
||||
const store = new CeSessionStore(h.db);
|
||||
const a = store.create({ stage: "brainstorm", projectId: "p1" });
|
||||
const b = store.create({ stage: "plan", projectId: "p1" });
|
||||
const c = store.create({ stage: "work" });
|
||||
expect(store.list()).toHaveLength(3);
|
||||
|
||||
expect(store.delete(b.id)).toBe(true);
|
||||
expect(store.get(b.id)).toBeUndefined();
|
||||
expect(store.get(a.id)).toBeDefined();
|
||||
expect(store.get(c.id)).toBeDefined();
|
||||
// Deleting a missing row reports false, no throw.
|
||||
expect(store.delete(b.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interval-relative staleness (FN-4172 rubric)", () => {
|
||||
it("does NOT misclassify a healthy-but-slow session as stale", () => {
|
||||
const store = new CeSessionStore(h.db);
|
||||
|
||||
@@ -303,3 +303,68 @@
|
||||
margin: 0 0 0.4rem;
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
|
||||
/* Sessions panel — manage/switch across multiple concurrent CE sessions. */
|
||||
.ce-sessions {
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
.ce-sessions-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.ce-session-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ce-session-row.is-active .ce-session-open {
|
||||
border-color: var(--color-accent, #36c);
|
||||
background: color-mix(in srgb, var(--color-accent, #36c) 8%, transparent);
|
||||
}
|
||||
.ce-session-open {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
text-align: left;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ce-session-open:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.ce-session-stage {
|
||||
font-weight: 600;
|
||||
}
|
||||
.ce-session-status {
|
||||
font-size: 0.74rem;
|
||||
text-transform: capitalize;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.ce-session-status-awaiting_input {
|
||||
color: var(--color-warning, #a60);
|
||||
font-weight: 600;
|
||||
opacity: 1;
|
||||
}
|
||||
.ce-session-status-error,
|
||||
.ce-session-status-interrupted {
|
||||
color: var(--color-danger, #d23);
|
||||
opacity: 1;
|
||||
}
|
||||
.ce-session-status-completed {
|
||||
color: var(--color-success, #2a7);
|
||||
opacity: 1;
|
||||
}
|
||||
.ce-session-updated {
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/t
|
||||
import { useArtifacts } from "./hooks/useArtifacts.js";
|
||||
import { useViewportMode } from "./hooks/useViewportMode.js";
|
||||
import { useCeSession, type CeSessionSubscribe } from "./hooks/useCeSession.js";
|
||||
import { useCeSessions, type CeSessionsSubscribe } from "./hooks/useCeSessions.js";
|
||||
import { getArtifactPreviewUrl } from "./hooks/api.js";
|
||||
import { CeFlow } from "./CeFlow.js";
|
||||
import { listStages, type CeStageDefinition } from "../session/stage-registry.js";
|
||||
import { getStage, listStages, type CeStageDefinition } from "../session/stage-registry.js";
|
||||
import type { CeArtifactEntry, CeArtifactGroup } from "../artifacts/discovery.js";
|
||||
import type { CeSession, CeSessionStatus } from "../session/session-store.js";
|
||||
|
||||
const CE_PLUGIN_ID = "fusion-plugin-compound-engineering";
|
||||
|
||||
@@ -56,6 +58,82 @@ function StageLauncher({
|
||||
);
|
||||
}
|
||||
|
||||
/** Statuses that are settled (no agent turn in flight). */
|
||||
const TERMINAL: ReadonlySet<CeSessionStatus> = new Set(["completed", "error", "interrupted"]);
|
||||
|
||||
function statusLabel(status: CeSessionStatus): string {
|
||||
return status.replace("_", " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions panel: every CE session (each an independent pipeline run) with its
|
||||
* stage, status, and last activity — open any to keep working on it, discard
|
||||
* settled ones. Sessions keep running server-side while not open here.
|
||||
*/
|
||||
function SessionsPanel({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
disabled,
|
||||
onOpen,
|
||||
onDiscard,
|
||||
}: {
|
||||
sessions: CeSession[];
|
||||
activeSessionId?: string;
|
||||
disabled: boolean;
|
||||
onOpen: (session: CeSession) => void;
|
||||
onDiscard: (session: CeSession) => void;
|
||||
}) {
|
||||
if (sessions.length === 0) return null;
|
||||
return (
|
||||
<section className="ce-sessions card" data-testid="ce-sessions">
|
||||
<header className="ce-group-header">
|
||||
<h3>Sessions</h3>
|
||||
<span className="ce-group-count">{sessions.length}</span>
|
||||
</header>
|
||||
<ul className="ce-sessions-list">
|
||||
{sessions.map((s) => {
|
||||
const stageLabel = getStage(s.stage)?.label ?? s.stage;
|
||||
const awaiting = s.status === "awaiting_input";
|
||||
return (
|
||||
<li
|
||||
key={s.id}
|
||||
className={`ce-session-row${s.id === activeSessionId ? " is-active" : ""}`}
|
||||
data-testid="ce-session-row"
|
||||
data-session={s.id}
|
||||
data-status={s.status}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="ce-session-open"
|
||||
data-testid="ce-session-open"
|
||||
disabled={disabled}
|
||||
onClick={() => onOpen(s)}
|
||||
>
|
||||
<span className="ce-session-stage">{stageLabel}</span>
|
||||
<span className={`ce-session-status ce-session-status-${s.status}`} data-testid="ce-session-status">
|
||||
{awaiting ? "needs your input" : statusLabel(s.status)}
|
||||
</span>
|
||||
<span className="ce-session-updated">{new Date(s.updatedAt).toLocaleString()}</span>
|
||||
</button>
|
||||
{TERMINAL.has(s.status) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ce-session-discard"
|
||||
data-testid="ce-session-discard"
|
||||
disabled={disabled}
|
||||
onClick={() => onDiscard(s)}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface CompoundEngineeringViewProps {
|
||||
context?: PluginDashboardViewContext;
|
||||
/** Test seam: override the active project id without a host context. */
|
||||
@@ -193,6 +271,16 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
|
||||
});
|
||||
}, [subscribePluginEvents]);
|
||||
const ceSession = useCeSession(subscribe ? { subscribe } : {});
|
||||
// Session list refresh: ANY CE push event means some session changed.
|
||||
const subscribeList = useMemo<CeSessionsSubscribe | undefined>(() => {
|
||||
if (!subscribePluginEvents) return undefined;
|
||||
return (onAnyEvent) => subscribePluginEvents(CE_PLUGIN_ID, () => onAnyEvent());
|
||||
}, [subscribePluginEvents]);
|
||||
const ceSessions = useCeSessions({
|
||||
projectId,
|
||||
enabled,
|
||||
...(subscribeList ? { subscribe: subscribeList } : {}),
|
||||
});
|
||||
const [launcherOpen, setLauncherOpen] = useState(false);
|
||||
|
||||
const totalArtifacts = result?.totalArtifacts ?? 0;
|
||||
@@ -208,20 +296,50 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
|
||||
const onLaunch = useCallback(
|
||||
(stage: CeStageDefinition) => {
|
||||
setLauncherOpen(false);
|
||||
void ceSession.start(stage.stageId, { message: `Start the ${stage.label} stage.`, projectId });
|
||||
void ceSession
|
||||
.start(stage.stageId, { message: `Start the ${stage.label} stage.`, projectId })
|
||||
.then(() => ceSessions.refresh());
|
||||
},
|
||||
[ceSession, ceSessions, projectId],
|
||||
);
|
||||
|
||||
const onOpenSession = useCallback(
|
||||
(s: CeSession) => {
|
||||
void ceSession.open(s.id, { projectId });
|
||||
},
|
||||
[ceSession, projectId],
|
||||
);
|
||||
|
||||
const onCloseFlow = useCallback(() => ceSession.reset(), [ceSession]);
|
||||
const onDiscardSession = useCallback(
|
||||
(s: CeSession) => {
|
||||
void ceSessions.remove(s.id);
|
||||
},
|
||||
[ceSessions],
|
||||
);
|
||||
|
||||
// Once a session exists, the flow renderer owns the surface until closed.
|
||||
// Closing the flow returns to the overview WITHOUT stopping the session —
|
||||
// it keeps running server-side and stays reachable from the sessions panel.
|
||||
const onCloseFlow = useCallback(() => {
|
||||
ceSession.reset();
|
||||
void ceSessions.refresh();
|
||||
}, [ceSession, ceSessions]);
|
||||
|
||||
// Once a session is active here, the flow renderer owns the surface until
|
||||
// closed — but the sessions panel stays visible so other sessions remain
|
||||
// one click away (switching does not stop the open one).
|
||||
if (ceSession.session) {
|
||||
return (
|
||||
<div className="ce-view" data-testid="compound-engineering-view" data-mobile={mobile ? "true" : "false"}>
|
||||
<div className="ce-view-header">
|
||||
<h2>Compound Engineering</h2>
|
||||
</div>
|
||||
<SessionsPanel
|
||||
sessions={ceSessions.sessions}
|
||||
activeSessionId={ceSession.session.id}
|
||||
disabled={ceSession.busy}
|
||||
onOpen={onOpenSession}
|
||||
onDiscard={onDiscardSession}
|
||||
/>
|
||||
<CeFlow
|
||||
session={ceSession.session}
|
||||
busy={ceSession.busy}
|
||||
@@ -256,6 +374,19 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
|
||||
<StageLauncher stages={stages} disabled={ceSession.busy} onLaunch={onLaunch} />
|
||||
) : null}
|
||||
|
||||
<SessionsPanel
|
||||
sessions={ceSessions.sessions}
|
||||
disabled={ceSession.busy}
|
||||
onOpen={onOpenSession}
|
||||
onDiscard={onDiscardSession}
|
||||
/>
|
||||
|
||||
{ceSessions.error ? (
|
||||
<div className="ce-view-error card" role="alert" data-testid="ce-sessions-error">
|
||||
Failed to load sessions: {ceSessions.error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{ceSession.error && !ceSession.session ? (
|
||||
<div className="ce-view-error card" role="alert" data-testid="ce-session-error">
|
||||
Failed to start session: {ceSession.error}
|
||||
|
||||
@@ -6,13 +6,43 @@ import type { DiscoveryResult } from "../../artifacts/discovery.js";
|
||||
const listArtifacts = vi.fn(async (): Promise<DiscoveryResult> => {
|
||||
throw new Error("listArtifacts mock not configured");
|
||||
});
|
||||
const listSessions = vi.fn(async (): Promise<CeSession[]> => []);
|
||||
const deleteSession = vi.fn(async (_id: string, _projectId?: string): Promise<void> => undefined);
|
||||
const getSession = vi.fn(async (_id: string, _projectId?: string): Promise<CeSession> => {
|
||||
throw new Error("getSession mock not configured");
|
||||
});
|
||||
vi.mock("../hooks/api.js", () => ({
|
||||
listArtifacts: () => listArtifacts(),
|
||||
getArtifactPreviewUrl: (id: string) => `/preview/${id}`,
|
||||
listSessions: () => listSessions(),
|
||||
deleteSession: (id: string, projectId?: string) => deleteSession(id, projectId),
|
||||
getSession: (id: string, projectId?: string) => getSession(id, projectId),
|
||||
startSession: vi.fn(),
|
||||
answerSession: vi.fn(),
|
||||
resumeSession: vi.fn(),
|
||||
}));
|
||||
|
||||
import { CompoundEngineeringView } from "../CompoundEngineeringView.js";
|
||||
import { __test_clearArtifactsCache } from "../hooks/useArtifacts.js";
|
||||
import type { CeSession } from "../../session/session-store.js";
|
||||
|
||||
function mkCeSession(over: Partial<CeSession>): CeSession {
|
||||
return {
|
||||
id: "sess-1",
|
||||
stage: "brainstorm",
|
||||
status: "awaiting_input",
|
||||
currentQuestion: null,
|
||||
conversationHistory: [],
|
||||
projectId: "p1",
|
||||
artifactPath: null,
|
||||
error: null,
|
||||
turnIntervalMs: 1000,
|
||||
lastActivityAt: Date.now(),
|
||||
createdAt: "2026-06-03T00:00:00Z",
|
||||
updatedAt: "2026-06-03T00:00:00Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const ALL_STAGES: Array<{ stage: DiscoveryResult["groups"][number]["stage"]; label: string }> = [
|
||||
{ stage: "strategy", label: "Strategy" },
|
||||
@@ -45,6 +75,11 @@ describe("CompoundEngineeringView", () => {
|
||||
beforeEach(() => {
|
||||
__test_clearArtifactsCache();
|
||||
listArtifacts.mockReset();
|
||||
listSessions.mockReset();
|
||||
listSessions.mockResolvedValue([]);
|
||||
deleteSession.mockReset();
|
||||
deleteSession.mockResolvedValue(undefined);
|
||||
getSession.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
@@ -101,6 +136,78 @@ describe("CompoundEngineeringView", () => {
|
||||
expect(screen.getByTestId("ce-summary").textContent).toMatch(/unreadable/i);
|
||||
});
|
||||
|
||||
it("lists multiple sessions with status badges; terminal sessions get a discard affordance", async () => {
|
||||
listArtifacts.mockResolvedValue(makeResult({}));
|
||||
listSessions.mockResolvedValue([
|
||||
mkCeSession({ id: "a", stage: "brainstorm", status: "awaiting_input" }),
|
||||
mkCeSession({ id: "b", stage: "plan", status: "active" }),
|
||||
mkCeSession({ id: "c", stage: "work", status: "completed" }),
|
||||
]);
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
|
||||
|
||||
await screen.findByTestId("ce-sessions");
|
||||
const rows = screen.getAllByTestId("ce-session-row");
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows.map((r) => r.getAttribute("data-status"))).toEqual([
|
||||
"awaiting_input",
|
||||
"active",
|
||||
"completed",
|
||||
]);
|
||||
// Awaiting sessions advertise that they need the user.
|
||||
expect(rows[0].textContent).toMatch(/needs your input/i);
|
||||
// Only the terminal session can be discarded.
|
||||
expect(screen.getAllByTestId("ce-session-discard")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("opens an existing session from the list into the flow (and back without losing it)", async () => {
|
||||
listArtifacts.mockResolvedValue(makeResult({}));
|
||||
listSessions.mockResolvedValue([
|
||||
mkCeSession({ id: "a", stage: "brainstorm", status: "awaiting_input" }),
|
||||
mkCeSession({ id: "b", stage: "plan", status: "active" }),
|
||||
]);
|
||||
getSession.mockResolvedValue(
|
||||
mkCeSession({
|
||||
id: "a",
|
||||
status: "awaiting_input",
|
||||
currentQuestion: { id: "q1", type: "text", question: "Topic?" },
|
||||
}),
|
||||
);
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
|
||||
|
||||
await screen.findByTestId("ce-sessions");
|
||||
fireEvent.click(screen.getAllByTestId("ce-session-open")[0]);
|
||||
|
||||
// The flow surface opens on the adopted session…
|
||||
const flow = await screen.findByTestId("ce-flow");
|
||||
expect(flow.getAttribute("data-stage")).toBe("brainstorm");
|
||||
expect(getSession).toHaveBeenCalledWith("a", "p1");
|
||||
// …while the sessions panel stays visible for switching, with the open
|
||||
// session marked active.
|
||||
expect(screen.getByTestId("ce-sessions")).toBeInTheDocument();
|
||||
const rows = screen.getAllByTestId("ce-session-row");
|
||||
expect(rows[0].className).toMatch(/is-active/);
|
||||
|
||||
// Closing returns to the overview; the session list survives (the session
|
||||
// itself keeps running server-side — close does not delete anything).
|
||||
fireEvent.click(screen.getByText("Close"));
|
||||
await screen.findByTestId("ce-empty-state");
|
||||
expect(screen.getByTestId("ce-sessions")).toBeInTheDocument();
|
||||
expect(deleteSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards a terminal session via the list", async () => {
|
||||
listArtifacts.mockResolvedValue(makeResult({}));
|
||||
listSessions.mockResolvedValue([mkCeSession({ id: "done", stage: "plan", status: "completed" })]);
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
|
||||
|
||||
await screen.findByTestId("ce-sessions");
|
||||
listSessions.mockResolvedValue([]);
|
||||
fireEvent.click(screen.getByTestId("ce-session-discard"));
|
||||
|
||||
await waitFor(() => expect(deleteSession).toHaveBeenCalledWith("done", "p1"));
|
||||
await waitFor(() => expect(screen.queryByTestId("ce-sessions")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("does not fetch when the viewport-gated flag is disabled", async () => {
|
||||
listArtifacts.mockResolvedValue(makeResult({}));
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride={false} />);
|
||||
|
||||
@@ -32,6 +32,7 @@ function Harness({ transport }: { transport: CeSessionTransport }) {
|
||||
<span data-testid="busy">{s.busy ? "busy" : "idle"}</span>
|
||||
<span data-testid="err">{s.error ?? ""}</span>
|
||||
<button onClick={() => void s.start("brainstorm", { projectId: "p1" })}>start</button>
|
||||
<button onClick={() => void s.open("s2", { projectId: "p2" })}>open</button>
|
||||
<button onClick={() => void s.answer("q1", "yes")}>answer</button>
|
||||
<button onClick={() => void s.resume()}>resume</button>
|
||||
<button onClick={() => s.reset()}>reset</button>
|
||||
@@ -120,6 +121,28 @@ describe("useCeSession lifecycle", () => {
|
||||
expect(screen.getByTestId("status")).toHaveTextContent("awaiting_input");
|
||||
});
|
||||
|
||||
it("open() adopts an existing session and threads ITS projectId to later calls", async () => {
|
||||
const transport: CeSessionTransport = {
|
||||
start: vi.fn(),
|
||||
answer: vi.fn(async () => mkSession({ id: "s2", status: "completed" })),
|
||||
resume: vi.fn(),
|
||||
get: vi.fn(async () => mkSession({ id: "s2", status: "awaiting_input", currentQuestion: Q })),
|
||||
};
|
||||
render(<Harness transport={transport} />);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("open").click();
|
||||
});
|
||||
expect(transport.get).toHaveBeenCalledWith("s2", "p2");
|
||||
expect(screen.getByTestId("status")).toHaveTextContent("awaiting_input");
|
||||
|
||||
// Subsequent answer goes to the opened session with the opened projectId.
|
||||
await act(async () => {
|
||||
screen.getByText("answer").click();
|
||||
});
|
||||
expect(transport.answer).toHaveBeenCalledWith("s2", "q1", "yes", "p2");
|
||||
});
|
||||
|
||||
it("surfaces a start error", async () => {
|
||||
const transport: CeSessionTransport = {
|
||||
start: vi.fn(async () => {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { useCeSessions, type CeSessionsTransport, type CeSessionsSubscribe } from "../useCeSessions.js";
|
||||
import type { CeSession } from "../../../session/session-store.js";
|
||||
|
||||
function mkSession(over: Partial<CeSession>): CeSession {
|
||||
return {
|
||||
id: "s1",
|
||||
stage: "brainstorm",
|
||||
status: "awaiting_input",
|
||||
currentQuestion: null,
|
||||
conversationHistory: [],
|
||||
projectId: null,
|
||||
artifactPath: null,
|
||||
error: null,
|
||||
turnIntervalMs: 1000,
|
||||
lastActivityAt: Date.now(),
|
||||
createdAt: "t",
|
||||
updatedAt: "t",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function Harness({
|
||||
transport,
|
||||
subscribe,
|
||||
}: {
|
||||
transport: CeSessionsTransport;
|
||||
subscribe?: CeSessionsSubscribe;
|
||||
}) {
|
||||
const s = useCeSessions({
|
||||
projectId: "p1",
|
||||
transport,
|
||||
pollIntervalMs: 5,
|
||||
...(subscribe ? { subscribe } : {}),
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="count">{s.sessions.length}</span>
|
||||
<span data-testid="ids">{s.sessions.map((x) => x.id).join(",")}</span>
|
||||
<span data-testid="err">{s.error ?? ""}</span>
|
||||
<button onClick={() => void s.refresh()}>refresh</button>
|
||||
<button onClick={() => void s.remove("s1")}>remove</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useCeSessions (multi-session list)", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("lists all sessions on mount with the projectId", async () => {
|
||||
const list = vi.fn(async () => [mkSession({ id: "s1" }), mkSession({ id: "s2", stage: "plan" })]);
|
||||
const transport: CeSessionsTransport = { list, remove: vi.fn() };
|
||||
render(<Harness transport={transport} />);
|
||||
|
||||
await act(async () => {});
|
||||
expect(list).toHaveBeenCalledWith("p1");
|
||||
expect(screen.getByTestId("count")).toHaveTextContent("2");
|
||||
expect(screen.getByTestId("ids")).toHaveTextContent("s1,s2");
|
||||
});
|
||||
|
||||
it("remove() deletes via the transport then refreshes the list", async () => {
|
||||
let removed = false;
|
||||
const transport: CeSessionsTransport = {
|
||||
list: vi.fn(async () => (removed ? [mkSession({ id: "s2" })] : [mkSession({ id: "s1" }), mkSession({ id: "s2" })])),
|
||||
remove: vi.fn(async () => {
|
||||
removed = true;
|
||||
}),
|
||||
};
|
||||
render(<Harness transport={transport} />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByTestId("count")).toHaveTextContent("2");
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("remove").click();
|
||||
});
|
||||
expect(transport.remove).toHaveBeenCalledWith("s1", "p1");
|
||||
expect(screen.getByTestId("ids")).toHaveTextContent("s2");
|
||||
});
|
||||
|
||||
it("refreshes when a push event fires", async () => {
|
||||
let fire: (() => void) | undefined;
|
||||
const subscribe: CeSessionsSubscribe = (onAnyEvent) => {
|
||||
fire = onAnyEvent;
|
||||
return () => {
|
||||
fire = undefined;
|
||||
};
|
||||
};
|
||||
let n = 1;
|
||||
const transport: CeSessionsTransport = {
|
||||
list: vi.fn(async () => Array.from({ length: n }, (_, i) => mkSession({ id: `s${i + 1}` }))),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
render(<Harness transport={transport} subscribe={subscribe} />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByTestId("count")).toHaveTextContent("1");
|
||||
|
||||
n = 2;
|
||||
await act(async () => {
|
||||
fire?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByTestId("count")).toHaveTextContent("2");
|
||||
});
|
||||
|
||||
it("polls while any session is mid-turn and stops when all settle", async () => {
|
||||
vi.useFakeTimers();
|
||||
let calls = 0;
|
||||
const transport: CeSessionsTransport = {
|
||||
list: vi.fn(async () => {
|
||||
calls += 1;
|
||||
return [mkSession({ id: "s1", status: calls >= 3 ? "completed" : "active" })];
|
||||
}),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
render(<Harness transport={transport} />);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
expect(screen.getByTestId("count")).toHaveTextContent("1");
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
});
|
||||
const settledCalls = calls;
|
||||
expect(calls).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// All settled → polling stops (no further list calls as time advances).
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
});
|
||||
expect(calls).toBe(settledCalls);
|
||||
});
|
||||
|
||||
it("surfaces a list error without crashing", async () => {
|
||||
const transport: CeSessionsTransport = {
|
||||
list: vi.fn(async () => {
|
||||
throw new Error("kaput");
|
||||
}),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
render(<Harness transport={transport} />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByTestId("err")).toHaveTextContent("kaput");
|
||||
expect(screen.getByTestId("count")).toHaveTextContent("0");
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,24 @@ export async function resumeSession(sessionId: string, projectId?: string): Prom
|
||||
return data.session;
|
||||
}
|
||||
|
||||
/** List CE sessions, newest-activity first (optionally filtered by status/stage). */
|
||||
export async function listSessions(
|
||||
opts: { projectId?: string; status?: string; stage?: string } = {},
|
||||
): Promise<CeSession[]> {
|
||||
const data = await request<{ sessions: CeSession[] }>(
|
||||
`/sessions${qp({ projectId: opts.projectId, status: opts.status, stage: opts.stage })}`,
|
||||
);
|
||||
return data.sessions;
|
||||
}
|
||||
|
||||
/** Discard a session (disposes any live handle, deletes the row). `projectId` must match start. */
|
||||
export async function deleteSession(sessionId: string, projectId?: string): Promise<void> {
|
||||
await request<{ deleted: boolean }>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}${qp({ projectId })}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
/** Poll the current persisted session state. `projectId` must match start (see answerSession). */
|
||||
export async function getSession(sessionId: string, projectId?: string): Promise<CeSession> {
|
||||
const data = await request<{ session: CeSession }>(`/sessions/${encodeURIComponent(sessionId)}${qp({ projectId })}`);
|
||||
|
||||
@@ -62,6 +62,8 @@ export interface UseCeSessionResult {
|
||||
busy: boolean;
|
||||
error?: string;
|
||||
start(stage: string, opts?: { message?: string; projectId?: string }): Promise<void>;
|
||||
/** Adopt an EXISTING session (e.g. from the session list) as the active one. */
|
||||
open(sessionId: string, opts?: { projectId?: string }): Promise<void>;
|
||||
answer(questionId: string, response: unknown): Promise<void>;
|
||||
resume(): Promise<void>;
|
||||
reset(): void;
|
||||
@@ -130,6 +132,18 @@ export function useCeSession(options: UseCeSessionOptions = {}): UseCeSessionRes
|
||||
[run, transport],
|
||||
);
|
||||
|
||||
// Adopt an existing session (started earlier, possibly in another view visit)
|
||||
// as this hook's active session. Like start(), it pins the projectId used for
|
||||
// every subsequent call — the session row lives in that project's store.
|
||||
const open = useCallback(
|
||||
(sessionId: string, opts: { projectId?: string } = {}) => {
|
||||
projectIdRef.current = opts.projectId;
|
||||
sessionIdRef.current = sessionId;
|
||||
return run(() => transport.get(sessionId, opts.projectId));
|
||||
},
|
||||
[run, transport],
|
||||
);
|
||||
|
||||
const answer = useCallback(
|
||||
(questionId: string, response: unknown) => {
|
||||
const id = sessionIdRef.current;
|
||||
@@ -198,5 +212,5 @@ export function useCeSession(options: UseCeSessionOptions = {}): UseCeSessionRes
|
||||
};
|
||||
}, [status, busy, transport, apply, pollIntervalMs]);
|
||||
|
||||
return { session, busy, error, start, answer, resume, reset };
|
||||
return { session, busy, error, start, open, answer, resume, reset };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { CeSession } from "../../session/session-store.js";
|
||||
import { deleteSession as deleteSessionApi, listSessions as listSessionsApi } from "./api.js";
|
||||
|
||||
/**
|
||||
* Injectable list transport so component tests can drive the session list
|
||||
* without a network. Defaults to the real routes.
|
||||
*/
|
||||
export interface CeSessionsTransport {
|
||||
list(projectId?: string): Promise<CeSession[]>;
|
||||
remove(sessionId: string, projectId?: string): Promise<void>;
|
||||
}
|
||||
|
||||
const defaultTransport: CeSessionsTransport = {
|
||||
list: (projectId) => listSessionsApi({ projectId }),
|
||||
remove: (id, projectId) => deleteSessionApi(id, projectId),
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to ANY CE plugin push event (no per-session filter — any session
|
||||
* turn/question/complete should refresh the list). Returns an unsubscribe fn.
|
||||
* Default no-op = polling only, same posture as useCeSession's subscribe.
|
||||
*/
|
||||
export type CeSessionsSubscribe = (onAnyEvent: () => void) => () => void;
|
||||
|
||||
export interface UseCeSessionsOptions {
|
||||
projectId?: string;
|
||||
/** Gate fetching (mirrors useArtifacts' viewport gating). Default true. */
|
||||
enabled?: boolean;
|
||||
/** Poll interval (ms) while any session has a turn in flight. */
|
||||
pollIntervalMs?: number;
|
||||
transport?: CeSessionsTransport;
|
||||
subscribe?: CeSessionsSubscribe;
|
||||
}
|
||||
|
||||
export interface UseCeSessionsResult {
|
||||
sessions: CeSession[];
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
/** Re-fetch the list now (e.g. after launching or closing a session). */
|
||||
refresh(): Promise<void>;
|
||||
/** Discard a session and refresh the list. */
|
||||
remove(sessionId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Statuses with an agent turn in flight — the list keeps polling while any exist. */
|
||||
const IN_FLIGHT = new Set<CeSession["status"]>(["active", "launching"]);
|
||||
|
||||
/**
|
||||
* Multi-session management list (server state is already multi-session: each
|
||||
* row is an independent pipeline run with its own live handle). Refreshes on
|
||||
* any plugin push event, and polls as a fallback while any session is
|
||||
* mid-turn so progress made in another tab/process still shows up.
|
||||
*/
|
||||
export function useCeSessions(options: UseCeSessionsOptions = {}): UseCeSessionsResult {
|
||||
const { projectId } = options;
|
||||
const enabled = options.enabled ?? true;
|
||||
const pollIntervalMs = options.pollIntervalMs ?? 5000;
|
||||
const transport = options.transport ?? defaultTransport;
|
||||
const subscribe = options.subscribe;
|
||||
|
||||
const [sessions, setSessions] = useState<CeSession[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const next = await transport.list(projectId);
|
||||
if (mounted.current) {
|
||||
setSessions(next);
|
||||
setError(undefined);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted.current) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (mounted.current) setLoading(false);
|
||||
}
|
||||
}, [transport, projectId]);
|
||||
|
||||
// Initial fetch (and on project switch).
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
setLoading(true);
|
||||
void refresh();
|
||||
}, [enabled, refresh]);
|
||||
|
||||
// Live push: any CE event means some session changed — refresh the list.
|
||||
useEffect(() => {
|
||||
if (!enabled || !subscribe) return;
|
||||
return subscribe(() => {
|
||||
void refresh();
|
||||
});
|
||||
}, [enabled, subscribe, refresh]);
|
||||
|
||||
// Poll fallback only while a turn is actually in flight somewhere.
|
||||
const anyInFlight = sessions.some((s) => IN_FLIGHT.has(s.status));
|
||||
useEffect(() => {
|
||||
if (!enabled || !anyInFlight) return;
|
||||
const timer = setInterval(() => {
|
||||
void refresh();
|
||||
}, pollIntervalMs);
|
||||
return () => clearInterval(timer);
|
||||
}, [enabled, anyInFlight, pollIntervalMs, refresh]);
|
||||
|
||||
const remove = useCallback(
|
||||
async (sessionId: string) => {
|
||||
try {
|
||||
await transport.remove(sessionId, projectId);
|
||||
} catch (err) {
|
||||
if (mounted.current) setError(err instanceof Error ? err.message : String(err));
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
},
|
||||
[transport, projectId, refresh],
|
||||
);
|
||||
|
||||
return { sessions, loading, error, refresh, remove };
|
||||
}
|
||||
@@ -128,6 +128,20 @@ export function createSessionRoutes(): PluginRouteDefinition[] {
|
||||
return { status: 200, body: { sessions } };
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "DELETE",
|
||||
path: "/sessions/:id",
|
||||
description: "Discard a CE session (disposes any live handle, deletes the row).",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const id = (req as RouteRequest).params.id;
|
||||
// Go through the orchestrator so an in-flight live handle is disposed,
|
||||
// not just the row removed (a bare store.delete would leave the agent
|
||||
// running unobserved in this process).
|
||||
const removed = getOrchestrator(ctx).discard(id);
|
||||
if (!removed) return { status: 404, body: { error: `Session ${id} not found` } };
|
||||
return { status: 200, body: { deleted: true } };
|
||||
},
|
||||
},
|
||||
{
|
||||
// U7 work bridge: observe the board tasks a CE pipeline (session) landed,
|
||||
// via their link records (the addressable back-reference, FN-5719). The
|
||||
|
||||
@@ -385,6 +385,17 @@ export class CeOrchestrator {
|
||||
return this.store.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard a session: dispose any live in-process handle (so an in-flight
|
||||
* agent doesn't keep running unobserved) and delete the persisted row.
|
||||
* Returns false when the session doesn't exist. Pipeline-link rows are NOT
|
||||
* touched — board tasks the session landed keep their provenance records.
|
||||
*/
|
||||
discard(sessionId: string): boolean {
|
||||
this.disposeLive(sessionId);
|
||||
return this.store.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one turn behind a timeout race, persist the resulting event, and on a
|
||||
* turn-level failure auto-save + emit. The `driver` performs the prompt/answer
|
||||
|
||||
@@ -255,6 +255,12 @@ export class CeSessionStore {
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Delete a session row. Returns true when a row was removed. */
|
||||
delete(id: string): boolean {
|
||||
const result = this.db.prepare(`DELETE FROM ce_sessions WHERE id = ?`).run(id);
|
||||
return Number(result.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** Append a turn to the conversation history (no other field touched). */
|
||||
appendHistory(id: string, turn: CeConversationTurn): CeSession | undefined {
|
||||
const existing = this.get(id);
|
||||
|
||||
Reference in New Issue
Block a user