diff --git a/apps/p2/docs/p2-notes.md b/apps/p2/docs/p2-notes.md index 718145525..bdf8186db 100644 --- a/apps/p2/docs/p2-notes.md +++ b/apps/p2/docs/p2-notes.md @@ -894,7 +894,7 @@ translated phone drawer and intercept chat taps. section. Call: `tools.terminal({ command, args })`, tmux's argv after the command name, parsed getopt-style (`commands.ts`: no string parsing, tmux-like errors such as `send-keys: unknown option -- x` or `usage: …`): `new-window [-n name] [-c dir] [-w W] [-T s] [command ...]` (a shell at the calling conversation's location, - `environments.locate`; subagents cannot choose code mode or this tool yet; `-c` relative to it; `command` is typed into it, then Enter, and the shell stays), + `environments.locate`: a subagent's script opens it at the subagent's own location; `-c` relative to it; `command` is typed into it, then Enter, and the shell stays), `list-windows`, `send-keys -t T [-l] [-w W] [-T s] key ...` (tmux key names as keys, encoded by libghostty in the terminal's modes, other arguments as text), `read -t T [-w W] [-T s]` (output since the agent's last read), `capture-pane -t T [-S -N] [-e] [-p]` (the screen, N scrollback lines, `-e` with SGR colors), `kill-window -t T`. @@ -1037,9 +1037,22 @@ translated phone drawer and intercept chat taps. the list does not start/hold a worker. The sidebar expands children (latest entry first), with working dots; `/s//c/` opens an independent chat, draft and model control. A parent link returns to the root. Browser configuration synchronizes the subagent registry via `ConversationConfigured`. Main Stop leaves child - work running; direct child prompts do not report to the parent. `test/p2/subagents.test.ts` covers bad models, - configured spawn, one report, answer retrieval, configuration, independent history, reload during child work, - idle restart, guest reads and sidebar persistence across server restart. + work running; direct child prompts do not report to the parent. **Tools** (`SUBAGENT_TOOLS` in `contract.worker.ts`, + validated by `resolveChoice` before any commit): `tools` may name the coding tools, `codemode` and the location tools; + the default is the coding tools and `codemode`, as the main agent has code mode while the `codemode` setting is on + (the default leaves out what is not installed: code mode while the setting is off or without the plugin; naming an + uninstalled tool is refused, "Not available in this session: codemode."). `team` and the history tools come on + top. Durable enables exactly the names in a conversation's `tools` array, and code mode's nested calls resolve among + the enabled tools, so a subagent with code mode also gets the script-only tools (`callers: ["tools"]`: MCP tools, + `terminal`, `agent`, code mode's `models.*`) by name: at spawn/configure those installed then (`withExtras`), and + later ones (an MCP server connecting) from the worker, which follows the registry's script-only tools and appends + missing names to every code mode subagent's array (`enableScriptTools`; a tool task resolves its agent once per + phase, so this cannot happen inside the script's own call). Names are never removed: Durable ignores a name whose + tool is gone. A subagent's scripts can thus start one-shot agents (which get no code mode). `test/p2/subagents.test.ts` + covers bad models, configured spawn, one report, answer retrieval, configuration, independent history, reload during + child work, idle restart, guest reads, sidebar persistence across server restart, and code mode: choosing and + validating it, the default with the setting on and off, a script calling a coding tool and `agent`, and MCP tools + installed after the spawn; `terminal-tool.test.ts` a subagent's script opening a window at its own location. - **Code mode** (`plugins/codemode`): the worker facet registers Durable's `codemode` extension (`createCodemodeExtension`, tool `codemode`, callers `["model"]`, replay-safe) in `Extensions`. One sandbox per worker (`CodemodeSandbox`, 256 MiB VM heap: `CODEMODE_MEMORY_LIMIT_BYTES`, kept constant because it is part of the VM @@ -1121,7 +1134,8 @@ translated phone drawer and intercept chat taps. `structuredOutput` is the value, `details` `{ conversationId, attempts }`; nothing is reported to the parent. It has no prompt section (as Pi Mobile 1b763d912): its description, Pi Mobile's verbatim, carries the usage notes, and code mode's section lists it with the other tools scripts call. Model/thinking/tool - validation is the subagent tool's `resolveChoice`. The plugin contributes the tool unconditionally and does not + validation is the subagent tool's `resolveChoice` with `ONE_SHOT_TOOLS`: the coding and location tools, never + `codemode` (a one-shot agent's own scripts could start one-shot agents again, fanning out without bound). The plugin contributes the tool unconditionally and does not depend on code mode: without a script nothing can call it (`callers: ["tools"]`), so code mode stays removable while it is loaded (code mode's listing then disappears; `agent.test.ts` removes and re-adds code mode). It depends on subagents, whose document lists its conversations. The creating commit diff --git a/apps/p2/plugins/agent/tool.ts b/apps/p2/plugins/agent/tool.ts index 689ce799b..e90532dc9 100644 --- a/apps/p2/plugins/agent/tool.ts +++ b/apps/p2/plugins/agent/tool.ts @@ -18,7 +18,7 @@ import { formats } from "@earendil-works/pi-schema/formats"; import type { JsonValue } from "@p2/app/v1"; import { record } from "@p2/app/v1"; import { isTopLevel } from "@p2/app/v1/worker"; -import { resolveChoice, Subagents } from "../subagents/contract.worker.ts"; +import { ONE_SHOT_TOOLS, resolveChoice, Subagents } from "../subagents/contract.worker.ts"; export const AGENT_TOOL = "agent"; const ATTEMPTS = 3; @@ -72,7 +72,10 @@ export function createAgentExtension(models: Models) { } // A rerun reattaches to the conversation it created, even if its model has since left the configuration. const existing = (await api.snapshot(AgentRun, api.taskId, context))?.conversationId; - const choice = existing === undefined ? await resolveChoice(models, api, args, undefined, context) : undefined; + const choice = + existing === undefined + ? await resolveChoice(models, api, args, undefined, ONE_SHOT_TOOLS, context) + : undefined; if (typeof choice === "string") return fail(choice); // Owned by this call: aborting the call aborts the agent's work. Listed in the same commit, by the call ID the // script's tool card shows it under, so the user can open its transcript. diff --git a/apps/p2/plugins/subagents/contract.worker.ts b/apps/p2/plugins/subagents/contract.worker.ts index 383baf539..a10b349cd 100644 --- a/apps/p2/plugins/subagents/contract.worker.ts +++ b/apps/p2/plugins/subagents/contract.worker.ts @@ -21,8 +21,21 @@ import type { Context, Location } from "@p2/app/v1"; export const CODING_TOOLS = ["read", "write", "edit", "bash", "send_file", "artifact"]; /** The location tools (plugins/locations): every main agent has them; a subagent only when chosen. */ export const LOCATION_TOOLS = ["location_envs", "location_dirs", "location_move"]; -/** What a subagent's `tools` may name: the coding tools (its default) and the location tools. */ -export const CHOOSABLE = [...CODING_TOOLS, ...LOCATION_TOOLS]; +/** The code mode tool (plugins/codemode), installed while the `codemode` setting is on. */ +export const CODEMODE_TOOL = "codemode"; + +/** The tools an agent may choose (`choosable`) and gets without a choice (`defaults`), by name. */ +export type ToolOffer = { readonly choosable: readonly string[]; readonly defaults: readonly string[] }; +/** + * A subagent's: the coding tools and code mode by default (code mode only while installed, as for the main agent), the + * location tools when chosen. + */ +export const SUBAGENT_TOOLS: ToolOffer = { + choosable: [...CODING_TOOLS, CODEMODE_TOOL, ...LOCATION_TOOLS], + defaults: [...CODING_TOOLS, CODEMODE_TOOL], +}; +/** A one-shot agent's (plugins/agent): no code mode, so a script's agents cannot run scripts that fan out again. */ +export const ONE_SHOT_TOOLS: ToolOffer = { choosable: [...CODING_TOOLS, ...LOCATION_TOOLS], defaults: CODING_TOOLS }; export const THINKING = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; export const TEAM_TOOL = "team"; @@ -107,14 +120,16 @@ export const Subagents = defineDoc({ export type AgentChoice = { model?: string; thinkingLevel?: string; tools?: string[] }; /** - * Validate a choice before any commit, so a typo changes nothing; an error is returned as text. Unset fields keep - * `current`'s, or for a new agent the caller's model and thinking level and every coding tool. + * Validate a choice before any commit, so a typo changes nothing; an error is returned as text. `offer` says which tools + * it may name. Unset fields keep `current`'s, or for a new agent the caller's model and thinking level and the offer's + * defaults that are installed. A chosen tool that is not installed (code mode while its setting is off) is an error. */ export async function resolveChoice( models: Models, api: Pick, choice: AgentChoice, current: Subagent | undefined, + offer: ToolOffer, context: Context, ): Promise<{ model: ModelRef; thinkingLevel: ModelThinkingLevel; tools: ToolRegistration[] } | string> { const own = current ? undefined : await api.agent(context); @@ -135,10 +150,13 @@ export async function resolveChoice( current?.thinkingLevel ?? own?.thinkingLevel ?? "off") as ModelThinkingLevel; - const names = (choice.tools ?? current?.tools ?? CODING_TOOLS).filter((tool) => !ALWAYS.includes(tool)); - const unknown = names.filter((tool) => !CHOOSABLE.includes(tool)); - if (unknown.length) return `Unknown tools: ${unknown.join(", ")}. Choose from ${CHOOSABLE.join(", ")}.`; + const names = (choice.tools ?? current?.tools ?? offer.defaults).filter((tool) => !ALWAYS.includes(tool)); + const unknown = names.filter((tool) => !offer.choosable.includes(tool)); + if (unknown.length) return `Unknown tools: ${unknown.join(", ")}. Choose from ${offer.choosable.join(", ")}.`; const installed = api.registry.tools().map((entry) => entry.tool); + const missing = + choice.tools === undefined ? [] : names.filter((tool) => !installed.some((each) => each.name === tool)); + if (missing.length) return `Not available in this session: ${missing.join(", ")}.`; return { model: ref, thinkingLevel: clampThinkingLevel(model, requested), diff --git a/apps/p2/plugins/subagents/tools.ts b/apps/p2/plugins/subagents/tools.ts index 33d9ef70a..449de995b 100644 --- a/apps/p2/plugins/subagents/tools.ts +++ b/apps/p2/plugins/subagents/tools.ts @@ -5,6 +5,7 @@ import type { AssistantMessage, ModelThinkingLevel } from "@earendil-works/pi-ai"; import type { Models } from "@earendil-works/pi-ai/models"; import { + AgentDoc, AssistantEntry, type ConversationHandle, type ConversationId, @@ -29,7 +30,14 @@ import type { Context, JsonValue } from "@p2/app/v1"; import { type Location, type LocationInput, literal, locationText } from "@p2/app/v1"; import { mainConversation, originOf } from "@p2/app/v1/worker"; import { AGENT_MESSAGE_ENTRY, type AgentMessage, SUBAGENT_TASK_ENTRY, SubagentLocationsSchema } from "./contract.ts"; -import { HISTORY_TOOLS, resolveChoice, Subagents, TEAM_TOOL } from "./contract.worker.ts"; +import { + CODEMODE_TOOL, + HISTORY_TOOLS, + resolveChoice, + SUBAGENT_TOOLS, + Subagents, + TEAM_TOOL, +} from "./contract.worker.ts"; const SUBAGENT_EXTENSION = "subagent-tools"; @@ -362,9 +370,10 @@ export function createSubagentExtension(models: Models, locator: SubagentLocator " env is an environment name from location_envs, or null for this device (default: yours; another environment needs", " the owner's request).", " model is 'provider/modelId' (default: yours); thinkingLevel is off|minimal|low|medium|high|xhigh|max", - " (clamped to the model); tools is a subset of read, write, edit, bash, send_file, artifact (default: all of these;", - " [] for none) and the location tools location_envs, location_dirs, location_move (never by default; with", - " location_move it can move itself).", + " (clamped to the model); tools is a subset of read, write, edit, bash, send_file, artifact, codemode (default: all", + " of these, codemode while code mode is on; [] for none) and the location tools location_envs, location_dirs,", + " location_move (never by default; with location_move it can move itself). With codemode its scripts call its", + " chosen tools and the tools behind code mode (MCP tools, terminal, one-shot agents).", "- send (name, message; followUp: true queues after the current answer instead of steering).", "- configure (name; optional model, thinkingLevel, tools, env, cwd, followSession): replace its model/thinking/tool", " selection for later work; env/cwd give it its own location, followSession: true makes it follow the session", @@ -488,7 +497,8 @@ export function createSubagentExtension(models: Models, locator: SubagentLocator if (action === "spawn" || action === "configure") { if (action === "spawn" && agent) return reply(`${name} already exists${agent.archived ? " (archived)" : ""}; use send.`, {}, true); - const choice = await resolveChoice(models, api, args, action === "spawn" ? undefined : agent, context); + const existing = action === "spawn" ? undefined : agent; + const choice = await resolveChoice(models, api, args, existing, SUBAGENT_TOOLS, context); if (typeof choice === "string") return reply(choice, {}, true); change = { model: choice.model, thinkingLevel: choice.thinkingLevel }; tools = choice.tools; @@ -600,11 +610,47 @@ export function createSubagentExtension(models: Models, locator: SubagentLocator /** * A subagent's tools plus the ones every subagent has: `team`, which is deferred (only conversations whose `modelTools` - * adds it are offered it), and the history tools among `installed`. + * adds it are offered it), and the history tools among `installed`; with code mode also the tools behind it among + * `installed` (`scriptOnly`; the worker adds ones installed later, see `enableScriptTools`). */ function withExtras(tools: readonly ToolRegistration[], installed: readonly ToolRegistration[]) { const history = HISTORY_TOOLS.flatMap((name) => installed.filter((tool) => tool.name === name).slice(0, 1)); - return { tools: [...tools, teamTool, ...history], modelTools: { add: [teamTool] } }; + const behind = tools.some((tool) => tool.name === CODEMODE_TOOL) ? installed.filter(scriptOnly) : []; + return { tools: [...tools, teamTool, ...history, ...behind], modelTools: { add: [teamTool] } }; +} + +/** + * Whether only code mode scripts call `tool` (`callers: ["tools"]`: MCP tools, `terminal`, `agent`, code mode's own + * `models.*`). Durable enables exactly the tools a conversation's `tools` array names, and nested calls resolve among + * the enabled ones, so a subagent's array must name them for its scripts to reach them. + */ +export const scriptOnly = (tool: ToolRegistration): boolean => tool.callers?.includes("model") === false; + +/** + * Adds the tools behind code mode installed now (`names`) to every subagent with code mode, by name: MCP tools come and + * go with their servers, so the names written at spawn go stale. It runs between scripts, not inside one: a script's + * call resolves the agent once, at its start. A name whose tool goes away is ignored by Durable and works again when it + * returns, so none is removed. + */ +export async function enableScriptTools( + harness: Pick, + names: readonly string[], + context: Context, +): Promise { + const registry = await harness.snapshot(Subagents, ROOT_CONVERSATION_ID, context); + const lacking: ConversationId[] = []; + for (const agent of Object.values(registry?.agents ?? {})) { + if (!agent.tools.includes(CODEMODE_TOOL)) continue; + const enabled = (await harness.snapshot(AgentDoc, agent.conversationId, context))?.tools; + if (Array.isArray(enabled) && names.some((name) => !enabled.includes(name))) lacking.push(agent.conversationId); + } + if (lacking.length === 0) return; + await harness.commit(async (tx) => { + for (const conversationId of lacking) { + const state = await tx.doc(AgentDoc, conversationId); + if (Array.isArray(state.tools)) state.tools = [...new Set([...state.tools, ...names])]; + } + }, context); } /** diff --git a/apps/p2/plugins/subagents/worker.ts b/apps/p2/plugins/subagents/worker.ts index 2d6d86fbc..87afb3949 100644 --- a/apps/p2/plugins/subagents/worker.ts +++ b/apps/p2/plugins/subagents/worker.ts @@ -2,7 +2,7 @@ import { setTimeout as sleep } from "node:timers/promises"; import type { Models } from "@earendil-works/pi-ai/models"; import { type ConversationId, ROOT_CONVERSATION_ID } from "@earendil-works/pi-durable"; import { canSend, contribute, defineFacet, expectVariant, forbidden, signalOf } from "@p2/app/v1"; -import { durableContext } from "@p2/app/v1/node"; +import { durableContext, ExtensionSwitches } from "@p2/app/v1/node"; import { CommitSignal, ConversationConfigured, @@ -21,8 +21,10 @@ import { type Child, SubagentControl, Subagents } from "./contract.ts"; import { ALWAYS, Subagents as Registry } from "./contract.worker.ts"; import { createSubagentExtension, + enableScriptTools, ownLocation, placeSubagents, + scriptOnly, setArchived, spawnEnvironments, teamExtension, @@ -60,6 +62,18 @@ export default defineFacet((host, self) => { directory: (target, from, context) => resolveDirectory(harness().environments, target, from, context), }); self.contribute(Extensions, { [extension.name]: extension, [teamExtension.name]: teamExtension }); + // The tools behind code mode, as the Harness's registry installs them (switched-off extensions left out). + const scriptTools = self.derive( + [self.registry(Extensions), self.registry(ExtensionSwitches)], + ([extensions, switches]) => + [...extensions] + .filter(([name]) => switches.get(name) !== false) + .flatMap(([, each]) => (each.tools ?? []).filter(scriptOnly).map((tool) => tool.name)) + .sort(), + ); + self.on(scriptTools, async ({ value }, ctx) => { + await enableScriptTools(harness().durable, value, durableContext(ctx)); + }); self.contribute(ConversationConfigured, { subagents: async (conversation, ctx) => { if (conversation.id === ROOT_CONVERSATION_ID) return; diff --git a/apps/p2/test/p2/subagents.test.ts b/apps/p2/test/p2/subagents.test.ts index a2ce2afd7..e6f4f2c8e 100644 --- a/apps/p2/test/p2/subagents.test.ts +++ b/apps/p2/test/p2/subagents.test.ts @@ -2,10 +2,12 @@ import assert from "node:assert/strict"; import { mkdir, readFile, realpath, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { after, before, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; import type { ToolResultMessage } from "@earendil-works/pi-ai"; import type { ConversationView } from "@earendil-works/pi-durable"; import { BACKGROUND_CONTEXT, whenLive } from "@p2/app/v1"; import { Chat } from "../../plugins/chat/contract.ts"; +import { Mcp } from "../../plugins/mcp/contract.ts"; import { Sessions } from "../../plugins/sessions/contract.ts"; import { Sharing } from "../../plugins/sharing/contract.ts"; import { SessionStatus } from "../../plugins/status/contract.ts"; @@ -15,6 +17,8 @@ import { startServer } from "../../src/hosts/server.ts"; import type { Host, RefIn } from "../../src/runtime/chord.ts"; import { expectChord, type Fixture, failureOf, holdIn, http, startFixture, until } from "./helpers.ts"; +const MCP_FIXTURE = fileURLToPath(new URL("../fixtures/mcp-server.mjs", import.meta.url)); + const texts = (view: ConversationView | null | undefined): string[] => (view?.entries ?? []).flatMap((entry) => (entry.model ?? []).flatMap((message) => @@ -325,3 +329,172 @@ describe("a subagent spawned with its own cwd (fake provider)", () => { children.release(); }); }); + +describe("subagents with code mode (fake provider)", () => { + let fixture: Fixture; + let owner: Host; + let chat: RefIn; + let children: RefIn; + let session: string; + let sent = 0; + /** Sends `subagent ` and waits for that call's result. */ + const subagent = async (args: object): Promise => { + const count = results(chat.view.value).length; + await chat.send( + { text: `subagent ${JSON.stringify(args)}`, requestId: `codemode-${++sent}` }, + BACKGROUND_CONTEXT, + ); + return results(await until(chat.view, (view) => results(view).length > count)).at(-1)!; + }; + /** Waits until `name`'s answer was reported and the main agent's turn on it ended, so it cannot race a later send. */ + const reported = (name: string) => + until( + chat.view, + (view) => + texts(view).some((each) => each.startsWith(`Echo: [subagent ${name} answered`)) && + !(view?.docs["pi.live"] as { run?: unknown } | undefined)?.run, + ); + const results = (view: ConversationView | null | undefined) => + (view?.entries ?? []) + .flatMap((entry) => entry.model ?? []) + .filter( + (message): message is ToolResultMessage => message.role === "toolResult" && message.toolName === "subagent", + ); + const text = (message: ToolResultMessage | undefined) => + (message?.content ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])).join(""); + + before(async () => { + fixture = await startFixture(); + await writeFile(join(fixture.workspace, "marker.txt"), "inside\n"); + owner = fixture.browser(); + session = ( + await (await holdIn(owner, Sessions, {}, "owner")).create({ cwd: fixture.workspace }, BACKGROUND_CONTEXT) + ).id; + chat = await holdIn(owner, Chat, { session, conversation: 1 }, "participant"); + children = await holdIn(owner, Subagents, {}, "reader"); + }); + after(async () => { + await fixture?.close(); + }); + + it("validates codemode as a choosable tool", async () => { + const bad = await subagent({ action: "spawn", name: "bad", message: "x", tools: ["codemode", "bogus"] }); + assert.equal(bad.isError, true); + assert.equal( + text(bad), + "Unknown tools: bogus. Choose from read, write, edit, bash, send_file, artifact, codemode, location_envs, location_dirs, location_move.", + ); + const chosen = await subagent({ action: "spawn", name: "chosen", message: "x", tools: ["bash", "codemode"] }); + assert.deepEqual((chosen.details as { tools?: string[] }).tools, ["bash", "codemode"]); + await reported("chosen"); + }); + + it("gives code mode by default; a script calls its tools and the tools behind code mode", async () => { + // The subagent's script reads with a chosen coding tool, then starts a one-shot agent (`agent` is script-only: + // `callers: ["tools"]`), whose tools may not include code mode. + const script = [ + 'const read = await tools.read({ path: "marker.txt" });', + 'const answer = await tools.agent({ prompt: \'json {"n":1}\', schema: { type: "object" } });', + "let refused;", + 'try { await tools.agent({ prompt: "x", schema: {}, tools: ["codemode"] }); } catch (error) { refused = String(error.message ?? error); }', + "return { read, answer, refused };", + ].join("\n"); + const spawned = await subagent({ action: "spawn", name: "coder", message: `codemode ${script}` }); + assert.equal(spawned.isError, false, text(spawned)); + assert.deepEqual((spawned.details as { tools?: string[] }).tools, [ + "read", + "write", + "edit", + "bash", + "send_file", + "artifact", + "codemode", + ]); + const listed = await until(children.children, (value) => + Boolean(value?.[session]?.some((each) => each.name === "coder")), + ); + const coder = listed![session]!.find((each) => each.name === "coder")!; + const sub = await holdIn(owner, Chat, { session, conversation: coder.conversationId }, "participant"); + const view = await until(sub.view, (value) => + (value?.entries ?? []).some((entry) => + entry.model?.some((message) => message.role === "toolResult" && message.toolName === "codemode"), + ), + ); + const ran = (view?.entries ?? []) + .flatMap((entry) => entry.model ?? []) + .find( + (message): message is ToolResultMessage => message.role === "toolResult" && message.toolName === "codemode", + ); + assert.equal(ran?.isError, false, text(ran)); + const output = text(ran); + assert.match(output, /^Script completed/); + const value = JSON.parse(output.slice(output.indexOf("{"))) as { read: string; answer: unknown; refused: string }; + assert.match(value.read, /inside/); + assert.deepEqual(value.answer, { n: 1 }); + assert.match( + value.refused, + /Unknown tools: codemode\. Choose from read, write, edit, bash, send_file, artifact, location_envs/, + ); + // The script-only tools were enabled by name before the script started; the model is still offered its choice. + const tools = (sub.view.value?.docs["pi.agent"] as { tools: string[] }).tools; + assert.ok(tools.includes("agent"), tools.join()); + await reported("coder"); + await until(children.children, (value) => + Boolean(value?.[session]?.find((each) => each.name === "coder")?.tools.includes("codemode")), + ); + sub.release(); + }); + + it("reaches MCP tools installed after the spawn", async () => { + // The server connects after `coder` was spawned: the worker adds its tools to the subagent's tool list. + await writeFile( + join(fixture.dataDir, "mcp.json"), + JSON.stringify({ mcpServers: { files: { command: process.execPath, args: [MCP_FIXTURE] } } }), + ); + const mcp = await holdIn(owner, Mcp, {}, "owner"); + await until(mcp.instances, (instances) => instances.some((item) => item.status === "connected")); + const coder = (await until(children.children, (value) => Boolean(value?.[session]?.length)))![session]!.find( + (each) => each.name === "coder", + )!; + const sub = await holdIn(owner, Chat, { session, conversation: coder.conversationId }, "participant"); + const scripts = () => + (sub.view.value?.entries ?? []) + .flatMap((entry) => entry.model ?? []) + .filter( + (message): message is ToolResultMessage => + message.role === "toolResult" && message.toolName === "codemode", + ); + const before = scripts().length; + const script = 'return await tools.mcp__files__echo({ text: "from coder" });'; + await subagent({ action: "send", name: "coder", message: `codemode ${script}` }); + await until(sub.view, () => scripts().length > before); + const ran = scripts().at(-1); + assert.equal(ran?.isError, false, text(ran)); + assert.match(text(ran), /echo: from coder$/); + await reported("coder"); + sub.release(); + mcp.release(); + }); + + it("leaves code mode out while the codemode setting is off, and refuses choosing it", async () => { + await writeFile(join(fixture.dataDir, "settings.json"), JSON.stringify({ codemode: false })); + // The worker applies the settings file asynchronously: spawn (a new name each time) until it refuses. + let refused: ToolResultMessage | undefined; + for (let attempt = 0; attempt < 20 && !refused?.isError; attempt++) { + if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 200)); + refused = await subagent({ action: "spawn", name: `off-${attempt}`, message: "x", tools: ["codemode"] }); + if (!refused.isError) await reported(`off-${attempt}`); + } + assert.equal(refused?.isError, true); + assert.equal(text(refused), "Not available in this session: codemode."); + const plain = await subagent({ action: "spawn", name: "plain", message: "x" }); + assert.deepEqual((plain.details as { tools?: string[] }).tools, [ + "read", + "write", + "edit", + "bash", + "send_file", + "artifact", + ]); + }); +}); diff --git a/apps/p2/test/p2/terminal-tool.test.ts b/apps/p2/test/p2/terminal-tool.test.ts index f2dc4d115..28f80d568 100644 --- a/apps/p2/test/p2/terminal-tool.test.ts +++ b/apps/p2/test/p2/terminal-tool.test.ts @@ -1,10 +1,11 @@ // The agent's terminal tool end to end with the fake provider and a real terminal host (skipped without a pi-env // daemon build): code mode scripts drive the session's terminals, which are the owner's (listed, labeled, typed into // by both); guest turns may use it; tmux errors come back as tool errors; the server serves only the session's current worker, on the session's environments, and archiving closes -// the session's terminals. Without code mode, `tool_search` loads the tool. +// the session's terminals; a subagent's scripts open windows at its own location. Without code mode, `tool_search` +// loads the tool. import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { realpathSync, writeFileSync } from "node:fs"; +import { mkdirSync, realpathSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { test } from "node:test"; import { setTimeout as sleep } from "node:timers/promises"; @@ -210,6 +211,30 @@ test( await expectChord(whenLive(owner.hold(AgentTerminals, {}, BACKGROUND_CONTEXT)), "forbidden"); }); + await t.test("a subagent's script opens windows at the subagent's own location", async () => { + const own = join(workspace, "own"); + mkdirSync(own); + const opener = + 'return await tools.terminal({ command: "new-window", args: ["-n", "sub", "-w", "/^in-sub$/", "echo in-s\'\'ub"] });'; + const spawn = { action: "spawn", name: "own", message: `codemode ${opener}`, cwd: "own" }; + const spawned = await call(`subagent ${JSON.stringify(spawn)}`); + assert.equal(spawned.isError, false, text(spawned)); + // Its answer is reported to the main conversation; that turn ends before the next script. + await until( + chat.view, + (value) => + JSON.stringify(value?.entries ?? []).includes("[subagent own answered") && + !(value?.docs["pi.live"] as { run?: unknown })?.run, + ); + const { windows } = (await script('return await tools.terminal({ command: "list-windows" });')) as { + windows: { name?: string; opener: string; location: string }[]; + }; + assert.deepEqual( + windows.filter((window) => window.name === "sub").map(({ opener, location }) => ({ opener, location })), + [{ opener: "agent", location: own }], + ); + }); + await t.test("archiving the session closes its terminals", async () => { await sessions.archive({ id: session.id }, BACKGROUND_CONTEXT); const service = (await fixture.server.host.registry(TerminalServices, BACKGROUND_CONTEXT)).value!.service;