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);
})