feat(resume): Resume in the kebab menu (A1) + harden the resume kickoff (A2)

A1 — SessionMenu gains a Resume item, gated to status!=="finalized" && !archived
(matching the backend's 409 read-only guard), wired in AppShell to doResume ->
POST /sessions/:id/resume. SessionMenu.test.tsx (3 tests); frontend 96/96, tsc clean.

A2 — diagnosis-first clean-room repro driving `pi --mode rpc` with the backend's
exact resume handshake shows the cold-start stall NO LONGER reproduces on pi
0.79.4 (8/8 chained into `tht session show` + `read SKILL.md` in-turn, fresh and
partway sessions). The earlier narrate-and-stop predates the pi upgrade.
Defense-in-depth anyway: RIPRENDI_KICKOFF hardened to force the in-turn tool call
(gate_resume_kickoff.test.js + live regression 2/2). Gate JS 34/34.

PROJECT_STATE open-item #1 (resume stall) flipped to RESOLVED; cross-model resume
robustness folded into workstream G.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 18:19:10 +02:00
co-authored by Claude Opus 4.8
parent cef9ae4368
commit 0a13f71b4d
6 changed files with 105 additions and 15 deletions
+23 -14
View File
@@ -81,8 +81,8 @@ Opens frontend at http://localhost:5173 → backend :8787.
## UI/UX redesign + Resume — IN PROGRESS (2026-06-30, evening)
Approved multi-workstream plan: **`~/.claude/plans/foamy-forging-dahl.md`** (read it to resume).
Memory: `thothii-ui-redesign-inprogress.md`. **D + E merged @ `0eeb3f7` (pushed); B + C @ `b056ff3`
(live-verified); F implemented + committed (live check deferred to G). All on `main`, not yet pushed.**
Memory: `thothii-ui-redesign-inprogress.md`. **D + E @ `0eeb3f7`, B + C @ `b056ff3`, F @ `cef9ae4`
all pushed to origin. A (Resume menu + stall diagnosis/hardening) DONE, uncommitted. Only G remains.**
- **D — DONE** (`c12bdcd`): session display `name` = 3-5 Italian keywords via **YAKE** (no LLM),
derived in `tht session new` (CLI layer); `create_session` core unchanged (`name=None` default).
@@ -114,12 +114,20 @@ Memory: `thothii-ui-redesign-inprogress.md`. **D + E merged @ `0eeb3f7` (pushed)
disciplines 2-3, Phase-1 single-pick) + the `CLAUDE.md` gate note. Gate JS **33/33**, harness 269.
**Live verification (model actually uses `reviewer_select`+decision, no follow-up gate, decision in
`review_decisions.jsonl`) deferred to G** — it is model-behavior-dependent.
- **A (pending, riskiest):** Resume command + **FIX the resume cold-start stall** (open item #1).
- **G (later):** cross-model behavior matrix (Qwen3.6 / GLM 5.2 / Deepseek V4 / others) — also the
home for F's live verification.
- **A — DONE** (uncommitted): **A1** — `SessionMenu` gains a **Resume** item (gated to
`status!=="finalized" && !archived`), wired in `AppShell` to the existing `doResume` → `POST
/sessions/:id/resume`. 3 tests (`SessionMenu.test.tsx`); frontend **96/96**, tsc clean. **A2** —
diagnosis-first clean-room repro shows the **resume cold-start stall NO LONGER reproduces on pi
0.79.4** (8/8 chained into the tool calls, fresh + partway; GLM 5.2 now narrates AND emits
`tht session show`+`read SKILL.md` in-turn). The earlier narrate-and-stop predates the pi upgrade.
Defense-in-depth applied: `RIPRENDI_KICKOFF` hardened to force the in-turn tool call (gate test +
live regression 2/2). The cross-model angle (weaker/older models) lives in **G**.
- **G (last, separate):** cross-model behavior matrix (Qwen3.6 / GLM 5.2 / Deepseek V4 / others) —
also the home for **F's live check** and **A's cross-model resume robustness**.
**Next chunk:** **A** (Resume in the kebab + the resume cold-start stall fix, diagnosis-first).
**G** (cross-model, incl. F's live check) later.
**Status:** A-G core work done (D, E, B, C, F, A). **Remaining:** **G** (cross-model matrix,
incl. F's live single-select check + A's resume-on-weaker-models), plus a one-off manual Playwright
kebab→resume pass through the live UI (open item 2).
## Live verification + reviewer_select fix (2026-06-30, afternoon)
@@ -190,13 +198,14 @@ Full session management modeled on Claude's UI, all three layers:
`docs/superpowers/plans/2026-06-29-session-management.md`.
### ⚠️ Open items / pending gates
1. **Resume cold-start STALLS (root cause confirmed 2026-06-30) — NOT fixed.** On
`/riprendi-sessione` GLM 5.2 narrates the bootstrap step ("esamino la sessione…") then ends
the turn without the tool call → Pi idle, unrecoverable from the UI (steer doesn't revive an
ended turn). The new-question path works, so it's resume-specific. Fix direction: harden the
resume kickoff/SKILL so the model chains into the tool call. Memory:
`thothii-resume-cold-start-stall.md`. **Do not advertise "Resume" as working until fixed.**
*(Scheduled as workstream A of the redesign plan above.)*
1. **Resume cold-start stall — RESOLVED on pi 0.79.4 (workstream A, 2026-06-30).** The earlier
narrate-and-stop (GLM 5.2 narrating the bootstrap step then ending the turn without the tool
call) **no longer reproduces**: a clean-room repro of the backend's exact resume handshake
chained into `tht session show`+`read SKILL.md` in-turn **8/8** (fresh + partway sessions). The
pi upgrade is the likely fix. Defense-in-depth: `RIPRENDI_KICKOFF` hardened to force the in-turn
tool call (gate test + live 2/2). Memory: `thothii-resume-cold-start-stall.md`. **Remaining:**
the cross-model angle (older/weaker models) is folded into **G**; a full Playwright kebab→resume
pass through the live UI is still worth one manual run (item 2).
2. **Full Playwright live-stack verification (MANUAL, not yet run).**
3. **Minor backlog (non-blocking):** explicit id-traversal guard in `delete_session`
(today gated by `load_session`); `close_session` could reuse `_save_touched` (DRY);
+1
View File
@@ -118,6 +118,7 @@ export function AppShell() {
<SessionMenu
session={s}
groups={groups}
onResume={() => doResume(s.id)}
onView={() => setPanelSession(s)}
onRename={() => setRenameTarget(s)}
onMove={(g) => move(s, g)}
+49
View File
@@ -0,0 +1,49 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import { SessionMenu } from "./SessionMenu";
import type { SessionSummary } from "../api/types";
const base: SessionSummary = {
id: "s1", status: "open", question: "q", summary: null,
created_at: "2026-01-01T00:00:00Z", updated_at: null, author: null,
name: null, group: null, archived: false,
};
function renderMenu(over: Partial<SessionSummary>, onResume = vi.fn()) {
render(
<SessionMenu
session={{ ...base, ...over }}
groups={[]}
onResume={onResume}
onView={vi.fn()}
onRename={vi.fn()}
onMove={vi.fn()}
onNewGroup={vi.fn()}
onArchiveToggle={vi.fn()}
onDelete={vi.fn()}
/>,
);
return onResume;
}
test("Resume shows for an open, non-archived session and calls onResume", async () => {
const onResume = renderMenu({ status: "open", archived: false });
await userEvent.click(screen.getByRole("button", { name: /session actions/i }));
await userEvent.click(await screen.findByText("Resume"));
expect(onResume).toHaveBeenCalledTimes(1);
});
test("Resume is hidden for a finalized session", async () => {
renderMenu({ status: "finalized" });
await userEvent.click(screen.getByRole("button", { name: /session actions/i }));
await screen.findByText("Split view"); // menu is open
expect(screen.queryByText("Resume")).not.toBeInTheDocument();
});
test("Resume is hidden for an archived session", async () => {
renderMenu({ status: "open", archived: true });
await userEvent.click(screen.getByRole("button", { name: /session actions/i }));
await screen.findByText("Split view"); // menu is open
expect(screen.queryByText("Resume")).not.toBeInTheDocument();
});
+8 -1
View File
@@ -5,6 +5,7 @@ import type { SessionSummary } from "../api/types";
interface Props {
session: SessionSummary;
groups: string[];
onResume: () => void;
onView: () => void;
onRename: () => void;
onMove: (group: string) => void;
@@ -16,7 +17,10 @@ interface Props {
const itemCls =
"flex w-full cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground";
export function SessionMenu({ session, groups, onView, onRename, onMove, onNewGroup, onArchiveToggle, onDelete }: Props) {
export function SessionMenu({ session, groups, onResume, onView, onRename, onMove, onNewGroup, onArchiveToggle, onDelete }: Props) {
// Resume re-enters the workflow; only meaningful while the session is still live
// (the backend returns 409 for finalized/archived, matching this guard).
const canResume = session.status !== "finalized" && !session.archived;
return (
<Menu.Root>
<Menu.Trigger
@@ -29,6 +33,9 @@ export function SessionMenu({ session, groups, onView, onRename, onMove, onNewGr
<Menu.Portal>
<Menu.Positioner side="bottom" align="end" sideOffset={4}>
<Menu.Popup className="z-50 min-w-44 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
{canResume && (
<Menu.Item className={itemCls} onClick={onResume}>Resume</Menu.Item>
)}
<Menu.Item className={itemCls} onClick={onView}>Split view</Menu.Item>
<Menu.Item className={itemCls} onClick={onRename}>Rename</Menu.Item>
<Menu.SubmenuRoot>
@@ -0,0 +1,21 @@
const test = require("node:test");
const assert = require("node:assert");
const { createFakePi } = require("./fake_pi_runtime.js");
const installGate = require("../../tht-gate.js").default ?? require("../../tht-gate.js");
// Workstream A (defense-in-depth): the resume cold-start stall (model narrates step 1
// then ends the turn without the tool call) no longer reproduces on pi 0.79.4, but the
// resume kickoff is hardened to force the in-turn tool call so weaker/older models don't
// regress into a dropped turn.
test("the resume kickoff injects the bootstrap steps and forces in-turn action", async () => {
const { pi } = createFakePi();
installGate(pi);
await pi.emit("input", { source: "rpc", text: "/riprendi-sessione 2026-06-30-000000-x" });
const injected = await pi.emit("before_agent_start", { systemPrompt: "" });
const text = injected?.systemPrompt ?? "";
assert.match(text, /tht session show/); // the resume kickoff is the one injected
// hardening: act now, in this same turn; do not stop after merely stating intent.
assert.match(text, /in QUESTO stesso turno/i);
assert.match(text, /NON terminare il turno/i);
});
+3
View File
@@ -104,6 +104,9 @@ const RIPRENDI_KICKOFF =
"comando di shell ne' `/skill:...`).\n" +
"3. Determina l'ultima fase completata dai fatti persistiti (le decisioni sono la verita': " +
"cio' che non e' registrato non e' avvenuto) e riprendi da li'.\n" +
"Esegui i passi 1-2 ORA, in QUESTO stesso turno, chiamando subito i tool (`bash` per " +
"`tht session show`, `read` per la skill): NON limitarti a dichiarare l'intenzione e NON " +
"terminare il turno prima di aver chiamato i tool.\n" +
"Valgono le stesse regole non negoziabili: una domanda per volta, conferma esplicita, tool " +
"reviewer_*, niente phase advance/reopen o decision add da shell, una fase alla volta.";