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:
+23
-14
@@ -81,8 +81,8 @@ Opens frontend at http://localhost:5173 → backend :8787.
|
|||||||
## UI/UX redesign + Resume — IN PROGRESS (2026-06-30, evening)
|
## UI/UX redesign + Resume — IN PROGRESS (2026-06-30, evening)
|
||||||
|
|
||||||
Approved multi-workstream plan: **`~/.claude/plans/foamy-forging-dahl.md`** (read it to resume).
|
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`
|
Memory: `thothii-ui-redesign-inprogress.md`. **D + E @ `0eeb3f7`, B + C @ `b056ff3`, F @ `cef9ae4`
|
||||||
(live-verified); F implemented + committed (live check deferred to G). All on `main`, not yet pushed.**
|
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),
|
- **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).
|
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.
|
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
|
**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.
|
`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).
|
- **A — DONE** (uncommitted): **A1** — `SessionMenu` gains a **Resume** item (gated to
|
||||||
- **G (later):** cross-model behavior matrix (Qwen3.6 / GLM 5.2 / Deepseek V4 / others) — also the
|
`status!=="finalized" && !archived`), wired in `AppShell` to the existing `doResume` → `POST
|
||||||
home for F's live verification.
|
/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).
|
**Status:** A-G core work done (D, E, B, C, F, A). **Remaining:** **G** (cross-model matrix,
|
||||||
**G** (cross-model, incl. F's live check) later.
|
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)
|
## 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`.
|
`docs/superpowers/plans/2026-06-29-session-management.md`.
|
||||||
|
|
||||||
### ⚠️ Open items / pending gates
|
### ⚠️ Open items / pending gates
|
||||||
1. **Resume cold-start STALLS (root cause confirmed 2026-06-30) — NOT fixed.** On
|
1. **Resume cold-start stall — RESOLVED on pi 0.79.4 (workstream A, 2026-06-30).** The earlier
|
||||||
`/riprendi-sessione` GLM 5.2 narrates the bootstrap step ("esamino la sessione…") then ends
|
narrate-and-stop (GLM 5.2 narrating the bootstrap step then ending the turn without the tool
|
||||||
the turn without the tool call → Pi idle, unrecoverable from the UI (steer doesn't revive an
|
call) **no longer reproduces**: a clean-room repro of the backend's exact resume handshake
|
||||||
ended turn). The new-question path works, so it's resume-specific. Fix direction: harden the
|
chained into `tht session show`+`read SKILL.md` in-turn **8/8** (fresh + partway sessions). The
|
||||||
resume kickoff/SKILL so the model chains into the tool call. Memory:
|
pi upgrade is the likely fix. Defense-in-depth: `RIPRENDI_KICKOFF` hardened to force the in-turn
|
||||||
`thothii-resume-cold-start-stall.md`. **Do not advertise "Resume" as working until fixed.**
|
tool call (gate test + live 2/2). Memory: `thothii-resume-cold-start-stall.md`. **Remaining:**
|
||||||
*(Scheduled as workstream A of the redesign plan above.)*
|
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).**
|
2. **Full Playwright live-stack verification (MANUAL, not yet run).**
|
||||||
3. **Minor backlog (non-blocking):** explicit id-traversal guard in `delete_session`
|
3. **Minor backlog (non-blocking):** explicit id-traversal guard in `delete_session`
|
||||||
(today gated by `load_session`); `close_session` could reuse `_save_touched` (DRY);
|
(today gated by `load_session`); `close_session` could reuse `_save_touched` (DRY);
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export function AppShell() {
|
|||||||
<SessionMenu
|
<SessionMenu
|
||||||
session={s}
|
session={s}
|
||||||
groups={groups}
|
groups={groups}
|
||||||
|
onResume={() => doResume(s.id)}
|
||||||
onView={() => setPanelSession(s)}
|
onView={() => setPanelSession(s)}
|
||||||
onRename={() => setRenameTarget(s)}
|
onRename={() => setRenameTarget(s)}
|
||||||
onMove={(g) => move(s, g)}
|
onMove={(g) => move(s, g)}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import type { SessionSummary } from "../api/types";
|
|||||||
interface Props {
|
interface Props {
|
||||||
session: SessionSummary;
|
session: SessionSummary;
|
||||||
groups: string[];
|
groups: string[];
|
||||||
|
onResume: () => void;
|
||||||
onView: () => void;
|
onView: () => void;
|
||||||
onRename: () => void;
|
onRename: () => void;
|
||||||
onMove: (group: string) => void;
|
onMove: (group: string) => void;
|
||||||
@@ -16,7 +17,10 @@ interface Props {
|
|||||||
const itemCls =
|
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";
|
"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 (
|
return (
|
||||||
<Menu.Root>
|
<Menu.Root>
|
||||||
<Menu.Trigger
|
<Menu.Trigger
|
||||||
@@ -29,6 +33,9 @@ export function SessionMenu({ session, groups, onView, onRename, onMove, onNewGr
|
|||||||
<Menu.Portal>
|
<Menu.Portal>
|
||||||
<Menu.Positioner side="bottom" align="end" sideOffset={4}>
|
<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">
|
<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={onView}>Split view</Menu.Item>
|
||||||
<Menu.Item className={itemCls} onClick={onRename}>Rename</Menu.Item>
|
<Menu.Item className={itemCls} onClick={onRename}>Rename</Menu.Item>
|
||||||
<Menu.SubmenuRoot>
|
<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);
|
||||||
|
});
|
||||||
@@ -104,6 +104,9 @@ const RIPRENDI_KICKOFF =
|
|||||||
"comando di shell ne' `/skill:...`).\n" +
|
"comando di shell ne' `/skill:...`).\n" +
|
||||||
"3. Determina l'ultima fase completata dai fatti persistiti (le decisioni sono la verita': " +
|
"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" +
|
"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 " +
|
"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.";
|
"reviewer_*, niente phase advance/reopen o decision add da shell, una fase alla volta.";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user