Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
72eff46
List a document's unarchived child channels from storage
MaggieAppleton Oct 11, 2026
b5b25ee
Give a root document's Chat checked access to its child documents
MaggieAppleton Oct 11, 2026
1c55f30
Tell the Planner which child document a member has open
MaggieAppleton Oct 11, 2026
ee95aff
Let the root Planner list, read, edit and anchor its child documents
MaggieAppleton Oct 11, 2026
91cbe35
Keep storage errors out of the focused-child refusal
MaggieAppleton Oct 11, 2026
15e5e57
Offer the child document tools to member Planner turns only
MaggieAppleton Oct 11, 2026
d1774f5
Remove child document Chats and clear their stored conversations
MaggieAppleton Oct 11, 2026
48be809
Keep Planner-ask backfill and stored Conversation state away from chi…
MaggieAppleton Oct 11, 2026
12f7c0c
Send child comments and MCP instructions to the root's Chat
MaggieAppleton Oct 11, 2026
17491c1
Hold a child's root until its own routed turn ends
MaggieAppleton Oct 11, 2026
6d78f57
Release a child's rooms when its root closes mid-turn
MaggieAppleton Oct 11, 2026
1d365b5
Send the open child as focus and open child threads from Chat
MaggieAppleton Oct 11, 2026
cb9de98
Scope comment opening to its document and wait for the thread
MaggieAppleton Oct 11, 2026
054cbcf
Let a child switch open its thread
MaggieAppleton Oct 11, 2026
9990842
Cover a root Planner editing its open child in the browser
MaggieAppleton Oct 11, 2026
fea4d8f
Hide unexpected errors from a child comment reply
MaggieAppleton Oct 11, 2026
576eb6e
Assert the child socket carries no Chat history and one Chat pane
MaggieAppleton Oct 11, 2026
637d223
Document archived refusals for MCP child instructions
MaggieAppleton Oct 11, 2026
01fd52b
Note which transition an MCP child invocation holds
MaggieAppleton Oct 11, 2026
42040a9
Renew design-contract review for Planner child documents
MaggieAppleton Oct 11, 2026
816e228
Move the document route swap out of the initial bundle
MaggieAppleton Oct 11, 2026
baba5bc
Renew design-contract review for the lazy document route swap
MaggieAppleton Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ per document. An MCP
runs under the channel's existing Planner owner; only a caller with a live
browser login can claim an unowned channel.

`Chat.Send.focus` names the child a member has open. The server resolves it
against storage and treats it as context, never as an authorization grant. A
child has no Chat: a comment sent to Chopin inside a child, and an
`invoke_planner` call naming a child, run on the root's Chat with that child as
focus, and the turn answers in the child's thread.

Under `copilot-sdk` and `pi` the Planner is a custom agent, not a general coding
agent; under `atomic` it is a full Atomic session. Either way its turn runs
through `HarnessAgent.stream()` from `@ai-sdk/harness`; the conversation stays
Expand Down Expand Up @@ -238,8 +244,12 @@ the request identity; observational reads must not restart terminal work.
Successful reconciliation validates the complete report and atomically creates
an initialized ordinary child channel plus the request link. Publication must be
idempotent and persistence must precede the ready card or nested navigation row.
A child owns ordinary document, Conversation, and Decisions state. V1 does not
offer child research, Background Work, implementation/tasks, or grandchildren.
A child owns ordinary document and Decisions state. It has no Chat: its root's
Chat and Planner reach it through the child tools, and a root message carries the
open child as `focus`. Startup clears any conversation a child stored earlier,
except one its decision records still cite, which is kept and logged but never
served. V1 does not offer child research, Background Work, implementation/tasks,
or grandchildren.

The server retains `research_workspaces`, turns, messages, and historical chat
references as staging and compatibility internals. Do not present them as a
Expand Down Expand Up @@ -450,11 +460,11 @@ run `bun run ci` and validate relative links with exact casing.
When a disposable Planner session is created, diagnostics report available tool
names and the external GitHub MCP contribution. Counts vary with SDK and remote
MCP versions. A healthy boundary includes Chopin's document tools (currently
plan-named), repository tools, and allowed pull-request tools, and excludes
ambient capabilities such as `bash`, filesystem access, URL fetch, host Git,
issues, and unrestricted search. The exception is an `atomic` Planner session,
which deliberately adds Atomic's builtins and coding tools; its summary and
research worker sessions must still show only their own tools.
plan-named), child document tools, repository tools, and allowed pull-request
tools, and excludes ambient capabilities such as `bash`, filesystem access, URL
fetch, host Git, issues, and unrestricted search. The exception is an `atomic`
Planner session, which deliberately adds Atomic's builtins and coding tools; its
summary and research worker sessions must still show only their own tools.

Treat a missing required tool or an unexpected ambient tool as a security or
configuration failure even when the overall count looks plausible.
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/agent/arguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/

import * as Question from "@chopin/question";
import { isChannelId } from "../channels/id";

import type { Operation } from "../plan/edit";
import type { Operation as GraphOperation, Revision, Task } from "../tasks/graphs";
Expand Down Expand Up @@ -145,6 +146,19 @@ function operation(raw: unknown, position: number): Operation {
}
}

/** A child tool's `child`, split from the arguments its root counterpart validates. */
export function childTarget(raw: unknown, name: string): {
child: string;
rest: Record<string, unknown>;
} {
let args = record(raw, `${name} arguments`);
if (typeof args.child !== "string" || !isChannelId(args.child)) {
fail("`child` must be a child document id returned by `list_children`.");
}
let { child, ...rest } = args;
return { child: child as string, rest };
}

/** Validate `edit_plan`'s tool input into the shape `edit.apply` expects. */
export function editPlan(raw: unknown): {
revision: number;
Expand Down
320 changes: 320 additions & 0 deletions apps/server/src/agent/child-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,320 @@
import { afterEach, expect, test } from "bun:test";

import { createChildDocuments } from "../chat/children";
import * as Service from "../plan/service";
import { openPlan, seedOpenThread } from "../testing/plan";
import { type DocumentRoom, documentTools } from "./tools";

import type { Plan } from "../plan/service";

let plans: Plan[] = [];
afterEach(async () => {
for (let plan of plans.splice(0)) await Service.close(plan);
});

async function rooted(options: { access?: () => Promise<boolean>; focus?: boolean } = {}) {
let child = await openPlan("# Child report\n\nOriginal finding.\n", { parent: "Root" });
let root = await openPlan("# Root\n\nRoot prose.\n");
plans.push(child.plan, root.plan);
let rootId = child.channel.parentChannelId!;
let committedAtBroadcast: string[] = [];
let server = {
publish(_topic: string, data: string) {
let frame = JSON.parse(data) as { kind: string };
if (frame.kind === "plan:update") {
committedAtBroadcast.push(child.plan.persistence.committedSource);
}
child.server.publish(_topic, data);
},
} as unknown as typeof child.server;
let children = createChildDocuments({
rootId,
repositoryId: "R_test",
channels: child.backend.storage.channels,
server,
withChildPlan: async (_id, action) => action(child.plan),
});
let room: DocumentRoom = {
id: rootId,
plan: root.plan,
server: root.server,
publish: async () => {
throw new Error("the root document must not change");
},
persist: () => Service.persist(root.plan),
exclusive: action => Service.exclusive(root.plan, action),
anchors() {},
changes() {},
children,
currentMemberRequest: () =>
options.focus
? {
entryId: "e",
userId: "U_ana",
handle: "ana",
text: "t",
claimantSessionId: undefined,
turnId: "turn",
lifecycle: 0,
focus: { channelId: child.channel.id, title: "Test plan" },
}
: undefined,
};
let owner = {
signal: new AbortController().signal,
revalidate: options.access ?? (async () => true),
};
let call = async (name: keyof typeof documentTools, input: unknown) => {
let output = await documentTools[name].execute!(input as never, {
context: { room, owner, repository: { id: "R_test" } },
toolCallId: "call",
messages: [],
} as never);
let text = String(output);
return text.startsWith("Error:") ? text : JSON.parse(text);
};
return { child, root, rootId, call, committedAtBroadcast };
}

test("list_children lists the root's children without focus", async () => {
let { call, child } = await rooted();
expect((await call("list_children", {})).children).toEqual([
{ id: child.channel.id, title: "Test plan", createdAt: child.channel.createdAt.toISOString() },
]);
});

test("list_children marks the child the current member request is focused on", async () => {
let { call } = await rooted({ focus: true });
expect((await call("list_children", {})).children[0].focused).toBe(true);
});

test("read_child returns the child's revision, source, and addressable blocks", async () => {
let { call, child } = await rooted();
let read = await call("read_child", { child: child.channel.id });
expect(read.child).toEqual({ id: child.channel.id, title: "Test plan" });
expect(read.source).toContain("Original finding.");
expect(read.revision).toBe(child.plan.revision);
expect(read.blocks.length).toBeGreaterThan(0);
});

test("edit_child commits the child before broadcasting and leaves the root alone", async () => {
let { call, child, root, committedAtBroadcast } = await rooted();
let read = await call("read_child", { child: child.channel.id });
let rootSource = await Service.readCurrentDocument(root.plan);
let result = await call("edit_child", {
child: child.channel.id,
revision: read.revision,
operations: [{ op: "insert", index: read.blocks.length - 1, source: "Planner note." }],
});
expect(result.ok).toBe(true);
expect(result.child).toBe(child.channel.id);
expect(child.plan.persistence.committedSource).toContain("Planner note.");
expect(committedAtBroadcast).toHaveLength(1);
expect(committedAtBroadcast[0]).toContain("Planner note.");
expect(await Service.readCurrentDocument(root.plan)).toEqual(rootSource);
});

test("edit_child refuses a stale revision and writes nothing", async () => {
let { call, child, committedAtBroadcast } = await rooted();
let before = child.plan.persistence.committedSource;
let result = await call("edit_child", {
child: child.channel.id,
revision: child.plan.revision + 5,
operations: [{ op: "insert_root", source: "Nope." }],
});
expect(result.ok).toBe(false);
expect(child.plan.persistence.committedSource).toBe(before);
expect(committedAtBroadcast).toEqual([]);
});

test("edit_child refuses once the root is archived while the edit waits its turn", async () => {
let { call, child, rootId, committedAtBroadcast } = await rooted();
let read = await call("read_child", { child: child.channel.id });
let before = child.plan.persistence.committedSource;
let release!: () => void;
let gate = new Promise<void>(resolve => release = resolve);
let busy = Service.exclusive(child.plan, () => gate);
let editing = call("edit_child", {
child: child.channel.id,
revision: read.revision,
operations: [{ op: "insert_root", source: "Too late." }],
});
await Bun.sleep(10);
await child.storage.channels.archive({ id: rootId, now: new Date() });
release();
await busy;
expect(await editing).toContain("no such child document");
expect(child.plan.persistence.committedSource).toBe(before);
expect(committedAtBroadcast).toEqual([]);
});

test("anchor_child anchors through the child's own persistence and refuses a stale revision", async () => {
let { call, child } = await rooted();
let read = await call("read_child", { child: child.channel.id });
let stale = await call("anchor_child", {
child: child.channel.id,
revision: read.revision + 1,
anchors: [{ thread: "T", blocks: [] }],
});
expect(stale).toMatchObject({ ok: false, reason: "stale" });
let unknown = await call("anchor_child", {
child: child.channel.id,
revision: read.revision,
anchors: [{ thread: "missing", blocks: [] }],
});
expect(unknown).toMatchObject({ ok: false, reason: "invalid" });
});

test("child tools refuse the root, a foreign id, a malformed id, and visual routes", async () => {
let { call, rootId, child } = await rooted();
for (let target of [rootId, crypto.randomUUID()]) {
expect(await call("read_child", { child: target })).toContain("no such child document");
}
expect(await call("read_child", { child: "../root" })).toContain("`child` must be");
expect(
await call("edit_child", {
child: child.channel.id,
revision: 0,
operations: [{ op: "insert_root", source: "x" }],
visual_route: "r",
}),
).toMatchObject({ ok: false, reason: "visual-routing-unavailable" });
});

test("every child tool refuses once the Planner owner loses repository access", async () => {
let allowed = true;
let { call, child } = await rooted({ access: async () => allowed });
let read = await call("read_child", { child: child.channel.id });
allowed = false;
let before = child.plan.persistence.committedSource;
expect(await call("list_children", {})).toContain("repository access could not be confirmed");
expect(await call("read_child", { child: child.channel.id })).toContain("could not be confirmed");
expect(
await call("edit_child", {
child: child.channel.id,
revision: read.revision,
operations: [{ op: "insert_root", source: "x" }],
}),
).toContain("could not be confirmed");
expect(
await call("anchor_child", {
child: child.channel.id,
revision: read.revision,
anchors: [{ thread: "T", blocks: [] }],
}),
).toContain("could not be confirmed");
expect(child.plan.persistence.committedSource).toBe(before);
});

test("child tools refuse without a Planner owner", async () => {
let { child, root } = await rooted();
let opened: string[] = [];
let children = {
list: async () => [],
resolve: async () => undefined,
open: async (id: string) => opened.push(id),
};
let output = await documentTools.read_child.execute!({ child: child.channel.id } as never, {
context: { room: { id: "x", plan: root.plan, children }, repository: { id: "R_test" } },
toolCallId: "call",
messages: [],
} as never);
expect(String(output)).toContain("repository access could not be confirmed");
expect(opened).toEqual([]);
});

test("child tools are unavailable in a room without children", async () => {
let bare = await openPlan("# Child\n", { parent: "Root" });
plans.push(bare.plan);
let output = await documentTools.list_children.execute!({} as never, {
context: {
room: { id: bare.channel.id, plan: bare.plan },
owner: { signal: new AbortController().signal, revalidate: async () => true },
},
toolCallId: "call",
messages: [],
} as never);
expect(String(output)).toContain("child documents are unavailable");
});

test("an unexpected storage failure reaches the model as a generic refusal", async () => {
let { call, child } = await rooted();
let original = console.error;
console.error = () => {};
let list = child.backend.storage.channels.children;
child.backend.storage.channels.children = async () => {
throw new Error("connection to 10.0.0.5:5432 refused password=hunter2");
};
try {
let text = await call("list_children", {});
expect(text).toBe("Error: child documents are temporarily unavailable");
} finally {
child.backend.storage.channels.children = list;
console.error = original;
}
});

test("reply_comment in a child hides an unexpected storage failure from the model", async () => {
let { call, child, root } = await rooted();
let thread = seedOpenThread(child.plan);
root.plan.chat.acting = thread;
root.plan.chat.actingChannel = child.channel.id;
let original = console.error;
console.error = () => {};
let get = child.backend.storage.channels.get;
child.backend.storage.channels.get = async () => {
throw new Error("connection to 10.0.0.5:5432 refused password=hunter2");
};
try {
let text = await call("reply_comment", { thread, text: "Done." });
expect(text).toBe("Error: child documents are temporarily unavailable");
} finally {
child.backend.storage.channels.get = get;
console.error = original;
root.plan.chat.actingChannel = undefined;
}
});

test("reply_comment in a child keeps its intentional refusals", async () => {
let { call, child, root } = await rooted({ access: async () => false });
let thread = seedOpenThread(child.plan);
root.plan.chat.acting = thread;
root.plan.chat.actingChannel = child.channel.id;
let original = console.error;
console.error = () => {};
try {
let text = await call("reply_comment", { thread, text: "Done." });
expect(text).toBe("Error: the Planner's repository access could not be confirmed");
} finally {
console.error = original;
root.plan.chat.actingChannel = undefined;
}
});

test("edit_child attributes the edit to the child thread the root turn is acting on", async () => {
let { call, child, root } = await rooted();
let thread = seedOpenThread(child.plan);
root.plan.chat.acting = thread;
root.plan.chat.actingChannel = child.channel.id;
let read = await call("read_child", { child: child.channel.id });
await call("edit_child", {
child: child.channel.id,
revision: read.revision,
operations: [{ op: "insert", index: read.blocks.length - 1, source: "Attributed." }],
});
expect(child.plan.threads.get(thread)?.result).toBeDefined();
});

test("edit_child does not attribute a root thread's turn to the child", async () => {
let { call, child, root } = await rooted();
let thread = seedOpenThread(child.plan);
root.plan.chat.acting = thread;
root.plan.chat.actingChannel = undefined;
let read = await call("read_child", { child: child.channel.id });
await call("edit_child", {
child: child.channel.id,
revision: read.revision,
operations: [{ op: "insert", index: read.blocks.length - 1, source: "Unattributed." }],
});
expect(child.plan.threads.get(thread)?.result).toBeUndefined();
});
Loading
Loading