fix: finalize session after memory promotion

This commit is contained in:
2026-07-12 14:26:48 +02:00
parent e02e61ea35
commit 08f0029793
6 changed files with 232 additions and 39 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "replay",
"runtimeExecutable": "node",
"runtimeArgs": ["tools/replay/server.mjs"],
"port": 5333
}
]
}
@@ -125,6 +125,31 @@ test("WIP icon toggles the Model activity panel and shows the streamed text", as
expect(screen.getByText(/FIRSTLINE/)).toBeInTheDocument(); expect(screen.getByText(/FIRSTLINE/)).toBeInTheDocument();
}); });
test("session finalization shows the completion banner and returns to landing", async () => {
useSessionStore.getState().resetSession();
let finalized = false;
server.use(
http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 204 })),
http.get("http://localhost:8787/sessions", () =>
HttpResponse.json(finalized ? [{ ...LIST[0], status: "finalized" }] : LIST)),
);
wrap();
await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
// The final turn ends: the session is now finalized on disk and agent_end
// arrives — the shell must refetch immediately (not wait for the 10s poll)
// and surface the new-session invite.
finalized = true;
act(() => {
useSessionStore.getState().applyEvent({ type: "system_event", event: "agent_end" } as any);
});
const cta = await screen.findByRole("button", { name: /start a new question/i });
expect(screen.getByText(/session completed and finalized/i)).toBeInTheDocument();
await userEvent.click(cta);
// Landing state: the composer invites a brand-new question.
expect(await screen.findByText(/type your question/i)).toBeInTheDocument();
});
test("renaming a group reassigns its members via setSessionGroup", async () => { test("renaming a group reassigns its members via setSessionGroup", async () => {
const groupSets: Array<{ id: string; group: string }> = []; const groupSets: Array<{ id: string; group: string }> = [];
server.use( server.use(
+18 -3
View File
@@ -177,9 +177,13 @@ export function AppShell() {
// stopSession already does, and this effect must stay side-effect-free on the // stopSession already does, and this effect must stay side-effect-free on the
// backend if the session is already inactive. // backend if the session is already inactive.
useEffect(() => { useEffect(() => {
if (lastSystemEvent?.type === "system_event" && (lastSystemEvent as any).event === "session_exit") { if (lastSystemEvent?.type !== "system_event") return;
stopSession(); const ev = (lastSystemEvent as any).event;
} if (ev === "session_exit") stopSession();
// The final workflow turn ends with the session already finalized on disk:
// refetch now instead of waiting for the 10s poll, so the completed state
// (and the new-session invite below the transcript) appears immediately.
if (ev === "agent_end") refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastSystemEvent]); }, [lastSystemEvent]);
@@ -241,6 +245,17 @@ export function AppShell() {
<> <>
<CentralStatus working={working} /> <CentralStatus working={working} />
<WidgetHost sessionId={activeSessionId} /> <WidgetHost sessionId={activeSessionId} />
{finalized && !agentActive && (
<div className="rounded-2xl border border-border/80 bg-card p-5 text-center shadow-md">
<p className="text-sm text-muted-foreground">
Session completed and finalized — the SQL and all phase
documents are saved.
</p>
<Button className="mt-3" onClick={startNewSession}>
Start a new question
</Button>
</div>
)}
</> </>
) : ( ) : (
<EmptyState /> <EmptyState />
@@ -0,0 +1,87 @@
const test = require("node:test");
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { createFakePi } = require("./fake_pi_runtime.js");
// F8 auto-close: reviewer_memory_promote is the last human interaction of the
// workflow — after recording the promotion the gate must advance F8 and finalize
// the session itself (no trailing reviewer_confirm "approve" form). These tests
// drive the real tool execute against a fake `tht` CLI placed on PATH.
// tht-gate.js calls a bare `require` (Pi's runtime provides it); under node --test
// the ESM import has none, so expose the CJS require globally for the gate module.
globalThis.require = require;
const installGatePromise = import("../../tht-gate.js").then((m) => m.default);
function setupFakeTht(t, { phase, status = "open" }) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fake-tht-"));
const log = path.join(dir, "calls.log");
fs.writeFileSync(log, "");
const script = `#!/bin/bash
echo "$@" >> "${log}"
case "$1 $2" in
"phase show") echo "Fase corrente: ${phase}";;
"phase meta") echo '{"max_phase":8,"phases":[{"num":2,"id":"F2","emits":[]},{"num":8,"id":"F8","emits":[]}]}';;
"memory promote") echo "[]";;
"session show") echo '{"status":"${status}"}';;
*) echo "OK";;
esac
`;
const bin = path.join(dir, "tht");
fs.writeFileSync(bin, script, { mode: 0o755 });
const oldPath = process.env.PATH;
process.env.PATH = `${dir}:${oldPath}`;
t.after(() => {
process.env.PATH = oldPath;
fs.rmSync(dir, { recursive: true, force: true });
});
return { dir, calls: () => fs.readFileSync(log, "utf8") };
}
async function runPromote(t, { phase }) {
const fake = setupFakeTht(t, { phase });
const installGate = await installGatePromise;
const { pi, ctx, tools } = createFakePi();
ctx.cwd = fake.dir; // execFileSync needs an existing cwd; no .env here is fine
installGate(pi);
const { def } = tools.get("reviewer_memory_promote");
const res = await def.execute("t1", { session: "s1" }, null, null, ctx);
return { res, calls: fake.calls() };
}
test("F8 + zero candidati: il gate avanza la fase e finalizza da solo", async (t) => {
const { res, calls } = await runPromote(t, { phase: 8 });
const text = res.content[0].text;
assert.match(text, /sessione finalizzata \(s1\)/);
assert.match(text, /NON presentare altri gate/);
assert.match(calls, /^phase advance --session s1$/m);
assert.match(calls, /^session finalize s1$/m);
});
test("fuori dall'ultima fase non chiude nulla (comportamento precedente)", async (t) => {
const { res, calls } = await runPromote(t, { phase: 2 });
assert.match(res.content[0].text, /prosegui con la chiusura della sessione/);
assert.doesNotMatch(calls, /phase advance/);
assert.doesNotMatch(calls, /session finalize/);
});
test("phase-gate su sessione gia' finalizzata: nessun widget, il modello viene fermato", async (t) => {
setupFakeTht(t, { phase: 8, status: "finalized" });
const installGate = await installGatePromise;
const { pi, ctx, tools } = createFakePi();
ctx.cwd = os.tmpdir();
installGate(pi);
const { def } = tools.get("reviewer_confirm");
const res = await def.execute(
"t2",
{ session: "s1", kind: "phase", title: "F8", artifact: { kind: "phase", data: {} } },
null,
null,
ctx,
);
assert.match(res.content[0].text, /gia' finalizzata/);
assert.equal(ctx.uiCalls.length, 0); // la form "approve" NON viene ripresentata
});
+75 -25
View File
@@ -832,6 +832,24 @@ export default function (pi) {
const curNum = currentPhase(ctx, session); const curNum = currentPhase(ctx, session);
const phase = phaseId(ctx, curNum); const phase = phaseId(ctx, curNum);
// Sessione gia' finalizzata: non c'e' piu' nulla da approvare — mai
// ripresentare il phase-gate (la form "approve") a workflow chiuso.
if (kind === "phase" && curNum >= phaseMeta(ctx).max_phase) {
try {
const show = JSON.parse(tht(ctx, ["session", "show", session, "--json"]));
if (show?.status === "finalized") {
lockActive = false;
return textResult(
`La sessione ${session} e' gia' finalizzata: il workflow e' completo. ` +
"Comunica al reviewer il riepilogo finale e termina il turno; NON " +
"presentare altri gate reviewer_*.",
);
}
} catch {
// show non leggibile: prosegui col gate normale
}
}
// --- v2 payload construction (WS2) ------------------------------------- // --- v2 payload construction (WS2) -------------------------------------
// The gate builds/validates the structured payload BEFORE showing the // The gate builds/validates the structured payload BEFORE showing the
// widget, so the reviewer approves a deterministic artifact (not the raw // widget, so the reviewer approves a deterministic artifact (not the raw
@@ -948,26 +966,9 @@ export default function (pi) {
return textResult("Il reviewer vuole uscire."); return textResult("Il reviewer vuole uscire.");
// outcome.kind === "approve" -> execute the privileged action via the CLI. // outcome.kind === "approve" -> execute the privileged action via the CLI.
if (kind === "phase") { if (kind === "phase") {
// Explicit human approval: advance unconditionally except for unmet const r = advancePhaseAndFinalize(ctx, session, curNum);
// prerequisites. Plain `phase advance` (no --auto) enforces advance_problems if (r.err) return r.err;
// and exits 6 with the missing items, which relayIfThtFails surfaces. if (r.finalized) {
const err = relayIfThtFails(
ctx,
["phase", "advance", "--session", session],
PHASE_RECOVERY,
);
if (err) return err;
// Auto-finalize after advancing the LAST phase: the model may not
// follow through (token budget, turn end) leaving the session open.
const meta = phaseMeta(ctx);
if (curNum >= meta.max_phase) {
const fErr = relayIfThtFails(
ctx,
["session", "finalize", session],
"Fase approvata ma finalizzazione fallita: esegui manualmente " +
`\`tht session finalize ${session}\`.`,
);
if (fErr) return fErr;
lockActive = false; lockActive = false;
lastSteered = false; lastSteered = false;
return textResult( return textResult(
@@ -1053,6 +1054,46 @@ export default function (pi) {
}, },
}); });
// Explicit human approval: advance unconditionally except for unmet
// prerequisites. Plain `phase advance` (no --auto) enforces advance_problems
// and exits 6 with the missing items, which relayIfThtFails surfaces.
// Auto-finalize after advancing the LAST phase: the model may not follow
// through (token budget, turn end) leaving the session open.
function advancePhaseAndFinalize(ctx, session, curNum) {
const err = relayIfThtFails(
ctx,
["phase", "advance", "--session", session],
PHASE_RECOVERY,
);
if (err) return { err };
if (curNum < phaseMeta(ctx).max_phase) return { finalized: false };
const fErr = relayIfThtFails(
ctx,
["session", "finalize", session],
"Fase approvata ma finalizzazione fallita: esegui manualmente " +
`\`tht session finalize ${session}\`.`,
);
if (fErr) return { err: fErr };
return { finalized: true };
}
// La promozione memorie e' l'ultima interazione umana di F8: chiudere qui
// (advance + finalize) evita il reviewer_confirm finale a workflow gia'
// deciso (la form "approve" ridondante a fine sessione). Fuori dall'ultima
// fase ritorna null e il chiamante mantiene il comportamento precedente.
function closeAfterPromotion(ctx, session, curNum, summary) {
if (curNum < phaseMeta(ctx).max_phase) return null;
const r = advancePhaseAndFinalize(ctx, session, curNum);
if (r.err) return r.err;
lockActive = false;
lastSteered = false;
return textResult(
`${summary} Fase ${curNum} approvata e sessione finalizzata (${session}): ` +
"il workflow e' completo. Comunica al reviewer il riepilogo finale " +
"della sessione e termina il turno; NON presentare altri gate reviewer_*.",
);
}
pi.registerTool({ pi.registerTool({
name: "reviewer_memory_promote", name: "reviewer_memory_promote",
label: "Promozione memorie riusabili (reviewer)", label: "Promozione memorie riusabili (reviewer)",
@@ -1062,14 +1103,17 @@ export default function (pi) {
"table_promoted/table_excluded, max 5, esclusi i gia' promossi/rifiutati). Le selezioni " + "table_promoted/table_excluded, max 5, esclusi i gia' promossi/rifiutati). Le selezioni " +
"vengono salvate nel vectordb (tht memory save-one) e registrate come memory_promoted; " + "vengono salvate nel vectordb (tht memory save-one) e registrate come memory_promoted; " +
"le deselezioni come memory_promotion_declined (non riproposte). Nessun parametro oltre " + "le deselezioni come memory_promotion_declined (non riproposte). Nessun parametro oltre " +
"alla sessione: i candidati sono deterministici, NON li scrivi tu.", "alla sessione: i candidati sono deterministici, NON li scrivi tu. Registrata la " +
"promozione, in F8 il gate chiude la fase e finalizza la sessione da solo: NON " +
"presentare un reviewer_confirm dopo.",
parameters: Type.Object({ parameters: Type.Object({
session: Type.String(), session: Type.String(),
}), }),
async execute(_id, params, _signal, _onUpdate, ctx) { async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true; lockActive = true;
const { session } = params; const { session } = params;
const phase = phaseId(ctx, currentPhase(ctx, session)); const curNum = currentPhase(ctx, session);
const phase = phaseId(ctx, curNum);
let candidates; let candidates;
try { try {
candidates = JSON.parse( candidates = JSON.parse(
@@ -1084,6 +1128,10 @@ export default function (pi) {
"Nessuna decisione riusabile da promuovere in memoria per questa sessione.", "Nessuna decisione riusabile da promuovere in memoria per questa sessione.",
"info", "info",
); );
const closed = closeAfterPromotion(
ctx, session, curNum, "Nessun candidato di promozione.",
);
if (closed) return closed;
return textResult( return textResult(
"Nessun candidato di promozione: prosegui con la chiusura della sessione.", "Nessun candidato di promozione: prosegui con la chiusura della sessione.",
); );
@@ -1128,10 +1176,12 @@ export default function (pi) {
}), ""); }), "");
if (err) return err; if (err) return err;
} }
return textResult( const summary =
`Promozione registrata: ${saved} memorie salvate nel vectordb, ` + `Promozione registrata: ${saved} memorie salvate nel vectordb, ` +
`${decline.length} candidati scartati.`, `${decline.length} candidati scartati.`;
); const closed = closeAfterPromotion(ctx, session, curNum, summary);
if (closed) return closed;
return textResult(summary);
}, },
}); });
+16 -11
View File
@@ -41,7 +41,7 @@ substantive decisions.
| F5 sintesi | — | `reviewer_confirm kind:"phase"` (after `tht session check`) | | F5 sintesi | — | `reviewer_confirm kind:"phase"` (after `tht session check`) |
| F6 cte | `cte_plan.json`, `ctes/`, `cte_tests.json` | approve each CTE `kind:"cte_result"`, then `reviewer_confirm kind:"phase"` | | F6 cte | `cte_plan.json`, `ctes/`, `cte_tests.json` | approve each CTE `kind:"cte_result"`, then `reviewer_confirm kind:"phase"` |
| F7 sql_finale | `sql_final.sql` | `kind:"sql"` records `sql_approved`, then `reviewer_confirm kind:"phase"` | | F7 sql_finale | `sql_final.sql` | `kind:"sql"` records `sql_approved`, then `reviewer_confirm kind:"phase"` |
| F8 datamart | — | `reviewer_confirm kind:"phase"` | | F8 datamart | — | auto: the `reviewer_memory_promote` gate advances F8 and finalizes the session itself (`reviewer_confirm kind:"phase"` only as fallback if it reports an error) |
## Disciplines (hold in every phase) ## Disciplines (hold in every phase)
@@ -387,19 +387,24 @@ Prerequisite: Phase 7 closed.
1. Ask the reviewer whether they want a datamart (`reviewer_select` yes/no). 1. Ask the reviewer whether they want a datamart (`reviewer_select` yes/no).
2. If yes: `tht datamart generate` (stub — raises NotImplementedError for now). Tell 2. If yes: `tht datamart generate` (stub — raises NotImplementedError for now). Tell
the reviewer that dbt generation is not implemented yet. the reviewer that dbt generation is not implemented yet.
3. **Memory promotion.** Call `reviewer_memory_promote` with ONLY the session id: the 3. **Memory promotion closes the session.** Call `reviewer_memory_promote` with ONLY
gate computes the candidates itself (`tht memory promote --preview` — the 3 the session id: the gate computes the candidates itself (`tht memory promote
reusable types, already excluding promoted/declined ones) and shows the reviewer a --preview` — the 3 reusable types, already excluding promoted/declined ones) and
pre-selected checklist. Selected → saved to the vectordb + `memory_promoted`; shows the reviewer a pre-selected checklist. Selected → saved to the vectordb +
deselected → `memory_promotion_declined` (never re-proposed). If the gate reports `memory_promoted`; deselected → `memory_promotion_declined` (never re-proposed).
zero candidates, move on — do not retry. After recording the promotion (even with zero candidates) the gate advances F8 and
4. Close with `reviewer_confirm kind:"phase"`. The gate auto-finalizes the session finalizes the session itself — do NOT present a `reviewer_confirm kind:"phase"`
after advancing the last phase — you do NOT need to call `tht session finalize` afterwards: there is nothing left to approve. When the gate answers "sessione
yourself. If auto-finalize fails, the error message tells you the recovery command. finalizzata", give the reviewer the final summary and end the turn.
4. If the gate reports an error instead (e.g. the datamart decision is missing),
fix the prerequisite and call `reviewer_memory_promote` again. Only if the gate
says the session is still open, close with `reviewer_confirm kind:"phase"` as a
fallback — it auto-finalizes after advancing the last phase too.
## Session end ## Session end
When Phase 8 is approved, the gate calls `tht session finalize` automatically. When the promotion gate (or, as fallback, the F8 phase gate) closes Phase 8, the
gate calls `tht session finalize` automatically.
Finalize also indexes the question→SQL pair in the vectordb (kind `solved_question`, Finalize also indexes the question→SQL pair in the vectordb (kind `solved_question`,
best-effort — on failure recover with `tht memory solved-index <id>`). The persisted best-effort — on failure recover with `tht memory solved-index <id>`). The persisted
state (ledger `review_decisions.jsonl` + artifacts) is the truth: what is not state (ledger `review_decisions.jsonl` + artifacts) is the truth: what is not