fix(compound-engineering): thread projectId through session answer/resume/poll

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.
This commit is contained in:
gsxdsm
2026-06-02 21:12:20 -07:00
parent 234a2a7ec5
commit 79e66e414f
3 changed files with 63 additions and 21 deletions

View File

@@ -31,7 +31,7 @@ function Harness({ transport }: { transport: CeSessionTransport }) {
<span data-testid="status">{s.session?.status ?? "none"}</span>
<span data-testid="busy">{s.busy ? "busy" : "idle"}</span>
<span data-testid="err">{s.error ?? ""}</span>
<button onClick={() => void s.start("brainstorm")}>start</button>
<button onClick={() => void s.start("brainstorm", { projectId: "p1" })}>start</button>
<button onClick={() => void s.answer("q1", "yes")}>answer</button>
<button onClick={() => void s.resume()}>resume</button>
<button onClick={() => s.reset()}>reset</button>
@@ -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(<Harness transport={transport} />);
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");
});
});

View File

@@ -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<CeSession> {
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<CeSession> {
/** Resume an interrupted/error/awaiting session. `projectId` must match start (see answerSession). */
export async function resumeSession(sessionId: string, projectId?: string): Promise<CeSession> {
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<CeSession> {
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<CeSession> {
const data = await request<{ session: CeSession }>(`/sessions/${encodeURIComponent(sessionId)}${qp({ projectId })}`);
return data.session;
}

View File

@@ -13,16 +13,16 @@ import {
*/
export interface CeSessionTransport {
start(stage: string, opts: { message?: string; projectId?: string }): Promise<CeSession>;
answer(sessionId: string, questionId: string, response: unknown): Promise<CeSession>;
resume(sessionId: string): Promise<CeSession>;
get(sessionId: string): Promise<CeSession>;
answer(sessionId: string, questionId: string, response: unknown, projectId?: string): Promise<CeSession>;
resume(sessionId: string, projectId?: string): Promise<CeSession>;
get(sessionId: string, projectId?: string): Promise<CeSession>;
}
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<string | undefined>(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<string | undefined>(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);
})