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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user