fix: harden resume and SSE replay
This commit is contained in:
@@ -0,0 +1,359 @@
|
|||||||
|
# Pre-deployment Fix Wave Report
|
||||||
|
|
||||||
|
Date: 2026-07-14
|
||||||
|
Worktree: `/home/chirone/ThothII/.worktrees/activity-log-cte-layout`
|
||||||
|
Base: `e5366d14a6da8fb331d94be60b8929cefb1fe3e0`
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
All three reviewed findings are implemented in one coherent backend/frontend wave:
|
||||||
|
|
||||||
|
1. Resume leaves the prior selection, Zustand state, document panel, and EventSource untouched
|
||||||
|
until `POST /resume` succeeds. Cold Resume changes state and reconnects only after backend
|
||||||
|
clear/rebind; already-active same-session Resume preserves the existing binding; failure is a
|
||||||
|
no-op apart from the fixed toast.
|
||||||
|
2. SSE uses monotonically increasing per-session ids, cursor-filtered replay, native and manual
|
||||||
|
reconnect cursors, id continuity across `hub.clear`, and descriptor-id pending-gate
|
||||||
|
idempotence at both backend and frontend layers.
|
||||||
|
3. Generic Pi system events and readiness errors are projected through explicit public
|
||||||
|
allowlists. Sentinel URLs, paths, tokens, stderr, commands, and extra fields do not reach HTTP
|
||||||
|
or SSE.
|
||||||
|
|
||||||
|
No harness, workflow, persistence, model, CTE viewer, CTE card, or shared Card file changed.
|
||||||
|
|
||||||
|
## Interfaces
|
||||||
|
|
||||||
|
- Frontend `resumeSession(id)` now returns
|
||||||
|
`Promise<{ id: string; alreadyActive: boolean }>` via `ResumeSessionResult`.
|
||||||
|
- Backend successful Resume always returns the same shape:
|
||||||
|
- running/waiting runtime: `{ id, alreadyActive: true }`
|
||||||
|
- validated cold runtime: `{ id, alreadyActive: false }`
|
||||||
|
- `SseHub.publish(sessionId, event, data): number` returns the assigned SSE id.
|
||||||
|
- `SseHub.subscribe(sessionId, send, { afterId, pending })` calls
|
||||||
|
`send(event, data, id)` for replay/live frames with `id > afterId`.
|
||||||
|
- `GET /sessions/:id/events` accepts native `Last-Event-ID` and manual
|
||||||
|
`?lastEventId=<integer>`; when both are valid it uses the greater cursor.
|
||||||
|
- Every emitted SSE frame is `id: <n>\nevent: <name>\ndata: <json>\n\n`.
|
||||||
|
- Public readiness failure is exactly:
|
||||||
|
`Session services are not ready. Check configuration and connectivity, then try again.`
|
||||||
|
- Generic Pi system events are exactly `{ type: "system_event", event }`, and `event` must be a
|
||||||
|
non-empty string.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
Backend production:
|
||||||
|
|
||||||
|
- `backend/src/bridge/session-bridge.ts`
|
||||||
|
- `backend/src/routes/sessions.ts`
|
||||||
|
- `backend/src/sse/sse-hub.ts`
|
||||||
|
|
||||||
|
Backend tests:
|
||||||
|
|
||||||
|
- `backend/test/routes-sessions.test.ts`
|
||||||
|
- `backend/test/session-bridge.test.ts`
|
||||||
|
- `backend/test/sse-hub.test.ts`
|
||||||
|
- `backend/test/sse-route.test.ts` (new)
|
||||||
|
|
||||||
|
Frontend production/support:
|
||||||
|
|
||||||
|
- `frontend/src/api/sessions.ts`
|
||||||
|
- `frontend/src/api/types.ts`
|
||||||
|
- `frontend/src/shell/AppShell.tsx`
|
||||||
|
- `frontend/src/store/sessionStore.ts`
|
||||||
|
- `frontend/src/stream/useSessionStream.ts`
|
||||||
|
- `frontend/src/test/fakeEventSource.ts`
|
||||||
|
|
||||||
|
Frontend tests:
|
||||||
|
|
||||||
|
- `frontend/src/api/sessions.test.ts`
|
||||||
|
- `frontend/src/shell/AppShell.session-mgmt.test.tsx`
|
||||||
|
- `frontend/src/store/sessionStore.test.ts`
|
||||||
|
- `frontend/src/stream/useSessionStream.test.tsx`
|
||||||
|
|
||||||
|
## TDD RED/GREEN evidence
|
||||||
|
|
||||||
|
### 1. Backend Resume result and client-boundary allowlists
|
||||||
|
|
||||||
|
RED command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd backend && npx vitest run test/routes-sessions.test.ts test/session-bridge.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
RED output (exit 1):
|
||||||
|
|
||||||
|
```text
|
||||||
|
Test Files 2 failed (2)
|
||||||
|
Tests 6 failed | 35 passed (41)
|
||||||
|
|
||||||
|
expected { id: 's1' } to deeply equal { id: 's1', alreadyActive: false }
|
||||||
|
expected raw readiness URL/token/path to equal the fixed public message
|
||||||
|
expected three raw generic system events to equal [{ type: 'system_event', event: 'session_exit' }]
|
||||||
|
```
|
||||||
|
|
||||||
|
GREEN command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd backend && npx vitest run test/routes-sessions.test.ts test/session-bridge.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
GREEN output (exit 0):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✓ test/session-bridge.test.ts (14 tests)
|
||||||
|
✓ test/routes-sessions.test.ts (27 tests)
|
||||||
|
Test Files 2 passed (2)
|
||||||
|
Tests 41 passed (41)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Backend exact-once SseHub and route framing
|
||||||
|
|
||||||
|
RED command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd backend && npx vitest run test/sse-hub.test.ts test/sse-route.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
RED output (exit 1):
|
||||||
|
|
||||||
|
```text
|
||||||
|
Test Files 2 failed (2)
|
||||||
|
Tests 7 failed (7)
|
||||||
|
|
||||||
|
expected [undefined, undefined, undefined] to deeply equal [1, 2, 3]
|
||||||
|
expected unconditional replay not to contain "one" / "two"
|
||||||
|
expected one buffered pending gate, received replay plus a second pending emission
|
||||||
|
```
|
||||||
|
|
||||||
|
GREEN command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd backend && npx vitest run test/sse-hub.test.ts test/sse-route.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
GREEN output (exit 0):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✓ test/sse-hub.test.ts (4 tests)
|
||||||
|
✓ test/sse-route.test.ts (3 tests)
|
||||||
|
Test Files 2 passed (2)
|
||||||
|
Tests 7 passed (7)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Frontend cursor tracking and gate idempotence
|
||||||
|
|
||||||
|
RED command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd frontend && npx vitest run src/stream/useSessionStream.test.tsx src/store/sessionStore.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
RED output (exit 1):
|
||||||
|
|
||||||
|
```text
|
||||||
|
Test Files 2 failed (2)
|
||||||
|
Tests 2 failed | 28 passed (30)
|
||||||
|
|
||||||
|
expected /sessions/s1/events to be /sessions/s1/events?lastEventId=7
|
||||||
|
expected duplicate gate pendingWidget to remain null, received gate-1
|
||||||
|
```
|
||||||
|
|
||||||
|
GREEN command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd frontend && npx vitest run src/stream/useSessionStream.test.tsx src/store/sessionStore.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
GREEN output (exit 0):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✓ src/store/sessionStore.test.ts (23 tests)
|
||||||
|
✓ src/stream/useSessionStream.test.tsx (7 tests)
|
||||||
|
Test Files 2 passed (2)
|
||||||
|
Tests 30 passed (30)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Frontend typed Resume and AppShell ordering/preservation
|
||||||
|
|
||||||
|
Typed API RED command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd frontend && npx tsc -b
|
||||||
|
```
|
||||||
|
|
||||||
|
Typed API RED output (exit 1):
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/api/sessions.test.ts(43,9): error TS2322: Type 'void' is not assignable to type
|
||||||
|
'{ id: string; alreadyActive: boolean; }'.
|
||||||
|
```
|
||||||
|
|
||||||
|
Lifecycle RED command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd frontend && npx vitest run src/api/sessions.test.ts src/shell/AppShell.session-mgmt.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Lifecycle RED output (exit 1):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✓ src/api/sessions.test.ts (9 tests)
|
||||||
|
❯ src/shell/AppShell.session-mgmt.test.tsx (15 tests | 4 failed)
|
||||||
|
Test Files 1 failed | 1 passed (2)
|
||||||
|
Tests 4 failed | 20 passed (24)
|
||||||
|
|
||||||
|
already-active same-session Resume created two EventSources instead of one
|
||||||
|
deferred cold Resume closed the document panel before POST completion
|
||||||
|
failed same-session Resume closed the prior EventSource
|
||||||
|
failed Resume with no active session opened an EventSource
|
||||||
|
```
|
||||||
|
|
||||||
|
GREEN commands:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd frontend && npx vitest run src/api/sessions.test.ts src/shell/AppShell.session-mgmt.test.tsx
|
||||||
|
cd frontend && npx tsc -b
|
||||||
|
```
|
||||||
|
|
||||||
|
GREEN output (exit 0):
|
||||||
|
|
||||||
|
```text
|
||||||
|
✓ src/api/sessions.test.ts (9 tests)
|
||||||
|
✓ src/shell/AppShell.session-mgmt.test.tsx (15 tests)
|
||||||
|
Test Files 2 passed (2)
|
||||||
|
Tests 24 passed (24)
|
||||||
|
TypeScript: no output, exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The AppShell cold-reconnect test additionally proves that the old source accepts an event while
|
||||||
|
Resume is pending, the replacement URL carries `lastEventId=8`, the replacement receives one
|
||||||
|
post-resume transcript/activity row, and two deliveries of the same descriptor id yield one gate.
|
||||||
|
|
||||||
|
## Affected verification
|
||||||
|
|
||||||
|
Backend command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd backend && npx vitest run test/routes-sessions.test.ts test/session-bridge.test.ts \
|
||||||
|
test/sse-hub.test.ts test/sse-route.test.ts test/health.test.ts test/e2e-f1.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Output (exit 0):
|
||||||
|
|
||||||
|
```text
|
||||||
|
Test Files 6 passed (6)
|
||||||
|
Tests 52 passed (52)
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend typecheck:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd backend && npx tsc --noEmit -p .
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: no output, exit 0.
|
||||||
|
|
||||||
|
Frontend command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd frontend && npx vitest run src/api/sessions.test.ts src/store/sessionStore.test.ts \
|
||||||
|
src/stream/useSessionStream.test.tsx src/shell/AppShell.session-mgmt.test.tsx \
|
||||||
|
src/shell/CentralStatus.test.tsx src/shell/ModelActivityPanel.test.tsx \
|
||||||
|
src/shell/f1-loop.test.tsx src/shell/AppShell.new-session.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Output (exit 0):
|
||||||
|
|
||||||
|
```text
|
||||||
|
Test Files 8 passed (8)
|
||||||
|
Tests 74 passed (74)
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend typecheck:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd frontend && npx tsc -b
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: no output, exit 0.
|
||||||
|
|
||||||
|
## Full verification
|
||||||
|
|
||||||
|
Backend full suite:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd backend && npx vitest run
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Test Files 22 passed (22)
|
||||||
|
Tests 177 passed (177)
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend full suite:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd frontend && npx vitest run
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Test Files 43 passed (43)
|
||||||
|
Tests 271 passed (271)
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend production build:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd backend && npm run build
|
||||||
|
> tsc -p tsconfig.json
|
||||||
|
exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend production build:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd frontend && npm run build
|
||||||
|
> tsc -b && vite build
|
||||||
|
✓ 4835 modules transformed.
|
||||||
|
✓ built in 8.25s
|
||||||
|
exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Final whitespace verification:
|
||||||
|
|
||||||
|
```text
|
||||||
|
git diff --check
|
||||||
|
no output, exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
|
||||||
|
- Resume sequencing: backend `clear` and runtime binding precede the HTTP success; frontend state
|
||||||
|
mutation and stream generation follow it. Failure catch only emits fixed UI copy.
|
||||||
|
- Already active: same-session returns before reset/generation/manifest repaint; different session
|
||||||
|
resets the single-session store and binds the new id only after success.
|
||||||
|
- SSE exact-once: ids are transport identity, not content hashes; replay is strictly `id > cursor`;
|
||||||
|
`clear` retains the counter; pending gate matching uses only descriptor id.
|
||||||
|
- Cursor behavior: hook tracks `MessageEvent.lastEventId`, carries it only to a same-id generation,
|
||||||
|
and resets it on session-id change. Native EventSource reconnect remains supported by the route
|
||||||
|
header.
|
||||||
|
- Gate defense: the Zustand set survives pending clear but resets with the session store.
|
||||||
|
- Client boundary: raw `ensure.error` is unused in public responses; generic Pi system events are
|
||||||
|
reconstructed rather than spread; frontend type mirrors the two-field event.
|
||||||
|
- Scope: `git diff` contains no CTE/Card/harness/workflow/persistence/model changes. Four pre-existing
|
||||||
|
modified `.superpowers/sdd/{progress,task-2-report,task-3-report,task-4-report}.md` files are user
|
||||||
|
work and are excluded from staging.
|
||||||
|
|
||||||
|
## Remaining concerns
|
||||||
|
|
||||||
|
- The 200-event SSE ring limit remains intentional. A brand-new page can reconstruct only retained
|
||||||
|
backlog; an in-memory same-session reconnect is exact-once from its cursor.
|
||||||
|
- Per-session sequence counters remain in backend memory after `clear` by design so later cold
|
||||||
|
same-id Resume cannot reuse ids. This is one numeric map entry per session id for the process
|
||||||
|
lifetime.
|
||||||
|
- Frontend tests still print pre-existing MSW unhandled-request and React ref/`act` warnings even
|
||||||
|
though all 271 tests pass. The frontend production build still reports pre-existing large chunk
|
||||||
|
warnings. Neither warning class was introduced or expanded by this change.
|
||||||
|
- No live Pi/DWH smoke was run; this wave changes only REST/SSE/frontend lifecycle boundaries and
|
||||||
|
is covered by fake-Pi, live Fastify SSE, component, full-suite, typecheck, and production-build
|
||||||
|
gates.
|
||||||
@@ -13,7 +13,7 @@ export type ClientEvent =
|
|||||||
| { type: "activity_delta"; text: string }
|
| { type: "activity_delta"; text: string }
|
||||||
| { type: "activity_event"; activity: ToolActivity }
|
| { type: "activity_event"; activity: ToolActivity }
|
||||||
| { type: "info"; [k: string]: any }
|
| { type: "info"; [k: string]: any }
|
||||||
| { type: "system_event"; [k: string]: any };
|
| { type: "system_event"; event: string };
|
||||||
|
|
||||||
export type TurnState = "idle" | "running" | "waiting" | "failed";
|
export type TurnState = "idle" | "running" | "waiting" | "failed";
|
||||||
|
|
||||||
@@ -61,7 +61,9 @@ export class SessionBridge {
|
|||||||
this.emitToolActivity(m, "end");
|
this.emitToolActivity(m, "end");
|
||||||
// Tool updates and raw payloads remain intentionally dropped.
|
// Tool updates and raw payloads remain intentionally dropped.
|
||||||
} else if (m.type === "system_event") {
|
} else if (m.type === "system_event") {
|
||||||
this.fan(m as ClientEvent);
|
if (typeof m.event === "string" && m.event.trim() !== "") {
|
||||||
|
this.fan({ type: "system_event", event: m.event });
|
||||||
|
}
|
||||||
} else if (m.type === "agent_end") {
|
} else if (m.type === "agent_end") {
|
||||||
if (this.state !== "failed" && !this.pending) this.state = "idle";
|
if (this.state !== "failed" && !this.pending) this.state = "idle";
|
||||||
this.fan({ type: "system_event", event: "agent_end" });
|
this.fan({ type: "system_event", event: "agent_end" });
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ import type { ReadinessManager } from "../runtime/readiness-manager.js";
|
|||||||
|
|
||||||
const BOOTSTRAP_FAILURE_MESSAGE =
|
const BOOTSTRAP_FAILURE_MESSAGE =
|
||||||
"Session startup failed. Check configuration and connectivity, then Resume the session.";
|
"Session startup failed. Check configuration and connectivity, then Resume the session.";
|
||||||
|
const READINESS_FAILURE_MESSAGE =
|
||||||
|
"Session services are not ready. Check configuration and connectivity, then try again.";
|
||||||
|
|
||||||
|
function eventCursor(...values: unknown[]): number {
|
||||||
|
let cursor = 0;
|
||||||
|
for (const value of values.flatMap((item) => Array.isArray(item) ? item : [item])) {
|
||||||
|
if (typeof value !== "string" || !/^\d+$/.test(value)) continue;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isSafeInteger(parsed)) cursor = Math.max(cursor, parsed);
|
||||||
|
}
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
export function sessionRoutes(
|
export function sessionRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
@@ -57,7 +69,7 @@ export function sessionRoutes(
|
|||||||
const b = req.body as { question: string; name?: string };
|
const b = req.body as { question: string; name?: string };
|
||||||
const s = d.getSettings();
|
const s = d.getSettings();
|
||||||
const ensure = await d.readiness.ensure(s.workspace ?? "");
|
const ensure = await d.readiness.ensure(s.workspace ?? "");
|
||||||
if (!ensure.ok) return reply.code(503).send({ error: ensure.error ?? "Ollama/embeddings non disponibili" });
|
if (!ensure.ok) return reply.code(503).send({ error: READINESS_FAILURE_MESSAGE });
|
||||||
// Settings (global) supply workspace/provider/model/thinking. The new-question
|
// Settings (global) supply workspace/provider/model/thinking. The new-question
|
||||||
// form sends only the question text. `workspace` selects the tht `-c <config>`.
|
// form sends only the question text. `workspace` selects the tht `-c <config>`.
|
||||||
const { id } = await d.tht.sessionNew({
|
const { id } = await d.tht.sessionNew({
|
||||||
@@ -115,7 +127,7 @@ export function sessionRoutes(
|
|||||||
}
|
}
|
||||||
const settings = d.getSettings();
|
const settings = d.getSettings();
|
||||||
const ensure = await d.readiness.ensure(settings.workspace ?? "");
|
const ensure = await d.readiness.ensure(settings.workspace ?? "");
|
||||||
if (!ensure.ok) return reply.code(503).send({ error: ensure.error ?? "Ollama/embeddings non disponibili" });
|
if (!ensure.ok) return reply.code(503).send({ error: READINESS_FAILURE_MESSAGE });
|
||||||
const saved = manifest as { provider?: string; model?: string; thinking?: string } | null;
|
const saved = manifest as { provider?: string; model?: string; thinking?: string } | null;
|
||||||
const options = {
|
const options = {
|
||||||
provider: saved?.provider,
|
provider: saved?.provider,
|
||||||
@@ -131,7 +143,7 @@ export function sessionRoutes(
|
|||||||
bindRuntime(id, rt);
|
bindRuntime(id, rt);
|
||||||
info(id, "Resuming session");
|
info(id, "Resuming session");
|
||||||
bootstrap(id, rt, d.mgr.configure(rt, options), null, () => d.mgr.start(id, rt, options));
|
bootstrap(id, rt, d.mgr.configure(rt, options), null, () => d.mgr.start(id, rt, options));
|
||||||
return reply.code(200).send({ id });
|
return reply.code(200).send({ id, alreadyActive: false });
|
||||||
});
|
});
|
||||||
app.post("/sessions/:id/close", async (req) => {
|
app.post("/sessions/:id/close", async (req) => {
|
||||||
const id = (req.params as { id: string }).id;
|
const id = (req.params as { id: string }).id;
|
||||||
@@ -146,6 +158,10 @@ export function sessionRoutes(
|
|||||||
app.get("/sessions/:id/events", (req, reply) => {
|
app.get("/sessions/:id/events", (req, reply) => {
|
||||||
const id = (req.params as any).id;
|
const id = (req.params as any).id;
|
||||||
const rt = d.mgr.get(id);
|
const rt = d.mgr.get(id);
|
||||||
|
const afterId = eventCursor(
|
||||||
|
req.headers["last-event-id"],
|
||||||
|
(req.query as { lastEventId?: unknown }).lastEventId,
|
||||||
|
);
|
||||||
// Add CORS headers manually: reply.raw.writeHead bypasses Fastify's onSend hooks
|
// Add CORS headers manually: reply.raw.writeHead bypasses Fastify's onSend hooks
|
||||||
// (where @fastify/cors injects headers), so we must set them explicitly here.
|
// (where @fastify/cors injects headers), so we must set them explicitly here.
|
||||||
const origin = (req.headers.origin as string | undefined) ?? "*";
|
const origin = (req.headers.origin as string | undefined) ?? "*";
|
||||||
@@ -160,8 +176,12 @@ export function sessionRoutes(
|
|||||||
// Send the handshake immediately. Without this, Node waits for the first event body and
|
// Send the handshake immediately. Without this, Node waits for the first event body and
|
||||||
// proxies/clients cannot establish an idle SSE subscription or inspect its headers.
|
// proxies/clients cannot establish an idle SSE subscription or inspect its headers.
|
||||||
reply.raw.flushHeaders();
|
reply.raw.flushHeaders();
|
||||||
const send = (event: string, data: object) => reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
const send = (event: string, data: object, eventId: number) =>
|
||||||
const off = d.hub.subscribe(id, send, rt?.bridge.pendingWidget() ?? null);
|
reply.raw.write(`id: ${eventId}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||||
|
const off = d.hub.subscribe(id, send, {
|
||||||
|
afterId,
|
||||||
|
pending: rt?.bridge.pendingWidget() ?? null,
|
||||||
|
});
|
||||||
req.raw.on("close", off);
|
req.raw.on("close", off);
|
||||||
});
|
});
|
||||||
app.post("/sessions/:id/rename", async (req, reply) => {
|
app.post("/sessions/:id/rename", async (req, reply) => {
|
||||||
|
|||||||
+68
-24
@@ -1,48 +1,92 @@
|
|||||||
type Send = (event: string, data: object) => void;
|
type Send = (event: string, data: object, id: number) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-session ring buffer of recent events.
|
* Per-session ring buffer of recent events.
|
||||||
*
|
*
|
||||||
* When the browser opens the SSE connection slightly after session creation
|
* Event ids are transport identity: they are monotonically increasing for the lifetime of a
|
||||||
* (React re-render, navigation, etc.), events produced by the Pi process in
|
* session id, including across a cold Resume that clears the old buffer and subscribers.
|
||||||
* that gap would be lost. The buffer replays them to late subscribers.
|
|
||||||
*
|
|
||||||
* `system_event` with `event: "agent_end"` acts as the natural sentinel:
|
|
||||||
* once a subscriber sees it, the buffer for that session is safe to clear.
|
|
||||||
*/
|
*/
|
||||||
const BUFFER_LIMIT = 200;
|
const BUFFER_LIMIT = 200;
|
||||||
|
|
||||||
interface BufferedEvent { event: string; data: object }
|
interface BufferedEvent {
|
||||||
|
id: number;
|
||||||
|
event: string;
|
||||||
|
data: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubscribeOptions {
|
||||||
|
afterId?: number;
|
||||||
|
pending?: object | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function descriptorId(value: unknown): string | null {
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
const id = (value as { id?: unknown }).id;
|
||||||
|
return typeof id === "string" && id.trim() !== "" ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferedGateId(item: BufferedEvent): string | null {
|
||||||
|
if (item.event !== "ui_request") return null;
|
||||||
|
const request = (item.data as { ui_request?: unknown }).ui_request;
|
||||||
|
return descriptorId(request);
|
||||||
|
}
|
||||||
|
|
||||||
export class SseHub {
|
export class SseHub {
|
||||||
private subs = new Map<string, Set<Send>>();
|
private subs = new Map<string, Set<Send>>();
|
||||||
private buffers = new Map<string, BufferedEvent[]>();
|
private buffers = new Map<string, BufferedEvent[]>();
|
||||||
|
private lastIds = new Map<string, number>();
|
||||||
|
|
||||||
subscribe(sessionId: string, send: Send, pending?: object | null): () => void {
|
subscribe(
|
||||||
|
sessionId: string,
|
||||||
|
send: Send,
|
||||||
|
options: SubscribeOptions = {},
|
||||||
|
): () => void {
|
||||||
if (!this.subs.has(sessionId)) this.subs.set(sessionId, new Set());
|
if (!this.subs.has(sessionId)) this.subs.set(sessionId, new Set());
|
||||||
this.subs.get(sessionId)!.add(send);
|
this.subs.get(sessionId)!.add(send);
|
||||||
|
|
||||||
// Replay buffered events so late subscribers don't miss the session lifecycle.
|
const afterId = Number.isSafeInteger(options.afterId) && (options.afterId ?? 0) >= 0
|
||||||
const buf = this.buffers.get(sessionId);
|
? options.afterId ?? 0
|
||||||
if (buf) for (const { event, data } of buf) send(event, data);
|
: 0;
|
||||||
|
const buf = this.buffers.get(sessionId) ?? [];
|
||||||
|
for (const item of buf) {
|
||||||
|
if (item.id > afterId) send(item.event, item.data, item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pending gate normally already exists in the buffer. If ring-buffer eviction removed it,
|
||||||
|
// give the fallback a fresh transport id and buffer it for future cursor-based reconnects.
|
||||||
|
// Matching is by stable descriptor identity only, never by event content.
|
||||||
|
const pendingId = descriptorId(options.pending);
|
||||||
|
const pendingBuffered = pendingId !== null && buf.some((item) => bufferedGateId(item) === pendingId);
|
||||||
|
if (pendingId !== null && !pendingBuffered) {
|
||||||
|
const data = { type: "ui_request", ui_request: options.pending! };
|
||||||
|
const item = this.buffer(sessionId, "ui_request", data);
|
||||||
|
send(item.event, item.data, item.id);
|
||||||
|
}
|
||||||
|
|
||||||
// Match the live-event shape (hub.publish sends the full ClientEvent):
|
|
||||||
// both carry { type, ui_request } so re-emit and live widgets are identical.
|
|
||||||
if (pending) send("ui_request", { type: "ui_request", ui_request: pending });
|
|
||||||
return () => this.subs.get(sessionId)?.delete(send);
|
return () => this.subs.get(sessionId)?.delete(send);
|
||||||
}
|
}
|
||||||
|
|
||||||
publish(sessionId: string, event: string, data: object): void {
|
publish(sessionId: string, event: string, data: object): number {
|
||||||
// Buffer the event for late subscribers.
|
const item = this.buffer(sessionId, event, data);
|
||||||
let buf = this.buffers.get(sessionId);
|
for (const send of this.subs.get(sessionId) ?? []) send(event, data, item.id);
|
||||||
if (!buf) { buf = []; this.buffers.set(sessionId, buf); }
|
return item.id;
|
||||||
buf.push({ event, data });
|
|
||||||
if (buf.length > BUFFER_LIMIT) buf.splice(0, buf.length - BUFFER_LIMIT);
|
|
||||||
|
|
||||||
for (const s of this.subs.get(sessionId) ?? []) s(event, data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear buffer and subscribers for a finished session. */
|
private buffer(sessionId: string, event: string, data: object): BufferedEvent {
|
||||||
|
const id = (this.lastIds.get(sessionId) ?? 0) + 1;
|
||||||
|
this.lastIds.set(sessionId, id);
|
||||||
|
let buf = this.buffers.get(sessionId);
|
||||||
|
if (!buf) {
|
||||||
|
buf = [];
|
||||||
|
this.buffers.set(sessionId, buf);
|
||||||
|
}
|
||||||
|
const item = { id, event, data };
|
||||||
|
buf.push(item);
|
||||||
|
if (buf.length > BUFFER_LIMIT) buf.splice(0, buf.length - BUFFER_LIMIT);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear buffered/runtime bindings while retaining session event-id monotonicity. */
|
||||||
clear(sessionId: string): void {
|
clear(sessionId: string): void {
|
||||||
this.buffers.delete(sessionId);
|
this.buffers.delete(sessionId);
|
||||||
this.subs.delete(sessionId);
|
this.subs.delete(sessionId);
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ test.each(["idle", "failed"])(
|
|||||||
});
|
});
|
||||||
const response = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
const response = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
expect(response.json()).toEqual({ id: "s1" });
|
expect(response.json()).toEqual({ id: "s1", alreadyActive: false });
|
||||||
expect(order).toEqual(["teardown:s1", "clear:s1", "reopen", "create", "start"]);
|
expect(order).toEqual(["teardown:s1", "clear:s1", "reopen", "create", "start"]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -223,7 +223,7 @@ test("POST resume without a runtime clears stale SSE state before cold start", a
|
|||||||
const response = await app.inject({ method: "POST", url: "/sessions/crashed/resume" });
|
const response = await app.inject({ method: "POST", url: "/sessions/crashed/resume" });
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
expect(response.json()).toEqual({ id: "crashed" });
|
expect(response.json()).toEqual({ id: "crashed", alreadyActive: false });
|
||||||
expect(order).toEqual(["clear:crashed", "reopen", "create", "start"]);
|
expect(order).toEqual(["clear:crashed", "reopen", "create", "start"]);
|
||||||
expect(createOptions).toMatchObject({
|
expect(createOptions).toMatchObject({
|
||||||
provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low", mode: "resume",
|
provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low", mode: "resume",
|
||||||
@@ -359,11 +359,13 @@ test("POST resume on an archived session is refused with 409", async () => {
|
|||||||
expect(res.statusCode).toBe(409);
|
expect(res.statusCode).toBe(409);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("POST /sessions refuses with 503 when ollamaEnsure fails (no session created)", async () => {
|
test("POST /sessions readiness failure returns one fixed public message without raw diagnostics", async () => {
|
||||||
let createdCalled = false;
|
let createdCalled = false;
|
||||||
|
const rawFailure =
|
||||||
|
"connect https://secret.invalid/ready?token=DO_NOT_LEAK using /srv/private/model-key";
|
||||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
thtRunner: {
|
thtRunner: {
|
||||||
ollamaEnsure: async () => ({ ok: false, stage: "model", error: "modello non installato" }),
|
ollamaEnsure: async () => ({ ok: false, stage: "model", error: rawFailure }),
|
||||||
searchPack: async () => {},
|
searchPack: async () => {},
|
||||||
sessionNew: async () => { createdCalled = true; return { id: "s1" }; },
|
sessionNew: async () => { createdCalled = true; return { id: "s1" }; },
|
||||||
} as any,
|
} as any,
|
||||||
@@ -372,7 +374,10 @@ test("POST /sessions refuses with 503 when ollamaEnsure fails (no session create
|
|||||||
});
|
});
|
||||||
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
||||||
expect(res.statusCode).toBe(503);
|
expect(res.statusCode).toBe(503);
|
||||||
expect(res.json().error).toContain("non installato");
|
expect(res.json()).toEqual({
|
||||||
|
error: "Session services are not ready. Check configuration and connectivity, then try again.",
|
||||||
|
});
|
||||||
|
expect(res.body).not.toMatch(/secret\.invalid|DO_NOT_LEAK|\/srv\/private\/model-key/);
|
||||||
expect(createdCalled).toBe(false);
|
expect(createdCalled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -403,10 +408,12 @@ test("POST /sessions/:id/resume returns 409 for a read-only session without call
|
|||||||
expect(ensureCalled).toBe(false);
|
expect(ensureCalled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("POST /sessions/:id/resume refuses with 503 when ollamaEnsure fails", async () => {
|
test("POST /sessions/:id/resume readiness failure returns the same fixed public message", async () => {
|
||||||
|
const rawFailure =
|
||||||
|
"stderr https://secret.invalid/resume?api_key=DO_NOT_LEAK /srv/private/resume-key";
|
||||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
thtRunner: {
|
thtRunner: {
|
||||||
ollamaEnsure: async () => ({ ok: false, error: "Ollama down" }),
|
ollamaEnsure: async () => ({ ok: false, error: rawFailure }),
|
||||||
sessionShow: async () => ({ status: "open", archived: false }),
|
sessionShow: async () => ({ status: "open", archived: false }),
|
||||||
} as any,
|
} as any,
|
||||||
getSettings: () => ({ workspace: "psd" }) as any,
|
getSettings: () => ({ workspace: "psd" }) as any,
|
||||||
@@ -414,6 +421,10 @@ test("POST /sessions/:id/resume refuses with 503 when ollamaEnsure fails", async
|
|||||||
});
|
});
|
||||||
const res = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
const res = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
||||||
expect(res.statusCode).toBe(503);
|
expect(res.statusCode).toBe(503);
|
||||||
|
expect(res.json()).toEqual({
|
||||||
|
error: "Session services are not ready. Check configuration and connectivity, then try again.",
|
||||||
|
});
|
||||||
|
expect(res.body).not.toMatch(/secret\.invalid|DO_NOT_LEAK|\/srv\/private\/resume-key/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("POST /runtime/prewarm returns 202 without awaiting readiness", async () => {
|
test("POST /runtime/prewarm returns 202 without awaiting readiness", async () => {
|
||||||
|
|||||||
@@ -164,6 +164,26 @@ test("agent_end di Pi diventa un system_event agent_end per il FE", () => {
|
|||||||
expect(seen).toEqual([{ type: "system_event", event: "agent_end" }]);
|
expect(seen).toEqual([{ type: "system_event", event: "agent_end" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("generic Pi system events expose only an allowlisted non-empty event name", () => {
|
||||||
|
const { rpc, fire } = fakeRpc();
|
||||||
|
const bridge = new SessionBridge(rpc);
|
||||||
|
const seen: any[] = [];
|
||||||
|
bridge.onClientEvent((event) => seen.push(event));
|
||||||
|
|
||||||
|
fire({
|
||||||
|
type: "system_event",
|
||||||
|
event: "session_exit",
|
||||||
|
command: "curl https://secret.invalid/?token=DO_NOT_LEAK",
|
||||||
|
result: { path: "/srv/private/key" },
|
||||||
|
});
|
||||||
|
fire({ type: "system_event", event: " ", stderr: "DO_NOT_LEAK_STDERR" });
|
||||||
|
fire({ type: "system_event", event: 42, args: "DO_NOT_LEAK_ARGS" });
|
||||||
|
|
||||||
|
expect(seen).toEqual([{ type: "system_event", event: "session_exit" }]);
|
||||||
|
expect(Object.keys(seen[0])).toEqual(["type", "event"]);
|
||||||
|
expect(JSON.stringify(seen)).not.toMatch(/DO_NOT_LEAK|command|result|path|stderr|args/);
|
||||||
|
});
|
||||||
|
|
||||||
test("steer invia un comando steer e riattiva il turno", () => {
|
test("steer invia un comando steer e riattiva il turno", () => {
|
||||||
const { rpc, sent, fire } = fakeRpc();
|
const { rpc, sent, fire } = fakeRpc();
|
||||||
const bridge = new SessionBridge(rpc);
|
const bridge = new SessionBridge(rpc);
|
||||||
|
|||||||
@@ -1,16 +1,93 @@
|
|||||||
import { test, expect } from "vitest";
|
import { test, expect } from "vitest";
|
||||||
import { SseHub } from "../src/sse/sse-hub.js";
|
import { SseHub } from "../src/sse/sse-hub.js";
|
||||||
|
|
||||||
test("re-emette il widget pendente alla sottoscrizione", () => {
|
test("publish assigns monotonic ids and a subscriber replays only ids newer than its cursor", () => {
|
||||||
const hub = new SseHub(); const sent: any[] = [];
|
const hub = new SseHub();
|
||||||
hub.subscribe("s1", (ev, data) => sent.push({ ev, data }), { id: "u1", widget: "select" });
|
const firstId = hub.publish("s1", "text_delta", { text: "one" });
|
||||||
expect(sent[0]).toEqual({ ev: "ui_request", data: { type: "ui_request", ui_request: { id: "u1", widget: "select" } } });
|
const secondId = hub.publish("s1", "text_delta", { text: "two" });
|
||||||
|
const sent: any[] = [];
|
||||||
|
|
||||||
|
hub.subscribe(
|
||||||
|
"s1",
|
||||||
|
(event, data, id) => sent.push({ event, data, id }),
|
||||||
|
{ afterId: firstId },
|
||||||
|
);
|
||||||
|
const thirdId = hub.publish("s1", "text_delta", { text: "three" });
|
||||||
|
|
||||||
|
expect([firstId, secondId, thirdId]).toEqual([1, 2, 3]);
|
||||||
|
expect(sent).toEqual([
|
||||||
|
{ event: "text_delta", data: { text: "two" }, id: 2 },
|
||||||
|
{ event: "text_delta", data: { text: "three" }, id: 3 },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("publish raggiunge i subscriber e unsubscribe li stacca", () => {
|
test("clear drops stale subscribers and buffers but preserves the per-session id sequence", () => {
|
||||||
const hub = new SseHub(); const sent: any[] = [];
|
const hub = new SseHub();
|
||||||
const off = hub.subscribe("s1", (ev, data) => sent.push({ ev, data }));
|
const stale: any[] = [];
|
||||||
hub.publish("s1", "text_delta", { text: "x" });
|
hub.subscribe("s1", (event, data, id) => stale.push({ event, data, id }));
|
||||||
off(); hub.publish("s1", "text_delta", { text: "y" });
|
expect(hub.publish("s1", "info", { text: "before" })).toBe(1);
|
||||||
expect(sent).toEqual([{ ev: "text_delta", data: { text: "x" } }]);
|
|
||||||
|
hub.clear("s1");
|
||||||
|
expect(hub.publish("s1", "info", { text: "after" })).toBe(2);
|
||||||
|
const resumed: any[] = [];
|
||||||
|
hub.subscribe(
|
||||||
|
"s1",
|
||||||
|
(event, data, id) => resumed.push({ event, data, id }),
|
||||||
|
{ afterId: 1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stale).toEqual([{ event: "info", data: { text: "before" }, id: 1 }]);
|
||||||
|
expect(resumed).toEqual([{ event: "info", data: { text: "after" }, id: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a buffered pending gate is emitted exactly once and matched by descriptor id", () => {
|
||||||
|
const hub = new SseHub();
|
||||||
|
const descriptor = { id: "gate-1", widget: "select", title: "Original" };
|
||||||
|
hub.publish("s1", "ui_request", { type: "ui_request", ui_request: descriptor });
|
||||||
|
const replayed: any[] = [];
|
||||||
|
|
||||||
|
hub.subscribe(
|
||||||
|
"s1",
|
||||||
|
(event, data, id) => replayed.push({ event, data, id }),
|
||||||
|
{ afterId: 0, pending: { ...descriptor, title: "Runtime copy" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(replayed).toEqual([{
|
||||||
|
event: "ui_request",
|
||||||
|
data: { type: "ui_request", ui_request: descriptor },
|
||||||
|
id: 1,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const alreadySeen: any[] = [];
|
||||||
|
hub.subscribe(
|
||||||
|
"s1",
|
||||||
|
(event, data, id) => alreadySeen.push({ event, data, id }),
|
||||||
|
{ afterId: 1, pending: descriptor },
|
||||||
|
);
|
||||||
|
expect(alreadySeen).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unbuffered pending gate receives one fresh buffered id", () => {
|
||||||
|
const hub = new SseHub();
|
||||||
|
hub.publish("s1", "info", { text: "ready" });
|
||||||
|
const first: any[] = [];
|
||||||
|
|
||||||
|
hub.subscribe(
|
||||||
|
"s1",
|
||||||
|
(event, data, id) => first.push({ event, data, id }),
|
||||||
|
{ afterId: 1, pending: { id: "gate-1", widget: "select" } },
|
||||||
|
);
|
||||||
|
expect(first).toEqual([{
|
||||||
|
event: "ui_request",
|
||||||
|
data: { type: "ui_request", ui_request: { id: "gate-1", widget: "select" } },
|
||||||
|
id: 2,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const reconnect: any[] = [];
|
||||||
|
hub.subscribe(
|
||||||
|
"s1",
|
||||||
|
(event, data, id) => reconnect.push({ event, data, id }),
|
||||||
|
{ afterId: 1, pending: { id: "gate-1", widget: "select" } },
|
||||||
|
);
|
||||||
|
expect(reconnect).toEqual(first);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { test, expect } from "vitest";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import { SseHub } from "../src/sse/sse-hub.js";
|
||||||
|
|
||||||
|
async function readUntil(
|
||||||
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||||
|
predicate: (text: string) => boolean,
|
||||||
|
): Promise<string> {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let text = "";
|
||||||
|
const deadline = Date.now() + 1_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const result = await Promise.race([
|
||||||
|
reader.read(),
|
||||||
|
new Promise<{ timeout: true }>((resolve) =>
|
||||||
|
setTimeout(() => resolve({ timeout: true }), Math.max(0, deadline - Date.now())),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
if ("timeout" in result || result.done) break;
|
||||||
|
text += decoder.decode(result.value);
|
||||||
|
if (predicate(text)) break;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureReplay(options: { query?: string; lastEventId?: string }): Promise<string> {
|
||||||
|
const hub = new SseHub();
|
||||||
|
hub.publish("s1", "info", { type: "info", text: "one" });
|
||||||
|
hub.publish("s1", "info", { type: "info", text: "two" });
|
||||||
|
hub.publish("s1", "info", { type: "info", text: "three" });
|
||||||
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/tmp/h" }), {
|
||||||
|
hub,
|
||||||
|
thtRunner: {} as any,
|
||||||
|
});
|
||||||
|
await app.listen({ port: 0, host: "127.0.0.1" });
|
||||||
|
const port = (app.server.address() as { port: number }).port;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`http://127.0.0.1:${port}/sessions/s1/events${options.query ?? ""}`,
|
||||||
|
{
|
||||||
|
headers: options.lastEventId ? { "Last-Event-ID": options.lastEventId } : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const reader = response.body!.getReader();
|
||||||
|
const body = await readUntil(reader, (text) => text.includes('"three"'));
|
||||||
|
await reader.cancel();
|
||||||
|
return body;
|
||||||
|
} finally {
|
||||||
|
controller.abort();
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("SSE emits ids and honors the native Last-Event-ID replay cursor", async () => {
|
||||||
|
const body = await captureReplay({ lastEventId: "1" });
|
||||||
|
|
||||||
|
expect(body).not.toContain('"one"');
|
||||||
|
expect(body).toContain("id: 2\nevent: info\n");
|
||||||
|
expect(body).toContain('data: {"type":"info","text":"two"}');
|
||||||
|
expect(body).toContain("id: 3\nevent: info\n");
|
||||||
|
expect(body).toContain('data: {"type":"info","text":"three"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SSE honors the manual lastEventId query cursor", async () => {
|
||||||
|
const body = await captureReplay({ query: "?lastEventId=2" });
|
||||||
|
|
||||||
|
expect(body).not.toContain('"one"');
|
||||||
|
expect(body).not.toContain('"two"');
|
||||||
|
expect(body).toContain("id: 3\nevent: info\n");
|
||||||
|
expect(body).toContain('data: {"type":"info","text":"three"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SSE uses the newer valid cursor when header and query are both present", async () => {
|
||||||
|
const body = await captureReplay({ query: "?lastEventId=2", lastEventId: "1" });
|
||||||
|
|
||||||
|
expect(body).not.toContain('"two"');
|
||||||
|
expect(body).toContain("id: 3\nevent: info\n");
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import { server } from "../test/msw";
|
import { server } from "../test/msw";
|
||||||
import { createSession, listSessions, prewarmRuntime } from "./sessions";
|
import { createSession, listSessions, prewarmRuntime, resumeSession } from "./sessions";
|
||||||
import {
|
import {
|
||||||
renameSession, setSessionGroup, archiveSession, unarchiveSession,
|
renameSession, setSessionGroup, archiveSession, unarchiveSession,
|
||||||
deleteSession, getSessionDocuments,
|
deleteSession, getSessionDocuments,
|
||||||
@@ -36,6 +36,14 @@ test("listSessions GETs the array", async () => {
|
|||||||
expect(rows[0].id).toBe("s1");
|
expect(rows[0].id).toBe("s1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("resumeSession returns the typed runtime disposition", async () => {
|
||||||
|
server.use(http.post("http://localhost:8787/sessions/s1/resume", () =>
|
||||||
|
HttpResponse.json({ id: "s1", alreadyActive: false })));
|
||||||
|
|
||||||
|
const result: { id: string; alreadyActive: boolean } = await resumeSession("s1");
|
||||||
|
expect(result).toEqual({ id: "s1", alreadyActive: false });
|
||||||
|
});
|
||||||
|
|
||||||
test("renameSession POSTs {name}", async () => {
|
test("renameSession POSTs {name}", async () => {
|
||||||
let body: unknown = null;
|
let body: unknown = null;
|
||||||
server.use(http.post("http://localhost:8787/sessions/s1/rename", async ({ request }) => {
|
server.use(http.post("http://localhost:8787/sessions/s1/rename", async ({ request }) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiFetch } from "./client";
|
import { apiFetch } from "./client";
|
||||||
import type { SessionSummary, SessionDocument, UiResponse } from "./types";
|
import type { ResumeSessionResult, SessionSummary, SessionDocument, UiResponse } from "./types";
|
||||||
|
|
||||||
export const createSession = (i: { question: string; name?: string }) =>
|
export const createSession = (i: { question: string; name?: string }) =>
|
||||||
apiFetch<{ id: string }>("/sessions", { method: "POST", body: JSON.stringify(i) });
|
apiFetch<{ id: string }>("/sessions", { method: "POST", body: JSON.stringify(i) });
|
||||||
@@ -29,7 +29,7 @@ export const closeSession = (id: string) =>
|
|||||||
apiFetch<void>(`/sessions/${id}/close`, { method: "POST" });
|
apiFetch<void>(`/sessions/${id}/close`, { method: "POST" });
|
||||||
|
|
||||||
export const resumeSession = (id: string) =>
|
export const resumeSession = (id: string) =>
|
||||||
apiFetch<void>(`/sessions/${id}/resume`, { method: "POST" });
|
apiFetch<ResumeSessionResult>(`/sessions/${id}/resume`, { method: "POST" });
|
||||||
|
|
||||||
export const renameSession = (id: string, name: string) =>
|
export const renameSession = (id: string, name: string) =>
|
||||||
apiFetch<void>(`/sessions/${id}/rename`, { method: "POST", body: JSON.stringify({ name }) });
|
apiFetch<void>(`/sessions/${id}/rename`, { method: "POST", body: JSON.stringify({ name }) });
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export type StreamEvent =
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
| { type: "info"; level?: "info" | "warning" | "error"; text: string }
|
| { type: "info"; level?: "info" | "warning" | "error"; text: string }
|
||||||
| { type: "system_event"; event: string; [k: string]: unknown };
|
| { type: "system_event"; event: string };
|
||||||
|
|
||||||
export interface SessionSummary {
|
export interface SessionSummary {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -104,6 +104,11 @@ export interface SessionSummary {
|
|||||||
archived: boolean;
|
archived: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResumeSessionResult {
|
||||||
|
id: string;
|
||||||
|
alreadyActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionDocument {
|
export interface SessionDocument {
|
||||||
phase: string;
|
phase: string;
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
@@ -17,9 +17,13 @@ const LIST = [
|
|||||||
{ id: "s2", status: "finalized", question: "Archiviata due", summary: null, created_at: "2026-01-01T00:00:00Z", updated_at: null, author: null, name: null, group: null, archived: true },
|
{ id: "s2", status: "finalized", question: "Archiviata due", summary: null, created_at: "2026-01-01T00:00:00Z", updated_at: null, author: null, name: null, group: null, archived: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const resumeResult = (id: string, alreadyActive = false) =>
|
||||||
|
HttpResponse.json({ id, alreadyActive });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
FakeEventSource.instances = [];
|
FakeEventSource.instances = [];
|
||||||
(globalThis as any).EventSource = FakeEventSource;
|
(globalThis as any).EventSource = FakeEventSource;
|
||||||
|
useSessionStore.getState().resetSession();
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json(LIST)),
|
http.get("http://localhost:8787/sessions", () => HttpResponse.json(LIST)),
|
||||||
http.get("http://localhost:8787/sessions/:id/documents", () => HttpResponse.json([
|
http.get("http://localhost:8787/sessions/:id/documents", () => HttpResponse.json([
|
||||||
@@ -74,7 +78,7 @@ test("Resume from the panel activates the session and closes the panel", async (
|
|||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => {
|
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => {
|
||||||
resumed = params.id as string;
|
resumed = params.id as string;
|
||||||
return new HttpResponse(null, { status: 204 });
|
return resumeResult(resumed);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
wrap();
|
wrap();
|
||||||
@@ -88,10 +92,10 @@ test("Resume from the panel activates the session and closes the panel", async (
|
|||||||
await waitFor(() => expect(screen.queryByText("Domanda originale")).not.toBeInTheDocument()); // panel closed
|
await waitFor(() => expect(screen.queryByText("Domanda originale")).not.toBeInTheDocument()); // panel closed
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Resume paints the re-entry phase from the manifest (optimistic, before the first gate)", async () => {
|
test("Resume paints the re-entry phase from the manifest after the cold Resume succeeds", async () => {
|
||||||
useSessionStore.getState().resetSession();
|
useSessionStore.getState().resetSession();
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 204 })),
|
http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")),
|
||||||
http.get("http://localhost:8787/sessions/:id", () =>
|
http.get("http://localhost:8787/sessions/:id", () =>
|
||||||
HttpResponse.json({ id: "s1", status: "open", phase: 4 })),
|
HttpResponse.json({ id: "s1", status: "open", phase: 4 })),
|
||||||
);
|
);
|
||||||
@@ -102,10 +106,13 @@ test("Resume paints the re-entry phase from the manifest (optimistic, before the
|
|||||||
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F4"));
|
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F4"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resuming the active session reconnects its EventSource", async () => {
|
test("an already-active same-session Resume preserves its EventSource and store", async () => {
|
||||||
|
let resumeCalls = 0;
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", () =>
|
http.post("http://localhost:8787/sessions/:id/resume", () => {
|
||||||
new HttpResponse(null, { status: 204 })),
|
resumeCalls += 1;
|
||||||
|
return resumeResult("s1", resumeCalls > 1);
|
||||||
|
}),
|
||||||
http.get("http://localhost:8787/sessions/:id", () =>
|
http.get("http://localhost:8787/sessions/:id", () =>
|
||||||
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
||||||
);
|
);
|
||||||
@@ -114,18 +121,23 @@ test("resuming the active session reconnects its EventSource", async () => {
|
|||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
||||||
const first = FakeEventSource.instances[0];
|
const first = FakeEventSource.instances[0];
|
||||||
|
act(() => first.emitNamed("info", { type: "info", text: "Live state" }, "4"));
|
||||||
|
const before = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
|
||||||
|
|
||||||
await userEvent.click(screen.getByText("Attiva uno"));
|
await userEvent.click(screen.getByText("Attiva uno"));
|
||||||
|
await screen.findByText("Domanda originale");
|
||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
|
|
||||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
|
await waitFor(() => expect(screen.queryByText("Domanda originale")).not.toBeInTheDocument());
|
||||||
expect(first.closed).toBe(true);
|
expect(FakeEventSource.instances).toHaveLength(1);
|
||||||
|
expect(first.closed).toBe(false);
|
||||||
|
expect(useSessionStore.getState().activityLog).toEqual(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("same-session Resume reconnects only after the deferred POST succeeds", async () => {
|
test("cold same-session Resume keeps the old stream until success then receives post-clear events once", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", () =>
|
http.post("http://localhost:8787/sessions/:id/resume", () =>
|
||||||
new HttpResponse(null, { status: 204 })),
|
resumeResult("s1")),
|
||||||
http.get("http://localhost:8787/sessions/:id", () =>
|
http.get("http://localhost:8787/sessions/:id", () =>
|
||||||
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
||||||
);
|
);
|
||||||
@@ -134,6 +146,7 @@ test("same-session Resume reconnects only after the deferred POST succeeds", asy
|
|||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
||||||
const first = FakeEventSource.instances[0];
|
const first = FakeEventSource.instances[0];
|
||||||
|
act(() => first.emitNamed("info", { type: "info", text: "Before cold Resume" }, "7"));
|
||||||
|
|
||||||
let releaseResume!: () => void;
|
let releaseResume!: () => void;
|
||||||
let markStarted!: () => void;
|
let markStarted!: () => void;
|
||||||
@@ -142,24 +155,62 @@ test("same-session Resume reconnects only after the deferred POST succeeds", asy
|
|||||||
server.use(http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
server.use(http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
||||||
markStarted();
|
markStarted();
|
||||||
await resumeReleased;
|
await resumeReleased;
|
||||||
return new HttpResponse(null, { status: 204 });
|
return resumeResult("s1");
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await userEvent.click(screen.getByText("Attiva uno"));
|
await userEvent.click(screen.getByText("Attiva uno"));
|
||||||
|
await screen.findByText("Domanda originale");
|
||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
await resumeStarted;
|
await resumeStarted;
|
||||||
expect(FakeEventSource.instances).toHaveLength(1);
|
expect(FakeEventSource.instances).toHaveLength(1);
|
||||||
expect(first.closed).toBe(false);
|
expect(first.closed).toBe(false);
|
||||||
|
expect(screen.getByText("Domanda originale")).toBeInTheDocument();
|
||||||
|
expect(useSessionStore.getState().activityLog).toContainEqual({
|
||||||
|
kind: "status", phase: "F1", text: "Before cold Resume", level: "info",
|
||||||
|
});
|
||||||
|
act(() => first.emitNamed("text_delta", { type: "text_delta", text: "Still attached" }, "8"));
|
||||||
|
expect(useSessionStore.getState().transcript).toEqual([
|
||||||
|
{ role: "assistant", text: "Still attached" },
|
||||||
|
]);
|
||||||
|
|
||||||
releaseResume();
|
releaseResume();
|
||||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
|
||||||
expect(first.closed).toBe(true);
|
expect(first.closed).toBe(true);
|
||||||
|
const replacement = FakeEventSource.instances[1];
|
||||||
|
expect(replacement.url).toContain("/sessions/s1/events?lastEventId=8");
|
||||||
|
expect(useSessionStore.getState().transcript).toEqual([]);
|
||||||
|
expect(useSessionStore.getState().activityLog).toEqual([
|
||||||
|
{ kind: "lifecycle", phase: null, text: "Resuming session" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
act(() => replacement.emitNamed(
|
||||||
|
"text_delta",
|
||||||
|
{ type: "text_delta", text: "Post-resume delivery" },
|
||||||
|
"9",
|
||||||
|
));
|
||||||
|
expect(useSessionStore.getState().transcript).toEqual([
|
||||||
|
{ role: "assistant", text: "Post-resume delivery" },
|
||||||
|
]);
|
||||||
|
expect(useSessionStore.getState().activityLog.filter((entry) => entry.kind === "assistant"))
|
||||||
|
.toEqual([{ kind: "assistant", phase: "F1", text: "Post-resume delivery" }]);
|
||||||
|
|
||||||
|
const gate = {
|
||||||
|
type: "ui_request" as const,
|
||||||
|
ui_request: { id: "post-resume-gate", widget: "select", title: "Review once" },
|
||||||
|
};
|
||||||
|
act(() => {
|
||||||
|
replacement.emitNamed("ui_request", gate, "10");
|
||||||
|
replacement.emitNamed("ui_request", gate, "11");
|
||||||
|
});
|
||||||
|
expect(useSessionStore.getState().pendingWidget?.id).toBe("post-resume-gate");
|
||||||
|
expect(useSessionStore.getState().activityLog.filter((entry) => entry.kind === "gate"))
|
||||||
|
.toEqual([{ kind: "gate", phase: "F1", text: "Review once" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a failed same-session Resume does not create a replacement EventSource", async () => {
|
test("a failed same-session Resume preserves its source, activity, and document panel", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", () =>
|
http.post("http://localhost:8787/sessions/:id/resume", () =>
|
||||||
new HttpResponse(null, { status: 204 })),
|
resumeResult("s1")),
|
||||||
http.get("http://localhost:8787/sessions/:id", () =>
|
http.get("http://localhost:8787/sessions/:id", () =>
|
||||||
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
||||||
);
|
);
|
||||||
@@ -167,6 +218,9 @@ test("a failed same-session Resume does not create a replacement EventSource", a
|
|||||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
||||||
|
const first = FakeEventSource.instances[0];
|
||||||
|
act(() => first.emitNamed("info", { type: "info", text: "Keep me" }, "4"));
|
||||||
|
const before = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
|
||||||
|
|
||||||
server.use(http.post("http://localhost:8787/sessions/:id/resume", () =>
|
server.use(http.post("http://localhost:8787/sessions/:id/resume", () =>
|
||||||
new HttpResponse(null, { status: 409 })));
|
new HttpResponse(null, { status: 409 })));
|
||||||
@@ -175,17 +229,21 @@ test("a failed same-session Resume does not create a replacement EventSource", a
|
|||||||
await waitFor(() => expect(screen.getByText("Domanda originale")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("Domanda originale")).toBeInTheDocument());
|
||||||
|
|
||||||
expect(FakeEventSource.instances).toHaveLength(1);
|
expect(FakeEventSource.instances).toHaveLength(1);
|
||||||
|
expect(first.closed).toBe(false);
|
||||||
|
expect(useSessionStore.getState().activityLog).toEqual(before);
|
||||||
|
act(() => first.emitNamed("text_delta", { type: "text_delta", text: "After failure" }, "5"));
|
||||||
|
expect(useSessionStore.getState().transcript.at(-1)?.text).toBe("After failure");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resuming a different session opens exactly one new EventSource", async () => {
|
test("resuming a different already-active session binds it only after success", async () => {
|
||||||
const other = {
|
const other = {
|
||||||
...LIST[0], id: "s3", question: "Attiva tre", group: null,
|
...LIST[0], id: "s3", question: "Attiva tre", group: null,
|
||||||
created_at: "2026-01-03T00:00:00Z",
|
created_at: "2026-01-03T00:00:00Z",
|
||||||
};
|
};
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", () =>
|
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) =>
|
||||||
new HttpResponse(null, { status: 204 })),
|
resumeResult(params.id as string, params.id === "s3")),
|
||||||
http.get("http://localhost:8787/sessions/:id", ({ params }) =>
|
http.get("http://localhost:8787/sessions/:id", ({ params }) =>
|
||||||
HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })),
|
HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })),
|
||||||
);
|
);
|
||||||
@@ -193,28 +251,42 @@ test("resuming a different session opens exactly one new EventSource", async ()
|
|||||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
||||||
|
const first = FakeEventSource.instances[0];
|
||||||
|
act(() => first.emitNamed("info", { type: "info", text: "Old target" }, "3"));
|
||||||
|
|
||||||
await userEvent.click(screen.getByText("Attiva tre"));
|
await userEvent.click(screen.getByText("Attiva tre"));
|
||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F3"));
|
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F3"));
|
||||||
|
|
||||||
expect(FakeEventSource.instances).toHaveLength(2);
|
expect(FakeEventSource.instances).toHaveLength(2);
|
||||||
|
expect(first.closed).toBe(true);
|
||||||
expect(FakeEventSource.instances[1].url).toContain("/sessions/s3/events");
|
expect(FakeEventSource.instances[1].url).toContain("/sessions/s3/events");
|
||||||
|
expect(FakeEventSource.instances[1].url).not.toContain("lastEventId");
|
||||||
|
expect(useSessionStore.getState().activityLog).not.toContainEqual(expect.objectContaining({
|
||||||
|
text: "Old target",
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a failed resume keeps the panel open and does not activate the session", async () => {
|
test("a failed Resume with no active session preserves the panel and existing activity", async () => {
|
||||||
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 409 })));
|
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 409 })));
|
||||||
|
useSessionStore.setState({
|
||||||
|
activityLog: [{ kind: "status", phase: "F7", text: "Preserve activity", level: "info" }],
|
||||||
|
});
|
||||||
wrap();
|
wrap();
|
||||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||||
await screen.findByText("Domanda originale"); // panel open
|
await screen.findByText("Domanda originale"); // panel open
|
||||||
await userEvent.click(screen.getByRole("button", { name: /resume/i }));
|
await userEvent.click(screen.getByRole("button", { name: /resume/i }));
|
||||||
// panel stays open (document still visible) after the failed resume
|
// panel stays open (document still visible) after the failed resume
|
||||||
await waitFor(() => expect(screen.getByText("Domanda originale")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("Domanda originale")).toBeInTheDocument());
|
||||||
|
expect(FakeEventSource.instances).toHaveLength(0);
|
||||||
|
expect(useSessionStore.getState().activityLog).toEqual([
|
||||||
|
{ kind: "status", phase: "F7", text: "Preserve activity", level: "info" },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("closing and reopening Model activity preserves the complete activity log", async () => {
|
test("closing and reopening Model activity preserves the complete activity log", async () => {
|
||||||
wrap();
|
wrap();
|
||||||
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 204 })));
|
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")));
|
||||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -257,7 +329,7 @@ test("session finalization shows the completion banner and returns to landing",
|
|||||||
useSessionStore.getState().resetSession();
|
useSessionStore.getState().resetSession();
|
||||||
let finalized = false;
|
let finalized = false;
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 204 })),
|
http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")),
|
||||||
http.get("http://localhost:8787/sessions", () =>
|
http.get("http://localhost:8787/sessions", () =>
|
||||||
HttpResponse.json(finalized ? [{ ...LIST[0], status: "finalized" }] : LIST)),
|
HttpResponse.json(finalized ? [{ ...LIST[0], status: "finalized" }] : LIST)),
|
||||||
);
|
);
|
||||||
@@ -299,7 +371,7 @@ test("renaming a group reassigns its members via setSessionGroup", async () => {
|
|||||||
|
|
||||||
|
|
||||||
test("opening Model activity replaces the session rail with a 40/60 activity and chat layout", async () => {
|
test("opening Model activity replaces the session rail with a 40/60 activity and chat layout", async () => {
|
||||||
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 204 })));
|
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")));
|
||||||
wrap();
|
wrap();
|
||||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
|
|||||||
@@ -100,22 +100,29 @@ export function AppShell() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function doResume(id: string) {
|
async function doResume(id: string) {
|
||||||
const s = sessions.find((x) => x.id === id) ?? null;
|
|
||||||
const reconnectSameSession = activeSessionId === id;
|
const reconnectSameSession = activeSessionId === id;
|
||||||
// Optimistic switch: change to the session view IMMEDIATELY so the click feels
|
|
||||||
// instant (the resume POST spawns a Pi process and can take seconds). The
|
|
||||||
// working spinner shows straight away; the backend calls run after.
|
|
||||||
setPanelSession(null);
|
|
||||||
resetSession();
|
|
||||||
recordLifecycle("Resuming session");
|
|
||||||
setAwaitingQuestion(false);
|
|
||||||
// Optimistic: the resume POST is about to hand the ball to the harness.
|
|
||||||
setAgentActive(true);
|
|
||||||
setActiveSessionId(id);
|
|
||||||
try {
|
try {
|
||||||
await resumeSession(id);
|
const result = await resumeSession(id);
|
||||||
if (reconnectSameSession) setStreamGeneration((value) => value + 1);
|
setPanelSession(null);
|
||||||
// Optimistic phase paint: colour the re-entry phase before the first gate.
|
setAwaitingQuestion(false);
|
||||||
|
|
||||||
|
// A running/waiting runtime for the currently selected session is already bound to this
|
||||||
|
// store and EventSource. Reopening it would replay state and can lose in-flight delivery.
|
||||||
|
if (result.alreadyActive && reconnectSameSession) return;
|
||||||
|
|
||||||
|
resetSession();
|
||||||
|
if (!result.alreadyActive) {
|
||||||
|
recordLifecycle("Resuming session");
|
||||||
|
setAgentActive(true);
|
||||||
|
}
|
||||||
|
setActiveSessionId(id);
|
||||||
|
// The backend has now completed clear/rebind. Recreate a same-id source with its cursor;
|
||||||
|
// switching ids naturally creates a new source and resets the cursor in the stream hook.
|
||||||
|
if (!result.alreadyActive && reconnectSameSession) {
|
||||||
|
setStreamGeneration((value) => value + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paint the persisted re-entry phase while the replacement stream starts replaying.
|
||||||
// The manifest's `phase` is the 1-based current phase (1..8).
|
// The manifest's `phase` is the 1-based current phase (1..8).
|
||||||
try {
|
try {
|
||||||
const m = (await getSession(id)) as { phase?: number };
|
const m = (await getSession(id)) as { phase?: number };
|
||||||
@@ -126,10 +133,6 @@ export function AppShell() {
|
|||||||
/* non-fatal: the first gate will set the phase */
|
/* non-fatal: the first gate will set the phase */
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Revert the optimistic switch and restore the panel.
|
|
||||||
resetSession();
|
|
||||||
setActiveSessionId(null);
|
|
||||||
if (s) setPanelSession(s);
|
|
||||||
toast.error("Failed to resume session.");
|
toast.error("Failed to resume session.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,29 @@ test("ui_request sets pendingWidget", () => {
|
|||||||
expect(useSessionStore.getState().pendingWidget?.id).toBe("u1");
|
expect(useSessionStore.getState().pendingWidget?.id).toBe("u1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the same gate descriptor id is handled once even after pending is cleared", () => {
|
||||||
|
const store = useSessionStore.getState();
|
||||||
|
const gate = {
|
||||||
|
type: "ui_request" as const,
|
||||||
|
ui_request: { id: "gate-1", widget: "select", title: "Review tables" },
|
||||||
|
};
|
||||||
|
|
||||||
|
store.applyEvent(gate);
|
||||||
|
store.applyEvent({
|
||||||
|
type: "ui_request",
|
||||||
|
ui_request: { ...gate.ui_request, title: "Duplicate transport copy" },
|
||||||
|
});
|
||||||
|
store.clearPending();
|
||||||
|
store.applyEvent(gate);
|
||||||
|
|
||||||
|
const state = useSessionStore.getState();
|
||||||
|
expect(state.pendingWidget).toBeNull();
|
||||||
|
expect(state.activityLog.filter((entry) => entry.kind === "gate")).toEqual([
|
||||||
|
{ kind: "gate", phase: null, text: "Review tables" },
|
||||||
|
]);
|
||||||
|
expect(state.seenGateIds).toEqual(new Set(["gate-1"]));
|
||||||
|
});
|
||||||
|
|
||||||
test("text_delta accumulates into transcript", () => {
|
test("text_delta accumulates into transcript", () => {
|
||||||
const s = useSessionStore.getState();
|
const s = useSessionStore.getState();
|
||||||
s.applyEvent({ type: "text_delta", text: "Ana" });
|
s.applyEvent({ type: "text_delta", text: "Ana" });
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface SessionState {
|
|||||||
lastSystemEvent: StreamEvent | null;
|
lastSystemEvent: StreamEvent | null;
|
||||||
currentPhase: string | null;
|
currentPhase: string | null;
|
||||||
phaseError: string | null;
|
phaseError: string | null;
|
||||||
|
seenGateIds: Set<string>;
|
||||||
// True while a Pi turn is in flight. Turned off by the backend-forwarded `agent_end`
|
// True while a Pi turn is in flight. Turned off by the backend-forwarded `agent_end`
|
||||||
// system event — the only end-of-turn signal on the final workflow step, where no
|
// system event — the only end-of-turn signal on the final workflow step, where no
|
||||||
// follow-up gate arrives to release the spinner.
|
// follow-up gate arrives to release the spinner.
|
||||||
@@ -62,6 +63,7 @@ const empty = {
|
|||||||
lastSystemEvent: null,
|
lastSystemEvent: null,
|
||||||
currentPhase: null as string | null,
|
currentPhase: null as string | null,
|
||||||
phaseError: null as string | null,
|
phaseError: null as string | null,
|
||||||
|
seenGateIds: new Set<string>(),
|
||||||
agentActive: false,
|
agentActive: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,9 +72,11 @@ export const useSessionStore = create<SessionState>((set) => ({
|
|||||||
applyEvent: (e) =>
|
applyEvent: (e) =>
|
||||||
set((st) => {
|
set((st) => {
|
||||||
if (e.type === "ui_request") {
|
if (e.type === "ui_request") {
|
||||||
|
if (st.seenGateIds.has(e.ui_request.id)) return {};
|
||||||
const currentPhase = phaseOf(e.ui_request.phase, st.currentPhase);
|
const currentPhase = phaseOf(e.ui_request.phase, st.currentPhase);
|
||||||
return {
|
return {
|
||||||
pendingWidget: e.ui_request,
|
pendingWidget: e.ui_request,
|
||||||
|
seenGateIds: new Set([...st.seenGateIds, e.ui_request.id]),
|
||||||
// The turn is blocked on the gate, not ended: keep the agent marked active.
|
// The turn is blocked on the gate, not ended: keep the agent marked active.
|
||||||
agentActive: true,
|
agentActive: true,
|
||||||
currentPhase,
|
currentPhase,
|
||||||
|
|||||||
@@ -70,16 +70,46 @@ test("closes the stream on unmount", () => {
|
|||||||
expect(es.closed).toBe(true);
|
expect(es.closed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reconnects the same session when the generation changes", () => {
|
test("a same-session generation reconnect includes the last consumed SSE id", () => {
|
||||||
const { rerender } = renderHook(
|
const { rerender } = renderHook(
|
||||||
({ generation }) => useSessionStream("s1", generation),
|
({ generation }) => useSessionStream("s1", generation),
|
||||||
{ initialProps: { generation: 0 } },
|
{ initialProps: { generation: 0 } },
|
||||||
);
|
);
|
||||||
const first = FakeEventSource.instances[0];
|
const first = FakeEventSource.instances[0];
|
||||||
|
act(() => first.emitNamed("text_delta", { type: "text_delta", text: "hello" }, "7"));
|
||||||
|
|
||||||
rerender({ generation: 1 });
|
rerender({ generation: 1 });
|
||||||
|
|
||||||
expect(first.closed).toBe(true);
|
expect(first.closed).toBe(true);
|
||||||
expect(FakeEventSource.instances).toHaveLength(2);
|
expect(FakeEventSource.instances).toHaveLength(2);
|
||||||
expect(FakeEventSource.instances[1].url).toBe(first.url);
|
expect(FakeEventSource.instances[1].url).toBe(
|
||||||
|
"http://localhost:8787/sessions/s1/events?lastEventId=7",
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
FakeEventSource.instances[1].emitNamed(
|
||||||
|
"text_delta",
|
||||||
|
{ type: "text_delta", text: " world" },
|
||||||
|
"8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(useSessionStore.getState().transcript).toEqual([
|
||||||
|
{ role: "assistant", text: "hello world" },
|
||||||
|
]);
|
||||||
|
expect(useSessionStore.getState().activityLog.filter((entry) => entry.kind === "assistant"))
|
||||||
|
.toEqual([{ kind: "assistant", phase: null, text: "hello world" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changing the session resets the manual reconnect cursor", () => {
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ sessionId }) => useSessionStream(sessionId),
|
||||||
|
{ initialProps: { sessionId: "s1" as string | null } },
|
||||||
|
);
|
||||||
|
const first = FakeEventSource.instances[0];
|
||||||
|
act(() => first.emitNamed("info", { type: "info", text: "ready" }, "12"));
|
||||||
|
|
||||||
|
rerender({ sessionId: "s2" });
|
||||||
|
|
||||||
|
expect(first.closed).toBe(true);
|
||||||
|
expect(FakeEventSource.instances[1].url).toBe("http://localhost:8787/sessions/s2/events");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { BASE } from "../api/client";
|
import { BASE } from "../api/client";
|
||||||
import { joinBackendPath } from "../api/runtime-config";
|
import { joinBackendPath } from "../api/runtime-config";
|
||||||
import { useSessionStore } from "../store/sessionStore";
|
import { useSessionStore } from "../store/sessionStore";
|
||||||
@@ -7,15 +7,24 @@ import type { StreamEvent } from "../api/types";
|
|||||||
export function useSessionStream(sessionId: string | null, generation = 0) {
|
export function useSessionStream(sessionId: string | null, generation = 0) {
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const applyEvent = useSessionStore((s) => s.applyEvent);
|
const applyEvent = useSessionStore((s) => s.applyEvent);
|
||||||
|
const cursor = useRef({ sessionId: null as string | null, lastEventId: "" });
|
||||||
|
|
||||||
|
if (cursor.current.sessionId !== sessionId) {
|
||||||
|
cursor.current = { sessionId, lastEventId: "" };
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
const es = new EventSource(joinBackendPath(BASE, `/sessions/${sessionId}/events`));
|
const query = cursor.current.lastEventId
|
||||||
|
? `?lastEventId=${encodeURIComponent(cursor.current.lastEventId)}`
|
||||||
|
: "";
|
||||||
|
const es = new EventSource(joinBackendPath(BASE, `/sessions/${sessionId}/events${query}`));
|
||||||
es.onopen = () => setConnected(true);
|
es.onopen = () => setConnected(true);
|
||||||
es.onerror = () => setConnected(false);
|
es.onerror = () => setConnected(false);
|
||||||
|
|
||||||
const handle = (ev: MessageEvent) => {
|
const handle = (ev: MessageEvent) => {
|
||||||
|
if (ev.lastEventId) cursor.current.lastEventId = ev.lastEventId;
|
||||||
try {
|
try {
|
||||||
applyEvent(JSON.parse(ev.data) as StreamEvent);
|
applyEvent(JSON.parse(ev.data) as StreamEvent);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
export class FakeEventSource {
|
export class FakeEventSource {
|
||||||
static instances: FakeEventSource[] = [];
|
static instances: FakeEventSource[] = [];
|
||||||
onmessage: ((e: { data: string }) => void) | null = null;
|
onmessage: ((e: { data: string; lastEventId: string }) => void) | null = null;
|
||||||
onopen: (() => void) | null = null;
|
onopen: (() => void) | null = null;
|
||||||
onerror: (() => void) | null = null;
|
onerror: (() => void) | null = null;
|
||||||
closed = false;
|
closed = false;
|
||||||
private listeners = new Map<string, Set<(e: { data: string }) => void>>();
|
private listeners = new Map<string, Set<(e: { data: string; lastEventId: string }) => void>>();
|
||||||
constructor(public url: string) {
|
constructor(public url: string) {
|
||||||
FakeEventSource.instances.push(this);
|
FakeEventSource.instances.push(this);
|
||||||
}
|
}
|
||||||
/** Dispatch to the default onmessage handler (unnamed `event: message`). */
|
/** Dispatch to the default onmessage handler (unnamed `event: message`). */
|
||||||
emit(obj: unknown) {
|
emit(obj: unknown, lastEventId = "") {
|
||||||
this.onmessage?.({ data: JSON.stringify(obj) });
|
this.onmessage?.({ data: JSON.stringify(obj), lastEventId });
|
||||||
}
|
}
|
||||||
/** Dispatch to handlers registered for a NAMED event (e.g. "ui_request"). */
|
/** Dispatch to handlers registered for a NAMED event (e.g. "ui_request"). */
|
||||||
emitNamed(type: string, obj: unknown) {
|
emitNamed(type: string, obj: unknown, lastEventId = "") {
|
||||||
const data = JSON.stringify(obj);
|
const data = JSON.stringify(obj);
|
||||||
for (const h of this.listeners.get(type) ?? []) h({ data });
|
for (const h of this.listeners.get(type) ?? []) h({ data, lastEventId });
|
||||||
}
|
}
|
||||||
addEventListener(type: string, handler: (e: { data: string }) => void) {
|
addEventListener(type: string, handler: (e: { data: string; lastEventId: string }) => void) {
|
||||||
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
||||||
this.listeners.get(type)!.add(handler);
|
this.listeners.get(type)!.add(handler);
|
||||||
}
|
}
|
||||||
removeEventListener(type: string, handler: (e: { data: string }) => void) {
|
removeEventListener(type: string, handler: (e: { data: string; lastEventId: string }) => void) {
|
||||||
this.listeners.get(type)?.delete(handler);
|
this.listeners.get(type)?.delete(handler);
|
||||||
}
|
}
|
||||||
close() {
|
close() {
|
||||||
|
|||||||
Reference in New Issue
Block a user