From 79e66e414fcc53cea1a17c9b5bb9becf04c2e06b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 2 Jun 2026 21:12:20 -0700 Subject: [PATCH] fix(compound-engineering): thread projectId through session answer/resume/poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session's owning store (and its live in-process handle) is selected per request by projectId. start() sent projectId but answer/resume/getSession did not, so any project-scoped session broke on the first answer (a different store resolved → session not found / no live handle). The client now captures the start projectId and reuses it on every subsequent call. Closes the multi-project session-identity residual. --- .../hooks/__tests__/useCeSession.test.tsx | 31 +++++++++++++++++-- .../src/dashboard/hooks/api.ts | 24 +++++++++----- .../src/dashboard/hooks/useCeSession.ts | 29 ++++++++++------- 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/__tests__/useCeSession.test.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/__tests__/useCeSession.test.tsx index e781a47ad1..5e6ccf2ae3 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/__tests__/useCeSession.test.tsx +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/__tests__/useCeSession.test.tsx @@ -31,7 +31,7 @@ function Harness({ transport }: { transport: CeSessionTransport }) { {s.session?.status ?? "none"} {s.busy ? "busy" : "idle"} {s.error ?? ""} - + @@ -58,7 +58,32 @@ describe("useCeSession lifecycle", () => { screen.getByText("answer").click(); }); expect(screen.getByTestId("status")).toHaveTextContent("completed"); - expect(transport.answer).toHaveBeenCalledWith("s1", "q1", "yes"); + // projectId from start() must thread through to answer() (FN: per-request + // store resolution selects the session's owning store/live handle). + expect(transport.answer).toHaveBeenCalledWith("s1", "q1", "yes", "p1"); + }); + + it("threads the start projectId through resume and poll", async () => { + const get = vi.fn(async () => mkSession({ status: "active" })); + const transport: CeSessionTransport = { + start: vi.fn(async () => mkSession({ status: "interrupted", currentQuestion: Q })), + answer: vi.fn(), + resume: vi.fn(async () => mkSession({ status: "active" })), + get, + }; + render(); + await act(async () => { + screen.getByText("start").click(); + }); + await act(async () => { + screen.getByText("resume").click(); + }); + expect(transport.resume).toHaveBeenCalledWith("s1", "p1"); + // The poll (active status) must also carry the projectId. + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + expect(get).toHaveBeenCalledWith("s1", "p1"); }); it("polls while active and stops once settled", async () => { @@ -118,7 +143,7 @@ describe("useCeSession lifecycle", () => { await act(async () => { screen.getByText("resume").click(); }); - expect(transport.resume).toHaveBeenCalledWith("s1"); + expect(transport.resume).toHaveBeenCalledWith("s1", "p1"); expect(screen.getByTestId("status")).toHaveTextContent("awaiting_input"); }); }); diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/api.ts b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/api.ts index 2e4424c26d..2a031ceda9 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/api.ts +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/api.ts @@ -60,30 +60,40 @@ export async function startSession( return data.session; } -/** Submit an answer to the awaiting question and advance the session. */ +/** + * Submit an answer to the awaiting question and advance the session. + * + * `projectId` MUST match the one used at `startSession` — it selects the + * project-scoped store that holds the session row and its live in-process + * handle. Omitting it (or sending a different one) resolves a different store + * and the session won't be found. + */ export async function answerSession( sessionId: string, questionId: string, response: unknown, + projectId?: string, ): Promise { const data = await request<{ session: CeSession }>(`/sessions/${encodeURIComponent(sessionId)}/answer`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ questionId, response }), + body: JSON.stringify({ questionId, response, projectId }), }); return data.session; } -/** Resume an interrupted/error/awaiting session back to its current question. */ -export async function resumeSession(sessionId: string): Promise { +/** Resume an interrupted/error/awaiting session. `projectId` must match start (see answerSession). */ +export async function resumeSession(sessionId: string, projectId?: string): Promise { const data = await request<{ session: CeSession }>(`/sessions/${encodeURIComponent(sessionId)}/resume`, { method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectId }), }); return data.session; } -/** Poll the current persisted session state. */ -export async function getSession(sessionId: string): Promise { - const data = await request<{ session: CeSession }>(`/sessions/${encodeURIComponent(sessionId)}`); +/** Poll the current persisted session state. `projectId` must match start (see answerSession). */ +export async function getSession(sessionId: string, projectId?: string): Promise { + const data = await request<{ session: CeSession }>(`/sessions/${encodeURIComponent(sessionId)}${qp({ projectId })}`); return data.session; } diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/useCeSession.ts b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/useCeSession.ts index b025eb426c..7269f19756 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/useCeSession.ts +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/useCeSession.ts @@ -13,16 +13,16 @@ import { */ export interface CeSessionTransport { start(stage: string, opts: { message?: string; projectId?: string }): Promise; - answer(sessionId: string, questionId: string, response: unknown): Promise; - resume(sessionId: string): Promise; - get(sessionId: string): Promise; + answer(sessionId: string, questionId: string, response: unknown, projectId?: string): Promise; + resume(sessionId: string, projectId?: string): Promise; + get(sessionId: string, projectId?: string): Promise; } const defaultTransport: CeSessionTransport = { start: (stage, opts) => startSessionApi(stage, opts), - answer: (id, qid, response) => answerSessionApi(id, qid, response), - resume: (id) => resumeSessionApi(id), - get: (id) => getSessionApi(id), + answer: (id, qid, response, projectId) => answerSessionApi(id, qid, response, projectId), + resume: (id, projectId) => resumeSessionApi(id, projectId), + get: (id, projectId) => getSessionApi(id, projectId), }; /** Statuses where no further polling is useful (settled or waiting on the user). */ @@ -71,6 +71,10 @@ export function useCeSession(options: UseCeSessionOptions = {}): UseCeSessionRes // Keep the live id for the polling effect without re-subscribing on every // session field change. const sessionIdRef = useRef(undefined); + // The projectId used at start() selects the project-scoped store that owns the + // session row + live handle. Every later call (answer/resume/poll) MUST reuse + // it, or the request resolves a different store and the session isn't found. + const projectIdRef = useRef(undefined); const mounted = useRef(true); useEffect(() => { mounted.current = true; @@ -101,8 +105,10 @@ export function useCeSession(options: UseCeSessionOptions = {}): UseCeSessionRes ); const start = useCallback( - (stage: string, opts: { message?: string; projectId?: string } = {}) => - run(() => transport.start(stage, opts)), + (stage: string, opts: { message?: string; projectId?: string } = {}) => { + projectIdRef.current = opts.projectId; + return run(() => transport.start(stage, opts)); + }, [run, transport], ); @@ -110,7 +116,7 @@ export function useCeSession(options: UseCeSessionOptions = {}): UseCeSessionRes (questionId: string, response: unknown) => { const id = sessionIdRef.current; if (!id) return Promise.resolve(); - return run(() => transport.answer(id, questionId, response)); + return run(() => transport.answer(id, questionId, response, projectIdRef.current)); }, [run, transport], ); @@ -118,11 +124,12 @@ export function useCeSession(options: UseCeSessionOptions = {}): UseCeSessionRes const resume = useCallback(() => { const id = sessionIdRef.current; if (!id) return Promise.resolve(); - return run(() => transport.resume(id)); + return run(() => transport.resume(id, projectIdRef.current)); }, [run, transport]); const reset = useCallback(() => { sessionIdRef.current = undefined; + projectIdRef.current = undefined; setSession(undefined); setError(undefined); setBusy(false); @@ -139,7 +146,7 @@ export function useCeSession(options: UseCeSessionOptions = {}): UseCeSessionRes let cancelled = false; const timer = setInterval(() => { transport - .get(id) + .get(id, projectIdRef.current) .then((next) => { if (!cancelled) apply(next); })