diff --git a/packages/cli-rs/parity/deviations/M05-mini-requests.md b/packages/cli-rs/parity/deviations/M05-mini-requests.md index 120d95adfe5..b92e24f6c18 100644 --- a/packages/cli-rs/parity/deviations/M05-mini-requests.md +++ b/packages/cli-rs/parity/deviations/M05-mini-requests.md @@ -9,26 +9,18 @@ form body, whose content does overflow, uses the scroll box's own scrollbar, which shows only on overflow as TypeScript's form does. -## No subagent views - -- TypeScript: the footer tracks child sessions (`stream-v2.subagent.ts`): the `N subagents` and `bg` hints, - `session.child.first` and its panel, the subagent inspector, `composer.subagent.interrupt`, - `session.background`, "View subagents" in the command menu, and the permissions and forms of descendant - sessions. -- Rust: none of these. A child session's permission or form does not show in mini. -- Reason: mini's transcript drops the events of other sessions, and the subagent stream is a port of its own. - It is a follow-up. - -## No global forms - -- TypeScript: MCP elicitations without a session (`sessionID: "global"`) of the session's location show after - the session's own forms. -- Rust: only the session's forms show. -- Reason: mini's transcript drops events of other owners; the global forms come with the subagent stream's - routing. - ## No mouse in the footer -- TypeScript: hovering a permission button or a form row selects it, and a click picks it. +- TypeScript: hovering a permission button or a form row selects it, and a click picks it. In the subagent + inspector, a click on `esc back` closes it and a click on `tab next` shows the next child. - Rust: keys only. - Reason: mini runs with mouse tracking off (M.1), so the footer never sees the pointer. + +## A child's listed permissions read their source messages + +- TypeScript: listing a child's permissions reads the source message only of a request whose tool call the child's + transcript does not hold yet, and a source that cannot be read fails the list, which then retries. +- Rust: every listed request with a tool source reads its message, as the session's own permissions do, and a + source that cannot be read leaves the request without its tool input. +- Reason: the session's permissions already resolve this way (`source` in `footers/mod.rs`). The screen is the + same whenever the server answers; only extra reads differ. diff --git a/packages/cli-rs/parity/scenarios/M.5/fixture.ts b/packages/cli-rs/parity/scenarios/M.5/fixture.ts new file mode 100644 index 00000000000..78b74a517a6 --- /dev/null +++ b/packages/cli-rs/parity/scenarios/M.5/fixture.ts @@ -0,0 +1,115 @@ +// M.3's `ses_mini` server with two child sessions its subagent calls started: `ses_run` still runs in the +// foreground, and `ses_done` finished. The children's reads (messages, inbox, permissions, forms), the family index, +// and the writes the subagent views make (background, interrupt) are routed here. +import type { Logged, Options, Route } from "../../lib/fake-server" +import { assistant, directory, history, user } from "../M.1/fixture" +import { server as base } from "../M.3/fixture" + +export { config, event, leave } from "../M.3/fixture" + +const subagent = (id: string, child: string, running: boolean, agent: string, description: string) => ({ + type: "tool", + id, + name: "subagent", + state: running + ? { status: "running", input: { agent, description }, metadata: { sessionID: child, status: "running" } } + : { + status: "completed", + input: { agent, description }, + content: [{ type: "text", text: "Read them." }], + metadata: { sessionID: child, status: "completed" }, + }, + time: running ? { created: 40, ran: 40 } : { created: 40, ran: 40, completed: 41 }, +}) + +// The history, then a turn that started both children. +export const messages = [ + ...history, + user("msg_u4", "check the tests", 40), + assistant( + "msg_a4", + [ + subagent("call_done", "ses_done", false, "general", "Read the docs"), + subagent("call_run", "ses_run", true, "explore", "Find the tests"), + ], + 41, + ), +] + +const child = (session: string) => [ + { ...user("msg_c1", "Read the docs and sum them up", 50), sessionID: session }, + { + ...assistant("msg_c2", [{ type: "text", text: "The docs cover setup and testing." }], 51), + sessionID: session, + }, +] + +type Script = Parameters[0] & { + // Requests the children wait on when mini opens the session. + childPermissions?: unknown[] + globalForms?: unknown[] +} + +const listed = (entry: Logged, all: unknown[]) => + entry.query.order?.[0] === "desc" ? { data: all.toReversed(), cursor: {} } : { data: all, cursor: {} } + +const location = { directory } + +export function server(script: Script = {}): Options { + const options = base({ messages, ...script }) + const reads = (session: string, permissions: unknown[] = []): Route[] => [ + { path: `/api/session/${session}/message`, json: (entry: Logged) => listed(entry, child(session)) }, + { path: `/api/session/${session}/inbox`, json: { data: [] } }, + { path: `/api/session/${session}/permission`, json: { data: permissions } }, + { path: `/api/session/${session}/form`, json: { data: [] } }, + ] + const routes: Route[] = [ + { path: "/api/session/active", json: { data: { ses_run: { type: "busy" } } } }, + { path: "/api/session", json: { data: [], cursor: {} } }, + { path: "/api/form", json: { location, data: script.globalForms ?? [] } }, + ...reads("ses_run", script.childPermissions), + ...reads("ses_done"), + { method: "POST", path: "/api/session/ses_mini/background", status: 204 }, + { method: "POST", path: "/api/session/ses_run/interrupt", json: { interrupted: true } }, + { + method: "POST", + path: "/api/session/ses_run/permission/:id/reply", + status: 204, + then: (entry: Logged) => [ + { + id: "evt_childreplied0001", + created: 120, + type: "permission.replied", + durable: { aggregateID: "ses_run", seq: 1, version: 1 }, + data: { + sessionID: "ses_run", + requestID: entry.path.split("/").at(-2), + reply: (entry.body as { decision: string }).decision, + }, + }, + ], + }, + { + method: "POST", + path: "/api/session/global/form/:id/reply", + status: 204, + then: (entry: Logged) => [ + { + id: "evt_globalreplied0001", + created: 121, + type: "form.replied", + location, + data: { + sessionID: "global", + id: entry.path.split("/").at(-2), + answer: (entry.body as { answer: unknown }).answer, + }, + }, + ], + }, + ] + return { ...options, routes: [...routes, ...(options.routes ?? [])] } +} + +// The children's tabs are in: the status row counts the running one. +export const opened = { until: { screen: "1 sub" }, settle: 800, timeout: 30_000 } diff --git a/packages/cli-rs/parity/scenarios/M.5/requests.pty.ts b/packages/cli-rs/parity/scenarios/M.5/requests.pty.ts new file mode 100644 index 00000000000..19f04e66873 --- /dev/null +++ b/packages/cli-rs/parity/scenarios/M.5/requests.pty.ts @@ -0,0 +1,36 @@ +// A running child waits on a permission and a form of no session waits at the session's location when mini opens. +// The child's permission shows first; Allow once answers it under the child's session. The global form follows; +// its answer goes to `session/global` with the location in a header, and the server's events take both away. +import { definePty } from "../../pty/run" +import { config, leave, server } from "./fixture" + +const childPermission = { + id: "per_child", + sessionID: "ses_run", + action: "webfetch", + resources: ["https://example.com/tests"], + metadata: { url: "https://example.com/tests" }, +} + +const globalForm = { + id: "frm_global", + sessionID: "global", + title: "Connect the tracker", + fields: [{ type: "boolean", key: "connect", title: "Connect now?", required: true }], +} + +export default definePty({ + args: ["mini", "-s", "ses_mini"], + cols: 80, + rows: 24, + server: server({ childPermissions: [childPermission], globalForms: [globalForm] }), + setup: (root) => config(root), + steps: [ + { until: { screen: "Permission required" }, settle: 800, timeout: 30_000, checkpoint: "permission" }, + { keys: "\r", until: { screen: "Connect the tracker" }, settle: 500, checkpoint: "form" }, + { keys: "1", until: { screen: "1 sub" }, settle: 600, checkpoint: "answered" }, + ...leave, + ], + exits: true, + requests: "writes", +}) diff --git a/packages/cli-rs/parity/scenarios/M.5/subagents.pty.ts b/packages/cli-rs/parity/scenarios/M.5/subagents.pty.ts new file mode 100644 index 00000000000..0f200fd353d --- /dev/null +++ b/packages/cli-rs/parity/scenarios/M.5/subagents.pty.ts @@ -0,0 +1,32 @@ +// Two child sessions from the history's subagent calls: the status row counts the running one and offers to +// background it. The command menu lists them; Down on the empty prompt opens the subagent panel with the running +// child, Tab shows the finished one, and Enter inspects its transcript. Escape goes back; Ctrl+B backgrounds the +// running child (`session.background`), and its inspector's Ctrl+D interrupts it. +import { definePty } from "../../pty/run" +import { config, leave, opened, server } from "./fixture" + +export default definePty({ + args: ["mini", "-s", "ses_mini"], + cols: 80, + rows: 30, + server: server(), + setup: (root) => config(root), + steps: [ + { ...opened, checkpoint: "hints" }, + { keys: "\x10", until: { screen: "View subagents" }, settle: 400, checkpoint: "menu" }, + { keys: "\x1b", settle: 300 }, + { keys: "\x1b[B", until: { screen: "Select subagent" }, settle: 400, checkpoint: "panel" }, + { keys: "\t", settle: 300, checkpoint: "inactive" }, + { keys: "\r", until: { screen: "The docs cover" }, settle: 500, checkpoint: "inspector" }, + { keys: "\x1b", until: { screen: "1 sub" }, settle: 400 }, + { keys: "\x02", settle: 400 }, + { keys: "\x1b[B", until: { screen: "Select subagent" }, settle: 300 }, + // The running child's spinner turns, so the screen never settles. + { keys: "\r", until: { screen: "ctrl+d interrupt" } }, + { keys: "\x04", until: { screen: "ctrl+d interrupt" } }, + { keys: "\x1b", until: { screen: "1 sub" }, settle: 400, checkpoint: "back" }, + ...leave, + ], + exits: true, + requests: "writes", +})