diff --git a/AGENTS.md b/AGENTS.md index ae5beefbd..6b4487cbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,8 +114,15 @@ external implementation runs are durable. model-backed research request supplies its GitHub App token and Copilot entitlement. The database stores only a token-free owner reference and durable context. -- **Planner tools are repository-fixed.** The hosted runtime has no shell, - checkout, host filesystem, skills, plugins, or arbitrary GitHub access. +- **Planner tools are repository-fixed under `copilot-sdk` and `pi`.** Those + Planners have no shell, checkout, host filesystem, skills, plugins, or arbitrary + GitHub access. `HARNESS=atomic` deliberately runs every Planner session, local + or hosted, as a full Atomic session with the operator's Atomic tools and + resources, including shell and filesystem access as the server process's user. + Its working directory is a checkout verified from `invoke_planner` and + remembered per channel in memory, or else an empty per-channel directory. Its + summary and research workers stay isolated. There is no flag for this; the + harness is the choice. - **Repository node IDs are authoritative.** Owner and repository names resolve GitHub requests but never replace the stored node identity. - **Persistence should precede publication.** Do not acknowledge or broadcast a @@ -131,9 +138,13 @@ Planner destination without a mention. `instruction()` strips the mention before model input. Recent room messages that did not address the Planner still enter a bounded backscroll for the next turn. -An accepted comment also starts an explicit Planner turn after it commits. +An accepted comment also starts an explicit Planner turn after it commits. An MCP +`invoke_planner` call posts its instruction as the caller's member message and +runs under the channel's existing Planner owner; only a caller with a live +browser login can claim an unowned channel. -The Planner is a custom agent, not a general coding agent. Its turn runs +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 active until the returned stream finishes. An interrupted turn is never replayed automatically because it may already have made durable tool changes. @@ -399,7 +410,9 @@ 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. +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. diff --git a/README.md b/README.md index a7413c5ff..b3f359132 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,11 @@ appear as documents in navigation. https://github.com/user-attachments/assets/72a85be8-685f-4d60-9937-b3855b46cebe The Planner can inspect the selected GitHub repository and its pull requests -through bounded, read-only tools, then co-author the document. It cannot write to -GitHub, edit a checkout, or implement code. A separate coding agent can connect +through bounded, read-only tools, then co-author the document. Under the default +`copilot-sdk` harness, and under `pi`, it cannot write to GitHub, edit a checkout, +or implement code. `HARNESS=atomic` deliberately runs it as a full Atomic session +with shell and filesystem access as the server process's user; see +[Self-hosting](docs/self-hosting.md#choose-and-trust-a-harness). A separate coding agent can connect to Chopin through MCP to create or revise documents and consume an approved implementation graph. @@ -60,8 +63,8 @@ and tool vocabulary remain optimized for planning. - Chopin supports GitHub.com. GitHub Enterprise Server endpoints are not configurable. - The Planner's file, tree, and history tools read the default branch captured - when its session starts; code search is repository-scoped. It never reads a - local checkout or uncommitted changes. + when its session starts; code search is repository-scoped. Except under + `HARNESS=atomic`, it never reads a local checkout or uncommitted changes. - Every browser participant signs in, passes the instance admission policy, and needs repository access through the GitHub App installation. MCP callers also pass instance admission, but use their own bearer token for repository diff --git a/apps/server/src/agent/planner.ts b/apps/server/src/agent/planner.ts index b0702b440..bb2aba7d4 100644 --- a/apps/server/src/agent/planner.ts +++ b/apps/server/src/agent/planner.ts @@ -17,6 +17,7 @@ import { COMPONENTS, DIFF_LANGUAGE, MERMAID_LANGUAGE } from "@chopin/dialect/dialect"; import type { Component } from "@chopin/dialect/dialect"; +import type { PlannerWorkspace } from "../harness/atomic/workspace"; /** Components the agent writes itself. The rest are created for it. */ const AUTHORABLE = ["Callout", "Tabs", "Tab", "Underline"]; @@ -230,13 +231,29 @@ Questionnaires are created by \`ask\`, never by hand, and their answers are owne elsewhere — leave them alone when you rewrite around them. To take one out of the plan, use the \`detach_question\` operation rather than deleting the block.`; -export function plannerInstructions(repository: string, bootstrap?: string): string { - let access = `Read before you propose. The selected repository is ${repository}. Use +export function plannerInstructions( + repository: string, + bootstrap?: string, + workspace?: PlannerWorkspace, +): string { + let reading = `Read before you propose. The selected repository is ${repository}. Use \`read_repository_file\`, \`list_repository_tree\`, \`search_repository\` and \`repository_history\` for its code, and \`list_pull_requests\` and -\`pull_request_read\` for its pull requests. Every repository tool is fixed to this repository. - -You have no shell, checkout, host filesystem, skills or repository instructions, +\`pull_request_read\` for its pull requests. Every repository tool is fixed to this repository.`; + if (!workspace) { + let isolated = `You have no shell, checkout, host filesystem, skills or repository instructions, and cannot change GitHub. Ground the plan in what those reading tools return.`; - return [PROMPT, access, bootstrap].filter(Boolean).join("\n\n"); + return [PROMPT, reading, isolated, bootstrap].filter(Boolean).join("\n\n"); + } + let place = workspace.checkout + ? `Your working directory, ${workspace.cwd}, is a local checkout of ${repository} +verified against its origin. Its branch and working tree may differ from what the +repository tools read.` + : `Your working directory, ${workspace.cwd}, is a scratch directory Chopin created +empty for this document. It is not a checkout and holds no repository files, so read +${repository} through the repository tools.`; + let questions = + `\`ask_user_question\` and \`workflow\` questions appear to the document's members as +Decisions. If one expires unanswered, proceed on your best judgement and say what you assumed.`; + return [PROMPT, reading, place, questions, bootstrap].filter(Boolean).join("\n\n"); } diff --git a/apps/server/src/auth/session.test.ts b/apps/server/src/auth/session.test.ts index 395137f72..f7d6db91d 100644 --- a/apps/server/src/auth/session.test.ts +++ b/apps/server/src/auth/session.test.ts @@ -30,6 +30,26 @@ function grant(accessToken: string, refreshToken = "ghr_refresh"): GitHubTokenGr } describe("hosted login sessions", () => { + it("finds the most recent live process-local session for only the requested user", async () => { + let storage = new MemoryStorage(); + let now = new Date("2026-08-13T12:00:00.000Z"); + for (let id of ["U_first", "U_second"]) { + await storage.users.put({ id, login: id, avatarUrl: "", now }); + } + let sessions = new Sessions(storage, false, () => now); + let first = await sessions.issue("U_first", grant("first-token")); + now = new Date(now.getTime() + 1_000); + let second = await sessions.issue("U_first", grant("newer-token")); + await sessions.issue("U_second", grant("other-user-token")); + expect((await sessions.forUser("U_first"))?.session.id).toBe(second.id); + expect(await sessions.forUser("U_unknown")).toBeUndefined(); + expect(await new Sessions(storage, false, () => now).forUser("U_first")).toBeUndefined(); + await sessions.revoke(request(pair(second.cookie))); + expect((await sessions.forUser("U_first"))?.session.id).toBe(first.id); + now = new Date(first.expiresAt); + expect(await sessions.forUser("U_first")).toBeUndefined(); + }); + it("stores only registry metadata while credentials remain process-local", async () => { let storage = new MemoryStorage(); let now = new Date("2026-08-13T12:00:00.000Z"); diff --git a/apps/server/src/auth/session.ts b/apps/server/src/auth/session.ts index 4fb66ec7c..3de98e931 100644 --- a/apps/server/src/auth/session.ts +++ b/apps/server/src/auth/session.ts @@ -267,6 +267,16 @@ export class Sessions { : undefined; } + /** An MCP handoff may borrow an existing login to claim ownership, never the caller's bearer. */ + async forUser(userId: string): Promise { + for (let current of [...this.#sessions.values()].toReversed()) { + if (current.session.userId !== userId) continue; + let resolved = await this.resolve(current.session.id); + if (resolved) return resolved; + } + return undefined; + } + /** Read current credentials without triggering rotation from inside an active agent callback. */ async inspect(id: string): Promise { if (this.#refreshes.has(id) || this.#revocations.has(id)) return undefined; diff --git a/apps/server/src/chat/address.ts b/apps/server/src/chat/address.ts index 32371866f..81dd2191f 100644 --- a/apps/server/src/chat/address.ts +++ b/apps/server/src/chat/address.ts @@ -93,8 +93,9 @@ export function compose( ...backscroll.flatMap(said => said.references ?? []), ...references, ], + verbatim = false, ): string { - let asked = references.length > 0 ? text : instruction(text); + let asked = verbatim || references.length > 0 ? text : instruction(text); let readableIds = new Set(catalogReferences.map(reference => reference.id)); let current = annotatedText(asked, references, readableIds); diff --git a/apps/server/src/chat/invoke.test.ts b/apps/server/src/chat/invoke.test.ts new file mode 100644 index 000000000..a20c02a57 --- /dev/null +++ b/apps/server/src/chat/invoke.test.ts @@ -0,0 +1,411 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, readdir, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { ActiveOwnerBindings } from "../agent/active-owner"; +import { Admission } from "../auth/admission"; +import { Sessions } from "../auth/session"; +import { fullPlanner } from "../harness/atomic/full"; +import { removeWorkspaces } from "../harness/atomic/workspace"; +import { openPlannerSession } from "../harness/session"; +import * as Plan from "../plan/service"; +import { MemoryStorage } from "../storage/memory/adapter"; +import { configured, LOCAL } from "../testing/config"; +import * as Chat from "./service"; + +import type { Server } from "bun"; +import type { HostedAuth } from "../auth/routes"; +import type { Config } from "../config"; +import type { GitHub } from "../github/client"; +import type { Socket, SocketData } from "../wire"; + +const ATOMIC = { HARNESS: "atomic", HARNESS_AUTH: "ai-gateway", MODEL: "stub/model" }; + +let cleanups: Array<() => Promise> = []; +afterEach(async () => { + for (let cleanup of cleanups.splice(0)) await cleanup(); + await removeWorkspaces(); +}); + +function grant(accessToken: string) { + return { + accessToken, + accessExpiresIn: 28_800, + refreshToken: "refresh-token", + refreshExpiresIn: 86_400, + }; +} + +async function setup(config: Config = { agent: true } as Config) { + let now = new Date(); + let storage = new MemoryStorage(); + let user = { id: "U_ana", login: "ana", avatarUrl: "" }; + let bob = { id: "U_bob", login: "bob", avatarUrl: "" }; + for (let person of [user, bob]) await storage.users.put({ ...person, now }); + let sessions = new Sessions(storage, false); + let login = await sessions.issue(user.id, grant("browser-token")); + let repository = { id: "R_score", owner: "octo-org", name: "score", defaultBranch: "main" }; + let github = { + repositoryAccess: async () => ({ + ...repository, + permissions: { push: true, pull: true, admin: false }, + }), + } as unknown as GitHub; + let authConfig = { + origin: "http://localhost:8795", + appSlug: "test", + clientId: "test", + encryptionKey: new Uint8Array(32), + }; + let auth: HostedAuth = { + config: authConfig, + github, + storage, + sessions, + clock: () => new Date(), + admission: new Admission(authConfig, github), + }; + let channel = await storage.channels.create({ + id: crypto.randomUUID(), + repositoryId: repository.id, + repositoryOwner: repository.owner, + repositoryName: repository.name, + title: "Document", + createdBy: user.id, + now, + }); + let lease = (await storage.leases.acquire("writer", "test", 60_000))!; + let events: Array<{ kind: string; [key: string]: unknown }> = []; + let server = { + publish(_topic: string, message: string) { + events.push(JSON.parse(message)); + }, + } as unknown as Server; + let backend: Plan.Backend = { + storage, + lease: () => lease, + fatal: error => { + throw error; + }, + }; + let plan = await Plan.open(channel.id, backend, server); + let owners = new ActiveOwnerBindings(auth); + let context: Chat.Room = { + chat: plan.chat, + plan, + room: channel.id, + server, + auth, + repository, + config, + claimantSessionId: login.id, + activeOwner: () => owners.resolve(channel.id), + persist: () => Plan.persist(plan), + }; + let closed = false; + let fixture = { + context, + events, + user, + bob, + login, + backend, + /** The session that owns the channel's Planner, if any. */ + owner: async () => + (await storage.channels.readAgent(channel.id, new Date()))?.agent?.ownerSessionId, + async close() { + if (closed) return; + closed = true; + owners.revokeAll(); + await Plan.close(plan); + }, + }; + cleanups.push(fixture.close); + return fixture; +} + +type Seen = { + owner: string; + full: ReturnType; + instructions: string; + prompt: string; +}; + +/** The real session opener, over a harness stub that records what each turn runs with. */ +function observe(context: Chat.Room): Seen[] { + let seen: Seen[] = []; + context.openPlannerSession = (owner, channel) => + openPlannerSession(owner, channel, { + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => ({ destroy: async () => {} }) as never, + registerCredential: () => () => {}, + agent: { + createSession: async ({ sessionId }: { sessionId: string }) => ({ + sessionId, + destroy: async () => {}, + }), + stream: async (call: { + session: { sessionId: string }; + prompt: string; + options: { instructions: string }; + }) => { + seen.push({ + owner: owner.ownerSessionId, + full: fullPlanner(call.session.sessionId), + instructions: call.options.instructions, + prompt: call.prompt, + }); + return { fullStream: (async function*() {})() }; + }, + } as never, + }); + return seen; +} + +async function checkout(origin: string): Promise { + let root = await mkdtemp(join(tmpdir(), "chopin-invoke-checkout-")); + cleanups.push(() => rm(root, { recursive: true, force: true })); + for (let args of [["init", "--quiet"], ["remote", "add", "origin", origin]]) { + let git = Bun.spawn(["git", "-C", root, ...args], { stdout: "pipe", stderr: "pipe" }); + expect(await git.exited).toBe(0); + } + return realpath(root); +} + +/** A browser member's `@chopin` message, through the ordinary composer path. */ +async function browserTurn(context: Chat.Room, text: string) { + let ws = { data: { handle: "ana", principalId: "U_ana" }, send() {} } as unknown as Socket; + await Chat.send(context, ws, { + kind: "chat:send", + rid: "send", + requestId: crypto.randomUUID(), + to: "planner", + text, + ts: 0, + } as never); + await context.chat.running; +} + +test("an invoked instruction persists verbatim as a member message before publication and returns before the turn", async () => { + let { context, events, user } = await setup(); + let saved = Promise.withResolvers(); + let persist = context.persist; + context.persist = async () => { + await saved.promise; + await persist(); + }; + let finished = Promise.withResolvers(); + let opened = Promise.withResolvers(); + let seen: string[] = []; + context.openPlannerSession = async () => ({ + ok: true, + value: { + async stream(prompt) { + seen.push(prompt); + opened.resolve(); + return { + fullStream: (async function*() { + await finished.promise; + yield { type: "finish" }; + })(), + } as never; + }, + destroy: async () => {}, + }, + }); + try { + let posting = Chat.invoke(context, user, " @chopin Plan this.\n"); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(events).toEqual([]); + expect(seen).toEqual([]); + saved.resolve(); + expect(await posting).toBeUndefined(); + await opened.promise; + expect(context.chat.busy).toBe(true); + expect(context.chat.entries[0]).toMatchObject({ + author: { kind: "member", handle: "ana" }, + text: " @chopin Plan this.\n", + }); + expect(events.some(event => event.kind === "chat:message")).toBe(true); + expect(seen).toEqual(["@ana: @chopin Plan this.\n"]); + finished.resolve(); + await context.chat.running; + expect(context.chat.busy).toBe(false); + } finally { + saved.resolve(); + finished.resolve(); + } +}); + +test("queued invocations post their durable message exactly once", async () => { + let fixture = await setup(); + let { context, user } = fixture; + let first = Promise.withResolvers(); + let opened = Promise.withResolvers(); + let prompts: string[] = []; + context.openPlannerSession = async () => ({ + ok: true, + value: { + async stream(prompt) { + prompts.push(prompt); + opened.resolve(); + return { + fullStream: (async function*() { + await first.promise; + yield { type: "finish" }; + })(), + } as never; + }, + destroy: async () => {}, + }, + }); + try { + await Chat.invoke(context, user, "First"); + await opened.promise; + await Chat.invoke(context, user, "Second"); + await Chat.invoke(context, user, "Second"); + expect(context.chat.waiting.map(item => item.text)).toEqual(["Second", "Second"]); + expect(context.chat.entries.map(entry => entry.text)).toEqual(["First", "Second", "Second"]); + expect(new Set(context.chat.entries.map(entry => entry.id)).size).toBe(3); + first.resolve(); + await context.chat.running; + expect(prompts).toEqual(["@ana: First", "@ana: Second", "@ana: Second"]); + expect(context.chat.entries.map(entry => entry.text)).toEqual(["First", "Second", "Second"]); + expect(context.chat.waiting).toEqual([]); + expect(context.chat.busy).toBe(false); + await fixture.close(); + let restored = await Plan.open(context.room, fixture.backend, context.server); + expect(restored.chat.entries.map(entry => entry.text)).toEqual(["First", "Second", "Second"]); + await Plan.close(restored); + } finally { + first.resolve(); + } +}); + +test("failed persistence, an unavailable Planner and a full queue start no invoked turn", async () => { + let { context, events, user } = await setup(); + context.config.agent = false; + expect(await Chat.invoke(context, user, "Review")).toBe("planner-unavailable"); + context.config.agent = true; + context.chat.busy = true; + context.chat.waiting = Array.from( + { length: 20 }, + (_, index) => ({ id: String(index), handle: "ana", text: "Waiting" }), + ); + expect(await Chat.invoke(context, user, "Review")).toBe("planner-queue-full"); + context.chat.waiting = []; + context.chat.busy = false; + context.persist = async () => { + throw new Error("storage failed"); + }; + await expect(Chat.invoke(context, user, "Review")).rejects.toThrow("storage failed"); + expect(context.chat.entries).toEqual([]); + expect(events).toEqual([]); + expect(context.chat.running).toBeUndefined(); +}); + +test("an invocation runs under the channel's existing owner and is attributed to its caller", async () => { + let fixture = await setup(); + let { context, user, bob } = fixture; + let owner = await context.auth.sessions.issue(bob.id, grant("bob-token")); + await Chat.resolveOwner(context.auth, context.repository, context.room, owner.id); + let seen = observe(context); + for (let claimant of [undefined, fixture.login.id]) { + expect(await Chat.invoke({ ...context, claimantSessionId: claimant }, user, "Review")) + .toBeUndefined(); + await context.chat.running; + } + expect(seen.map(turn => ({ owner: turn.owner, prompt: turn.prompt }))).toEqual([ + { owner: owner.id, prompt: "@ana: Review" }, + { owner: owner.id, prompt: "@ana: Review" }, + ]); + expect(context.chat.entries.filter(entry => entry.author.kind === "member")).toMatchObject([ + { author: { kind: "member", handle: "ana" }, text: "Review" }, + { author: { kind: "member", handle: "ana" }, text: "Review" }, + ]); + expect(await fixture.owner()).toBe(owner.id); +}); + +test("without an owner the caller's live login claims the Planner, and without either nothing starts", async () => { + let fixture = await setup(); + let { context, events, user } = fixture; + let seen = observe(context); + expect(await Chat.invoke({ ...context, claimantSessionId: undefined }, user, "Review")) + .toBe("planner-owner-unavailable"); + expect(context.chat.entries).toEqual([]); + expect(events).toEqual([]); + expect(context.chat.running).toBeUndefined(); + expect(await fixture.owner()).toBeUndefined(); + expect(await Chat.invoke(context, user, "Review")).toBeUndefined(); + await context.chat.running; + expect(await fixture.owner()).toBe(fixture.login.id); + expect(seen.map(turn => turn.owner)).toEqual([fixture.login.id]); +}); + +test("the atomic harness verifies a checkout before posting and every later session reuses it", async () => { + let fixture = await setup(configured(ATOMIC)); + let { context, events, user } = fixture; + let seen = observe(context); + let matching = await checkout("git@github.com:octo-org/score.git"); + let other = await checkout("https://github.com/octo-org/other.git"); + for (let path of [other, join(matching, "missing")]) { + expect(await Chat.invoke(context, user, "Review", path)).toBe("checkout-unverified"); + } + expect(context.chat.entries).toEqual([]); + expect(events).toEqual([]); + expect(await fixture.owner()).toBeUndefined(); + + expect(await Chat.invoke(context, user, "Review", matching)).toBeUndefined(); + await context.chat.running; + await browserTurn(context, "@chopin Continue"); + expect(await Chat.invoke(context, user, "Again", other)).toBe("checkout-unverified"); + await browserTurn(context, "@chopin Once more"); + expect(seen).toHaveLength(3); + for (let turn of seen) { + expect(turn.full?.cwd).toBe(matching); + expect(turn.full?.humanInput.questionnaire).toBeFunction(); + expect(turn.instructions).toContain(`${matching}, is a local checkout of octo-org/score`); + } +}); + +test("other harnesses ignore a checkout: it is neither verified, used, nor remembered", async () => { + let { context, user } = await setup(configured()); + let seen = observe(context); + let matching = await checkout("workgit:octo-org/score.git"); + for (let path of ["/does/not/exist", matching]) { + expect(await Chat.invoke(context, user, "Review", path)).toBeUndefined(); + await context.chat.running; + } + expect(seen.map(turn => turn.full)).toEqual([undefined, undefined]); + for (let turn of seen) expect(turn.instructions).toContain("You have no shell"); + context.config = configured(ATOMIC); + await browserTurn(context, "@chopin Continue"); + let atomic = seen.at(-1)!.full!; + expect(atomic.cwd).not.toBe(matching); + expect(await readdir(atomic.cwd)).toEqual([]); +}); + +test("HARNESS=atomic runs every Planner session full in hosted and local configuration", async () => { + for ( + let [overrides, full] of [ + [ATOMIC, true], + [{ ...ATOMIC, ...LOCAL }, true], + [{}, false], + [{ HARNESS: "pi", HARNESS_AUTH: "ai-gateway", MODEL: "stub/model", ...LOCAL }, false], + ] as const + ) { + let { context, user } = await setup(configured(overrides)); + let seen = observe(context); + expect(await Chat.invoke(context, user, "Review")).toBeUndefined(); + await context.chat.running; + await browserTurn(context, "@chopin Continue"); + expect(seen).toHaveLength(2); + for (let turn of seen) { + let harness = context.config.harness; + expect({ harness, full: !!turn.full }).toEqual({ harness, full }); + expect(!!turn.full?.humanInput).toBe(full); + expect(turn.instructions.includes("proceed on your best judgement")).toBe(full); + } + } +}); diff --git a/apps/server/src/chat/limits.ts b/apps/server/src/chat/limits.ts new file mode 100644 index 000000000..d8e420753 --- /dev/null +++ b/apps/server/src/chat/limits.ts @@ -0,0 +1 @@ +export const MAX_MESSAGE_BYTES = 64 * 1024; diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index e20192984..a7c983ce2 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -22,6 +22,8 @@ import { ulid } from "@chopin/dialect"; import { PLANNER_TOOL_NAMES } from "../harness/tool-names"; import type { PlannerSession } from "../harness/session"; +import { verifiedCheckout } from "../harness/atomic/checkout"; +import { rememberCheckout } from "../harness/atomic/workspace"; import { plannerInstructions } from "../agent/planner"; import type { ActiveOwnerBinding } from "../agent/active-owner"; import type { DocumentRoom, ResearchWorkspaceRequest } from "../agent/tools"; @@ -37,6 +39,7 @@ import { JOB_TOOLS } from "../agent/job-scope"; import type { JobOutcome } from "../conversation-plan/jobs"; export type { Announcer, NoticeInput } from "./notices"; import { broadcast, fail, reply, tell } from "../wire"; +import { MAX_MESSAGE_BYTES } from "./limits"; import type { Server } from "bun"; import type { TextStreamPart, ToolSet } from "ai"; @@ -53,7 +56,6 @@ import type { Socket, SocketData } from "../wire"; /** Beyond this the queue is a backlog nobody is going to read. */ const MAX_QUEUE = 20; const MAX_PENDING_SENDS = 20; -const MAX_MESSAGE_BYTES = 64 * 1024; const MAX_SESSION_REFERENCES = 50; const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const FINGERPRINT = /^sha256:[0-9a-f]{64}$/; @@ -91,6 +93,9 @@ export type Waiting = Wire.Waiting & { userId?: string; /** A queued background turn, resolved only after its scope is cleaned up. */ job?: JobTurn; + posted?: boolean; + /** MCP caller whose instruction this is; it never claims ownership without their login. */ + invokedBy?: string; }; export type ActiveMemberRequest = { @@ -98,7 +103,7 @@ export type ActiveMemberRequest = { userId: string; handle: string; text: string; - claimantSessionId: string; + claimantSessionId: string | undefined; turnId: string; lifecycle: number; }; @@ -418,13 +423,19 @@ export type Room = { room: string; config: Config; auth: HostedAuth; - claimantSessionId: string; + /** + * Login session that may claim Planner ownership for this room's turns. + * Absent for an MCP caller without a live browser login: their instruction + * runs only under an owner the channel already has. + */ + claimantSessionId: string | undefined; repository: HostedRepository; activeOwner?: () => Promise; persist: () => Promise; commitRoomMessage?: (entry: Wire.Entry) => Promise; roomMessagePublished?: () => void; openPlannerSession?: typeof import("../harness/session")["openPlannerSession"]; + invokedBy?: string; ownerAvailable?: () => Promise; jobs?: JobService; references?: ReferenceService; @@ -458,6 +469,89 @@ export function send(context: Room, ws: Socket, msg: Request): Promis return completed; } +/** + * Accept an MCP instruction without waiting for its Planner turn. + * + * The caller is attributed on the posted message, and the turn runs under the + * channel's Planner owner. A checkout matters only to the atomic harness, which + * verifies it before anything is posted and remembers it for the channel. + */ +export async function invoke( + context: Room, + user: { id: string; login: string }, + text: string, + checkout?: string, +): Promise< + | "planner-unavailable" + | "planner-owner-unavailable" + | "planner-queue-full" + | "checkout-unverified" + | undefined +> { + let { chat, server, room } = context; + if (chat.pendingSends >= MAX_PENDING_SENDS) return "planner-queue-full"; + chat.pendingSends++; + let post = async () => { + if (!context.config.agent || chat.closed) return "planner-unavailable" as const; + let verified: string | undefined; + if (checkout !== undefined && context.config.harness === "atomic") { + verified = await verifiedCheckout(context.repository, checkout); + if (!verified) return "checkout-unverified" as const; + } + try { + await resolveOwner(context.auth, context.repository, room, context.claimantSessionId); + } catch { + return "planner-owner-unavailable" as const; + } + if (chat.closed) return "planner-unavailable" as const; + if (chat.busy && chat.waiting.length >= MAX_QUEUE) return "planner-queue-full" as const; + let entry: Wire.Entry = { + id: ulid(), + author: { kind: "member", handle: user.login }, + text, + ts: now(), + }; + chat.entries.push(entry); + try { + await context.persist(); + } catch (error) { + chat.entries = chat.entries.filter(value => value !== entry); + throw error; + } + if (chat.closed) return "planner-unavailable" as const; + if (verified) rememberCheckout(room, verified); + announce(server, room, entry); + if (chat.busy) { + chat.waiting.push({ + id: entry.id, + handle: user.login, + text, + message: true, + posted: true, + sessionId: context.claimantSessionId, + userId: user.id, + invokedBy: user.id, + }); + queued(chat, server, room); + } else { + startRun( + { ...context, invokedBy: user.id }, + user.login, + text, + undefined, + context.claimantSessionId, + false, + { entryId: entry.id, userId: user.id }, + ); + } + return undefined; + }; + let accepted = chat.sending.then(post, post); + let completed = accepted.finally(() => chat.pendingSends--); + chat.sending = completed.then(() => {}, () => {}); + return completed; +} + async function processSend(context: Room, ws: Socket, msg: Request): Promise { let { chat, room, server } = context; let suppliedRequestId = (msg as Request & { requestId?: unknown }).requestId; @@ -735,7 +829,7 @@ function currentMemberRequest(chat: Chat): ActiveMemberRequest | undefined { let active = chat.activeRequest; if ( !active || chat.closed || !chat.busy || chat.lifecycle !== active.lifecycle - || chat.turn?.id !== active.turnId || !active.userId || !active.claimantSessionId + || chat.turn?.id !== active.turnId || !active.userId ) return undefined; let entry = chat.entries.find(value => value.id === active.entryId); if ( @@ -879,7 +973,7 @@ export function consumeBootstrapBackscroll(chat: Chat): void { async function repositorySession( context: Room, - claimantSessionId: string, + claimantSessionId: string | undefined, currentEntryId?: string, currentReferences: Wire.Reference[] = [], ): Promise<{ session: PlannerSession; binding: ActiveOwnerBinding }> { @@ -932,11 +1026,14 @@ async function repositorySession( let result = await open(binding, { room: documentRoom(context), repository, - instructions: plannerInstructions( - `${repository.owner}/${repository.name}`, - bootstrap, - ), + instructions: workspace => + plannerInstructions( + `${repository.owner}/${repository.name}`, + bootstrap, + workspace, + ), model: context.config.model, + harness: context.config.harness, }); if (!result.ok) throw new Error(`Planner session unavailable (${result.error.kind})`); opened = result.value; @@ -968,19 +1065,23 @@ async function repositorySession( } } +/** + * The channel's Planner owner, claimed for the claimant when it has none. + * Without a claimant only an owner the channel already has will do. + */ export async function resolveOwner( auth: HostedAuth, repository: HostedRepository, channelId: string, - claimantSessionId: string, + claimantSessionId: string | undefined, ) { - let ownership = await auth.storage.channels.claimAgentOwner( - channelId, - claimantSessionId, - new Date(), - ); - let ownerSessionId = ownership.ownerSessionId; - if (!ownerSessionId) throw new Error("This channel's Copilot owner is unavailable."); + let ownership = claimantSessionId + ? await auth.storage.channels.claimAgentOwner(channelId, claimantSessionId, new Date()) + : (await auth.storage.channels.readAgent(channelId, new Date()))?.agent; + let ownerSessionId = ownership?.ownerSessionId; + if (!ownership || !ownerSessionId) { + throw new Error("This channel's Copilot owner is unavailable."); + } let owner = await auth.sessions.resolve(ownerSessionId); if (!owner) { throw new Error("The Copilot owner must sign in again or reset this channel's agent."); @@ -1021,7 +1122,7 @@ async function run( handle: string, text: string, thread: string | undefined, - claimantSessionId: string, + claimantSessionId: string | undefined, reserved = false, member?: MemberRequest, references: Wire.Reference[] = [], @@ -1089,7 +1190,7 @@ async function run( ]; retainReferences(chat, promptReferences); let available = promptReferences.filter(reference => chat.referenceCache.has(reference.id)); - prompt = compose(backscroll, handle, text, references, available); + prompt = compose(backscroll, handle, text, references, available, !!context.invokedBy); } sendStarted = true; let signal = AbortSignal.any([opened.binding.signal, turnController.signal]); @@ -1224,13 +1325,13 @@ async function run( (next.job ? jobQueued : queued)(chat, server, room); let nextId = next.id; let entry = next.message ? chat.entries.find(item => item.id === nextId) : undefined; - if (entry) announce(server, room, entry); + if (entry && !next.posted) announce(server, room, entry); await run( - context, + { ...context, invokedBy: next.invokedBy }, next.handle, next.text, next.thread, - next.sessionId ?? context.claimantSessionId, + next.invokedBy ? next.sessionId : next.sessionId ?? context.claimantSessionId, false, next.message && next.userId ? { entryId: next.id, userId: next.userId } : undefined, next.references, @@ -1249,7 +1350,7 @@ function startRun( handle: string, text: string, thread: string | undefined, - claimantSessionId: string, + claimantSessionId: string | undefined, reserved = false, member?: MemberRequest, references?: Wire.Reference[], @@ -1287,7 +1388,7 @@ function startRun( export function pending(chat: Chat): Waiting | undefined { let next = chat.waiting.shift(); while (next?.spent?.()) next = chat.waiting.shift(); - if (next?.message) { + if (next?.message && !next.posted) { let entry: MemberEntry = { id: next.id, author: { kind: "member", handle: next.handle }, diff --git a/apps/server/src/comments/service.ts b/apps/server/src/comments/service.ts index bf4e941e9..1c75b32f8 100644 --- a/apps/server/src/comments/service.ts +++ b/apps/server/src/comments/service.ts @@ -396,13 +396,29 @@ async function settle( let mutation: { update: Uint8Array; source: string } | undefined; if (kind === "accept") { try { - mutation = room.insertDecision(plan.document, { + let decision = { id: msg.id, quote: quote.slice(0, limits.MAX_QUOTE), by: ws.data.handle, at: new Date(claimed.claim.result.at * 1_000).toISOString(), notes: claimed.thread.notes.map(note => ({ by: note.handle, text: note.text })), - }); + }; + let preview = await room.create(room.project(plan.document)); + try { + room.insertDecision(preview, decision); + if ( + !room.fitsOrShrinks( + room.project(plan.document), + room.project(preview), + plan.questions.open.size, + ) + ) { + throw new Error("The decision would leave no room for the open questions to expire"); + } + } finally { + preview.doc.destroy(); + } + mutation = room.insertDecision(plan.document, decision); } catch (err) { mutationError = err; return; diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index ade6b346a..614aebb66 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -1,51 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { describe as description, load } from "./config"; - -const REQUIRED = { - STORAGE_DRIVER: "postgres", - DATABASE_URL: "postgresql://chopin:secret@database.test/chopin", - APP_ORIGIN: "https://chopin.example", - GITHUB_APP_SLUG: "chopin-test", - GITHUB_APP_CLIENT_ID: "client-id", - GITHUB_APP_CLIENT_SECRET: "client-secret", - SESSION_ENCRYPTION_KEY: "11".repeat(32), -}; - -function configured(overrides: Record = {}) { - let env: Record = { - ...REQUIRED, - AGENT: undefined, - BACKGROUND_JOBS: undefined, - WEB_RESEARCH: undefined, - CONVERSATION_PLAN: undefined, - JEV_API_KEY: undefined, - JEV_MODEL: undefined, - JEV_TIMEOUT_MS: undefined, - TYPESAFE_API_KEY: undefined, - HARNESS: undefined, - HARNESS_AUTH: undefined, - AUTH_MODE: undefined, - SERVER_HOST: undefined, - PORT: undefined, - MODEL: undefined, - CHOPIN_LOCAL_CREDENTIALS_DIR: undefined, - GITHUB_ALLOWED_USERS: undefined, - GITHUB_ALLOWED_ORGANIZATIONS: undefined, - ...overrides, - }; - let previous = { ...process.env }; - for (let [key, value] of Object.entries(env)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - try { - return load(); - } finally { - for (let key of Object.keys(env)) delete process.env[key]; - Object.assign(process.env, previous); - } -} +import { describe as description } from "./config"; +import { configured, LOCAL, REQUIRED } from "./testing/config"; describe("configuration", () => { it("loads the mandatory GitHub and PostgreSQL services without printing secrets", () => { @@ -131,6 +87,27 @@ describe("configuration", () => { }); }); + it("chooses a full Atomic Planner by harness alone, in hosted and local configuration", () => { + let atomic = { HARNESS: "atomic", HARNESS_AUTH: "ai-gateway", MODEL: "stub/model" }; + for (let mode of [{}, LOCAL]) { + let config = configured({ ...atomic, ...mode }); + expect(config.harness).toBe("atomic"); + expect(description(config)).toContain( + "Planner: full Atomic session (shell and filesystem access as this process's user)", + ); + } + for ( + let isolated of [ + {}, + { HARNESS: "pi", HARNESS_AUTH: "ai-gateway", MODEL: "stub/model" }, + ] + ) { + for (let mode of [{}, LOCAL]) { + expect(description(configured({ ...isolated, ...mode }))).not.toContain("full Atomic"); + } + } + }); + it("requires a valid PostgreSQL URL", () => { expect(() => configured({ DATABASE_URL: undefined })).toThrow("DATABASE_URL is required"); expect(() => configured({ DATABASE_URL: "https://database.test" })).toThrow("PostgreSQL URL"); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index b082dd4c0..537b235f7 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -157,6 +157,9 @@ export function describe(config: Config): string { config.devClient ? `client: vite (${config.devClient})` : "client: built", config.agent ? `agent: ${config.model} (on demand)` : "agent: off", `harness: ${config.harness}`, + ...(config.harness === "atomic" + ? ["Planner: full Atomic session (shell and filesystem access as this process's user)"] + : []), config.backgroundJobs ? "background jobs: on" : "background jobs: off", config.webResearch ? "web research: on" : "web research: off", config.conversationPlan diff --git a/apps/server/src/conversation-plan/job-context.ts b/apps/server/src/conversation-plan/job-context.ts index b7515af54..4bd7d7138 100644 --- a/apps/server/src/conversation-plan/job-context.ts +++ b/apps/server/src/conversation-plan/job-context.ts @@ -154,7 +154,8 @@ export function createJobContexts() { ?? [...(byMessage?.values() ?? [])].findLast(item => item.claimantSessionId === owner); return context ? { context, claimantSessionId: owner } : undefined; } - return triggered + // An MCP instruction without a live login has no session to claim ownership with. + return triggered?.claimantSessionId ? { context: triggered, claimantSessionId: triggered.claimantSessionId } : undefined; }, diff --git a/apps/server/src/harness/atomic.contract.test.ts b/apps/server/src/harness/atomic.contract.test.ts index 9a87841cf..1e3509d43 100644 --- a/apps/server/src/harness/atomic.contract.test.ts +++ b/apps/server/src/harness/atomic.contract.test.ts @@ -66,6 +66,10 @@ function createStubAtomic(overrides: Partial = {}) { harnessContract("atomic", createStubAtomic, createJustBashNetworkSandboxSession); +/** + * Sessions here are never registered as Planner sessions, so each is the + * isolated session the summary and research workers run in. + */ async function run(prompt: string, options: { structured?: boolean; instructions?: string; @@ -109,7 +113,7 @@ async function run(prompt: string, options: { } } -describe("atomic turns", () => { +describe("atomic worker turns", () => { it("answers a plain text turn with exactly the neutral system prompt and no tools", async () => { stub.requests.length = 0; let result = await run("plain"); @@ -351,7 +355,7 @@ describe("atomic HarnessV1 lifecycle", () => { }); }); -describe("atomic structured-output result tool", () => { +describe("atomic worker structured-output result tool", () => { it("ends a structured turn on the terminating tool without a follow-up model request", async () => { stub.requests.length = 0; let result = await run("output", { structured: true }); @@ -387,7 +391,7 @@ describe("atomic structured-output result tool", () => { }); }); -describe("atomic host isolation and model resolution", () => { +describe("atomic worker host isolation and model resolution", () => { async function snapshot(root: string): Promise> { let files = new Map(); for (let entry of await readdir(root, { recursive: true })) { @@ -405,7 +409,14 @@ describe("atomic host isolation and model resolution", () => { let project = join(root, "project"); let previous = { home: process.env.HOME, cwd: process.cwd() }; try { - for (let base of [join(home, ".atomic", "agent"), join(home, ".pi", "agent"), project]) { + for ( + let base of [ + join(home, ".atomic", "agent"), + join(home, ".pi", "agent"), + join(home, ".agents"), + project, + ] + ) { for (let name of ["extensions", "skills/marker", "prompts"]) { await mkdir(join(base, name), { recursive: true }); } diff --git a/apps/server/src/harness/atomic/adapter.test.ts b/apps/server/src/harness/atomic/adapter.test.ts index 0e4fd3a52..8e35994eb 100644 --- a/apps/server/src/harness/atomic/adapter.test.ts +++ b/apps/server/src/harness/atomic/adapter.test.ts @@ -37,7 +37,8 @@ function fakeExtensionApi(policy: TurnPolicy) { get active() { return active; }, - start: () => handlers.get("before_agent_start")!({ type: "before_agent_start" }), + start: (systemPrompt?: string) => + handlers.get("before_agent_start")!({ type: "before_agent_start", systemPrompt }), call: (toolName: string) => handlers.get("tool_call")!({ type: "tool_call", toolName }), }; } @@ -51,7 +52,7 @@ test("registers one result tool that terminates the turn and records its argumen expect(policy.result).toEqual({ value: { answer: "yes" } }); }); -test("applies the turn's tools and exact system prompt when the agent starts", () => { +test("applies a worker turn's tools and exact system prompt when the agent starts", () => { let policy: TurnPolicy = { hostNames: ["first_host"], structured: true, systemPrompt: "Plan." }; let atomic = fakeExtensionApi(policy); expect(atomic.start()).toEqual({ systemPrompt: "Plan." }); @@ -63,7 +64,7 @@ test("applies the turn's tools and exact system prompt when the agent starts", ( expect(atomic.active).toEqual(["first_host"]); }); -test("blocks the result tool off structured turns and anything outside the turn", () => { +test("blocks, for a worker, the result tool off structured turns and anything outside the turn", () => { let policy: TurnPolicy = { hostNames: ["first_host"], structured: false, systemPrompt: "S." }; let atomic = fakeExtensionApi(policy); expect(atomic.call(ATOMIC_RESULT_TOOL_NAME)).toMatchObject({ block: true }); @@ -73,6 +74,23 @@ test("blocks the result tool off structured turns and anything outside the turn" expect(atomic.call(ATOMIC_RESULT_TOOL_NAME)).toBeUndefined(); }); +test("a Planner turn keeps Atomic's tools and prompt but still refuses the result tool on plain turns", () => { + let policy: TurnPolicy = { + full: true, + hostNames: ["first_host"], + structured: false, + systemPrompt: "Plan.", + }; + let atomic = fakeExtensionApi(policy); + expect(atomic.start("Atomic.")).toEqual({ systemPrompt: "Atomic.\n\nPlan." }); + expect(atomic.active).toEqual([]); + expect(atomic.call("bash")).toBeUndefined(); + expect(atomic.call("first_host")).toBeUndefined(); + expect(atomic.call(ATOMIC_RESULT_TOOL_NAME)).toMatchObject({ block: true }); + policy.structured = true; + expect(atomic.call(ATOMIC_RESULT_TOOL_NAME)).toBeUndefined(); +}); + test("builds the system prompt from instructions and the structured response format", () => { expect(turnSystemPrompt(undefined, undefined)).toBe(ATOMIC_DEFAULT_SYSTEM_PROMPT); expect(turnSystemPrompt("", { type: "text" })).toBe(ATOMIC_DEFAULT_SYSTEM_PROMPT); @@ -118,7 +136,7 @@ test("resolves provider/model exactly and never substitutes a default", () => { .toThrow("HARNESS_AUTH ai-gateway allows only vercel-ai-gateway models, not anthropic/claude"); }); -test("reports every way a built session exceeds the turn", () => { +test("reports every way a built worker session exceeds the turn", () => { function session(options: { tools?: string[]; agentsFiles?: number; diff --git a/apps/server/src/harness/atomic/adapter.ts b/apps/server/src/harness/atomic/adapter.ts index f74712f5c..81f1f31f9 100644 --- a/apps/server/src/harness/atomic/adapter.ts +++ b/apps/server/src/harness/atomic/adapter.ts @@ -1,13 +1,11 @@ /** * Chopin's Atomic `HarnessV1` adapter. * - * Embeds Atomic in-process through its headless SDK. Each harness session owns - * one Atomic `AgentSession` with every shipped builtin package off, no Atomic - * coding tools, an inert resource loader, and in-memory session, settings, and - * credential stores. The model is offered only the host tools `HarnessAgent` - * passes for the turn, plus a terminating result tool on structured turns. The - * tool set is checked before every prompt and before every model request, and a - * mismatch fails the turn. + * A Planner session that Chopin registers by session ID runs as a full Atomic + * session: Atomic builtins and coding tools, the operator's Atomic resources, a + * channel working directory, and Chopin as HostInput. Every other session (the + * summary and research workers) stays isolated: only Chopin host tools, no host + * resources, and a checked per-turn tool boundary. */ import { HarnessCapabilityUnsupportedError } from "@ai-sdk/harness"; @@ -17,13 +15,15 @@ import { DefaultResourceLoader, FileAuthStorageBackend, getAgentConfigPaths, + getAgentDir, ModelRuntime, SessionManager, SettingsManager, } from "@bastani/atomic"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fullPlanner } from "./full"; import type { HarnessV1, @@ -72,6 +72,37 @@ const SETTINGS = { sessionSummary: { enabled: false }, cacheWarming: "off" as const, }; + +async function optionalFile(path: string): Promise { + return readFile(path, "utf8").catch(error => { + if (error.code === "ENOENT") return undefined; + throw error; + }); +} + +/** + * The operator's settings plus the working directory's project settings, held in + * memory so the session never writes either file. A verified checkout's + * `.atomic/settings.json` can add project packages the way it does for a local + * Atomic session; the empty per-channel directory has none. + */ +async function fullSettings(agentDir: string, cwd: string): Promise { + let scopes: Record<"global" | "project", string | undefined> = { + global: JSON.stringify({ + ...JSON.parse(await optionalFile(join(agentDir, "settings.json")) ?? "{}"), + ...SETTINGS, + }), + project: await optionalFile(join(cwd, ".atomic", "settings.json")), + }; + let manager = SettingsManager.fromStorage({ + withLock(scope, fn) { + let next = fn(scopes[scope]); + if (next !== undefined) scopes[scope] = next; + }, + }, { projectTrusted: true }); + manager.applyOverrides(SETTINGS); + return manager; +} const ZERO_USAGE = { inputTokens: { total: undefined, @@ -107,6 +138,7 @@ export type TurnPolicy = { structured: boolean; systemPrompt: string; result?: { value: unknown }; + full?: boolean; }; /** @@ -132,11 +164,16 @@ export function atomicTurnExtension(policy: TurnPolicy): ExtensionFactory { }; }, }); - atomic.on("before_agent_start", () => { - atomic.setActiveTools(activeNames(policy)); - return { systemPrompt: policy.systemPrompt }; + atomic.on("before_agent_start", event => { + if (!policy.full) atomic.setActiveTools(activeNames(policy)); + return { + systemPrompt: policy.full + ? `${event.systemPrompt}\n\n${policy.systemPrompt}` + : policy.systemPrompt, + }; }); atomic.on("tool_call", event => { + if (policy.full && event.toolName !== ATOMIC_RESULT_TOOL_NAME) return; if (activeNames(policy).includes(event.toolName)) return; return { block: true, reason: `${event.toolName} is not available in this turn.` }; }); @@ -314,9 +351,11 @@ export function createAtomicAdapter( throw unsupported("resuming an in-memory session"); } let directory: string | undefined; + let full = fullPlanner(startOptions.sessionId); let sessionManager: SessionManager | undefined; let settingsManager = SettingsManager.inMemory(SETTINGS); let policy: TurnPolicy = { + full: !!full, hostNames: [], structured: false, systemPrompt: ATOMIC_DEFAULT_SYSTEM_PROMPT, @@ -367,7 +406,7 @@ export function createAtomicAdapter( if (!run) return; if (event.type === "turn_start") { let offered = session.agent.state.tools.map(tool => tool.name); - if (!sameNames(offered, activeNames(policy))) { + if (!full && !sameNames(offered, activeNames(policy))) { run.failure ??= new ToolBoundaryError( `Atomic offered unexpected tools: ${offered.join(", ") || "none"}.`, ); @@ -427,37 +466,45 @@ export function createAtomicAdapter( policy.hostNames = hostNames; policy.structured = false; if (!live) { - directory ??= await mkdtemp(join(tmpdir(), "chopin-atomic-planner-")); - sessionManager ??= SessionManager.inMemory(directory); + if (!full) directory ??= await mkdtemp(join(tmpdir(), "chopin-atomic-planner-")); + let cwd = full?.cwd ?? directory!; + let agentDir = full ? getAgentDir() : directory!; + if (full) settingsManager = await fullSettings(agentDir, cwd); + sessionManager ??= SessionManager.inMemory(cwd); let loader = new DefaultResourceLoader({ - cwd: directory, - agentDir: directory, + cwd, + agentDir, settingsManager, - noExtensions: true, - noSkills: true, - noPromptTemplates: true, - noThemes: true, - noContextFiles: true, - systemPrompt: ATOMIC_DEFAULT_SYSTEM_PROMPT, - appendSystemPrompt: [], + ...(full ? {} : { + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + systemPrompt: ATOMIC_DEFAULT_SYSTEM_PROMPT, + appendSystemPrompt: [], + }), extensionFactories: [atomicTurnExtension(policy)], }); await loader.reload(); let created = await createAgentSession({ - cwd: directory, - agentDir: directory, + cwd, + agentDir, modelRuntime: models, model, fallbackModels: [], sessionManager, settingsManager, resourceLoader: loader, - builtins: BUILTINS_OFF, - tools: [...hostNames, ATOMIC_RESULT_TOOL_NAME], + builtins: full ? undefined : BUILTINS_OFF, + tools: full ? undefined : [...hostNames, ATOMIC_RESULT_TOOL_NAME], + extensionBindings: full ? { humanInput: full.humanInput } : undefined, customTools: turn.tools.map(hostTool), }); let session = created.session; - let leak = hostLeak(session, created.extensionsResult.extensions.length, hostNames); + let leak = full + ? undefined + : hostLeak(session, created.extensionsResult.extensions.length, hostNames); if (leak || closed) { try { await session.dispose(); @@ -525,9 +572,10 @@ export function createAtomicAdapter( let text = promptText(turn.prompt); let agent = await prepare(turn); let structured = turn.responseFormat?.type === "json"; - let expected = structured - ? [...policy.hostNames, ATOMIC_RESULT_TOOL_NAME] + let names = full + ? agent.getActiveToolNames().filter(name => name !== ATOMIC_RESULT_TOOL_NAME) : policy.hostNames; + let expected = structured ? [...names, ATOMIC_RESULT_TOOL_NAME] : names; agent.setActiveToolsByName(expected); if (!sameNames(agent.getActiveToolNames(), expected)) { throw new ToolBoundaryError( @@ -561,7 +609,7 @@ export function createAtomicAdapter( try { if (!run.stopped) { run.emit({ type: "stream-start", modelId: agent.model?.id }); - await agent.prompt(text, { expandPromptTemplates: false }); + await agent.prompt(text, { expandPromptTemplates: !!full }); } if (run.failure) throw run.failure; if (run.stopped) return; diff --git a/apps/server/src/harness/atomic/checkout.test.ts b/apps/server/src/harness/atomic/checkout.test.ts new file mode 100644 index 000000000..22b788f28 --- /dev/null +++ b/apps/server/src/harness/atomic/checkout.test.ts @@ -0,0 +1,37 @@ +import { afterAll, expect, test } from "bun:test"; +import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { verifiedCheckout } from "./checkout"; + +let root = await mkdtemp(join(tmpdir(), "chopin-checkouts-")); +afterAll(() => rm(root, { recursive: true, force: true })); +let repository = { owner: "Octo-Org", name: "Score" }; + +async function checkout(name: string, origin: string) { + let path = join(root, name); + await mkdir(path); + for (let args of [["init", "--quiet"], ["remote", "add", "origin", origin]]) { + let process = Bun.spawn(["git", "-C", path, ...args], { stdout: "pipe", stderr: "pipe" }); + expect(await process.exited).toBe(0); + } + return path; +} + +test("verifies origin owner/name for URL, SSH and alias checkouts, and nothing else", async () => { + for ( + let [index, origin] of [ + "https://github.com/octo-org/score.git", + "ssh://git@github.com/Octo-Org/Score", + "git@github.com:octo-org/score.git", + "workgit:octo-org/score.git", + ].entries() + ) { + let path = await checkout(String(index), origin); + expect(await verifiedCheckout(repository, path)).toBe(await realpath(path)); + } + let mismatch = await checkout("mismatch", "https://github.com/other/score.git"); + for (let path of [mismatch, root, join(root, "missing")]) { + expect(await verifiedCheckout(repository, path)).toBeUndefined(); + } +}); diff --git a/apps/server/src/harness/atomic/checkout.ts b/apps/server/src/harness/atomic/checkout.ts new file mode 100644 index 000000000..0a6f710b8 --- /dev/null +++ b/apps/server/src/harness/atomic/checkout.ts @@ -0,0 +1,32 @@ +import { realpath } from "node:fs/promises"; + +/** Origin host names may be SSH aliases; the repository coordinates must match. */ +export async function verifiedCheckout( + repository: { owner: string; name: string }, + candidate: string, +): Promise { + try { + let cwd = await realpath(candidate); + let git = async (...args: string[]) => { + let process = Bun.spawn(["git", "-C", cwd, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + let output = await new Response(process.stdout).text(); + if (await process.exited !== 0) throw new Error("not a checkout"); + return output.trim(); + }; + if (await git("rev-parse", "--is-inside-work-tree") !== "true") return undefined; + let origin = await git("remote", "get-url", "origin"); + let path = origin.includes("://") + ? new URL(origin).pathname.replace(/^\//, "") + : /^[^/:]+:(.+)$/.exec(origin)?.[1]; + return path?.replace(/\.git$/, "").toLowerCase() + === `${repository.owner}/${repository.name}`.toLowerCase() + ? cwd + : undefined; + } catch { + // A missing path or an unrelated checkout is simply not verified. + return undefined; + } +} diff --git a/apps/server/src/harness/atomic/full.test.ts b/apps/server/src/harness/atomic/full.test.ts new file mode 100644 index 000000000..dd31ba080 --- /dev/null +++ b/apps/server/src/harness/atomic/full.test.ts @@ -0,0 +1,308 @@ +import { afterAll, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { HarnessAgent } from "@ai-sdk/harness/agent"; +import { createJustBashNetworkSandboxSession } from "@ai-sdk/sandbox-just-bash"; +import { jsonSchema, tool } from "ai"; +import { + ATOMIC_DEFAULT_SYSTEM_PROMPT, + ATOMIC_RESULT_TOOL_NAME, + createAtomicAdapter, +} from "./adapter"; +import { registerFullPlanner } from "./full"; +import { startStubModelServer } from "../pi/model-stub"; +import { hostInputRoom } from "../../testing/decisions"; +import type { HostInput, QuestionParams } from "@bastani/atomic"; + +let params: QuestionParams = { + questions: [{ + header: "Choice", + question: "Which option?", + options: [ + { label: "First", description: "One" }, + { label: "Second", description: "Two" }, + ], + }], +}; +let freeText: Record = { + multiple: { + questions: [{ + header: "Scope", + question: "Which areas?", + multiSelect: true, + options: [ + { label: "Server", description: "Back end" }, + { label: "Web", description: "Front end" }, + ], + }], + }, + preview: { + questions: [{ + header: "Layout", + question: "Which layout?", + options: [ + { label: "Rows", description: "Stacked", preview: "row\nrow" }, + { label: "Columns", description: "Side by side", preview: "col | col" }, + ], + }], + }, +}; +let stub = startStubModelServer((prompt, prior) => + prior + ? { kind: "text", text: "Answered." } + : prompt === "question" + ? { kind: "tool", name: "ask_user_question", arguments: JSON.stringify(params) } + : freeText[prompt] + ? { kind: "tool", name: "ask_user_question", arguments: JSON.stringify(freeText[prompt]) } + : prompt === "cwd" + ? { kind: "tool", name: "bash", arguments: JSON.stringify({ command: "pwd" }) } + : { kind: "text", text: "Ready." } +); +afterAll(stub.stop); + +async function run( + registered: boolean, + prompt = "plain", + { host, checkout = true, worker = false, projectPackage = false }: { + host?: HostInput; + /** False runs the Planner in an empty directory, as a channel without a checkout does. */ + checkout?: boolean; + /** Also run an unregistered worker session on the same harness while the Planner is open. */ + worker?: boolean; + /** Install a local package through the checkout's `.atomic/settings.json`. */ + projectPackage?: boolean; + } = {}, +) { + let root = await mkdtemp(join(tmpdir(), "chopin-atomic-full-")); + let agentDir = join(root, "agent"); + let cwd = join(root, checkout ? "checkout" : "empty"); + let previous = process.env.ATOMIC_CODING_AGENT_DIR; + let received: QuestionParams[] = []; + let harness = createAtomicAdapter({ + auth: "ai-gateway", + model: "stub/model", + providers: { + stub: { + baseUrl: stub.baseUrl, + apiKey: "stub", + api: "openai-completions", + models: [{ + id: "model", + name: "Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4096, + }], + }, + }, + }); + let sandbox = await createJustBashNetworkSandboxSession(); + let release: (() => void) | undefined; + try { + for ( + let path of [ + cwd, + join(agentDir, "skills", "marker"), + join(agentDir, "extensions"), + join(agentDir, "prompts"), + ] + ) await mkdir(path, { recursive: true }); + await writeFile(join(agentDir, "AGENTS.md"), "OPERATOR-CONTEXT-MARKER"); + await writeFile( + join(agentDir, "skills", "marker", "SKILL.md"), + "---\nname: marker\ndescription: OPERATOR-SKILL-MARKER\n---\nTest skill.\n", + ); + await writeFile(join(agentDir, "prompts", "marker.md"), "OPERATOR-PROMPT-MARKER"); + await writeFile( + join(agentDir, "extensions", "marker.ts"), + `export default api => api.registerTool({name: "operator_tool", label: "Operator", description: "Marker", parameters: {type:"object"}, async execute() { return {content:[{type:"text",text:"marker"}], details:{}}; }});`, + ); + if (checkout) { + let git = Bun.spawn(["git", "-C", cwd, "init", "--quiet"], { + stdout: "pipe", + stderr: "pipe", + }); + expect(await git.exited).toBe(0); + } + if (projectPackage) { + let pkg = join(root, "project-package"); + await mkdir(join(pkg, "skills", "project-marker"), { recursive: true }); + await writeFile( + join(pkg, "package.json"), + JSON.stringify({ + name: "project-package", + type: "module", + atomic: { extensions: ["./extension.ts"], skills: ["./skills"] }, + }), + ); + await writeFile( + join(pkg, "extension.ts"), + `export default api => api.registerTool({name: "project_tool", label: "Project", description: "Marker", parameters: {type:"object"}, async execute() { return {content:[{type:"text",text:"project"}], details:{}}; }});`, + ); + await writeFile( + join(pkg, "skills", "project-marker", "SKILL.md"), + "---\nname: project-marker\ndescription: PROJECT-SKILL-MARKER\n---\nProject skill.\n", + ); + await mkdir(join(cwd, ".atomic"), { recursive: true }); + await writeFile(join(cwd, ".atomic", "settings.json"), JSON.stringify({ packages: [pkg] })); + } + process.env.ATOMIC_CODING_AGENT_DIR = agentDir; + let humanInput: HostInput = host ?? { + confirm: async () => false, + select: async () => undefined, + input: async () => undefined, + editor: async () => undefined, + questionnaire: async (value, options) => { + received.push(value); + expect(options.requestId).toBeString(); + return { + answers: [{ + questionIndex: 0, + question: value.questions[0]!.question, + kind: "option", + answer: "Second", + }], + cancelled: false, + }; + }, + }; + let sessionId = crypto.randomUUID(); + if (registered) release = registerFullPlanner(sessionId, { cwd, humanInput }); + let agent = new HarnessAgent({ + harness, + instructions: "CHOPIN-INSTRUCTIONS-MARKER", + permissionMode: "allow-reads", + tools: { + host_tool: tool({ + inputSchema: jsonSchema({ type: "object" }), + execute: async () => "host", + }), + }, + activeTools: ["host_tool"], + }); + let session = await agent.createSession({ sessionId, sandboxSession: sandbox }); + stub.requests.length = 0; + try { + let result = await agent.stream({ session, prompt }); + await result.consumeStream(); + await result.text; + let requests = [...stub.requests]; + if (!worker) { + let files = { + operator: await Bun.file(join(agentDir, "settings.json")).exists(), + project: projectPackage + ? await Bun.file(join(cwd, ".atomic", "settings.json")).text() + : undefined, + }; + return { cwd, received, requests, workerRequests: [], files }; + } + let workerSession = await agent.createSession({ sandboxSession: sandbox }); + try { + stub.requests.length = 0; + let output = await agent.stream({ session: workerSession, prompt: "plain" }); + await output.consumeStream(); + return { cwd, received, requests, workerRequests: [...stub.requests] }; + } finally { + await workerSession.destroy(); + } + } finally { + await session.destroy(); + } + } finally { + release?.(); + await harness.shutdown(); + await sandbox.destroy(); + if (previous === undefined) delete process.env.ATOMIC_CODING_AGENT_DIR; + else process.env.ATOMIC_CODING_AGENT_DIR = previous; + await rm(root, { recursive: true, force: true }); + } +} + +const FULL_TOOLS = [ + "read", + "bash", + "edit", + "write", + "ask_user_question", + "workflow", + "subagent", + "intercom", + "web_search", + "operator_tool", + "host_tool", +]; + +test("registered Planner sessions load operator resources and expose Atomic tools beside host tools", async () => { + let result = await run(true); + let request = result.requests[0]!; + for (let name of FULL_TOOLS) expect(request.toolNames).toContain(name); + expect(request.toolNames).not.toContain(ATOMIC_RESULT_TOOL_NAME); + for ( + let marker of [ + "OPERATOR-CONTEXT-MARKER", + "OPERATOR-SKILL-MARKER", + "CHOPIN-INSTRUCTIONS-MARKER", + result.cwd, + ] + ) expect(request.system).toContain(marker); + expect(request.system).not.toBe(ATOMIC_DEFAULT_SYSTEM_PROMPT); +}); + +test("Planner sessions use their checkout cwd and route ask_user_question through HostInput", async () => { + let cwd = await run(true, "cwd"); + expect(cwd.requests.at(-1)!.toolResults.join("\n")).toContain(cwd.cwd); + let question = await run(true, "question"); + expect(question.received).toEqual([params]); + expect(question.requests.at(-1)!.toolResults.join("\n")).toContain("Second"); + let prompt = await run(true, "/marker"); + expect(prompt.requests[0]!.prompt).toBe("OPERATOR-PROMPT-MARKER"); +}); + +test("a Planner session without a checkout is just as full in its empty working directory", async () => { + let result = await run(true, "cwd", { checkout: false }); + for (let name of FULL_TOOLS) expect(result.requests[0]!.toolNames).toContain(name); + expect(result.requests[0]!.system).toContain(result.cwd); + expect(result.requests.at(-1)!.toolResults.join("\n")).toContain(result.cwd); +}); + +test("a checkout's project settings add its packages without writing either settings file", async () => { + let result = await run(true, "plain", { projectPackage: true }); + let request = result.requests[0]!; + expect(request.toolNames).toContain("project_tool"); + expect(request.system).toContain("PROJECT-SKILL-MARKER"); + for (let name of FULL_TOOLS) expect(request.toolNames).toContain(name); + expect(result.files!.operator).toBe(false); + expect(JSON.parse(result.files!.project!).packages).toHaveLength(1); + let plain = await run(true); + expect(plain.requests[0]!.toolNames).not.toContain("project_tool"); +}); + +test("free-text Decisions answers to multi-select and preview questions reach the model", async () => { + let room = await hostInputRoom(); + try { + for (let prompt of ["multiple", "preview"]) { + let turn = run(true, prompt, { host: room.input }); + let [card] = await room.cards(1); + await room.answer(card!.id, ` typed ${prompt}\n`); + let output = (await turn).requests.at(-1)!.toolResults.join("\n"); + expect(output).toContain(` typed ${prompt}\n`); + expect(output).not.toContain("InvalidHostInput"); + } + } finally { + await room.close(); + } +}); + +test("worker sessions stay isolated, even beside a full Planner session on the same harness", async () => { + let alone = await run(false); + expect(alone.requests[0]!.toolNames).toEqual(["host_tool"]); + expect(alone.requests[0]!.system).toBe("CHOPIN-INSTRUCTIONS-MARKER"); + let beside = await run(true, "plain", { worker: true }); + expect(beside.requests[0]!.toolNames).toContain("bash"); + expect(beside.workerRequests).toHaveLength(1); + expect(beside.workerRequests[0]!.toolNames).toEqual(["host_tool"]); + expect(beside.workerRequests[0]!.system).toBe("CHOPIN-INSTRUCTIONS-MARKER"); +}); diff --git a/apps/server/src/harness/atomic/full.ts b/apps/server/src/harness/atomic/full.ts new file mode 100644 index 000000000..fc79c5a56 --- /dev/null +++ b/apps/server/src/harness/atomic/full.ts @@ -0,0 +1,17 @@ +import type { HostInput } from "@bastani/atomic"; + +export type FullPlanner = { cwd: string; humanInput: HostInput }; +let planners = new Map(); + +/** Every Atomic Planner session is registered; background workers never are. */ +export function registerFullPlanner(sessionId: string, planner: FullPlanner): () => void { + if (planners.has(sessionId)) throw new Error("Atomic Planner session is already registered"); + planners.set(sessionId, planner); + return () => { + if (planners.get(sessionId) === planner) planners.delete(sessionId); + }; +} + +export function fullPlanner(sessionId: string): FullPlanner | undefined { + return planners.get(sessionId); +} diff --git a/apps/server/src/harness/atomic/human-input.test.ts b/apps/server/src/harness/atomic/human-input.test.ts new file mode 100644 index 000000000..890f92196 --- /dev/null +++ b/apps/server/src/harness/atomic/human-input.test.ts @@ -0,0 +1,1207 @@ +import { afterEach, expect, test } from "bun:test"; +import { $createParagraphNode, $createTextNode, $getRoot, $nodesOfType } from "lexical"; +import * as Y from "yjs"; +import { cardStatus, limits, QuestionnaireNode, ulid } from "@chopin/dialect"; +import { limits as questionLimits } from "@chopin/question"; +import * as QuestionModel from "@chopin/question"; +import * as Questions from "../../questions/service"; +import * as Store from "../../questions/store"; +import * as Plan from "../../plan/service"; +import * as Room from "../../plan/room"; +import * as Edit from "../../plan/edit"; +import * as Comments from "../../comments/service"; +import { hostInputRoom } from "../../testing/decisions"; +import { peer } from "../../testing/peer"; +import type { QuestionParams } from "@bastani/atomic"; + +let cleanups: Array<() => Promise> = []; +afterEach(async () => { + for (let cleanup of cleanups.splice(0)) await cleanup(); +}); + +async function fixture(expiresInMs?: number) { + let f = await hostInputRoom(expiresInMs); + cleanups.push(f.close); + return f; +} + +let params: QuestionParams = { + questions: [ + { + header: "Choice", + question: " Which? ", + options: [ + { label: " A ", description: " first ", preview: "preview A" }, + { label: "B", description: "second" }, + ], + }, + { + header: "Multiple", + question: "Select several", + multiSelect: true, + options: [ + { label: "C", description: "" }, + { label: "D", description: "" }, + ], + }, + { + header: "Custom", + question: "Or free text?", + options: [ + { label: "E", description: "" }, + { label: "F", description: "" }, + ], + }, + ], +}; + +test("HostInput appends one ordered questionnaire batch and returns typed answers verbatim", async () => { + let f = await fixture(); + let edited = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace_root", + source: "# Existing document\n\nLast paragraph.\n", + }]); + if (!edited.ok) throw new Error("fixture edit failed"); + if (edited.mutation) await Plan.publish(f.plan, f.server, f.room.id, edited.mutation); + let response = f.input.questionnaire(params, f.options); + let cards = await f.cards(3); + expect(cards.map(card => card.definition.questions[0].header)).toEqual([ + "Choice", + "Multiple", + "Custom", + ]); + expect(cards[0]!.definition.questions[0].options[0]).toMatchObject({ + label: " A ", + description: " first \n\npreview A", + }); + expect(cards[0]!.definition.questions[0].options[1]).toMatchObject({ + label: "B", + description: "second", + }); + let source = Plan.source(f.plan); + expect(source.indexOf("Last paragraph.")).toBeLessThan(source.indexOf(" { + let { HostInputBridge, getHostQuestionnaire } = await import( + new URL("./core/extensions/host-input.js", import.meta.resolve("@bastani/atomic")).href + ); + let f = await fixture(); + let bridge = new HostInputBridge(() => "session", () => undefined); + bridge.bind(f.input, "chopin"); + let questionnaire = getHostQuestionnaire(bridge.wrap({})); + let [preview, multiple, plain] = params.questions; + for ( + let [question, value, expected] of [ + [preview!, [1], { kind: "option", answer: "B" }], + [preview!, [0], { kind: "option", answer: " A ", preview: "preview A" }], + [preview!, " typed\n", { kind: "chat", answer: " typed\n" }], + [multiple!, [1, 0], { kind: "multi", answer: null, selected: ["C", "D"] }], + [multiple!, "several\n", { kind: "chat", answer: "several\n" }], + [plain!, [1], { kind: "option", answer: "F" }], + [plain!, " own ", { kind: "custom", answer: " own " }], + ] as const + ) { + let response = questionnaire({ questions: [question] }, new AbortController().signal); + let [card] = await f.cards(1); + await f.answer(card!.id, typeof value === "string" ? value : [...value]); + expect(await response).toEqual({ + cancelled: false, + answers: [{ questionIndex: 0, question: question.question, ...expected }], + }); + } +}); + +test("maps confirm/select dialogs without treating custom text as approval or a selection", async () => { + let f = await fixture(); + for (let [value, approved] of [[[0], true], [[1], false], ["Yes", false]] as const) { + let result = f.input.confirm("Proceed?", "This changes files.", f.options); + let [card] = await f.cards(1); + expect(card!.definition.questions[0].question).toBe("Proceed?\n\nThis changes files."); + await f.answer(card!.id, typeof value === "string" ? value : [...value]); + expect(await result).toBe(approved); + } + let selected = f.input.select("Pick", [" second ", "first", "first"], f.options); + let [card] = await f.cards(1); + expect(card!.definition.questions[0].options.map(option => option.label)).toEqual([ + " second ", + "first", + "first", + ]); + await f.answer(card!.id, [0]); + expect(await selected).toBe(" second "); + let custom = f.input.select("Pick", ["yes", "no"], f.options); + await f.answer((await f.cards(1))[0]!.id, "yes"); + expect(await custom).toBeUndefined(); +}); + +test("input and editor are cards with no options, answered by an option a member adds", async () => { + let f = await fixture(); + for (let method of ["input", "editor"] as const) { + let response = f.input[method]("Title", " initial or hint\n", f.options); + let [card] = await f.cards(1); + let question = card!.definition.questions[0]; + expect(question.options).toEqual([]); + expect(question.question).toBe("Title\n\n initial or hint\n"); + let source = Plan.source(f.plan); + let roundTrip = await Room.create(source); + expect(Room.project(roundTrip)).toContain( + `header="${method === "input" ? "Input" : "Editor"}"`, + ); + let projected = Room.project(roundTrip); + let own = projected.slice(projected.indexOf(`"))).not.toContain(" { + let f = await fixture(); + let response = f.input.questionnaire(params, f.options); + let cards = await f.cards(3); + await f.answer(cards[0]!.id, [await f.addOption(cards[0]!.id, "Neither")]); + await f.answer(cards[1]!.id, [0, await f.addOption(cards[1]!.id, "G")]); + await f.answer(cards[2]!.id, [await f.addOption(cards[2]!.id, "Something else")]); + expect((await response).answers).toEqual([ + { questionIndex: 0, question: " Which? ", kind: "chat", answer: "Neither" }, + { questionIndex: 1, question: "Select several", kind: "chat", answer: "C, G" }, + { questionIndex: 2, question: "Or free text?", kind: "custom", answer: "Something else" }, + ]); +}); + +test("member cancellation retains answered questions and marks the batch cancelled", async () => { + let f = await fixture(); + let response = f.input.questionnaire(params, f.options); + let cards = await f.cards(3); + await f.answer(cards[0]!.id, [1]); + for (let card of cards.slice(1)) { + await Questions.cancel(f.plan, f.server, f.room.id, f.ws, { + kind: "question:cancel", + ts: 0, + rid: "cancel", + id: card.id, + }); + } + expect(await response).toEqual({ + cancelled: true, + answers: [ + { questionIndex: 0, question: " Which? ", kind: "option", answer: "B" }, + ], + }); +}); + +test("abort withdraws only its request's open cards, persists cancellation, and ignores late submission", async () => { + let f = await fixture(); + let response = f.input.questionnaire(params, f.options); + let cards = await f.cards(3); + await f.answer(cards[0]!.id, [1]); + f.controller.abort(); + expect(await response).toEqual({ answers: [], cancelled: true }); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + expect(f.plan.records.get(cards[0]!.id)?.status).toBe("answered"); + for (let card of cards.slice(1)) { + expect(f.plan.records.get(card.id)).toMatchObject({ status: "cancelled", resolver: "chopin" }); + expect(Plan.source(f.plan)).not.toContain(card.id); + expect(f.frames).toContainEqual( + expect.objectContaining({ + kind: "question:resolved", + id: card.id, + status: "cancelled", + resolver: "chopin", + }), + ); + await Questions.submit(f.plan, f.server, f.room.id, f.ws, { + kind: "question:submit", + ts: 0, + rid: "late", + id: card.id, + revision: 0, + }); + expect(f.plan.records.get(card.id)?.status).toBe("cancelled"); + } + let saved = await Plan.readStored((await f.storage.collaboration.load(f.room.id, new Date()))!); + expect(saved.source).not.toContain(cards[1]!.id); +}); + +test("expiry keeps every unanswered card in Decisions, marked expired, and answers no HostInput method", async () => { + let f = await fixture(30); + for ( + let [invoke, expected] of [ + [() => f.input.questionnaire(params, f.options), { answers: [], cancelled: true }], + [() => f.input.confirm("Confirm", "Proceed?", f.options), false], + [() => f.input.select("Select", ["A", "B"], f.options), undefined], + [() => f.input.input("Input", undefined, f.options), undefined], + [() => f.input.editor("Editor", undefined, f.options), undefined], + ] as Array<[() => Promise, unknown]> + ) { + expect(await invoke()).toEqual(expected); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + } + let records = [...f.plan.records.values()]; + expect(records).toHaveLength(7); + for (let record of records) { + expect(record).toMatchObject({ status: "expired", resolver: "chopin" }); + expect(record.at).toBeNumber(); + expect(f.frames).toContainEqual( + expect.objectContaining({ + kind: "question:resolved", + id: record.id, + status: "expired", + resolver: "chopin", + }), + ); + } + let saved = await Plan.readStored((await f.storage.collaboration.load(f.room.id, new Date()))!); + let cards = await documentCards(saved.source); + expect(cards.map(card => card.id)).toEqual(records.map(record => record.id)); + for (let card of cards) { + expect(card.status).toBe("expired"); + expect(card.at).toBeString(); + expect(card.by).toBeUndefined(); + expect(cardStatus(card)).toBe("expired"); + } + await Questions.submit(f.plan, f.server, f.room.id, f.ws, { + kind: "question:submit", + ts: 0, + rid: "late", + id: records[0]!.id, + revision: 0, + }); + expect(f.frames.findLast(frame => frame.rid === "late")).toMatchObject({ + ok: false, + reason: "resolved", + status: "expired", + resolver: "chopin", + }); + expect(f.plan.records.get(records[0]!.id)?.status).toBe("expired"); +}); + +test("an abort still withdraws its cards rather than expiring them", async () => { + let f = await fixture(60_000); + let response = f.input.confirm("Confirm", "Proceed?", f.options); + let [card] = await f.cards(1); + f.controller.abort(); + expect(await response).toBe(false); + expect(f.plan.records.get(card!.id)).toMatchObject({ status: "cancelled", resolver: "chopin" }); + expect(Plan.source(f.plan)).not.toContain(card!.id); + expect(f.frames.filter(frame => frame.kind === "question:resolved")).toMatchObject([ + { kind: "question:resolved", id: card!.id, status: "cancelled", resolver: "chopin" }, + ]); +}); + +test("host input waits the shared 30-minute limit before expiring by default", async () => { + let f = await fixture(); + let delays: number[] = []; + let original = globalThis.setTimeout; + globalThis.setTimeout = ((handler: () => void, delay?: number, ...rest: unknown[]) => { + if (delay !== undefined) delays.push(delay); + return original(handler, delay, ...rest); + }) as typeof setTimeout; + try { + let response = f.input.confirm("Confirm", "Proceed?", f.options); + await f.cards(1); + expect(questionLimits.INPUT_EXPIRY_MS).toBe(30 * 60 * 1_000); + expect(delays).toContain(questionLimits.INPUT_EXPIRY_MS); + f.controller.abort(); + expect(await response).toBe(false); + } finally { + globalThis.setTimeout = original; + } +}); + +test("workflow identity labels each card while returned question text remains verbatim", async () => { + let f = await fixture(); + let response = f.input.questionnaire(params, { + ...f.options, + workflowRunId: "run-123", + workflowStageId: "review", + }); + let cards = await f.cards(3); + for (let card of cards) { + expect(card.definition.questions[0].question).toContain("Workflow run: run-123; stage: review"); + await f.answer(card.id, [0]); + } + expect((await response).answers.map(answer => answer.question)).toEqual( + params.questions.map(question => question.question), + ); +}); + +test("abort before presentation creates no card and cancellation waits for durable storage", async () => { + let f = await fixture(); + let cancelled = new AbortController(); + cancelled.abort(); + expect(await f.input.confirm("Confirm", "Proceed?", { ...f.options, signal: cancelled.signal })) + .toBe(false); + expect(f.plan.records.size).toBe(0); + let settled = false; + let result = f.input.confirm("Confirm", "Proceed?", f.options).then(value => { + settled = true; + return value; + }); + await f.cards(1); + let gate = Promise.withResolvers(); + let original = f.storage.collaboration.commit; + f.storage.collaboration.commit = async input => { + await gate.promise; + return original(input); + }; + f.controller.abort(); + await Bun.sleep(15); + expect(settled).toBe(false); + expect(f.frames.filter(frame => frame.kind === "question:resolved")).toHaveLength(0); + gate.resolve(); + expect(await result).toBe(false); + expect(f.frames.filter(frame => frame.kind === "question:resolved")).toHaveLength(1); +}); + +test("blank dialog titles and supplied empty choices remain valid verbatim inputs", async () => { + let f = await fixture(); + let result = f.input.select("", ["", " "], f.options); + let [card] = await f.cards(1); + await f.answer(card!.id, [0]); + expect(await result).toBe(""); + let source = Plan.source(f.plan); + let restored = await Room.create(source); + expect(Room.project(restored)).toContain('label=""'); + expect(Room.project(restored)).toContain('value=""'); + restored.doc.destroy(); +}); + +/** Re-import the canonical document, proving the dialect accepts every card it holds. */ +async function documentCards(source: string) { + let document = await Room.create(source); + try { + return document.editor.getEditorState().read(() => + $nodesOfType(QuestionnaireNode).map(node => node.getQuestionnaire()) + ); + } finally { + document.doc.destroy(); + } +} + +async function documentQuestionnaires(source: string) { + return (await documentCards(source)).map(card => card.questions[0]!); +} + +test("dialogs beyond the Planner's per-field limits become verbatim cards and answers", async () => { + let f = await fixture(); + let initial = "Keep this line of the plan.\n".repeat(180); + let edited = `${initial}${"Add this reviewed line.\n".repeat(60)}`; + let editor = f.input.editor("Edit plan", initial, f.options); + let [card] = await f.cards(1); + expect(initial.length).toBeGreaterThan(5_000); + expect(card!.definition.questions[0].question).toBe(`Edit plan\n\n${initial}`); + await f.answer(card!.id, edited); + expect(edited.length).toBeGreaterThan(6_000); + expect(await editor).toBe(edited); + + let message = "This workflow step rewrites generated files. ".repeat(30); + let confirmed = f.input.confirm("Proceed?", message, f.options); + [card] = await f.cards(1); + await f.answer(card!.id, [0]); + expect(await confirmed).toBe(true); + + let choices = Array.from({ length: 25 }, (_, index) => `Choice ${index}`); + choices[24] = `The long choice ${"x".repeat(250)}`; + let selected = f.input.select("Pick one", choices, f.options); + [card] = await f.cards(1); + expect(card!.definition.questions[0].options.map(option => option.label)).toEqual(choices); + await f.answer(card!.id, [24]); + expect(await selected).toBe(choices[24]); + + let rollout = "Explain the rollout, its owners, and its checks. ".repeat(40); + let asked = f.input.questionnaire({ + questions: [{ + header: "Rollout", + question: "Which rollout?", + options: [{ label: "Staged", description: rollout }, { label: "All", description: "" }], + }], + }, f.options); + [card] = await f.cards(1); + await f.answer(card!.id, [0]); + expect((await asked).answers[0]).toMatchObject({ kind: "option", answer: "Staged" }); + + expect(await documentQuestionnaires(Plan.source(f.plan))).toMatchObject([ + { header: "Editor", prompt: `Edit plan\n\n${initial}`, options: [], answer: edited }, + { header: "Confirm", prompt: `Proceed?\n\n${message}`, answer: "Yes" }, + { + header: "Select", + prompt: "Pick one", + options: choices.map(label => ({ label })), + answer: choices[24], + }, + { + header: "Rollout", + options: [{ label: "Staged", description: rollout }, { label: "All" }], + answer: "Staged", + }, + ]); +}); + +test("a workflow label never pushes a verbatim question into a rejection", async () => { + let f = await fixture(); + let question = "Should the review stage accept this change? ".repeat(23).slice(0, 990); + let long: QuestionParams = { + questions: [{ + header: "Review", + question, + options: [{ label: "Ship", description: "" }, { label: "Hold", description: "" }], + }], + }; + let response = f.input.questionnaire(long, { + ...f.options, + workflowRunId: "run-123", + workflowStageId: "review", + }); + let [card] = await f.cards(1); + let labelled = `${question}\n\nWorkflow run: run-123; stage: review`; + expect(card!.definition.questions[0].question).toBe(labelled); + expect(await documentQuestionnaires(Plan.source(f.plan))).toMatchObject([{ prompt: labelled }]); + await f.answer(card!.id, [0]); + expect(await response).toEqual({ + cancelled: false, + answers: [{ questionIndex: 0, question, kind: "option", answer: "Ship" }], + }); +}); + +test("a dialog that cannot fit in the document fails loudly without leaving a card", async () => { + let f = await fixture(); + let edited = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace_root", + source: `${"Existing prose in the document. ".repeat(3_000)}\n`, + }]); + if (!edited.ok || !edited.mutation) throw new Error("fixture edit failed"); + await Plan.publish(f.plan, f.server, f.room.id, edited.mutation); + let before = Plan.source(f.plan); + await expect( + f.input.editor("Too large", "x".repeat(limits.MAX_SOURCE_BYTES - 90_000), f.options), + ).rejects.toThrow(`${limits.MAX_SOURCE_BYTES / 1024} KiB`); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + expect(f.plan.records.size).toBe(0); + expect(Plan.source(f.plan)).toBe(before); +}); + +test("an answer that would make the document too large leaves it unchanged and the question open", async () => { + let f = await fixture(); + let edited = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace_root", + source: "# Plan\n\n" + ("Prose paragraph. ".repeat(60) + "\n\n").repeat(100), + }]); + if (!edited.ok) throw new Error("fixture edit failed"); + if (edited.mutation) await Plan.publish(f.plan, f.server, f.room.id, edited.mutation); + let response = f.input.questionnaire({ + questions: [{ header: "Input", question: "Write the brief", options: [] }], + }, f.options); + let [card] = await f.cards(1); + let question = card!.definition.questions[0]!; + // Each edit stays under the per-edit limit; together they outgrow the document. + for (let chunk = 0; chunk < 4; chunk++) { + let opened = Store.snapshot(f.plan.questions, card!.id); + if (!opened.open) throw new Error("question closed"); + let model = QuestionModel.restore( + opened.model, + Store.get(f.plan.questions, card!.id)!.definition, + ); + if (chunk === 0) model.api.val([question.id, "mode"]).set("custom"); + let text = model.api.str([question.id, "custom"]); + text.ins(text.length(), "x".repeat(50_000)); + await Questions.edit(f.plan, f.ws, { + kind: "question:edit", + rid: `edit-${chunk}`, + ts: 0, + id: card!.id, + patch: [...model.api.flush()!.toBinary()], + }); + } + let before = Plan.source(f.plan); + let current = Store.snapshot(f.plan.questions, card!.id); + if (!current.open) throw new Error("question closed"); + await Questions.submit(f.plan, f.server, f.room.id, f.ws, { + kind: "question:submit", + rid: "submit", + ts: 0, + id: card!.id, + revision: current.revision, + }); + expect(Plan.source(f.plan)).toBe(before); + expect(new TextEncoder().encode(Plan.source(f.plan)).length).toBeLessThanOrEqual( + limits.MAX_SOURCE_BYTES, + ); + expect(Store.outstanding(f.plan.questions).map(open => open.id)).toEqual([card!.id]); + f.controller.abort(); + expect((await response).cancelled).toBe(true); +}); + +test( + "accepting a comment cannot leave too little room for an open question to expire", + async () => { + let f = await fixture(); + let size = () => new TextEncoder().encode(Plan.source(f.plan)).length; + let quote = "caches tiles for 60 seconds"; + let seeded = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace_root", + source: `# Plan\n\nThe renderer ${quote}.\n`, + }]); + if (!seeded.ok || !seeded.mutation) throw new Error("fixture edit failed"); + await Plan.publish(f.plan, f.server, f.room.id, seeded.mutation); + + let response = f.input.questionnaire({ + questions: [{ + header: "Choice", + question: "Which?", + options: [{ label: "A", description: "" }, { label: "B", description: "" }], + }], + }, f.options); + await f.cards(1); + + let replies: any[] = []; + let ana = { + data: { handle: "ana", client: "client-ana", room: f.room.id }, + send: (raw: string) => replies.push(JSON.parse(raw)), + publish() {}, + } as unknown as Parameters[3]; + await Comments.start(f.plan, f.server, f.room.id, ana, { + kind: "comment:start", + rid: "start", + ts: 0, + blocks: [1], + quote, + offset: 0, + length: quote.length, + text: "Too long.", + }); + let id = replies.findLast(frame => frame.kind === "comment:start")?.thread?.id as string; + expect(id).toBeString(); + + let scratch = await Room.create(Plan.source(f.plan)); + let before = size(); + Room.insertDecision(scratch, { + id, + quote, + by: "ana", + at: new Date().toISOString(), + notes: [{ by: "ana", text: "Too long." }], + }); + let decision = new TextEncoder().encode(Room.project(scratch)).length - before; + scratch.doc.destroy(); + + let target = limits.MAX_SOURCE_BYTES - decision - 10; + for (let fill = 1; fill > 0;) { + fill = Math.min(40_000, target - size() - 2); + if (fill < 1) break; + let edited = Edit.replace( + f.plan, + f.plan.revision, + `${Plan.source(f.plan)}\n${"x".repeat(fill)}\n`, + ); + if (!edited.ok) throw new Error(`edit refused: ${edited.reason}`); + if (edited.mutation) await Plan.publish(f.plan, f.server, f.room.id, edited.mutation); + } + + let context = { + chat: f.plan.chat, + config: { agent: false }, + plan: f.plan, + room: f.room.id, + server: f.server, + auth: {}, + claimantSessionId: "session", + repository: { id: "repo", owner: "owner", name: "repo", defaultBranch: "main" }, + persist: () => Plan.persist(f.plan), + } as unknown as Parameters[0]; + let complain = console.error; + console.error = () => {}; + try { + await Comments.accept(context, ana, { kind: "comment:accept", rid: "accept", ts: 0, id }); + } finally { + console.error = complain; + } + expect(replies.findLast(frame => frame.kind === "comment:accept")).toMatchObject({ + ok: false, + reason: "invalid", + }); + expect(f.plan.threads.get(id)?.status).toBe("open"); + + for (let { id: open } of Store.outstanding(f.plan.questions)) { + await Questions.expire(f.plan, f.server, f.room.id, open); + } + await response; + + expect(size()).toBeLessThanOrEqual(limits.MAX_SOURCE_BYTES); + let retitled = Edit.replace( + f.plan, + f.plan.revision, + Plan.source(f.plan).replace("# Plan", "# Pla2"), + ); + expect(retitled.ok).toBe(true); + }, + 30_000, +); + +type Fixture = Awaited>; + +async function seedDocument(f: Fixture) { + let seeded = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace_root", + source: "# Plan\n\nStart.\n", + }]); + if (!seeded.ok || !seeded.mutation) throw new Error("fixture edit failed"); + await Plan.publish(f.plan, f.server, f.room.id, seeded.mutation); +} + +/** Fill the document until `fits` just holds, leaving a margin smaller than one card's expiry. */ +async function crowd(f: Fixture, fits: (scratch: Room.Document) => boolean) { + let size = () => new TextEncoder().encode(Plan.source(f.plan)).length; + let base = Plan.source(f.plan); + let low = 0; + for (let high = limits.MAX_SOURCE_BYTES; low < high;) { + let mid = Math.ceil((low + high) / 2); + let scratch = await Room.create(`${base}\n${"x".repeat(mid)}\n`).catch(() => undefined); + let ok = scratch ? fits(scratch) : false; + scratch?.doc.destroy(); + if (ok) low = mid; + else high = mid - 1; + } + let target = size() + low - 10; + for (let fill = 1; fill > 0;) { + fill = Math.min(40_000, target - size() - 2); + if (fill < 1) break; + let edited = Edit.replace( + f.plan, + f.plan.revision, + `${Plan.source(f.plan)}\n${"x".repeat(fill)}\n`, + ); + if (!edited.ok) throw new Error(`edit refused: ${edited.reason}`); + if (edited.mutation) await Plan.publish(f.plan, f.server, f.room.id, edited.mutation); + } +} + +const single = (header: string): QuestionParams => ({ + questions: [{ + header, + question: "Which?", + options: [{ label: "A", description: "" }, { label: "B", description: "" }], + }], +}); + +const bytes = (value: string) => new TextEncoder().encode(value).length; +const FILLER = /\n\nq{10,}(?=\n)/g; + +/** Resize the Planner's filler paragraphs until the document is exactly `target` bytes. */ +async function fillTo(f: Fixture, target: number) { + let size = () => bytes(Plan.source(f.plan)); + let aim = target; + for (let pass = 0; pass < 3 && size() !== target; pass++) { + let base = Plan.source(f.plan).replace(FILLER, ""); + let paragraphs: string[] = []; + let room = aim - bytes(base); + for (; room > 40_002; room -= 40_002) paragraphs.push("q".repeat(40_000)); + if (room > 0) { + if (room < 12) throw new Error("filler would be too small"); + paragraphs.push("q".repeat(room - 2)); + } + let edited = Edit.replace( + f.plan, + f.plan.revision, + base + paragraphs.map(text => `\n${text}\n`).join(""), + ); + if (!edited.ok) { + throw new Error(`fill refused: ${"message" in edited ? edited.message : edited.reason}`); + } + if (edited.mutation) await Plan.publish(f.plan, f.server, f.room.id, edited.mutation); + aim += target - size(); + } + expect(size()).toBe(target); +} + +/** What one step adds to the document, measured on a copy without the filler. */ +async function growth(f: Fixture, step: (scratch: Room.Document) => void) { + let scratch = await Room.create(Plan.source(f.plan).replace(FILLER, "")); + let before = bytes(Room.project(scratch)); + step(scratch); + let after = bytes(Room.project(scratch)); + scratch.doc.destroy(); + return after - before; +} + +test( + "questions that fit when asked still fit when they expire, however the document changed since", + async () => { + let f = await fixture(); + let max = limits.MAX_SOURCE_BYTES; + let low = 0; + for (let high = max; low < high;) { + let mid = Math.ceil((low + high) / 2); + if (Room.fitsExpiry("a".repeat(mid), 1)) low = mid; + else high = mid - 1; + } + let expiry = max - low; + let quote = "caches tiles for 60 seconds"; + let seeded = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace_root", + source: `# Plan\n\nThe renderer ${quote}.\n`, + }]); + if (!seeded.ok || !seeded.mutation) throw new Error("fixture edit failed"); + await Plan.publish(f.plan, f.server, f.room.id, seeded.mutation); + + let responses = [ + f.input.questionnaire(single("First"), f.options), + f.input.questionnaire(single("Second"), { ...f.options, requestId: "second" }), + ]; + let [first, second] = await f.cards(2); + + let replies: any[] = []; + let ana = { + data: { handle: "ana", client: "client-ana", room: f.room.id }, + send: (raw: string) => replies.push(JSON.parse(raw)), + publish() {}, + } as unknown as Parameters[3]; + await Comments.start(f.plan, f.server, f.room.id, ana, { + kind: "comment:start", + rid: "start", + ts: 0, + blocks: [1], + quote, + offset: 0, + length: quote.length, + text: "Too long.", + }); + let thread = replies.findLast(frame => frame.kind === "comment:start")?.thread?.id as string; + expect(thread).toBeString(); + let context = { + chat: f.plan.chat, + config: { agent: false }, + plan: f.plan, + room: f.room.id, + server: f.server, + auth: {}, + claimantSessionId: "session", + repository: { id: "repo", owner: "owner", name: "repo", defaultBranch: "main" }, + persist: () => Plan.persist(f.plan), + } as unknown as Parameters[0]; + let accept = async () => { + let complain = console.error; + console.error = () => {}; + try { + await Comments.accept(context, ana, { + kind: "comment:accept", + rid: `accept-${replies.length}`, + ts: 0, + id: thread, + }); + } finally { + console.error = complain; + } + return replies.findLast(frame => frame.kind === "comment:accept"); + }; + + // Planner text edits: two questions are open, so growth stops at their reserve. + await fillTo(f, max - 2 * expiry); + let source = Plan.source(f.plan); + expect(Edit.apply(f.plan, f.plan.revision, [{ op: "insert", index: 0, source: "y" }])) + .toMatchObject({ ok: false, message: expect.stringContaining("expire") }); + expect(Edit.replace(f.plan, f.plan.revision, `${source}\ny\n`)) + .toMatchObject({ ok: false, message: expect.stringContaining("expire") }); + expect(Plan.source(f.plan)).toBe(source); + + // An option that would take the document into the reserve. + let label = "Written ".repeat(25); + let question = first!.definition.questions[0]!; + let option = await growth( + f, + scratch => + Room.appendQuestionOption(scratch, first!.id, question.id, { + id: ulid(), + label, + description: "", + }), + ); + await fillTo(f, max - 10 - option); + await expect(f.addOption(first!.id, label)).rejects.toThrow("no room"); + await fillTo(f, max - option - 2 * expiry - 100); + await f.addOption(first!.id, label); + + // An answer that would do the same, then one that fits. + let text = "a".repeat(1_500); + let answered = await growth( + f, + scratch => + Room.projectAnswer(scratch, first!.id, { [question.id]: text }, { + by: "reader", + at: new Date().toISOString(), + }), + ); + await fillTo(f, max - 10 - answered); + await expect(f.answer(first!.id, text)).rejects.toThrow("no room"); + expect(Store.outstanding(f.plan.questions)).toHaveLength(2); + await fillTo(f, max - answered - expiry - 100); + await f.answer(first!.id, ""); + expect(Store.outstanding(f.plan.questions).map(open => open.id)).toEqual([second!.id]); + + // An accepted comment becomes a Decision in the document. + let decision = await growth(f, scratch => + Room.insertDecision(scratch, { + id: thread, + quote, + by: "ana", + at: new Date().toISOString(), + notes: [{ by: "ana", text: "Too long." }], + })); + await fillTo(f, max - 10 - decision); + expect(await accept()).toMatchObject({ ok: false, reason: "invalid" }); + expect(f.plan.threads.get(thread)?.status).toBe("open"); + await fillTo(f, max - decision - expiry - 100); + expect(await accept()).toMatchObject({ ok: true }); + expect(f.plan.threads.get(thread)?.status).not.toBe("open"); + + // A question that would take the document into the reserve, then one that fits. + let probe = { + id: ulid(), + questions: [{ + id: ulid(), + header: "Third", + prompt: "Which?", + multiple: false, + options: [{ id: ulid(), label: "A" }, { id: ulid(), label: "B" }], + }], + }; + let asked = await growth(f, scratch => + Room.insertQuestionnaire(scratch, { + ...probe, + status: "expired", + at: new Date().toISOString(), + })); + await fillTo(f, max - 10 - asked); + let refused = f.input.questionnaire(single("Third"), { ...f.options, requestId: "third" }); + expect( + await Promise.race([ + refused.then(() => "asked", (err: Error) => err.message), + Bun.sleep(300).then(() => "asked"), + ]), + ).toContain("KiB limit"); + expect(Store.outstanding(f.plan.questions)).toHaveLength(1); + await fillTo(f, max - asked - expiry - 100); + responses.push( + f.input.questionnaire(single("Third"), { ...f.options, requestId: "third-again" }), + ); + await f.cards(2); + + // A person's text edit: growth into the reserve is refused, shrinking or fitting is not. + await fillTo(f, max - 2 * expiry); + let sent: any[] = []; + let ben = { + data: { handle: "ben", client: "client-ben", room: f.room.id }, + send: (raw: string) => sent.push(JSON.parse(raw)), + close() {}, + publish() {}, + } as unknown as Parameters[1]; + let type = async (client: ReturnType, change: () => void) => { + let before = Y.encodeStateVector(client.doc); + client.editor.update(change, { discrete: true }); + let rid = `ben-${sent.length}`; + Plan.submit( + f.plan, + ben, + { + kind: "plan:update", + rid, + ts: 0, + epoch: f.plan.document.epoch, + id: rid, + update: Buffer.from(Y.encodeStateAsUpdate(client.doc, before)).toString("base64"), + } as Parameters[2], + ); + await Bun.sleep(30); + await Plan.drain(f.plan); + return sent.findLast(frame => frame.kind === "plan:ack" && frame.id === rid); + }; + let grow = () => { + let paragraph = $createParagraphNode(); + paragraph.append($createTextNode("More text.")); + $getRoot().append(paragraph); + }; + let epoch = f.plan.document.epoch; + let before = Plan.source(f.plan); + let ben1 = peer(); + Y.applyUpdate(ben1.doc, Room.sync(f.plan.document), "remote"); + await Room.settle(); + expect(await type(ben1, grow)).toBeUndefined(); + expect(Plan.source(f.plan)).toBe(before); + expect(f.plan.document.epoch).not.toBe(epoch); + + let ben2 = peer(); + Y.applyUpdate(ben2.doc, Room.sync(f.plan.document), "remote"); + await Room.settle(); + let shrunk = await type(ben2, () => { + $getRoot().getChildren().find(node => node.getTextContent().startsWith("qq"))?.remove(); + }); + expect(shrunk).toBeDefined(); + expect(bytes(Plan.source(f.plan))).toBeLessThan(bytes(before)); + let fitted = await type(ben2, grow); + expect(fitted).toBeDefined(); + expect(Plan.source(f.plan)).toContain("More text."); + + // Both questions expire from a document with exactly their reserve left. + await fillTo(f, max - 2 * expiry); + for (let { id } of Store.outstanding(f.plan.questions)) { + await Questions.expire(f.plan, f.server, f.room.id, id); + } + await Promise.allSettled(responses); + + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + expect(bytes(Plan.source(f.plan))).toBeLessThanOrEqual(max); + let retitled = Edit.replace( + f.plan, + f.plan.revision, + Plan.source(f.plan).replace("# Plan", "# Pla2"), + ); + expect(retitled.ok).toBe(true); + }, + 30_000, +); + +test( + "a new question is refused when the questions already open would no longer have room to expire", + async () => { + let f = await fixture(); + await seedDocument(f); + let first = f.input.questionnaire(single("First"), f.options); + await f.cards(1); + let probe = { + id: ulid(), + questions: [{ + id: ulid(), + header: "Second", + prompt: "Which?", + multiple: false, + options: [{ id: ulid(), label: "A" }, { id: ulid(), label: "B" }], + }], + }; + await crowd(f, scratch => Room.fitsQuestionnaires(scratch, [probe], 0)); + + let second = f.input.questionnaire(single("Second"), { ...f.options, requestId: "second" }); + let outcome = await Promise.race([ + second.then(() => "asked", (err: Error) => err.message), + Bun.sleep(500).then(() => "asked"), + ]); + expect(outcome).toContain("KiB limit"); + expect(Store.outstanding(f.plan.questions)).toHaveLength(1); + f.controller.abort(); + await first; + }, + 30_000, +); + +test( + "an option is refused when the questions open would no longer have room to expire", + async () => { + let f = await fixture(); + await seedDocument(f); + let response = f.input.questionnaire({ + questions: [{ + header: "Choice", + question: "Which?", + options: [{ label: "A", description: "" }, { label: "B", description: "" }], + }], + }, f.options); + let [card] = await f.cards(1); + let question = card!.definition.questions[0]!; + await crowd(f, scratch => { + Room.appendQuestionOption(scratch, card!.id, question.id, { + id: ulid(), + label: "Another", + description: "", + }); + return Room.fitsExpiry(Room.project(scratch), 0); + }); + + await expect(f.addOption(card!.id, "Another")).rejects.toThrow("no room"); + expect(Store.get(f.plan.questions, card!.id)!.definition.questions[0]!.options).toHaveLength(2); + f.controller.abort(); + await response; + }, + 30_000, +); + +test( + "an answer is refused when the questions still open would no longer have room to expire", + async () => { + let f = await fixture(); + await seedDocument(f); + let first = f.input.questionnaire(single("First"), f.options); + let second = f.input.questionnaire(single("Second"), { ...f.options, requestId: "second" }); + let [card] = await f.cards(2); + let answer = "x".repeat(300); + let question = card!.definition.questions[0]!; + await crowd(f, scratch => { + Room.projectAnswer(scratch, card!.id, { [question.id]: answer }, { + by: "reader", + at: new Date().toISOString(), + }); + return Room.fitsExpiry(Room.project(scratch), 0); + }); + + let before = Plan.source(f.plan); + await expect(f.answer(card!.id, answer)).rejects.toThrow("no room"); + expect(Plan.source(f.plan)).toBe(before); + expect(Store.outstanding(f.plan.questions)).toHaveLength(2); + f.controller.abort(); + await Promise.allSettled([first, second]); + }, + 30_000, +); + +test( + "a Planner edit is refused when the open question would no longer have room to expire", + async () => { + let f = await fixture(); + await seedDocument(f); + let response = f.input.questionnaire(single("First"), f.options); + await f.cards(1); + await crowd(f, scratch => Room.fitsExpiry(Room.project(scratch), 1)); + + let before = Plan.source(f.plan); + let grown = Edit.apply(f.plan, f.plan.revision, [{ + op: "insert", + index: 0, + source: "y".repeat(30), + }]); + expect(grown).toMatchObject({ + ok: false, + reason: "invalid", + message: expect.stringContaining("expire"), + }); + expect(Plan.source(f.plan)).toBe(before); + + let retitled = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace", + index: 0, + source: "# Pla2", + }]); + expect(retitled.ok).toBe(true); + f.controller.abort(); + await response; + }, + 30_000, +); + +test( + "a replaced plan is refused when the open question would no longer have room to expire", + async () => { + let f = await fixture(); + await seedDocument(f); + let response = f.input.questionnaire(single("First"), f.options); + await f.cards(1); + await crowd(f, scratch => Room.fitsExpiry(Room.project(scratch), 1)); + + let before = Plan.source(f.plan); + let grown = Edit.replace(f.plan, f.plan.revision, `${before}\n${"y".repeat(30)}\n`); + expect(grown).toMatchObject({ + ok: false, + reason: "invalid", + message: expect.stringContaining("expire"), + }); + expect(Plan.source(f.plan)).toBe(before); + + let retitled = Edit.replace(f.plan, f.plan.revision, before.replace("# Plan", "# Pla2")); + expect(retitled.ok).toBe(true); + f.controller.abort(); + await response; + }, + 30_000, +); + +/** A document already inside the reserve of its open questions, as one that has two open but room for one. */ +async function overcommitted(f: Fixture) { + await seedDocument(f); + let response = f.input.questionnaire(single("First"), f.options); + let [card] = await f.cards(1); + await crowd(f, scratch => Room.fitsExpiry(Room.project(scratch), 1)); + void Store.ask(f.plan.questions, ulid(), Store.get(f.plan.questions, card!.id)!.definition); + expect(Room.fitsExpiry(Plan.source(f.plan), f.plan.questions.open.size)).toBe(false); + return { response }; +} + +test( + "a Planner block edit that shrinks the document is accepted even inside the expiry reserve", + async () => { + let f = await fixture(); + let { response } = await overcommitted(f); + let before = new TextEncoder().encode(Plan.source(f.plan)).length; + + let grown = Edit.apply(f.plan, f.plan.revision, [{ + op: "insert", + index: 0, + source: "y".repeat(30), + }]); + expect(grown).toMatchObject({ + ok: false, + reason: "invalid", + message: expect.stringContaining("expire"), + }); + + let shrunk = Edit.apply(f.plan, f.plan.revision, [{ op: "delete", index: 1 }]); + expect(shrunk.ok).toBe(true); + let trimmed = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace", + index: 0, + source: "# P", + }]); + expect(trimmed.ok).toBe(true); + if (trimmed.ok && trimmed.mutation) { + await Plan.publish(f.plan, f.server, f.room.id, trimmed.mutation); + } + expect(new TextEncoder().encode(Plan.source(f.plan)).length).toBeLessThan(before); + f.controller.abort(); + await response; + }, + 30_000, +); + +test( + "a replaced plan that shrinks the document is accepted even inside the expiry reserve", + async () => { + let f = await fixture(); + let { response } = await overcommitted(f); + let before = Plan.source(f.plan); + + let grown = Edit.replace(f.plan, f.plan.revision, `${before}\n${"y".repeat(30)}\n`); + expect(grown).toMatchObject({ + ok: false, + reason: "invalid", + message: expect.stringContaining("expire"), + }); + + let shrunk = Edit.replace(f.plan, f.plan.revision, before.replace("Start.", "S")); + expect(shrunk.ok).toBe(true); + f.controller.abort(); + await response; + }, + 30_000, +); diff --git a/apps/server/src/harness/atomic/human-input.ts b/apps/server/src/harness/atomic/human-input.ts new file mode 100644 index 000000000..f64ed743c --- /dev/null +++ b/apps/server/src/harness/atomic/human-input.ts @@ -0,0 +1,137 @@ +import { limits } from "@chopin/question"; + +import * as Questions from "../../questions/service"; + +import type { + HostInput, + HostInputOptions, + QuestionAnswer, + QuestionnaireResult, + QuestionParams, +} from "@bastani/atomic"; +import type { DocumentRoom } from "../../agent/tools"; + +/** + * Atomic owns input requests; Chopin owns their shared decision records. + * + * A request nobody answers within `expiresInMs` expires: its cards stay in + * Decisions marked expired, and Atomic gets no answer. + */ +export function createHumanInput( + room: DocumentRoom, + expiresInMs = limits.INPUT_EXPIRY_MS, +): HostInput { + async function questionnaire( + params: QuestionParams, + options: HostInputOptions, + ): Promise { + if (options.signal.aborted) return { answers: [], cancelled: true }; + let workflow = [ + options.workflowRunId === undefined ? undefined : `Workflow run: ${options.workflowRunId}`, + options.workflowStageId === undefined ? undefined : `stage: ${options.workflowStageId}`, + ].filter(value => value !== undefined).join("; "); + let definition = Questions.identify({ + questions: params.questions.map(question => ({ + header: question.header, + question: workflow ? `${question.question}\n\n${workflow}` : question.question, + options: question.options.map(option => ({ + label: option.label, + // Decisions shows no preview pane, so the mockup or code a member + // needs to see before choosing travels in the description. + description: [option.description, option.preview] + .filter(value => value !== undefined) + .join("\n\n"), + })), + multiple: question.multiSelect ?? false, + })), + }, { verbatim: true }); + let ended = await Questions.ask( + room.plan, + room.server, + room.id, + definition, + undefined, + room.anchors, + options.signal, + expiresInMs, + ); + if (options.signal.aborted || ended.some(outcome => outcome.status === "expired")) { + return { answers: [], cancelled: true }; + } + let answers: QuestionAnswer[] = []; + ended.forEach((outcome, questionIndex) => { + if (outcome.status !== "answered") return; + let answer = outcome.answers[0]!; + let question = params.questions[questionIndex]!; + let identity = { questionIndex, question: question.question }; + // Atomic accepts `custom` only where its own dialog offers a typed row. + let typed = !question.multiSelect && !question.options.some(option => option.preview); + let offered = new Set(question.options.map(option => option.label)); + let choices = answer.choices ?? []; + // A member writes an answer by adding an option, which Atomic never offered. + let written = answer.custom + || (choices.some(label => !offered.has(label)) ? choices.join(", ") : undefined); + if (written !== undefined) { + answers.push({ ...identity, kind: typed ? "custom" : "chat", answer: written }); + } else if (question.multiSelect) { + answers.push({ ...identity, kind: "multi", answer: null, selected: choices }); + } else { + let label = choices[0]!; + let preview = question.options.find(option => option.label === label)?.preview; + answers.push({ + ...identity, + kind: "option", + answer: label, + ...(preview === undefined ? {} : { preview }), + }); + } + }); + return { answers, cancelled: ended.some(outcome => outcome.status === "cancelled") }; + } + async function single( + header: string, + question: string, + choices: string[], + options: HostInputOptions, + ) { + let result = await questionnaire({ + questions: [{ + header, + question, + options: choices.map(label => ({ label, description: "" })), + }], + }, options); + return result.cancelled ? undefined : result.answers[0]; + } + return { + questionnaire, + async confirm(title, message, options) { + let answer = await single("Confirm", `${title}\n\n${message}`, ["Yes", "No"], options); + return answer?.kind === "option" && answer.answer === "Yes"; + }, + async select(title, choices, options) { + let answer = await single("Select", title, choices, options); + return answer?.kind === "option" && answer.answer !== null && choices.includes(answer.answer) + ? answer.answer + : undefined; + }, + async input(title, placeholder, options) { + let answer = await single( + "Input", + [title, placeholder].filter(value => value !== undefined).join("\n\n"), + [], + options, + ); + return answer?.kind === "custom" ? answer.answer ?? undefined : undefined; + }, + async editor(title, initial, options) { + let answer = await single( + "Editor", + [title, initial].filter(value => value !== undefined).join("\n\n"), + [], + options, + ); + return answer?.kind === "custom" ? answer.answer ?? undefined : undefined; + }, + }; +} diff --git a/apps/server/src/harness/atomic/workspace.ts b/apps/server/src/harness/atomic/workspace.ts new file mode 100644 index 000000000..a7e657726 --- /dev/null +++ b/apps/server/src/harness/atomic/workspace.ts @@ -0,0 +1,67 @@ +/** + * Where a channel's Atomic Planner works. + * + * A checkout is only ever supplied through `invoke_planner`, verified against + * the channel's repository, and remembered for the channel until the process + * exits. Every later Planner session for that channel re-verifies it before use. + * Without one, the session gets a directory Chopin created empty for that + * channel alone; it keeps whatever the Planner writes there until shutdown. + */ + +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { verifiedCheckout } from "./checkout"; + +export type PlannerWorkspace = { + cwd: string; + /** True when `cwd` is a verified checkout rather than the empty channel directory. */ + checkout: boolean; +}; + +let checkouts = new Map(); +let directories = new Map>(); + +export function rememberCheckout(channelId: string, checkout: string): void { + checkouts.set(channelId, checkout); +} + +export async function plannerWorkspace( + channelId: string, + repository: { owner: string; name: string }, +): Promise { + let remembered = checkouts.get(channelId); + let checkout = remembered === undefined + ? undefined + : await verifiedCheckout(repository, remembered); + if (checkout) return { cwd: checkout, checkout: true }; + return { cwd: await directory(channelId), checkout: false }; +} + +/** + * Chained per channel so concurrent sessions share one directory. `mkdtemp` + * creates it with mode 0700; one removed from under a running server is + * replaced rather than recreated at a name somebody else could have taken. + */ +function directory(channelId: string): Promise { + let previous = directories.get(channelId); + let next = (async () => { + let path = await previous?.catch(() => undefined); + if (path && await stat(path).then(found => found.isDirectory(), () => false)) return path; + return mkdtemp(join(tmpdir(), "chopin-planner-")); + })(); + directories.set(channelId, next); + return next; +} + +export async function removeWorkspaces(): Promise { + let paths = await Promise.all( + [...directories.values()].map(path => path.catch(() => undefined)), + ); + directories.clear(); + checkouts.clear(); + await Promise.all( + paths.map(path => path && rm(path, { recursive: true, force: true }).catch(() => {})), + ); +} diff --git a/apps/server/src/harness/harnesses.ts b/apps/server/src/harness/harnesses.ts index 408d6f647..44228f29b 100644 --- a/apps/server/src/harness/harnesses.ts +++ b/apps/server/src/harness/harnesses.ts @@ -1,4 +1,5 @@ import { ATOMIC_AUTH_MODES, createAtomicAdapter } from "./atomic/adapter"; +import { removeWorkspaces } from "./atomic/workspace"; import { createCopilotSdk } from "./copilot-sdk/adapter"; import { createPiAdapter } from "./pi/adapter"; @@ -112,4 +113,5 @@ export async function shutdownHarnesses(): Promise { credentials.clear(); await selected?.shutdown(); selected = undefined; + await removeWorkspaces(); } diff --git a/apps/server/src/harness/pi/model-stub.ts b/apps/server/src/harness/pi/model-stub.ts index 869992d96..21a257057 100644 --- a/apps/server/src/harness/pi/model-stub.ts +++ b/apps/server/src/harness/pi/model-stub.ts @@ -43,6 +43,7 @@ export type StubRequest = { system: string; toolNames: string[]; hasPriorToolResult: boolean; + toolResults: string[]; }; export function startStubModelServer( @@ -73,6 +74,9 @@ export function startStubModelServer( system, hasPriorToolResult, toolNames: (body.tools ?? []).map(entry => entry.function?.name ?? ""), + toolResults: body.messages.filter(message => message.role === "tool").map(message => + extractText(message.content) + ), }); let turn = respond(prompt, hasPriorToolResult); let id = crypto.randomUUID(); diff --git a/apps/server/src/harness/session.test.ts b/apps/server/src/harness/session.test.ts index d36e67091..f9573744b 100644 --- a/apps/server/src/harness/session.test.ts +++ b/apps/server/src/harness/session.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtemp, readdir, realpath, rm, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { openPlannerSession } from "./session"; +import { fullPlanner } from "./atomic/full"; +import { rememberCheckout, removeWorkspaces } from "./atomic/workspace"; +import { plannerInstructions } from "../agent/planner"; import type { ActiveOwnerBinding } from "../agent/active-owner"; import type { PlannerSessionDependencies } from "./session"; @@ -111,3 +117,104 @@ describe("openPlannerSession", () => { expect(destroyed).toEqual({ sandbox: 1, session: 0, unregistered: 1 }); }); }); + +describe("Planner workspaces", () => { + let roots: string[] = []; + afterEach(async () => { + await removeWorkspaces(); + for (let root of roots.splice(0)) await rm(root, { recursive: true, force: true }); + }); + + async function checkout(origin: string): Promise { + let root = await mkdtemp(join(tmpdir(), "chopin-planner-checkout-")); + roots.push(root); + for (let args of [["init", "--quiet"], ["remote", "add", "origin", origin]]) { + expect( + await Bun.spawn(["git", "-C", root, ...args], { stdout: "pipe", stderr: "pipe" }).exited, + ).toBe(0); + } + return realpath(root); + } + + /** Open and stream one Planner session, observing what the harness would see. */ + async function open(channelId: string, harness?: string) { + let { owner, channel, deps } = fixture(); + let sessionId = ""; + let instructions = ""; + let registered: ReturnType; + deps.agent = { + createSession: async (options: { sessionId: string }) => { + sessionId = options.sessionId; + return { destroy: async () => {} }; + }, + stream: async (call: { options: { instructions: string } }) => { + instructions = call.options.instructions; + registered = fullPlanner(sessionId); + }, + } as never; + let opened = await openPlannerSession(owner, { + ...channel, + room: { id: channelId, plan: {}, server: {} } as never, + harness, + instructions: workspace => plannerInstructions("owner/repo", "BOOTSTRAP", workspace), + }, deps); + if (!opened.ok) throw new Error("Planner unavailable"); + await opened.value.stream("prompt", new AbortController().signal); + await opened.value.destroy(); + expect(fullPlanner(sessionId)).toBeUndefined(); + expect(instructions).toContain("BOOTSTRAP"); + return { registered: registered!, instructions }; + } + + it("reuses a channel's remembered checkout for every later atomic session while it verifies", async () => { + let path = await checkout("workgit:owner/repo.git"); + rememberCheckout("channel", path); + for (let attempt of [1, 2]) { + let { registered, instructions } = await open("channel", "atomic"); + expect({ attempt, cwd: registered?.cwd }).toEqual({ attempt, cwd: path }); + expect(registered?.humanInput.questionnaire).toBeFunction(); + expect(instructions).toContain(`${path}, is a local checkout of owner/repo`); + expect(instructions).toContain("proceed on your best judgement"); + expect(instructions).not.toContain("You have no shell"); + } + let git = Bun.spawn(["git", "-C", path, "remote", "set-url", "origin", "workgit:other/repo"], { + stdout: "pipe", + stderr: "pipe", + }); + expect(await git.exited).toBe(0); + let fallback = await open("channel", "atomic"); + expect(fallback.registered?.cwd).not.toBe(path); + expect(await readdir(fallback.registered!.cwd)).toEqual([]); + expect(fallback.instructions).toContain("scratch directory Chopin created"); + }); + + it("gives each atomic channel without a checkout its own empty, private directory", async () => { + let first = await open("first", "atomic"); + let again = await open("first", "atomic"); + let second = await open("second", "atomic"); + expect(again.registered?.cwd).toBe(first.registered!.cwd); + expect(second.registered?.cwd).not.toBe(first.registered!.cwd); + for (let { registered, instructions } of [first, second]) { + let cwd = registered!.cwd; + expect((await stat(cwd)).mode & 0o777).toBe(0o700); + expect(await readdir(cwd)).toEqual([]); + expect(registered?.humanInput.questionnaire).toBeFunction(); + expect(instructions).toContain(`${cwd}, is a scratch directory Chopin created`); + expect(instructions).toContain("holds no repository files"); + expect(instructions).toContain("`read_repository_file`"); + expect(instructions).toContain("proceed on your best judgement"); + } + await removeWorkspaces(); + expect(await stat(first.registered!.cwd).catch(() => undefined)).toBeUndefined(); + }); + + it("keeps copilot-sdk and pi Planner sessions isolated, whatever the channel remembers", async () => { + rememberCheckout("channel", await checkout("workgit:owner/repo.git")); + for (let harness of ["copilot-sdk", "pi", undefined]) { + let { registered, instructions } = await open("channel", harness); + expect(registered).toBeUndefined(); + expect(instructions).toContain("You have no shell"); + expect(instructions).not.toContain("proceed on your best judgement"); + } + }); +}); diff --git a/apps/server/src/harness/session.ts b/apps/server/src/harness/session.ts index e5e1346b1..d9ff4431c 100644 --- a/apps/server/src/harness/session.ts +++ b/apps/server/src/harness/session.ts @@ -3,6 +3,9 @@ import { createJustBashNetworkSandboxSession } from "@ai-sdk/sandbox-just-bash"; import { headingPlannerAgent, plannerAgent, prosePlannerAgent, refinePlannerAgent } from "./agents"; import { githubTools, type GitHubToolsError, type Result } from "./github-tools"; import { registerCredential } from "./harnesses"; +import { registerFullPlanner } from "./atomic/full"; +import { createHumanInput } from "./atomic/human-input"; +import { type PlannerWorkspace, plannerWorkspace } from "./atomic/workspace"; import type { HarnessAgent } from "@ai-sdk/harness/agent"; import type { ActiveOwnerBinding } from "../agent/active-owner"; @@ -19,11 +22,13 @@ type Sandbox = Awaited>; type PlannerAgent = typeof plannerAgent; -type PlannerChannel = { +export type PlannerChannel = { room: DocumentRoom; repository: HostedRepository; - instructions: string; + instructions: string | ((workspace?: PlannerWorkspace) => string); model?: string; + /** `atomic` runs the session as a full Atomic session in the channel's workspace. */ + harness?: string; }; export type PlannerSession = { @@ -83,12 +88,26 @@ export async function openPlannerSession( return { ok: false, error: { kind: "Unavailable", cause } }; } let unregister: (() => void) | undefined; + let unregisterFull: (() => void) | undefined; let session: Awaited> | undefined; let timeout: ReturnType | undefined; try { if (owner.signal.aborted) throw new Error("Planner owner unavailable"); let sessionId = crypto.randomUUID(); unregister = (deps.registerCredential ?? registerCredential)(sessionId, owner.currentToken); + // Background jobs keep the isolated boundary; only a Planner turn may run as a full session. + let workspace = channel.harness === "atomic" && !job + ? await plannerWorkspace(channel.room.id, channel.repository) + : undefined; + if (workspace) { + unregisterFull = registerFullPlanner(sessionId, { + cwd: workspace.cwd, + humanInput: createHumanInput(channel.room), + }); + } + let instructions = typeof channel.instructions === "function" + ? channel.instructions(workspace) + : channel.instructions; let agent = deps.agent ?? (job?.kind === "heading" ? deps.headingAgent ?? headingPlannerAgent : job?.kind === "refine" || job?.kind === "suggest" @@ -126,6 +145,7 @@ export async function openPlannerSession( abortSignal, options: { ...channel, + instructions, owner, githubTools: tools.value, }, @@ -139,6 +159,7 @@ export async function openPlannerSession( await sandbox.destroy(); } finally { release(); + unregisterFull?.(); } } })(), @@ -156,6 +177,7 @@ export async function openPlannerSession( console.error("[agent] Planner sandbox cleanup failed:", cleanupError); } unregister?.(); + unregisterFull?.(); let message = cause instanceof Error ? cause.message : String(cause); let kind: "Timeout" | "ShuttingDown" | "HarnessCapabilityUnsupported" | "Unavailable" = message.includes("timed out") diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index bec2f69d8..dc78c075c 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -240,8 +240,21 @@ async function plan(room: Rooms.Room, server: Server): Promise Service.persist(opened), activeOwner: () => ownerBindings!.resolve(room.id), ownerAvailable: () => jobRunner?.ownerAvailable(room.id) ?? Promise.resolve(), @@ -296,7 +304,7 @@ function chat(room: Rooms.Room, ws: Socket): Chat.Room { async function closeRoom(room: Rooms.Room, force = false): Promise { if (room.closing) return room.closing; let closing = withDocumentLock(room.id, async () => { - if (!force && room.members.size > 0) return; + if (!force && (room.members.size > 0 || room.holds)) return; let held = room.plan; room.plan = undefined; if (held) { @@ -315,10 +323,10 @@ async function closeRoom(room: Rooms.Room, force = false): Promise { } function evict(room: Rooms.Room): void { - if (room.eviction || room.members.size > 0) return; + if (room.eviction || room.members.size > 0 || room.holds) return; room.eviction = setTimeout(() => { room.eviction = undefined; - if (room.members.size > 0) return; + if (room.members.size > 0 || room.holds) return; void closeRoom(room).catch(err => { console.error("chopin: room close failed -", err); }); @@ -1342,6 +1350,31 @@ registerMcpRoutes(router, hostedAuth, { return heldLease; }, }, { + invokePlanner: input => + withDocumentTransition(input.channel.id, async () => { + let channel = await storage.channels.get(input.channel.id); + if (!channel || deletingChannels.has(channel.id)) return "document-unavailable"; + if (channel.archivedAt) return "document-archived"; + await Rooms.get(channel.id)?.closing; + let held = Rooms.hold(channel.id); + let release = () => { + held.release(); + evict(held.room); + }; + let running = false; + try { + await plan(held.room, server); + let context = conversation(held.room, input.session?.session.id, input.repository); + let code = await Chat.invoke(context, input.user, input.instruction, input.checkout); + if (!code && context.chat.running) { + running = true; + void context.chat.running.finally(release).catch(() => {}); + } + return code; + } finally { + if (!running) release(); + } + }), archiveChannel, isChannelDeleting: channelId => deletingChannels.has(channelId), onChannelRenamed: announceChannel, diff --git a/apps/server/src/mcp.ts b/apps/server/src/mcp.ts index e92f569a8..d0ec91a98 100644 --- a/apps/server/src/mcp.ts +++ b/apps/server/src/mcp.ts @@ -5,6 +5,9 @@ * module only validates and presents their results through MCP. */ +import { isAbsolute } from "node:path"; +import { MAX_MESSAGE_BYTES } from "./chat/limits"; + import { BRIEF, isRepository, @@ -141,6 +144,24 @@ export type RestoreDocument = { >; }; +export type InvokePlannerInput = { id: string; instruction: string; checkout?: string }; + +export type InvokePlannerError = + | "document-unavailable" + | "repository-forbidden" + | "document-archived" + | "planner-owner-unavailable" + | "checkout-unverified" + | "planner-unavailable" + | "planner-queue-full"; + +export type InvokePlanner = { + invoke(caller: Caller, input: InvokePlannerInput): Promise< + | { kind: "invoked"; document: DocumentSummary & { url: string } } + | { kind: "refused"; code: InvokePlannerError } + >; +}; + export type McpOptions = { /** The host owns authentication; MCP only receives its result. */ caller(request: Request): Promise | Caller | undefined; @@ -150,6 +171,7 @@ export type McpOptions = { rename?: RenameDocument; archive?: ArchiveDocument; restore?: RestoreDocument; + invoke?: InvokePlanner; implementations?: Implementations; }; @@ -402,6 +424,7 @@ export const TOOLS: Tool[] = [ additionalProperties: false, }, outputSchema: { + type: "object", oneOf: [ ACTIVE_DOCUMENT, outcome([ @@ -430,6 +453,7 @@ export const TOOLS: Tool[] = [ additionalProperties: false, }, outputSchema: { + type: "object", oneOf: [ ARCHIVED_DOCUMENT, outcome(["repository-forbidden", "document-unavailable"]), @@ -453,12 +477,66 @@ export const TOOLS: Tool[] = [ additionalProperties: false, }, outputSchema: { + type: "object", oneOf: [ ACTIVE_DOCUMENT, outcome(["repository-forbidden", "document-unavailable"]), ], }, }, + { + name: "invoke_planner", + description: "Post an instruction to a document's Planner, attributed to the caller, and " + + "return its URL without waiting for the turn. The turn runs under the document's " + + "Planner owner; without one, the caller's live Chopin browser login becomes the " + + "owner, and otherwise the call is refused. Questions appear in Decisions.", + inputSchema: { + type: "object", + properties: { + id: { + type: "string", + minLength: 1, + maxLength: MAX_DOCUMENT_LOCATOR_LENGTH, + pattern: "\\S", + }, + instruction: { + type: "string", + minLength: 1, + maxLength: MAX_MESSAGE_BYTES, + pattern: "\\S", + description: "Instruction text, at most 64 KiB in UTF-8.", + }, + checkout: { + type: "string", + description: "Absolute path, on the Chopin server's machine, to a checkout of this " + + "repository. Used only by HARNESS=atomic, which verifies its origin and " + + "remembers it as the document's Planner working directory; ignored otherwise.", + }, + }, + required: ["id", "instruction"], + additionalProperties: false, + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", + properties: { ...DOCUMENT_IDENTITY, url: { type: "string" } }, + required: ["id", "title", "url"], + additionalProperties: false, + }, + outcome([ + "document-unavailable", + "repository-forbidden", + "document-archived", + "planner-owner-unavailable", + "checkout-unverified", + "planner-unavailable", + "planner-queue-full", + ]), + ], + }, + }, { name: "read_implementation", description: @@ -660,6 +738,7 @@ function serviceInstructions(tools: Tool[]): string | undefined { || tool.name === "rename_document" || tool.name === "archive_document" || tool.name === "restore_document" + || tool.name === "invoke_planner" ); let implementation = tools .filter(tool => @@ -730,6 +809,7 @@ export function handler( && (tool.name !== "rename_document" || renaming) && (tool.name !== "archive_document" || archiving) && (tool.name !== "restore_document" || restoring) + && (tool.name !== "invoke_planner" || options.invoke) && (!["read_implementation", "start_implementation"].includes(tool.name) || options.implementations) && (!isLifecycleTool(tool.name) || options.implementations?.reportLifecycle) @@ -914,6 +994,29 @@ export function handler( : "document-unavailable", }, true)); } + if (tool.name === "invoke_planner" && options.invoke) { + let args = tool.arguments; + if ( + Object.keys(args).some(key => !["id", "instruction", "checkout"].includes(key)) + || !isLocator(args.id) + || typeof args.instruction !== "string" || !args.instruction.trim() + || Buffer.byteLength(args.instruction) > MAX_MESSAGE_BYTES + || (Object.hasOwn(args, "checkout") + && (typeof args.checkout !== "string" || !isAbsolute(args.checkout))) + ) { + return notification + ? undefined + : error( + call.id, + -32602, + "invoke_planner requires an id or URL, instruction, and optional absolute checkout", + ); + } + let result = await options.invoke.invoke(caller, args as InvokePlannerInput); + return result.kind === "invoked" + ? respond(text(result.document)) + : respond(text({ code: result.code }, true)); + } if (tool.name === "read_implementation") { if (Object.keys(tool.arguments).length !== 1 || !isLocator(tool.arguments.id)) { return notification diff --git a/apps/server/src/mcp/documents.test.ts b/apps/server/src/mcp/documents.test.ts index ba51ab995..19ad96a40 100644 --- a/apps/server/src/mcp/documents.test.ts +++ b/apps/server/src/mcp/documents.test.ts @@ -50,7 +50,38 @@ async function json(response: Response): Promise> { return await response.json() as Record; } +async function unavailable() { + return { kind: "unavailable" as const }; +} + describe("the MCP document protocol", () => { + it("exposes only top-level object output schemas for every tool", async () => { + let mcp = handler({ + caller: () => "octocat", + documents: reader(), + create: { create: unavailable }, + update: { update: unavailable }, + rename: { rename: unavailable }, + archive: { archive: unavailable }, + restore: { restore: unavailable }, + invoke: { invoke: async () => ({ kind: "refused", code: "planner-unavailable" }) }, + implementations: { + readImplementation: async () => undefined, + startImplementation: unavailable, + reportLifecycle: unavailable, + }, + }); + let response = await mcp(request({ jsonrpc: "2.0", id: 1, method: "tools/list" })); + let listed = (await response.json()).result.tools as typeof TOOLS; + expect(listed).toEqual(TOOLS); + for (let tool of [...TOOLS, ...listed]) { + expect({ name: tool.name, type: tool.outputSchema.type }).toEqual({ + name: tool.name, + type: "object", + }); + } + }); + it("authenticates initialize, lists repository documents, and reads canonical source", async () => { let mcp = endpoint(); diff --git a/apps/server/src/mcp/hosted.test.ts b/apps/server/src/mcp/hosted.test.ts index 6c771ccae..236e9df00 100644 --- a/apps/server/src/mcp/hosted.test.ts +++ b/apps/server/src/mcp/hosted.test.ts @@ -5,6 +5,7 @@ import { ulid } from "@chopin/dialect"; import { Sessions } from "../auth/session"; import { Admission } from "../auth/admission"; import { GitHubError } from "../github/client"; +import { handler } from "../mcp"; import * as Room from "../plan/room"; import * as Service from "../plan/service"; import * as Rooms from "../rooms"; @@ -24,6 +25,7 @@ import type { } from "../github/client"; import type { CreateDocumentInput } from "../mcp"; import type { Socket, SocketData } from "../wire"; +import type { PlannerInvocation } from "./hosted"; let creation: CreateDocumentInput = { idempotencyKey: "create-plan-1", @@ -204,6 +206,146 @@ async function plan(context: ReturnType) { } describe("the hosted MCP adapter", () => { + it("offers invoke_planner in hosted and local configuration, passing the caller and any live login", async () => { + for (let local of [false, true]) { + let context = setup(); + if (local) { + context.auth.config.local = { + installation: "local", + credentialsDir: "/tmp/unused", + port: 8795, + }; + } + context.github.repositoryValue.permissions.push = true; + let opened = await plan(context); + let started: PlannerInvocation[] = []; + let refusal: "planner-owner-unavailable" | undefined; + let adapter = hosted(context.auth, undefined, { + async invokePlanner(input) { + started.push(input); + return refusal; + }, + }); + let caller = (await adapter.caller(request("Bearer allowed")))!; + let input = { id: opened.channel.id, instruction: " Review this.\n" }; + try { + expect(hosted(context.auth).invoke).toBeUndefined(); + let listed = await handler(adapter)( + new Request("https://chopin.test/mcp", { + method: "POST", + headers: { authorization: "Bearer allowed" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }), + ); + expect((await listed.json()).result.tools.map((tool: { name: string }) => tool.name)) + .toContain("invoke_planner"); + await context.storage.users.put({ + id: "U_other", + login: "other", + avatarUrl: "", + now: context.now, + }); + await context.auth.sessions.issue("U_other", grant("another-browser")); + expect(await adapter.invoke!.invoke(caller, input)).toEqual({ + kind: "invoked", + document: { + id: opened.channel.id, + title: opened.channel.title, + url: "https://chopin.test/documents/octo-org/score/release-readiness", + }, + }); + expect(started[0]).toMatchObject({ + instruction: input.instruction, + user: { id: caller.user.id, login: caller.user.login }, + channel: { id: opened.channel.id }, + }); + expect(started[0]!.session).toBeUndefined(); + expect(Object.hasOwn(started[0]!, "checkout")).toBe(false); + let session = await context.auth.sessions.issue(caller.user.id, grant("browser-token")); + refusal = "planner-owner-unavailable"; + expect(await adapter.invoke!.invoke(caller, input)).toEqual({ + kind: "refused", + code: "planner-owner-unavailable", + }); + expect(started[1]).toMatchObject({ + session: { session: { id: session.id }, user: { id: caller.user.id } }, + }); + } finally { + await Service.close(opened.plan); + } + } + }); + + it("passes a URL invocation's checkout through unverified for the harness to judge", async () => { + let context = setup(); + context.github.repositoryValue.permissions.admin = true; + let opened = await plan(context); + let started: PlannerInvocation[] = []; + let adapter = hosted(context.auth, undefined, { + async invokePlanner(input) { + started.push(input); + return undefined; + }, + }); + try { + let caller = (await adapter.caller(request("Bearer allowed")))!; + let input = { + id: "https://chopin.test/documents/octo-org/score/release-readiness", + instruction: "Review", + checkout: "/does-not-exist", + }; + expect(await adapter.invoke!.invoke(caller, input)).toMatchObject({ + kind: "invoked", + document: { id: opened.channel.id, url: input.id }, + }); + expect(started).toHaveLength(1); + expect(started[0]).toMatchObject({ + checkout: "/does-not-exist", + instruction: input.instruction, + repository: { id: "R_score", owner: "octo-org", name: "score" }, + }); + } finally { + await Service.close(opened.plan); + } + }); + + it("refuses unavailable, forbidden, archived and deleting documents before starting a Planner", async () => { + let context = setup(); + let opened = await plan(context); + let calls = 0; + let deleting = false; + let callbacks = { + async invokePlanner() { + calls++; + return undefined; + }, + isChannelDeleting: () => deleting, + }; + let adapter = hosted(context.auth, undefined, callbacks); + let caller = (await adapter.caller(request("Bearer allowed")))!; + let invoke = (id = opened.channel.id) => + adapter.invoke!.invoke(caller, { id, instruction: "Review" }); + try { + expect(await invoke(crypto.randomUUID())).toEqual({ + kind: "refused", + code: "document-unavailable", + }); + expect(await invoke()).toEqual({ kind: "refused", code: "repository-forbidden" }); + context.github.repositoryValue.permissions.push = true; + context.github.repositoryValue.permissions.pull = false; + expect(await invoke()).toEqual({ kind: "refused", code: "repository-forbidden" }); + context.github.repositoryValue.permissions.pull = true; + deleting = true; + expect(await invoke()).toEqual({ kind: "refused", code: "document-unavailable" }); + deleting = false; + await context.storage.channels.archive({ id: opened.channel.id, now: context.now }); + expect(await invoke()).toEqual({ kind: "refused", code: "document-archived" }); + expect(calls).toBe(0); + } finally { + await Service.close(opened.plan); + } + }); + it("accepts exactly one bearer token and validates its GitHub identity per request", async () => { let { auth, github } = setup(); let adapter = hosted(auth); diff --git a/apps/server/src/mcp/hosted.ts b/apps/server/src/mcp/hosted.ts index bf59bd2fa..29b979404 100644 --- a/apps/server/src/mcp/hosted.ts +++ b/apps/server/src/mcp/hosted.ts @@ -12,10 +12,13 @@ import { implementationLifecycle } from "../tasks/lifecycle"; import type { Server } from "bun"; import type { HostedAuth } from "../auth/routes"; import type { GitHubUser } from "../github/client"; +import type { AuthenticatedSession } from "../auth/session"; +import type { HostedRepository } from "../agent/repository"; import type { DocumentSummary, Implementation, ImplementationInput, + InvokePlannerError, McpOptions, RenameDocumentInput, } from "../mcp"; @@ -30,6 +33,17 @@ export type HostedCaller = { user: GitHubUser; }; +export type PlannerInvocation = { + channel: ChannelRecord; + repository: HostedRepository; + user: GitHubUser; + /** The caller's live browser login, which may claim ownership for a channel without one. */ + session?: AuthenticatedSession; + instruction: string; + /** Unverified; only the atomic harness reads it. */ + checkout?: string; +}; + export type ImplementationPersistence = { lease(): Lease }; export type HostedCallbacks = { archiveChannel?: (channelId: string, now: Date) => Promise; @@ -38,6 +52,7 @@ export type HostedCallbacks = { serializeDocument?: (channelId: string, action: () => Promise) => Promise; onChannelRenamed?: (channel: ChannelRecord) => void; onDocumentPersisted?: (target: Plan.DocumentTarget) => void; + invokePlanner?: (input: PlannerInvocation) => Promise; }; const BEARER = new RegExp("^Bearer ([A-Za-z0-9._~+/-]+=*)$", "i"); @@ -255,6 +270,47 @@ export function hosted( } return { + invoke: callbacks.invokePlanner + ? { + async invoke(caller, input) { + let located = await locatedChannel(caller, input.id); + if (!located) return { kind: "refused", code: "document-unavailable" }; + if ( + located === "forbidden" + || (!located.repository.permissions.push && !located.repository.permissions.admin) + ) { + return { kind: "refused", code: "repository-forbidden" }; + } + let { channel, repository } = located; + if (callbacks.isChannelDeleting?.(channel.id)) { + return { + kind: "refused", + code: "document-unavailable", + }; + } + if (channel.archivedAt) return { kind: "refused", code: "document-archived" }; + let code = await callbacks.invokePlanner!({ + channel, + repository, + user: caller.user, + session: await auth.sessions.forUser(caller.user.id), + instruction: input.instruction, + ...(input.checkout === undefined ? {} : { checkout: input.checkout }), + }); + if (code) return { kind: "refused", code }; + return { + kind: "invoked", + document: { + ...summary(channel), + url: new URL( + documentPath(channel.repositoryOwner, channel.repositoryName, channel.slug), + auth.config.origin, + ).href, + }, + }; + }, + } + : undefined, async caller(request) { let match = request.headers.get("authorization")?.match(BEARER); if (!match) return undefined; diff --git a/apps/server/src/mcp/invoke.test.ts b/apps/server/src/mcp/invoke.test.ts new file mode 100644 index 000000000..e774e92eb --- /dev/null +++ b/apps/server/src/mcp/invoke.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from "bun:test"; + +import { handler, TOOLS } from "../mcp"; + +import type { InvokePlanner, InvokePlannerInput } from "../mcp"; + +function endpoint(invoke?: InvokePlanner) { + return handler({ + caller: () => "octocat", + documents: { list: async () => [], read: async () => undefined }, + invoke, + }); +} + +async function call(mcp: ReturnType, method: string, params?: unknown) { + let response = await mcp( + new Request("http://localhost/mcp", { + method: "POST", + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }), + ); + return response.json(); +} + +test("invoke_planner is available only with its capability and returns the document URL", async () => { + let input = { + id: "/documents/octo-org/score/release", + instruction: " Plan this.\n", + checkout: "/tmp/score", + }; + let document = { + id: "document-id", + title: "Release", + url: "http://localhost/documents/octo-org/score/release", + }; + let received: Array<{ caller: string; input: InvokePlannerInput }> = []; + let mcp = endpoint({ + async invoke(caller, input) { + received.push({ caller, input }); + return { kind: "invoked", document }; + }, + }); + let tool = TOOLS.find(tool => tool.name === "invoke_planner"); + expect(tool).toBeDefined(); + expect((await call(endpoint(), "tools/list")).result.tools).not.toContainEqual(tool); + expect( + (await call(endpoint(), "tools/call", { name: "invoke_planner", arguments: input })).error.code, + ).toBe(-32601); + expect((await call(mcp, "tools/list")).result.tools).toContainEqual(tool); + expect((await call(mcp, "initialize", {})).result.instructions).toContain("invoke_planner:"); + let result = await call(mcp, "tools/call", { name: "invoke_planner", arguments: input }); + expect(result.result.structuredContent).toEqual(document); + expect(received).toEqual([{ caller: "octocat", input }]); +}); + +test("invoke_planner validates strict arguments and counts instruction bytes without rewriting text", async () => { + let received: InvokePlannerInput[] = []; + let mcp = endpoint({ + async invoke(_caller, input) { + received.push(input); + return { kind: "invoked", document: { id: input.id, title: "Document", url: "/document" } }; + }, + }); + let valid = { id: "document-id", instruction: "go" }; + for ( + let args of [ + {}, + { ...valid, id: " " }, + { ...valid, instruction: " \n\t" }, + { ...valid, instruction: "a".repeat(65_537) }, + { ...valid, instruction: "é".repeat(32_769) }, + { ...valid, instruction: 3 }, + { ...valid, checkout: "relative/path" }, + { ...valid, checkout: null }, + { ...valid, checkout: "" }, + { ...valid, extra: true }, + ] + ) { + let result = await call(mcp, "tools/call", { name: "invoke_planner", arguments: args }); + expect(result.error.code).toBe(-32602); + } + expect(received).toEqual([]); + let exact = { ...valid, instruction: "é".repeat(32_768) }; + expect( + (await call(mcp, "tools/call", { name: "invoke_planner", arguments: exact })).result.isError, + ).toBeUndefined(); + expect(received).toEqual([exact]); +}); + +test("invoke_planner exposes each refusal as an object code", async () => { + for ( + let code of [ + "document-unavailable", + "repository-forbidden", + "document-archived", + "planner-owner-unavailable", + "checkout-unverified", + "planner-unavailable", + "planner-queue-full", + ] as const + ) { + let mcp = endpoint({ invoke: async () => ({ kind: "refused", code }) }); + let result = await call(mcp, "tools/call", { + name: "invoke_planner", + arguments: { id: "document-id", instruction: "go" }, + }); + expect(result.result).toMatchObject({ isError: true, structuredContent: { code } }); + } +}); diff --git a/apps/server/src/plan/anchors.test.ts b/apps/server/src/plan/anchors.test.ts index b28564bf3..a54b06075 100644 --- a/apps/server/src/plan/anchors.test.ts +++ b/apps/server/src/plan/anchors.test.ts @@ -8,6 +8,7 @@ * block edited, the block deleted, and two blocks that look identical. */ +import * as Questions from "../questions/store"; import { describe, expect, it } from "bun:test"; import { $getAnchorAndFocusForUserState } from "@lexical/yjs"; import { $getNodeByKey } from "lexical"; @@ -30,7 +31,12 @@ The third paragraph. `; async function plan(source = SOURCE): Promise { - return { document: await room.create(source), revision: 1, outlines: new Map() } as Room; + return { + document: await room.create(source), + revision: 1, + outlines: new Map(), + questions: Questions.create(), + } as Room; } /** Anchor the block at `index`, the way the agent's tool does. */ diff --git a/apps/server/src/plan/edit.test.ts b/apps/server/src/plan/edit.test.ts index 400c86e52..c07a17d87 100644 --- a/apps/server/src/plan/edit.test.ts +++ b/apps/server/src/plan/edit.test.ts @@ -12,6 +12,7 @@ import { beforeEach, describe, expect, it } from "bun:test"; import * as edit from "./edit"; import * as room from "./room"; +import * as Questions from "../questions/store"; import type { Plan } from "./service"; const SOURCE = `# Title @@ -21,12 +22,13 @@ First paragraph. Second paragraph. `; -/** Enough of a plan for the edit engine, which only needs these three fields. */ +/** Enough of a plan for the edit engine, which only needs these fields. */ async function plan(source = SOURCE): Promise { return { document: await room.create(source), revision: 1, outlines: new Map(), + questions: Questions.create(), } as Plan; } diff --git a/apps/server/src/plan/edit.ts b/apps/server/src/plan/edit.ts index c81bff686..5441c17d9 100644 --- a/apps/server/src/plan/edit.ts +++ b/apps/server/src/plan/edit.ts @@ -26,6 +26,8 @@ import * as room from "./room"; import type { Nodes, Root, RootContent } from "mdast"; import type { Plan } from "./service"; +const EXPIRY_ROOM = "The plan would leave no room for the open questions to expire."; + /** Enough leading text to tell two blocks apart without resending the source. */ const PREVIEW = 120; @@ -199,6 +201,9 @@ export function apply(plan: Plan, revision: number, operations: Operation[]): Re // to write — a rejected batch is recoverable, a poisoned room is not. let parsed = parse(next); assert(parsed, { bytes: new TextEncoder().encode(next).byteLength }); + if (!room.fitsOrShrinks(source(plan), next, plan.questions.open.size)) { + throw new Error(EXPIRY_ROOM); + } if (parsed.children.length !== children.length) { throw new Error("plan blocks merge or split during Markdown normalisation"); } @@ -274,6 +279,9 @@ export function replace(plan: Plan, revision: number, nextSource: string): Resul let next = serialize({ ...root, children }); let parsed = parse(next); assert(parsed, { bytes: new TextEncoder().encode(next).byteLength }); + if (!room.fitsOrShrinks(source(plan), next, plan.questions.open.size)) { + throw new Error(EXPIRY_ROOM); + } if (parsed.children.length !== children.length) { throw new Error("plan blocks merge or split during Markdown normalisation"); } diff --git a/apps/server/src/plan/passages.test.ts b/apps/server/src/plan/passages.test.ts index 93afac268..8b209fe89 100644 --- a/apps/server/src/plan/passages.test.ts +++ b/apps/server/src/plan/passages.test.ts @@ -13,6 +13,7 @@ * these fail rather than the highlight quietly landing a word to the left. */ +import * as Questions from "../questions/store"; import { describe, expect, it } from "bun:test"; import { $getNodeByKey, $getRoot, $isElementNode, $isParagraphNode } from "lexical"; @@ -32,7 +33,12 @@ The third paragraph. `; async function plan(source = SOURCE): Promise { - return { document: await room.create(source), revision: 1, outlines: new Map() } as Room; + return { + document: await room.create(source), + revision: 1, + outlines: new Map(), + questions: Questions.create(), + } as Room; } /** diff --git a/apps/server/src/plan/room.test.ts b/apps/server/src/plan/room.test.ts index e610ab201..9f249c9f9 100644 --- a/apps/server/src/plan/room.test.ts +++ b/apps/server/src/plan/room.test.ts @@ -7,69 +7,20 @@ */ import { describe, expect, it, spyOn } from "bun:test"; -import { createHeadlessEditor } from "@lexical/headless"; -import { createYjsBinding, syncLexicalUpdateToYjs, syncYjsChangesToLexical } from "@lexical/yjs"; -import { $createParagraphNode, $getRoot, $isElementNode, $isParagraphNode } from "lexical"; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $isElementNode, + $isParagraphNode, +} from "lexical"; import * as Y from "yjs"; -import { $importPlan, registry } from "@chopin/dialect"; +import { $importPlan, limits } from "@chopin/dialect"; +import { peer, REGISTRY } from "../testing/peer"; import * as room from "./room"; -import type { Binding, Provider } from "@lexical/yjs"; -import type { LexicalEditor } from "lexical"; - -const REGISTRY = registry(); - -const PROVIDER = { - awareness: { - getLocalState: () => null, - getStates: () => new Map(), - off() {}, - on() {}, - setLocalState() {}, - setLocalStateField() {}, - }, - connect() {}, - disconnect() {}, - off() {}, - on() {}, -} as unknown as Provider; - -/** A client: its own editor and Y.Doc, exactly as a browser would have. */ -function peer(): { editor: LexicalEditor; doc: Y.Doc; binding: Binding } { - let editor = createHeadlessEditor({ - nodes: REGISTRY.nodes, - onError(err) { - throw err; - }, - }); - let doc = new Y.Doc(); - let binding = createYjsBinding({ editor, id: "plan", doc, docMap: new Map([["plan", doc]]) }); - - editor.registerUpdateListener( - ({ dirtyElements, dirtyLeaves, editorState, normalizedNodes, prevEditorState, tags }) => { - if (tags.has("skip-collab")) return; - syncLexicalUpdateToYjs( - binding, - PROVIDER, - prevEditorState, - editorState, - dirtyElements, - dirtyLeaves, - normalizedNodes, - tags, - ); - }, - ); - - binding.root.getSharedType().observeDeep((events, transaction) => { - if (transaction.origin !== binding) syncYjsChangesToLexical(binding, PROVIDER, events, false); - }); - - return { editor, doc, binding }; -} - function questionnaire(id: string, question: string, option: string, header: string) { return { id, @@ -315,6 +266,48 @@ describe("recovery", () => { expect(outcome.issues.length).toBeGreaterThan(0); }); + it("with questions open, refuses a human edit that grows into the expiry reserve but allows one that shrinks", async () => { + let filler = "x".repeat(limits.MAX_SOURCE_BYTES - 40); + let document = await room.create(`# T\n\n${filler}\n\nTail.\n`); + room.mark(document); + expect(room.fitsExpiry(room.project(document), 1)).toBe(false); + + let client = peer(); + Y.applyUpdate(client.doc, room.sync(document), "remote"); + await room.settle(); + + let before = Y.encodeStateVector(client.doc); + client.editor.update(() => { + let paragraph = $createParagraphNode(); + paragraph.append($createTextNode("More.")); + $getRoot().append(paragraph); + }, { discrete: true }); + let grown = await room.apply( + document, + [Y.encodeStateAsUpdate(client.doc, before)], + undefined, + 1, + ); + expect(grown.ok).toBe(false); + + let fresh = await room.rebuild(document); + let shrinker = peer(); + Y.applyUpdate(shrinker.doc, room.sync(fresh), "remote"); + await room.settle(); + let start = Y.encodeStateVector(shrinker.doc); + shrinker.editor.update(() => { + $getRoot().getLastChild()?.remove(); + }, { discrete: true }); + let shrunk = await room.apply( + fresh, + [Y.encodeStateAsUpdate(shrinker.doc, start)], + undefined, + 1, + ); + expect(shrunk.ok).toBe(true); + expect(room.project(fresh)).not.toContain("Tail."); + }); + it("rebuilds to the last known-good state under a fresh epoch", async () => { let document = await room.create("# Title\n"); room.mark(document); diff --git a/apps/server/src/plan/room.ts b/apps/server/src/plan/room.ts index 0af53e562..436079193 100644 --- a/apps/server/src/plan/room.ts +++ b/apps/server/src/plan/room.ts @@ -28,6 +28,7 @@ import { limits, parse, PlanValidationError, + questionnaireElement, QuestionnaireNode, registry as buildRegistry, ResearchNode, @@ -352,11 +353,12 @@ export async function apply( target: Document, updates: Uint8Array[], authorizeResearch?: (id: string, action: "add" | "remove") => Promise, + open = 0, ): Promise { if (updates.length === 0) return { ok: true, seq: target.seq, researchProjections: [] }; let before = parse(project(target)).children; let researchProjections: ResearchProjectionChange[] = []; - + let held = open > 0 ? Buffer.byteLength(project(target)) : 0; for (let update of updates) Y.applyUpdate(target.doc, update, REMOTE); await settle(); @@ -385,6 +387,10 @@ export async function apply( if (protectProjections(before, after, added, removed)) { return { ok: false, issues: ["protected-projection"] }; } + let source = project(target); + if (open > 0 && Buffer.byteLength(source) > held && !fitsExpiry(source, open)) { + return { ok: false, issues: ["source-too-large"] }; + } } catch (err) { if (!(err instanceof PlanValidationError)) throw err; return { ok: false, issues: err.issues.map(issue => issue.code) }; @@ -512,6 +518,54 @@ export function insertQuestionnaires( }, insertions); } +/** What expiring one open questionnaire adds to its card: the status and the date. */ +const EXPIRY_BYTES = (() => { + let size = (value: Questionnaire) => + Buffer.byteLength(serialize({ type: "root", children: [questionnaireElement(value)] })); + let card = { id: "", questions: [] }; + return size({ ...card, status: "expired", at: new Date(0).toISOString() }) - size(card); +})(); + +/** + * Whether this source still has room for `open` questionnaires to expire. + * + * Expiry adds to a card the document already holds, and a document past its + * limit stops accepting Planner edits, so every change leaves this room. + */ +export function fitsExpiry(source: string, open: number): boolean { + return Buffer.byteLength(source) + open * EXPIRY_BYTES <= limits.MAX_SOURCE_BYTES; +} + +/** + * Whether a change from `before` to `after` may proceed with `open` questionnaires + * waiting: it may always shrink the source, and may grow it only while the + * reserve for their expiry still fits. + */ +export function fitsOrShrinks(before: string, after: string, open: number): boolean { + return Buffer.byteLength(after) <= Buffer.byteLength(before) || fitsExpiry(after, open); +} + +/** + * Whether appending these questionnaires keeps the source within its byte limit + * once they, and the `open` questionnaires already waiting, have expired. + * + * Measured before mutating, because Yjs cannot undo an insertion that turns out + * to be too large. + */ +export function fitsQuestionnaires( + target: Document, + values: Questionnaire[], + open = 0, +): boolean { + let tree = parse(project(target)); + let at = new Date().toISOString(); + // Reserve the metadata expiry adds to every card in this batch. + tree.children.push( + ...values.map(value => questionnaireElement({ ...value, status: "expired", at })), + ); + return fitsExpiry(serialize(tree), open); +} + /** Append one questionnaire to the plan. */ export function insertQuestionnaire(target: Document, value: Questionnaire): Mutation | undefined { return insertQuestionnaires(target, [{ value }]); @@ -588,6 +642,19 @@ export function appendQuestionOption( }); } +/** Mark host input nobody answered in time as expired, leaving the card where it is. */ +export function projectExpiry(target: Document, id: string, at: string): Mutation | undefined { + return mutate(target, () => { + let found = false; + for (let node of $nodesOfType(QuestionnaireNode)) { + if (node.getId() !== id) continue; + found = true; + node.setQuestionnaire({ ...node.getQuestionnaire(), status: "expired", at }); + } + return found; + }); +} + /** Take a questionnaire out of the plan, leaving its record as history. */ export function removeQuestionnaire(target: Document, id: string): Mutation | undefined { return mutate(target, () => { diff --git a/apps/server/src/plan/service.ts b/apps/server/src/plan/service.ts index 1cb402d16..1f5674048 100644 --- a/apps/server/src/plan/service.ts +++ b/apps/server/src/plan/service.ts @@ -1351,6 +1351,7 @@ async function commit(plan: Plan): Promise { job?.job, ); }, + plan.questions.open.size, ); if (!outcome.ok) { diff --git a/apps/server/src/questions/prose-1.test.ts b/apps/server/src/questions/prose-1.test.ts index c19961a7f..505461844 100644 --- a/apps/server/src/questions/prose-1.test.ts +++ b/apps/server/src/questions/prose-1.test.ts @@ -5,6 +5,7 @@ import * as edit from "../plan/edit"; import * as room from "../plan/room"; import * as Prose from "./prose"; +import * as Store from "./store"; import type { Plan as RoomPlan } from "../plan/service"; @@ -37,7 +38,12 @@ it("keeps the original Yjs block when a person edits it, even beside identical p it("follows a moved paragraph when its old Yjs position is gone", async () => { let { document, anchor } = await subject(); try { - let plan = { document, revision: 1, outlines: new Map() } as RoomPlan; + let plan = { + document, + revision: 1, + outlines: new Map(), + questions: Store.create(), + } as RoomPlan; edit.apply(plan, 1, [{ op: "move", index: 2, to: 1 }]); expect(room.resolveAnchor(document, anchor)).toBeUndefined(); diff --git a/apps/server/src/questions/record-fields.ts b/apps/server/src/questions/record-fields.ts index cf601a7df..a0334fddc 100644 --- a/apps/server/src/questions/record-fields.ts +++ b/apps/server/src/questions/record-fields.ts @@ -1,4 +1,11 @@ -export const STATUSES = new Set(["open", "answered", "reopened", "discarded", "cancelled"]); +export const STATUSES = new Set([ + "open", + "answered", + "reopened", + "discarded", + "cancelled", + "expired", +]); export const ORIGINS = new Set(["chat", "planner", "human"]); export const MAX_UNIX_SECONDS = 253_402_300_799; @@ -28,4 +35,7 @@ export function unix(value: unknown): number { return value as number; } -export type Known = { questions: Map; options: Map }; +export type Known = { + questions: Map; + options: Map; +}; diff --git a/apps/server/src/questions/record-types.ts b/apps/server/src/questions/record-types.ts index b998b0c40..2fb580e9b 100644 --- a/apps/server/src/questions/record-types.ts +++ b/apps/server/src/questions/record-types.ts @@ -20,7 +20,7 @@ export type Record = { id: string; definition: Definition; /** "answered" is the stored status of a decided card. */ - status: "open" | "answered" | "reopened" | "discarded" | "cancelled"; + status: "open" | "answered" | "reopened" | "discarded" | "cancelled" | "expired"; answers?: { [question: string]: string }; resolver?: string; at?: number; diff --git a/apps/server/src/questions/record-validation.ts b/apps/server/src/questions/record-validation.ts index 5d569bfa6..107435340 100644 --- a/apps/server/src/questions/record-validation.ts +++ b/apps/server/src/questions/record-validation.ts @@ -10,21 +10,24 @@ export function options(definition: unknown, pending: boolean): Known { if ( !Array.isArray(source.questions) || source.questions.length === 0 || source.questions.length > limits.MAX_QUESTIONS + && !source.questions.every(question => object(question).verbatim === true) ) invalid(); let ids = new Map(); - let questions = new Map(); + let questions = new Map(); for (let candidate of source.questions) { let question = object(candidate); let id = text(question.id); if (questions.has(id)) invalid(); + // Host dialogs keep their raw text and counts, so only the Planner's questions are bounded. + let verbatim = question.verbatim === true; if ( typeof question.multiple !== "boolean" || !Array.isArray(question.options) - || question.options.length === 0 + || question.options.length === 0 && !verbatim && !(pending && source.questions.length === 1 && question.multiple === false) - || question.options.length > limits.MAX_OPTIONS + || !verbatim && question.options.length > limits.MAX_OPTIONS ) invalid(); - questions.set(id, { multiple: question.multiple as boolean }); + questions.set(id, { multiple: question.multiple as boolean, verbatim }); for (let candidate of question.options) { let option = object(candidate); let optionId = text(option.id); @@ -53,8 +56,11 @@ export function choices(value: unknown, known: Known): string[] { export function answers(value: unknown, known: Known): { [question: string]: string } { let entries = object(value); for (let [id, answer] of Object.entries(entries)) { - if (!known.questions.has(id)) invalid(); - text(answer, limits.MAX_CUSTOM); + let question = known.questions.get(id); + if (!question) invalid(); + if (question!.verbatim) { + if (typeof answer !== "string") invalid(); + } else text(answer, limits.MAX_CUSTOM); } return value as { [question: string]: string }; } diff --git a/apps/server/src/questions/service-append-option.ts b/apps/server/src/questions/service-append-option.ts index da5871fde..ba44917f0 100644 --- a/apps/server/src/questions/service-append-option.ts +++ b/apps/server/src/questions/service-append-option.ts @@ -139,6 +139,17 @@ export async function appendOption( }; let mutation = room.appendQuestionOption(stagedDocument, msg.id, msg.question, result.option); if (!mutation) throw new Error("question projection is missing"); + // Every open question needs room to expire, so growth stops where that room ends. + if ( + !room.fitsOrShrinks( + room.project(plan.document), + room.project(stagedDocument), + plan.questions.open.size, + ) + ) { + outcome = refuse("invalid", "The document has no room for another option"); + return; + } await Service.publishStaged(plan, server, roomId, candidate, mutation); outcome = { kind: "question:option", diff --git a/apps/server/src/questions/service-ask.ts b/apps/server/src/questions/service-ask.ts index ccf396a70..5d7f06b70 100644 --- a/apps/server/src/questions/service-ask.ts +++ b/apps/server/src/questions/service-ask.ts @@ -1,4 +1,4 @@ -import { ulid } from "@chopin/dialect"; +import { limits, ulid } from "@chopin/dialect"; import * as Question from "@chopin/question"; import * as Y from "yjs"; @@ -22,8 +22,15 @@ import type { Ended } from "./store"; import type { Record } from "./records"; import { announce } from "./card-notifications"; +import { expire, withdraw } from "./service-cancel"; import { type AskPlacement, validatePlacement } from "./service-definition"; +/** + * Ask each decision independently; register its record before publishing its node. + * + * An abort withdraws the cards still open. After `expiresInMs` without an answer + * they expire instead, and stay in the document marked as such. + */ export async function ask( plan: Plan, server: Server, @@ -31,7 +38,10 @@ export async function ask( definition: Definition, placement?: AskPlacement, created?: () => void, + signal?: AbortSignal, + expiresInMs?: number, ): Promise { + if (signal?.aborted) return []; if (Service.implementationActive(plan)) throw new Error("implementation is active"); if (definition.questions.length === 0) { Question.reject("A questionnaire needs at least one question"); @@ -58,7 +68,8 @@ export async function ask( let id = ulid(); // Classifier capacity must not prevent an ordinary Planner questionnaire. let available = MAX_EVENTS - conversationPlan.events.length - plan.pendingCardActions.length; - let threadId = conversationPlan.threads.length < MAX_THREADS + // Host dialogs keep their raw text, which conversation events cannot carry. + let threadId = !question.verbatim && conversationPlan.threads.length < MAX_THREADS && question.options.length + 2 <= available ? ulid() : undefined; @@ -137,6 +148,19 @@ export async function ask( records.set(id, record); return { id, single, value, waiting, at: placement?.blocks[index]?.[0] }; }); + // Verbatim host input has no per-field limits, and every card needs room to expire. Refuse before + // anything is registered or published. + if ( + !room.fitsQuestionnaires( + plan.document, + asked.map(item => item.value), + plan.questions.open.size, + ) + ) { + Question.reject( + `This input would take the document past its ${limits.MAX_SOURCE_BYTES / 1024} KiB limit`, + ); + } let document = await room.restore( plan.document.epoch, Y.encodeStateAsUpdate(plan.document.doc), @@ -175,5 +199,30 @@ export async function ask( created?.(); }); - return Promise.all(asked.map(item => item.waiting)); + let failed = Promise.withResolvers(); + let closing: Promise | undefined; + let close = (status: "cancelled" | "expired") => { + closing ??= Promise.all( + asked.map(item => + status === "expired" + ? expire(plan, server, roomId, item.id) + : withdraw(plan, server, roomId, item.id, "chopin") + ), + ); + void closing.catch(failed.reject); + }; + let abort = () => close("cancelled"); + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) abort(); + let timer = expiresInMs === undefined + ? undefined + : setTimeout(() => close("expired"), expiresInMs); + try { + let ended = await Promise.race([Promise.all(asked.map(item => item.waiting)), failed.promise]); + await closing; + return ended; + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + } } diff --git a/apps/server/src/questions/service-cancel.ts b/apps/server/src/questions/service-cancel.ts index e02d2678c..4e393ab77 100644 --- a/apps/server/src/questions/service-cancel.ts +++ b/apps/server/src/questions/service-cancel.ts @@ -20,17 +20,99 @@ export async function cancel( msg: Request, ): Promise { if (Service.implementationActive(plan)) return fail(ws, msg.rid, "implementation is active"); - let claimed = Store.claimCancel(plan.questions, msg.id, ws.data.handle); - if (!claimed.ok) { - return reply(ws, msg.rid, { kind: "question:cancel", ts: 0, id: msg.id, ...claimed }); + let result: Awaited>; + let acknowledged = false; + try { + result = await withdraw(plan, server, roomId, msg.id, ws.data.handle, () => { + acknowledged = true; + reply(ws, msg.rid, { + kind: "question:cancel", + ts: 0, + id: msg.id, + ok: true, + resolver: ws.data.handle, + }); + }); + } catch (failure) { + return fail( + ws, + msg.rid, + failure instanceof ConversationCapacityError + || failure instanceof Error && failure.message === "implementation is active" + ? failure.message + : "could not cancel the questionnaire", + ); } + if (!acknowledged) reply(ws, msg.rid, { kind: "question:cancel", ts: 0, id: msg.id, ...result }); +} + +type Closed = { status: "cancelled" | "expired"; resolver: string; at?: number }; + +/** + * Withdraw input on behalf of a member or the Planner, after its durable commit. + * `acknowledge` runs first once the withdrawal is committed, before it is announced. + */ +export function withdraw( + plan: Plan, + server: Server, + roomId: string, + id: string, + resolver: string, + acknowledge?: () => void, +): Promise { + return closeUnanswered( + plan, + server, + roomId, + id, + { status: "cancelled", resolver }, + document => room.removeQuestionnaire(document, id), + acknowledge, + ); +} + +/** + * Expire host input nobody answered in time, after its durable commit. + * + * Unlike a withdrawal the card stays in the document, marked expired, so the + * room can still see what was asked and that nobody answered it. + */ +export function expire( + plan: Plan, + server: Server, + roomId: string, + id: string, +): Promise { + let at = Math.floor(Date.now() / 1_000); + return closeUnanswered( + plan, + server, + roomId, + id, + { status: "expired", resolver: "chopin", at }, + document => room.projectExpiry(document, id, new Date(at * 1_000).toISOString()), + ); +} + +async function closeUnanswered( + plan: Plan, + server: Server, + roomId: string, + id: string, + closed: Closed, + project: (document: Parameters[0]) => room.Mutation | undefined, + acknowledge?: () => void, +): Promise { + let { status, resolver } = closed; + let claimed = Store.claimCancel(plan.questions, id, resolver, status); + if (!claimed.ok) return claimed; let mutationError: unknown; let failure: unknown; let finish: (() => Store.Ended) | undefined; try { await Service.exclusive(plan, async () => { if (Service.implementationActive(plan)) throw new Error("implementation is active"); - if (plan.questions.open.get(msg.id) !== claimed.claim.entry) { + if (plan.questions.open.get(id) !== claimed.claim.entry) { throw new Error("questionnaire is no longer open"); } let document = await room.restore( @@ -43,15 +125,15 @@ export async function cancel( try { let mutation: room.Mutation | undefined; try { - mutation = room.removeQuestionnaire(document, msg.id); + mutation = project(document); } catch (err) { mutationError = err; return; } - let record = plan.records.get(msg.id); + let record = plan.records.get(id); let records = new Map(plan.records); if (record) { - records.set(msg.id, { ...record, status: "cancelled", resolver: ws.data.handle }); + records.set(id, { ...record, ...closed }); } let questions: Store.Questions = { open: new Map(plan.questions.open), @@ -63,11 +145,11 @@ export async function cancel( document, questions, records, - pendingCardActions: record + pendingCardActions: record && status === "cancelled" ? pending(plan, record, { kind: "discarded", - id: msg.id, - actor: ws.data.handle, + id, + actor: resolver, }) : plan.pendingCardActions, }, mutation); @@ -80,55 +162,35 @@ export async function cancel( failure = err; } if (!finish) Store.rollback(plan.questions, claimed.claim); - if (failure) { - return fail( - ws, - msg.rid, - failure instanceof ConversationCapacityError - || failure instanceof Error && failure.message === "implementation is active" - ? failure.message - : "could not cancel the questionnaire", - ); - } + if (failure) throw failure; if (mutationError) { console.error("[questions] could not remove the node:", mutationError); - return reply(ws, msg.rid, { - kind: "question:cancel", - ts: 0, - id: msg.id, - ok: false, - reason: "resolving", - }); + return { ok: false, reason: "resolving" }; } - if (!finish) return fail(ws, msg.rid, "could not cancel the questionnaire"); + if (!finish) throw new Error("could not cancel the questionnaire"); finish(); - let record = plan.records.get(msg.id); + let record = plan.records.get(id); for ( let notify of [ - () => - reply(ws, msg.rid, { - kind: "question:cancel", - ts: 0, - id: msg.id, - ok: true, - resolver: ws.data.handle, - }), + ...(acknowledge ? [acknowledge] : []), () => broadcast(server, roomId, { kind: "question:resolved", ts: 0, - id: msg.id, - status: "cancelled", - resolver: ws.data.handle, - }), - () => announce(plan, server, roomId, msg.id), - () => - emit(plan, { - kind: "discarded", - id: msg.id, - ...(record?.threadId ? { threadId: record.threadId } : {}), - actor: ws.data.handle, + id, + status, + resolver, }), + () => announce(plan, server, roomId, id), + ...(status === "cancelled" + ? [() => + emit(plan, { + kind: "discarded", + id, + ...(record?.threadId ? { threadId: record.threadId } : {}), + actor: resolver, + })] + : []), ] ) { try { @@ -137,4 +199,5 @@ export async function cancel( console.error("[questions] could not announce a cancelled questionnaire:", err); } } + return { ok: true, resolver }; } diff --git a/apps/server/src/questions/service-definition.ts b/apps/server/src/questions/service-definition.ts index 5fc1ef3f6..439dd37dc 100644 --- a/apps/server/src/questions/service-definition.ts +++ b/apps/server/src/questions/service-definition.ts @@ -14,8 +14,8 @@ export type AskPlacement = { blocks: Array>; }; -export function identify(raw: unknown): Definition { - let definition = Question.normalize(raw); +export function identify(raw: unknown, options?: { verbatim?: boolean }): Definition { + let definition = Question.normalize(raw, options); return { questions: definition.questions.map(question => ({ ...question, diff --git a/apps/server/src/questions/service-submit.ts b/apps/server/src/questions/service-submit.ts index 35029e2c9..ec3d82584 100644 --- a/apps/server/src/questions/service-submit.ts +++ b/apps/server/src/questions/service-submit.ts @@ -98,6 +98,20 @@ export async function submit( let at = Math.floor(Date.now() / 1_000); let settled = { by: ws.data.handle, at: new Date(at * 1_000).toISOString() }; let mutation = room.projectAnswer(stagedDocument, msg.id, answers, settled, chosen); + // A long answer can push the document past its size limit, after which the + // Planner can no longer edit it; check the staged copy so a refusal changes nothing. + try { + let source = room.project(stagedDocument); + room.validate(source); + if ( + !room.fitsOrShrinks(room.project(plan.document), source, plan.questions.open.size - 1) + ) { + throw new Error("The answer would leave no room for the open questions to expire"); + } + } catch (err) { + invalid = err instanceof Error ? err.message : "could not record the answer"; + return; + } let records = new Map(plan.records); let answered: Record = { ...record, diff --git a/apps/server/src/questions/service.ts b/apps/server/src/questions/service.ts index 3ab8e4e03..4359e24b3 100644 --- a/apps/server/src/questions/service.ts +++ b/apps/server/src/questions/service.ts @@ -32,7 +32,7 @@ export { export type { DecisionEntry, OptionOrigin, Record } from "./records"; export { appendOption as addOption } from "./service-append-option"; export { ask } from "./service-ask"; -export { cancel } from "./service-cancel"; +export { cancel, expire, withdraw } from "./service-cancel"; export { identify } from "./service-definition"; export type { AskPlacement } from "./service-definition"; export { discard } from "./service-discard"; diff --git a/apps/server/src/questions/store-types.ts b/apps/server/src/questions/store-types.ts index b821f11d8..9b137efc7 100644 --- a/apps/server/src/questions/store-types.ts +++ b/apps/server/src/questions/store-types.ts @@ -9,7 +9,7 @@ export type Collaborator = { export type Ended = | { status: "answered"; answers: Answer[]; resolver: string } - | { status: "cancelled"; resolver: string }; + | { status: "cancelled" | "expired"; resolver: string }; export type Open = { id: string; diff --git a/apps/server/src/questions/store.ts b/apps/server/src/questions/store.ts index 92dc00b28..b0f04240a 100644 --- a/apps/server/src/questions/store.ts +++ b/apps/server/src/questions/store.ts @@ -297,7 +297,7 @@ export function away(questions: Questions, client: string): string[] { export type Settled = { ok: false; reason: "resolved"; - status: "answered" | "cancelled"; + status: Ended["status"]; resolver: string; answers?: Answer[]; }; @@ -379,10 +379,12 @@ export function claimSubmit( }; } +/** Reserve an unanswered close: a cancellation, or an expiry nobody answered in time. */ export function claimCancel( questions: Questions, id: string, resolver: string, + status: "cancelled" | "expired" = "cancelled", ): { ok: true; claim: Claim; widget?: string } | CancelRefusal { let ended = questions.closed.get(id); if (ended) return resolved(ended); @@ -397,7 +399,7 @@ export function claimCancel( return { ok: true, ...(entry.widget ? { widget: entry.widget } : {}), - claim: { id, entry, result: { status: "cancelled", resolver } }, + claim: { id, entry, result: { status, resolver } }, }; } diff --git a/apps/server/src/rooms.test.ts b/apps/server/src/rooms.test.ts new file mode 100644 index 000000000..c69e802a2 --- /dev/null +++ b/apps/server/src/rooms.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test"; + +import * as Rooms from "./rooms"; + +import type { Socket } from "./wire"; + +test("a Planner hold opens a memberless room, cancels eviction, and releases without affecting membership", () => { + let id = crypto.randomUUID(); + let first = Rooms.hold(id); + let room = first.room; + try { + expect(Rooms.get(id)).toBe(room); + expect(room.members.size).toBe(0); + expect(room.holds).toBe(1); + room.eviction = setTimeout(() => { + throw new Error("held room evicted"); + }, 10); + let second = Rooms.hold(id); + expect(second.room).toBe(room); + expect(room.eviction).toBeUndefined(); + expect(room.holds).toBe(2); + let socket = { data: { room: id, client: "client", handle: "ana" } } as Socket; + expect(Rooms.join(socket)).toBe(room); + first.release(); + first.release(); + expect(room.holds).toBe(1); + Rooms.leave(socket); + expect(room.members.size).toBe(0); + expect(room.holds).toBe(1); + second.release(); + expect(room.holds).toBe(0); + } finally { + Rooms.forget(room); + } +}); diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts index 83405b7f7..85424b20a 100644 --- a/apps/server/src/rooms.ts +++ b/apps/server/src/rooms.ts @@ -31,6 +31,8 @@ export type Room = { closing?: Promise; /** Pending eviction, cancelled if somebody comes back. */ eviction?: ReturnType; + /** MCP work keeps a memberless room alive until its turn and queue finish. */ + holds?: number; }; const rooms = new Map(); @@ -47,6 +49,22 @@ export function get(id: string): Room | undefined { return rooms.get(id); } +export function hold(id: string): { room: Room; release: () => void } { + let room = open(id); + if (room.eviction) clearTimeout(room.eviction); + room.eviction = undefined; + room.holds = (room.holds ?? 0) + 1; + let released = false; + return { + room, + release() { + if (released) return; + released = true; + room.holds = room.holds! - 1; + }, + }; +} + export function join(ws: Socket): Room { let room = open(ws.data.room); // A reload is a departure followed by an arrival. Cancelling here is what diff --git a/apps/server/src/testing/config.ts b/apps/server/src/testing/config.ts new file mode 100644 index 000000000..011e99adf --- /dev/null +++ b/apps/server/src/testing/config.ts @@ -0,0 +1,54 @@ +import { load } from "../config"; + +export const REQUIRED = { + STORAGE_DRIVER: "postgres", + DATABASE_URL: "postgresql://chopin:secret@database.test/chopin", + APP_ORIGIN: "https://chopin.example", + GITHUB_APP_SLUG: "chopin-test", + GITHUB_APP_CLIENT_ID: "client-id", + GITHUB_APP_CLIENT_SECRET: "client-secret", + SESSION_ENCRYPTION_KEY: "11".repeat(32), +}; + +/** Local-mode overrides for a loopback instance. */ +export const LOCAL = { + AUTH_MODE: "local", + APP_ORIGIN: "http://localhost:8790", + PORT: "8790", +}; + +/** Load configuration from exactly these variables, restoring the environment afterwards. */ +export function configured(overrides: Record = {}) { + let env: Record = { + ...REQUIRED, + AGENT: undefined, + BACKGROUND_JOBS: undefined, + WEB_RESEARCH: undefined, + CONVERSATION_PLAN: undefined, + JEV_API_KEY: undefined, + JEV_MODEL: undefined, + JEV_TIMEOUT_MS: undefined, + TYPESAFE_API_KEY: undefined, + HARNESS: undefined, + HARNESS_AUTH: undefined, + AUTH_MODE: undefined, + SERVER_HOST: undefined, + PORT: undefined, + MODEL: undefined, + CHOPIN_LOCAL_CREDENTIALS_DIR: undefined, + GITHUB_ALLOWED_USERS: undefined, + GITHUB_ALLOWED_ORGANIZATIONS: undefined, + ...overrides, + }; + let previous = { ...process.env }; + for (let [key, value] of Object.entries(env)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return load(); + } finally { + for (let key of Object.keys(env)) delete process.env[key]; + Object.assign(process.env, previous); + } +} diff --git a/apps/server/src/testing/decisions.ts b/apps/server/src/testing/decisions.ts new file mode 100644 index 000000000..c8fe60820 --- /dev/null +++ b/apps/server/src/testing/decisions.ts @@ -0,0 +1,156 @@ +import * as Question from "@chopin/question"; + +import { createHumanInput } from "../harness/atomic/human-input"; +import * as Plan from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { MemoryStorage } from "../storage/memory/adapter"; + +import type { Server } from "bun"; +import type { HostInputOptions } from "@bastani/atomic"; +import type { DocumentRoom } from "../agent/tools"; +import type { Socket, SocketData } from "../wire"; + +/** A room whose Decisions answer Atomic HostInput requests, with a member to answer them. */ +export async function hostInputRoom(expiresInMs?: number) { + let storage = new MemoryStorage(); + let now = new Date(); + await storage.users.put({ id: "user", login: "reader", avatarUrl: "", now }); + let channel = await storage.channels.create({ + id: crypto.randomUUID(), + repositoryId: "R_test", + repositoryOwner: "org", + repositoryName: "repo", + title: "Questions", + createdBy: "user", + now, + }); + let lease = (await storage.leases.acquire("writer", "test", 60_000))!; + let frames: any[] = []; + let server = { + publish(_topic: string, frame: string) { + frames.push(JSON.parse(frame)); + }, + } as unknown as Server; + let plan = await Plan.open(channel.id, { + storage, + lease: () => lease, + fatal: error => { + throw error; + }, + }, server); + let room = { + id: channel.id, + plan, + server, + anchors() {}, + } as DocumentRoom; + let ws = { + data: { handle: "reader", room: channel.id, client: "client" }, + send(frame: string) { + frames.push(JSON.parse(frame)); + }, + publish(_topic: string, frame: string) { + frames.push(JSON.parse(frame)); + }, + } as unknown as Socket; + let controller = new AbortController(); + let options: HostInputOptions = { + requestId: "request", + sessionId: "session", + signal: controller.signal, + }; + let input = createHumanInput(room, expiresInMs); + async function cards(count: number) { + for (let deadline = Date.now() + 4_000; Date.now() < deadline; await Bun.sleep(5)) { + if (Store.outstanding(plan.questions).length === count) { + return Store.outstanding(plan.questions); + } + } + throw new Error("question did not arrive"); + } + function refused(rid: string) { + let frame = frames.findLast(frame => frame.rid === rid); + if (frame?.accepted === false || frame?.ok === false || frame?.kind === "session:error") { + throw new Error(frame.message ?? frame.reason); + } + } + /** A member adds an option to the card's first question, the way Decisions offers written answers. */ + async function addOption(id: string, label: string) { + let question = Store.get(plan.questions, id)!.definition.questions[0]!; + await Questions.addOption(plan, server, channel.id, ws, { + kind: "question:option", + rid: "option", + ts: 0, + id, + question: question.id, + key: crypto.randomUUID(), + label, + }); + refused("option"); + return Store.get(plan.questions, id)!.definition.questions[0]!.options.length - 1; + } + async function answer(id: string, value: number[] | string) { + let opened = Store.snapshot(plan.questions, id); + if (!opened.open) throw new Error("question closed"); + let definition = Store.get(plan.questions, id)!.definition; + let model = Question.restore(opened.model, definition); + let question = definition.questions[0]; + if (typeof value === "string") { + model.api.val([question.id, "mode"]).set("custom"); + if (value) model.api.str([question.id, "custom"]).ins(0, value); + } else if (question.multiple) { + model.api.val([question.id, "mode"]).set("choices"); + let held = + (model.view() as Record }>)[question.id] + ?.options ?? {}; + for (let index of value) { + let option = question.options[index]!.id; + // An option appended after the draft began has no register yet, as in the client. + if (Object.hasOwn(held, option)) model.api.val([question.id, "options", option]).set(true); + else {model.api.obj([question.id, "options"]).set({ + [option]: Question.crdt.schema.val(Question.crdt.schema.con(true)), + });} + } + } else { + model.api.val([question.id, "mode"]).set("choices"); + model.api.val([question.id, "choice"]).set(question.options[value[0]!]!.id); + } + let patch = model.api.flush(); + if (patch) { + await Questions.edit(plan, ws, { + kind: "question:edit", + rid: "edit", + ts: 0, + id, + patch: [...patch.toBinary()], + }); + refused("edit"); + } + let current = Store.snapshot(plan.questions, id); + if (!current.open) throw new Error("question closed"); + await Questions.submit(plan, server, channel.id, ws, { + kind: "question:submit", + rid: "submit", + ts: 0, + id, + revision: current.revision, + }); + refused("submit"); + } + return { + input, + plan, + storage, + frames, + controller, + options, + cards, + answer, + addOption, + ws, + server, + room, + close: () => Plan.close(plan), + }; +} diff --git a/apps/server/src/testing/peer.ts b/apps/server/src/testing/peer.ts new file mode 100644 index 000000000..03402cbc4 --- /dev/null +++ b/apps/server/src/testing/peer.ts @@ -0,0 +1,59 @@ +import { createHeadlessEditor } from "@lexical/headless"; +import { createYjsBinding, syncLexicalUpdateToYjs, syncYjsChangesToLexical } from "@lexical/yjs"; +import * as Y from "yjs"; + +import { registry } from "@chopin/dialect"; + +import type { Binding, Provider } from "@lexical/yjs"; +import type { LexicalEditor } from "lexical"; + +export const REGISTRY = registry(); + +const PROVIDER = { + awareness: { + getLocalState: () => null, + getStates: () => new Map(), + off() {}, + on() {}, + setLocalState() {}, + setLocalStateField() {}, + }, + connect() {}, + disconnect() {}, + off() {}, + on() {}, +} as unknown as Provider; + +/** A client: its own editor and Y.Doc, exactly as a browser would have. */ +export function peer(): { editor: LexicalEditor; doc: Y.Doc; binding: Binding } { + let editor = createHeadlessEditor({ + nodes: REGISTRY.nodes, + onError(err) { + throw err; + }, + }); + let doc = new Y.Doc(); + let binding = createYjsBinding({ editor, id: "plan", doc, docMap: new Map([["plan", doc]]) }); + + editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, editorState, normalizedNodes, prevEditorState, tags }) => { + if (tags.has("skip-collab")) return; + syncLexicalUpdateToYjs( + binding, + PROVIDER, + prevEditorState, + editorState, + dirtyElements, + dirtyLeaves, + normalizedNodes, + tags, + ); + }, + ); + + binding.root.getSharedType().observeDeep((events, transaction) => { + if (transaction.origin !== binding) syncYjsChangesToLexical(binding, PROVIDER, events, false); + }); + + return { editor, doc, binding }; +} diff --git a/docs/architecture.md b/docs/architecture.md index 1759f412f..8d1f2183f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,8 +119,11 @@ request becomes that channel's Planner owner for the lifetime of the process session. Permission callbacks recheck admission, session identity, credential revision, ownership generation, repository role, and App installation before execution. -The Planner has bounded, repository-fixed read tools and no ambient checkout, -shell, or host filesystem; no harness built-in is active. Model-backed +Under `copilot-sdk` and `pi` the Planner has bounded, repository-fixed read tools +and no ambient checkout, shell, or host filesystem; no harness built-in is active. +Under `atomic` every Planner session is a +[full Atomic session](hosted-agent.md#full-atomic-planner) with shell and +filesystem access as the server process's user. Model-backed `active-planner` workers use the same owner credential but fresh isolated harness sessions; see [Background jobs](background-jobs.md). diff --git a/docs/authentication.md b/docs/authentication.md index ec24bed55..21b991809 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -202,6 +202,11 @@ Each browser has its own binding; configured instance admission lists still apply to every authorized account. Loopback binding does not make this a mode for internet-facing or exposed multi-user deployments. +The authentication mode does not change the Planner's tools. Under +`HARNESS=atomic`, every Planner session, local or hosted, has shell and +filesystem access as the server process's user; loopback binding is not a +filesystem sandbox. See [Full Atomic Planner](hosted-agent.md#full-atomic-planner). + ## Instance admission `GITHUB_ALLOWED_USERS` and `GITHUB_ALLOWED_ORGANIZATIONS` are optional, @@ -254,6 +259,12 @@ lookup. An MCP-created document for a repository outside the App installation is not available through browser routes, WebSockets, or the hosted agent until the installation includes that repository. See [Local agent MCP](local-agent-mcp.md). +`invoke_planner` is the one MCP tool that starts a hosted agent turn. It never +lends the caller's bearer to the Planner: the turn runs under the channel's +existing Planner owner, and a channel without one is claimed only for the +caller's own live browser login, which must already have passed the +installation-gated owner checks. + The authorization-code flow uses state, S256 PKCE, the exact configured callback, and the App client secret. It does not request OAuth scopes; permissions come from the App registration and each installation. The setup diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index fae44c3ce..e69e329c3 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -3,8 +3,9 @@ Chopin's Copilot-backed document agent is currently named Planner. It can inspect one selected GitHub repository, co-author the shared document, ask the participants structured questions, and anchor decisions to prose. For documents -used as plans, it can also draft an implementation graph. It does not implement -code or change GitHub. +used as plans, it can also draft an implementation graph. Under `copilot-sdk` and +`pi` it does not implement code or change GitHub; under `atomic` it runs as a full +Atomic session, described [below](#full-atomic-planner). The product role is document co-authoring. The current prompt and tool vocabulary remain optimized for planning and may structure another document type as a plan; @@ -15,9 +16,14 @@ from a code-owned map, defaulting to `copilot-sdk`, a host-process adapter over `@github/copilot-sdk`. `pi`, over `@ai-sdk/harness-pi`, and `atomic`, which embeds Atomic's headless SDK (`@bastani/atomic`) in the server process, are further reviewed adapters. Both require an explicit `HARNESS_AUTH`. The -harness owns the agent loop and model; Chopin owns every tool the Planner can -call. See [Self-hosting](self-hosting.md) for `HARNESS`/`HARNESS_AUTH` -selection and adapter trust. +harness owns the agent loop and model. Under `copilot-sdk` and `pi`, Chopin owns +every tool the Planner can call. + +**Choosing `HARNESS=atomic` gives the Planner shell and filesystem access as the +server process's user, on hosted instances as well as local ones.** No other flag +turns this on or off. Operators who do not want it should use `copilot-sdk` or +`pi`. See [Self-hosting](self-hosting.md#choose-and-trust-a-harness) for +`HARNESS`/`HARNESS_AUTH` selection and adapter trust. ## Ownership @@ -36,6 +42,12 @@ user without Copilot entitlement sees the provider failure on the first model-backed action and remains owner until one of those release conditions occurs. +An MCP [`invoke_planner`](local-agent-mcp.md#hand-an-instruction-to-the-planner) +call follows the same rules. Its instruction is posted as the caller's own +message; the turn runs under the channel's current owner, whoever that is. A +channel without one is claimed for the caller's live browser login, hosted or +local, and without such a login the call is refused and nothing is posted. + PostgreSQL stores the owner session ID only so durable ownership can refer to an active process session. The cookie verifier and GitHub credential remain in memory. Startup clears every browser-session registry row and owner reference, @@ -44,16 +56,16 @@ ownership generation. ## Runtime isolation -Every harness receives only Chopin's host-executed tools: document, question, -relationship, implementation-graph, repository, and GitHub tools, each bound to -the channel's repository through `toolsContext`. No harness built-in is active -for the Planner. The default `copilot-sdk` adapter additionally runs the shared -Copilot runtime in SDK `mode: "empty"`: each disposable session receives its -owner's token when created and has no client-level service token or -logged-in-user fallback. That detail is specific to the Copilot SDK adapter, -not a property every harness in `HARNESS` shares. +Under `copilot-sdk` and `pi` the Planner receives only Chopin's host-executed +tools: document, question, relationship, implementation-graph, repository, and +GitHub tools, each bound to the channel's repository through `toolsContext`. No +harness built-in is active for the Planner. The default `copilot-sdk` adapter +additionally runs the shared Copilot runtime in SDK `mode: "empty"`: each +disposable session receives its owner's token when created and has no +client-level service token or logged-in-user fallback. That detail is specific to +the Copilot SDK adapter, not a property every harness in `HARNESS` shares. -The Planner has no: +That isolated Planner has no: - checkout, shell, or host filesystem; - skills, plugins, or configuration discovery; @@ -65,20 +77,7 @@ Under `HARNESS=pi`, Chopin patches `@ai-sdk/harness-pi` 1.0.128 so Pi does not load `AGENTS.md` or `CLAUDE.md` context files from the host filesystem. See [Self-hosting](self-hosting.md#choose-and-trust-a-harness). -Under `HARNESS=atomic`, each harness session owns one in-process Atomic -`AgentSession`. It is built with every shipped Atomic package disabled -(workflows, subagents, MCP, web access, and Intercom). It has no Atomic coding -tools, and its resource loader discovers no extensions, skills, prompt -templates, themes, or context files. Its working and configuration directory is -an empty private temporary directory, and its session, settings, and -credentials stay in memory. A per-turn hook replaces the whole system prompt -with the turn's instructions, so the model never receives Atomic's -coding-agent preamble. The adapter fails the turn before any model request -when the live session reports an extension, tool, context file, skill, prompt -template, or system prompt beyond that set. It checks the tools offered to the -model again before every model request. - -Available capabilities are: +Chopin's tools available to the Planner under every harness are: - Chopin document, question, relationship, and implementation-graph tools (with current plan-oriented tool names); @@ -97,15 +96,120 @@ REST tools construct owner and repository coordinates on the server, bound response sizes and line ranges, reject path escape, and post-filter code search by GitHub repository node ID. -The Planner does not see a user's local checkout, current branch, working tree, +The isolated Planner does not see a user's local checkout, current branch, working tree, or uncommitted changes. A coding agent must compare the repository context returned by Chopin with its checkout before claiming work. The server validates the shape of creation provenance but does not resolve its branch and commit against GitHub or independently inspect the coding agent's checkout. +Under `HARNESS=atomic` the Planner is a [full Atomic session](#full-atomic-planner), +but the summary and research workers each still own one isolated in-process +Atomic `AgentSession`. It is built with every shipped Atomic package disabled +(workflows, subagents, MCP, web access, and Intercom). It has no Atomic coding +tools, and its resource loader discovers no extensions, skills, prompt +templates, themes, or context files. Its working and configuration directory is +an empty private temporary directory, and its session, settings, and +credentials stay in memory. A per-turn hook replaces the whole system prompt +with the turn's instructions, so the model never receives Atomic's +coding-agent preamble. The adapter fails the turn before any model request +when the live session reports an extension, tool, context file, skill, prompt +template, or system prompt beyond that set. It checks the tools offered to the +model again before every model request. + +## Full Atomic Planner + +Under `HARNESS=atomic` every Planner session is a full Atomic session, in local +and hosted deployments alike, with no separate flag. This gives the Planner +shell and filesystem access **as the server process's user**, not a sandbox +confined to a repository. Operators who do not want that should use +`copilot-sdk` or `pi`. Both `HARNESS_AUTH=auto` and `ai-gateway` work, under +their usual [bind rules](self-hosting.md#choose-and-trust-a-harness). + +Atomic's workflows, subagents, MCP, web access, Intercom, and default coding +tools run alongside Chopin's document and repository tools. The normal Atomic +agent directory (including `ATOMIC_CODING_AGENT_DIR` or the legacy +`PI_CODING_AGENT_DIR` override) supplies extensions, skills, prompt templates, +context files, and a read-only copy of its settings. A verified checkout's +`.atomic/settings.json` is read the same way, as trusted project settings, so +packages installed for that project load too; the empty per-channel directory +has none. Chopin's compaction, summary, and cache overrides still apply on top. +Chopin appends its Planner +instructions to Atomic's assembled prompt. Session, settings, and model +credentials remain in memory; Atomic's own enabled tools, extensions, MCP +servers, and workflow storage can perform writes as the server process's user. +Background summary and research workers still use the isolated sessions described +above. + +The working directory comes only from the `checkout` argument of +[`invoke_planner`](local-agent-mcp.md#hand-an-instruction-to-the-planner). +Chopin checks the path with Git and compares `origin`'s owner/repository to the +document's repository, case-insensitively. HTTPS, `ssh://`, and scp-style +remotes are accepted. Remote host spellings are ignored because SSH aliases are +common; this verifies repository coordinates, not the authenticity of a remote +host. An unverified path refuses the invocation before anything is posted. A +verified one is remembered in memory for that document until the process exits, +and every later Planner session for the document, browser-started or MCP-started, +re-verifies it before using it as its working directory. + +Without a remembered checkout that still verifies, the session runs with the +same tools in an empty working directory Chopin creates for that document alone, +under the operating system's temporary directory with mode `0700`. It is never +shared between documents, keeps the Planner's own files for later sessions of the +same document, and is removed when the server shuts down cleanly. The Planner's +instructions state which case applies: a verified checkout, or an empty +directory with no repository files, in which case it reads the repository through +Chopin's repository tools. + +Chopin implements Atomic's `HostInput`, bound through +`extensionBindings.humanInput`; it does not intercept tools. `ask_user_question`, +extension dialogs, and workflow-stage input become ordinary shared Decisions: + +- Questionnaires retain question and option order, multi-selection, and the + exact text Atomic supplied; answers return their Atomic question indices and + answer kinds, including a selected option's preview. +- Every card offers a written answer. It returns as Atomic's `custom` kind where + Atomic's own dialog accepts typed text (single-select without previews), and + otherwise as typed `chat`, which Atomic delivers to the model as the member's + inline message (blank `chat` text is Atomic's plain request to talk first). + Chopin never reports written text as a chosen option, and Atomic workflows + treat `chat` as a request to keep discussing, not approval. +- Confirm uses Yes/No; only choosing Yes approves. Select returns only a supplied + choice. Input and editor use free-text cards; hints and initial editor text + appear verbatim in the prompt. Explicitly submitted empty text is an answer, + not cancellation. +- Workflow cards show `Workflow run: ; stage: ` below their question. + The label is added to the card only; Atomic receives its question text as + asked. A request is appended as one adjacent batch at the end of the document. +- An abort withdraws still-open cards, removes their document nodes, and records + cancellation by `@chopin`. Member cancellations retain any answers already + given in that batch. Late submissions cannot approve withdrawn input. +- A request nobody answers within 30 minutes expires. Its still-open cards stay + in the document and in Decisions, among the resolved cards, marked + `status="expired"`; each reads "Nobody answered within 30 minutes. The + Planner will use its best judgement for this decision." Nobody can answer an + expired card. Atomic receives no answer: a questionnaire comes back cancelled + with no answers, confirm returns false, and select, input, and editor return + nothing. The limit is fixed in code, not configured. + +Host input is not held to the Planner `ask` tool's per-field limits on question +and option counts or header, question, label, description, and answer lengths. +Only Chopin's aggregate bounds apply. A request whose cards would take the +document past its 256 KiB source limit fails with that message before any card +appears. A written answer is limited by one shared-draft edit (64 KiB) and the +whole draft (256 KiB). Text is never silently truncated. Atomic keeps durable +workflow approvals pending on withdrawal; Chopin does not automatically resume +workflows or replay interrupted turns after a restart. A new Atomic session must +explicitly resume a saved run. + +A coding agent can hand an instruction to the Planner with +[`invoke_planner`](local-agent-mcp.md#hand-an-instruction-to-the-planner) under +any harness. The instruction is posted as the MCP caller's own message and runs +under the document's Planner ownership rules (see [Ownership](#ownership)). +Harnesses other than `atomic` ignore its `checkout`. + ## Permission checks -Before each custom or MCP tool executes, callbacks recheck: +Before each Chopin host tool or its repository-bound GitHub MCP tool executes, callbacks recheck: - current instance admission; - the owner process session and its user; @@ -117,6 +221,9 @@ Before each custom or MCP tool executes, callbacks recheck: Permission is decided before execution. A refusal therefore produces no normal tool start or completion event; the Chat service renders permission denials explicitly so the boundary remains visible. +These checks do not mediate the atomic Planner's Atomic coding tools, operator +extensions, or operator-configured MCP servers; those act with the server +process's own authority. A harness session is bound to one credential revision. Before an eight-hour GitHub App token refresh, Chopin aborts and discards every Planner session diff --git a/docs/local-agent-mcp.md b/docs/local-agent-mcp.md index ee3d63e3d..972961709 100644 --- a/docs/local-agent-mcp.md +++ b/docs/local-agent-mcp.md @@ -24,9 +24,10 @@ authorization. The MCP bearer boundary is separate from Chopin's browser GitHub App session. Chopin authenticates the supplied token, applies the instance admission policy, and asks GitHub directly for that token's repository permissions. The GitHub App -for Chopin does not need to be installed on a repository for MCP access. Browser -routes, WebSockets, and the hosted agent still require an active App -installation that includes the repository. +for Chopin does not need to be installed for ordinary MCP document operations. +Browser routes, WebSockets, and the Planner still require an active App +installation that includes the repository. The `invoke_planner` handoff never +lends the MCP bearer to the Planner; it runs under the document's Planner owner. ```bash export CHOPIN_URL="https://your-chopin-instance.example" @@ -52,7 +53,9 @@ The current MCP contract can: - rename, archive, and restore documents without deleting their durable state; - read an approved implementation graph and its document source; and - claim a graph and report task, pull-request, blocker, revision, and - verification lifecycle transitions. + verification lifecycle transitions; and +- hand an instruction to a document's Planner with `invoke_planner`, described + below. Chopin validates the shape of `baseBranch` and `baseCommit` during creation but does not resolve them against GitHub. The creating agent is responsible for @@ -69,6 +72,75 @@ graph, but the product has no user-facing way to approve the Planner's draft. See [Experimental implementation lifecycle](implementation-lifecycle.md). +## Hand an instruction to the Planner + +`invoke_planner` is offered on every harness and in both hosted and local +authentication modes. It posts an instruction to an existing document's Planner +and returns without waiting for the turn. + +The instruction is posted as a Chat message under the MCP caller's own handle, +and the turn runs under the document's Planner ownership rules, exactly as a +browser `@chopin` message would: + +- If the document already has a Planner owner, the turn runs under that owner, + even when the owner is someone other than the caller. +- Otherwise the caller's live Chopin browser login, hosted or local, claims + ownership through the ordinary claim path. Sign in to Chopin in a browser as + the same GitHub account as the MCP bearer first. +- Without either, the call is refused with `planner-owner-unavailable` and + nothing is posted. + +The MCP bearer never becomes the owner or supplies its credentials. The bearer +needs repository write access; an owner must also pass the GitHub App +installation check. + +Call the tool with an existing document's UUID or canonical URL, an instruction, +and optionally an absolute checkout path: + +```json +{ + "id": "/documents/octo-org/score/release-readiness", + "instruction": "Run the planning workflow and ask blocking questions in Decisions.", + "checkout": "/absolute/path/to/score" +} +``` + +The instruction must contain non-whitespace text and fit within 64 KiB in UTF-8; +its text is preserved verbatim. + +`checkout` applies only when the server runs `HARNESS=atomic`, where the Planner +is a [full Atomic session](hosted-agent.md#full-atomic-planner) with **shell and +filesystem access as the server process's user**. The path is on the Chopin +server's machine. Chopin verifies that its `origin` matches the document +repository and refuses the request before posting if it does not. A verified +path is remembered for the document until the server restarts, and every later +Planner session for it, from the browser or through MCP, works there after +checking the path again. Without one, the Planner works in an empty directory +Chopin keeps for that document. Other harnesses ignore `checkout` entirely: it is +neither verified, used, nor remembered. + +The result contains `id`, `title`, and the canonical `url` (plus any generated +`description`). It returns after the instruction is durably posted, not after the +Planner finishes. The message joins the existing Planner queue if busy. Progress +and results appear in the document and Chat; questions appear in Decisions. Under +`atomic`, Atomic input nobody answers within 30 minutes expires: the card stays in +Decisions marked expired and the Planner proceeds on its own judgement. The +browser need not already have the document open: Chopin keeps its room alive +while the invoked turn and queue run. An interrupted turn is not replayed +automatically on restart. + +Refusals return an object with `code`: + +- `planner-owner-unavailable`: the document has no usable Planner owner and the + caller has no live browser login to claim it; sign in to Chopin in a browser + as the MCP account, or ask the owner to sign in again. +- `checkout-unverified`: under `atomic`, correct the supplied path or its + repository origin. +- `planner-unavailable`: the Planner is disabled or the room is closing. +- `planner-queue-full`: wait for queued work to drain before invoking again. +- `document-unavailable`, `document-archived`, and `repository-forbidden` retain + their ordinary document/access meanings. + ## Document URLs and IDs The `url` returned by `create_document` is the readable canonical route: @@ -78,8 +150,8 @@ The `url` returned by `create_document` is the readable canonical route: ``` `read_document`, `read_implementation`, `archive_document`, `restore_document`, -and `update_document` accept either a document UUID or that canonical URL in their -`id` input. The URL may be passed back exactly as returned; an absolute URL must +`update_document`, and `invoke_planner` accept either a document UUID +or that canonical URL in their `id` input. The URL may be passed back exactly as returned; an absolute URL must use the configured Chopin origin. Both reads return the stable UUID as the document `id`. @@ -222,9 +294,10 @@ token's `read:org` or Members access, SSO authorization, and GitHub availability lacks the operation's repository permission; it does not mean the GitHub App for Chopin must be installed. Pull access is enough for `list_documents`, `read_document`, and `read_implementation`. Pull plus push or admin access is -required for create, update, rename, archive, restore, start, and report lifecycle -operations. Use an account with the required access or ask a repository owner to -grant it. +required for create, update, rename, archive, restore, invoke, start, and report +lifecycle operations. `invoke_planner` additionally needs a Planner owner or the +caller's live browser login, as described above. Use an account with the required access or ask a repository +owner to grant it. Use the optional [creating-chopin-plans skill](../skills/creating-chopin-plans/SKILL.md) to turn a diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 1285d07ad..8bdae75f0 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -117,10 +117,18 @@ covers this against the real Pi agent loop; run it before bumping `HARNESS=atomic` runs Atomic 0.9.25 in the Chopin server process through its headless SDK (`createAgentSession()`). It does not spawn the `atomic` CLI, use -RPC mode, or substitute Atomic for Pi's runtime. Each harness session owns one -in-memory Atomic `AgentSession` whose only tools are Chopin's host tools; see -[Hosted agent](hosted-agent.md#runtime-isolation) for the isolation it -enforces. +RPC mode, or substitute Atomic for Pi's runtime. + +**Choosing `HARNESS=atomic` gives the Planner shell and filesystem access as the +server process's user, on hosted instances as well as local ones.** Every Planner +session is a full Atomic session: Atomic's workflows, subagents, MCP, web access, +Intercom, and default coding tools run beside Chopin's document tools, with the +operator's Atomic extensions, skills, prompt templates, and context files. There is +no separate flag; choosing the harness is the choice. Operators who do not want that +should use `copilot-sdk` or `pi`, whose Planner keeps the isolated, Chopin-tools-only +boundary. Only the summary and research workers still run isolated Atomic sessions. +See [The atomic Planner](#the-atomic-planner) below and +[Hosted agent](hosted-agent.md#full-atomic-planner) for the details. `HARNESS=atomic` requires an explicit `HARNESS_AUTH`, either `auto` or `ai-gateway`. Unset, `direct`, and every other value are refused at startup. @@ -165,14 +173,16 @@ Atomic caveats: enables its shipped packages, including a mandatory Intercom, and its coding tools. It discovers host resources, gives turns without instructions a coding-agent system prompt, and saves tool results over 50,000 characters to a - temporary file. The adapter overrides each default and fails closed on the - ones it can observe, but a new Atomic release can add a default the suite does - not check. + temporary file. For the isolated worker sessions the adapter overrides each + default and fails closed on the ones it can observe, but a new Atomic release + can add a default the suite does not check. Planner sessions deliberately keep + those capabilities. - Sessions live only in memory. `doStop` returns a state the adapter refuses to resume, so a session never survives a restart. Chopin does not resume harness sessions. -- Compaction, suspending a turn, and skills are unsupported. Atomic's automatic - retries remain on. +- Harness-level compaction, suspending a turn, and supplied harness skills are + unsupported. A Planner session's own resource discovery does load Atomic + skills. Atomic's automatic retries remain on. - Under `auto`, refreshing an OAuth token in memory can rotate the refresh token stored by the host CLI, which may then ask the operator to sign in again. `auto` also runs `!command` API-key entries in `auth.json` to resolve them. @@ -191,6 +201,44 @@ usage alerts. A Copilot credit limit applies to one harness session, including a turns of its job stage, not as a platform-wide budget; see [Background jobs](background-jobs.md#executor-owned-limits). +### The atomic Planner + +A Planner session's working directory comes from one place: a `checkout` path +supplied through the MCP +[`invoke_planner`](local-agent-mcp.md#hand-an-instruction-to-the-planner) tool. +Chopin checks it with Git and compares `origin`'s owner/repository to the +document's repository, ignoring case and the remote host spelling (so SSH host +aliases work). A mismatching or missing path refuses the invocation before +anything is posted. A verified path is remembered for that document until the +server process exits; nothing is written to storage. Every later Planner session +for the document, whether started from the browser or through MCP, re-verifies +the remembered path before using it. The check establishes repository +coordinates, not remote-host authenticity, and it does not confine the shell. + +Without a remembered checkout that still verifies, the session runs in an empty +working directory that Chopin creates for that document alone under the operating +system's temporary directory, with mode `0700`. It is never shared with another +document, keeps what the Planner writes there for later sessions of the same +document, and is removed when the server shuts down cleanly. The session has the +same tools either way; the Planner is told whether it is in a checkout or in an +empty directory without repository files, and its repository tools remain +available. + +Atomic input (`ask_user_question`, extension dialogs, and workflow-stage +questions) appears as shared Decisions instead of terminal dialogs, including +workflow run and stage labels. Unanchored batches append at the document's end. +Dialog text and written answers stay verbatim, bounded only by the document's +256 KiB source limit and the shared-draft limits rather than the Planner's own +question limits. A request nobody answers within 30 minutes expires: its cards +stay in Decisions marked as expired, and Atomic receives no answer. Cancellation +withdraws pending cards; neither ever approves an action. See +[Full Atomic Planner](hosted-agent.md#full-atomic-planner) for the mapping, +resource loading, persistence, and workflow restart boundaries. + +`invoke_planner` itself is offered on every harness and in both authentication +modes. Other harnesses ignore its `checkout`: they neither verify nor use nor +remember it. + ## Prerequisites - Docker for the application image, or Bun 1.4.2 for a source deployment. @@ -258,7 +306,7 @@ the image. | `SERVER_HOST` | `127.0.0.1` | Source-process bind address. The image sets `0.0.0.0`, which local mode refuses. A `HARNESS_AUTH` mode that falls back to a host-logged-in subscription is refused unless this stays loopback-only. | | `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. | | `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi` and `HARNESS=atomic`; under `atomic` it must be `provider/model` from Atomic's catalog. | -| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, or `atomic`). An unknown name refuses at startup. | +| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, or `atomic`). An unknown name refuses at startup. `atomic` gives every Planner session shell and filesystem access as the server process's user, hosted instances included; see [Choose and trust a harness](#choose-and-trust-a-harness). | | `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. | | `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | | `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | diff --git a/packages/dialect/src/convert.test.ts b/packages/dialect/src/convert.test.ts index 72aa6d48b..d1c90f999 100644 --- a/packages/dialect/src/convert.test.ts +++ b/packages/dialect/src/convert.test.ts @@ -5,10 +5,13 @@ import { $createParagraphNode, $getRoot, $isElementNode } from "lexical"; import { $createPlanNodes, exportPlan, importPlan } from "./convert"; import { $isCodeBlockNode } from "./nodes/content"; +import { cardStatus, toElement } from "./nodes/questionnaire"; import { parse } from "./parse"; import { registry } from "./registry"; import { serialize } from "./serialize"; +import type { QuestionnaireNode } from "./nodes/questionnaire"; + import type { ElementNode, LexicalEditor, RootNode } from "lexical"; const REGISTRY = registry(); @@ -106,6 +109,34 @@ describe("conversion", () => { expect(instance.getEditorState().read(() => $getRoot().getFirstChild()!.getKey())).toBe(key); }); + it("round-trips an expired questionnaire's marker through its Lexical node", () => { + let value = { + id: "01K0N4TR8K7JGM4R1J7PW4R8YJ", + status: "expired" as const, + at: "2026-09-28T10:30:00.000Z", + questions: [{ + id: "01K0N4V4E7Y6P4MJ5WD8XZF3B2", + header: "Confirm", + prompt: "Ship it?", + multiple: false, + options: [{ id: "01K0N4W3B7P27CBAEC7A8C8WEA", label: "Yes" }], + }], + }; + let source = serialize({ type: "root", children: [toElement(value)] }); + expect(source).toStartWith( + ``, + ); + expect(through(source)).toBe(source); + let instance = editor(); + importPlan(instance, source, { registry: REGISTRY }); + let imported = instance.getEditorState().read(() => + ($getRoot().getFirstChild() as QuestionnaireNode).getQuestionnaire() + ); + expect(imported).toEqual(value); + expect(cardStatus(imported)).toBe("expired"); + expect(cardStatus({ ...imported, status: undefined })).toBe("open"); + }); + it("round-trips tables through native nodes", () => { let source = "| Name | Status |\n| ---- | ------ |\n| API | Ready |\n"; expect(through(source)).toBe(source); diff --git a/packages/dialect/src/dialect.test.ts b/packages/dialect/src/dialect.test.ts index 196a4fbc1..21368484c 100644 --- a/packages/dialect/src/dialect.test.ts +++ b/packages/dialect/src/dialect.test.ts @@ -174,6 +174,19 @@ describe("components", () => { ); }); + it("accepts `expired` and rejects unknown questionnaire statuses", () => { + let questionnaire = (status: string) => + `\n` + + `\n` + + `\n` + + ``; + accepts(questionnaire("expired")); + for (let status of ["cancelled", "answered", "Expired"]) { + expect(codes(questionnaire(status))).toContain("bad-attribute-value"); + } + }); + it("accepts an accepted comment thread", () => { accepts( `\n` @@ -290,6 +303,20 @@ describe("limits", () => { expect(codes(`y`)) .toContain("attribute-too-long"); }); + + it("bounds questionnaire projections only by the source size", () => { + let questionnaire = (text: string) => + `\n` + + `\n` + + `\n` + + ``; + accepts(questionnaire("x".repeat(5_000))); + let source = questionnaire("x".repeat(limits.MAX_SOURCE_BYTES)); + let result = validate(parse(source), { bytes: new TextEncoder().encode(source).byteLength }); + expect(result.ok ? [] : result.issues.map(issue => issue.code)).toEqual(["source-too-large"]); + }); }); describe("diagnostics", () => { diff --git a/packages/dialect/src/dialect.ts b/packages/dialect/src/dialect.ts index 6116483e9..60813b855 100644 --- a/packages/dialect/src/dialect.ts +++ b/packages/dialect/src/dialect.ts @@ -92,6 +92,9 @@ export const COMPONENTS: Readonly> = Object.freeze({ * * Both are optional: a questionnaire has neither until it is answered, and * one settled before this was recorded has neither for good. + * + * `status="expired"` marks host input nobody answered within its time limit. + * It stays in the document, unanswered, with `at` saying when it expired. */ Questionnaire: component({ name: "Questionnaire", @@ -103,7 +106,7 @@ export const COMPONENTS: Readonly> = Object.freeze({ status: { type: "enum", required: false, - values: ["open", "decided", "reopened", "discarded"], + values: ["open", "decided", "reopened", "discarded", "expired"], }, by: { type: "text", required: false, max: limits.MAX_HANDLE }, at: { type: "text", required: false, max: limits.MAX_TIMESTAMP }, @@ -116,8 +119,8 @@ export const COMPONENTS: Readonly> = Object.freeze({ content: { type: "components", names: ["Option", "Answer", "Previous"] }, parent: ["Questionnaire"], attributes: { - header: { type: "text", required: true, max: limits.MAX_QUESTION_HEADER }, - prompt: { type: "text", required: true, max: limits.MAX_QUESTION_PROMPT }, + header: { type: "text", required: true, max: limits.MAX_QUESTIONNAIRE_TEXT }, + prompt: { type: "text", required: true, max: limits.MAX_QUESTIONNAIRE_TEXT }, multiple: { type: "enum", required: true, values: ["true", "false"] }, }, }), @@ -128,8 +131,8 @@ export const COMPONENTS: Readonly> = Object.freeze({ content: { type: "empty" }, parent: ["Question"], attributes: { - label: { type: "text", required: true, max: limits.MAX_OPTION_LABEL }, - description: { type: "text", required: false, max: limits.MAX_OPTION_DESCRIPTION }, + label: { type: "text", required: true, max: limits.MAX_QUESTIONNAIRE_TEXT }, + description: { type: "text", required: false, max: limits.MAX_QUESTIONNAIRE_TEXT }, }, }), @@ -143,7 +146,7 @@ export const COMPONENTS: Readonly> = Object.freeze({ content: { type: "empty" }, parent: ["Question"], attributes: { - value: { type: "text", required: true, max: limits.MAX_CUSTOM_ANSWER }, + value: { type: "text", required: true, max: limits.MAX_QUESTIONNAIRE_TEXT }, choices: { type: "text", required: false, max: limits.MAX_ANSWER_CHOICES }, }, }), diff --git a/packages/dialect/src/index.ts b/packages/dialect/src/index.ts index d9a87ef52..cfe1acda7 100644 --- a/packages/dialect/src/index.ts +++ b/packages/dialect/src/index.ts @@ -75,6 +75,7 @@ export { $isQuestionnaireNode, cardStatus, QuestionnaireNode, + toElement as questionnaireElement, } from "./nodes/questionnaire"; export type { CardStatus, Option, Previous, Question, Questionnaire } from "./nodes/questionnaire"; diff --git a/packages/dialect/src/limits.ts b/packages/dialect/src/limits.ts index 5d03d7634..e936a3163 100644 --- a/packages/dialect/src/limits.ts +++ b/packages/dialect/src/limits.ts @@ -32,14 +32,20 @@ export const MAX_CALLOUT_TITLE = 100; /** Questionnaire shape, matching the `ask` tool's contract. */ export const MAX_QUESTIONS = 10; export const MAX_OPTIONS = 20; -export const MAX_QUESTION_HEADER = 80; -export const MAX_QUESTION_PROMPT = 1_000; -export const MAX_OPTION_LABEL = 200; -export const MAX_OPTION_DESCRIPTION = 1_000; +/** A reopened question's previous custom answer, kept at the `ask` tool's bound. */ export const MAX_CUSTOM_ANSWER = 4_000; /** Up to twenty ULIDs with separators in one answer. */ export const MAX_ANSWER_CHOICES = 600; +/** + * Questionnaire text, a projection of its server-owned record. + * + * The `ask` tool bounds each field of its own questions; a verbatim host dialog + * keeps whatever its runtime permitted. The Planner cannot author these + * components, so the source size is the only bound the document adds. + */ +export const MAX_QUESTIONNAIRE_TEXT = MAX_SOURCE_BYTES; + /** * Accepted comment threads, projected into the plan as ``. * diff --git a/packages/dialect/src/nodes/questionnaire-fields.ts b/packages/dialect/src/nodes/questionnaire-fields.ts index 9863e8734..f323297e2 100644 --- a/packages/dialect/src/nodes/questionnaire-fields.ts +++ b/packages/dialect/src/nodes/questionnaire-fields.ts @@ -8,7 +8,8 @@ export type Option = { description?: string; }; -export type CardStatus = "open" | "decided" | "reopened" | "discarded"; +/** `expired` is host input nobody answered in time: settled, but with no answer. */ +export type CardStatus = "open" | "decided" | "reopened" | "discarded" | "expired"; export type Previous = { choices: string[]; value?: string; by: string; at: string }; @@ -47,7 +48,13 @@ export type Questionnaire = { export const EMPTY: Questionnaire = { id: "", questions: [] }; -const STATUSES: ReadonlySet = new Set(["open", "decided", "reopened", "discarded"]); +const STATUSES: ReadonlySet = new Set([ + "open", + "decided", + "reopened", + "discarded", + "expired", +]); export function cardStatus(value: Questionnaire): CardStatus { if (value.status !== undefined) { diff --git a/packages/dialect/src/nodes/questionnaire-mdx.ts b/packages/dialect/src/nodes/questionnaire-mdx.ts index 43d44720a..141e8910e 100644 --- a/packages/dialect/src/nodes/questionnaire-mdx.ts +++ b/packages/dialect/src/nodes/questionnaire-mdx.ts @@ -95,16 +95,27 @@ export function toElement(value: Questionnaire): MdxJsxFlowElement { children: value.questions.map(question => ({ type: "mdxJsxFlowElement", name: "Question", - attributes: identity(question.id, { - header: question.header, - prompt: question.prompt, - multiple: String(question.multiple), - }), + attributes: [ + ...identity(question.id), + { type: "mdxJsxAttribute" as const, name: "header", value: question.header }, + { type: "mdxJsxAttribute" as const, name: "prompt", value: question.prompt }, + { type: "mdxJsxAttribute" as const, name: "multiple", value: String(question.multiple) }, + ], children: [ ...question.options.map(option => ({ type: "mdxJsxFlowElement" as const, name: "Option", - attributes: identity(option.id, { label: option.label, description: option.description }), + attributes: [ + ...identity(option.id), + { type: "mdxJsxAttribute" as const, name: "label", value: option.label }, + ...(option.description + ? [{ + type: "mdxJsxAttribute" as const, + name: "description", + value: option.description, + }] + : []), + ], children: [], })), // A projection of sidecar state: addressed through its Question, @@ -112,7 +123,10 @@ export function toElement(value: Questionnaire): MdxJsxFlowElement { ...(question.answer === undefined ? [] : [{ type: "mdxJsxFlowElement" as const, name: "Answer", - attributes: attributes({ value: question.answer, choices: question.choices?.join(" ") }), + attributes: [ + { type: "mdxJsxAttribute" as const, name: "value", value: question.answer }, + ...attributes({ choices: question.choices?.join(" ") }), + ], children: [], }]), ...(question.previous diff --git a/packages/dialect/src/validate.ts b/packages/dialect/src/validate.ts index 236fd6373..b186605a4 100644 --- a/packages/dialect/src/validate.ts +++ b/packages/dialect/src/validate.ts @@ -346,7 +346,7 @@ class Validator { break; case "text": - if (!value.trim()) { + if (!value.trim() && !["Question", "Option", "Answer"].includes(spec.name)) { this.add("empty-attribute", `\`${spec.name}.${name}\` cannot be empty`, path, node); } else if (value.length > attribute.max) { this.add( @@ -407,7 +407,12 @@ class Validator { stringAttribute(parent, "status") ?? "", ) && stringAttribute(node, "multiple") === "false"; - if (found === 0 && !pendingQuestion) { + // A free-text question, from a card with no thread that is open or expired, + // has no options until its answer is projected. + let freeTextQuestion = spec.name === "Question" && parent?.name === "Questionnaire" + && !stringAttribute(parent, "thread") + && [undefined, "expired"].includes(stringAttribute(parent, "status")); + if (found === 0 && !pendingQuestion && !freeTextQuestion) { this.add( "missing-children", `\`${spec.name}\` requires at least one ${allowed.join(" or ")}`, diff --git a/packages/editor/src/decision-state.test.ts b/packages/editor/src/decision-state.test.ts index 0a3760bf6..d7b5a7109 100644 --- a/packages/editor/src/decision-state.test.ts +++ b/packages/editor/src/decision-state.test.ts @@ -98,6 +98,14 @@ describe("decision attention", () => { ])).toBe(2); }); + it("never waits on an expired questionnaire", () => { + let waiting = entry([undefined]); + let expired = { ...waiting, value: { ...waiting.value, status: "expired" as const } }; + expect(countUnanswered([expired, entry([undefined, undefined])])).toBe(2); + expect(firstOpenDecision([expired])).toBeUndefined(); + expect(firstOpenDecision([waiting])).toBe(waiting); + }); + it("forces only a questionnaire-only opening document into Decisions", () => { expect(visibleDecisionView({ phase: "initial", preferred: "plan" }, false, 2)).toBe( "decisions", diff --git a/packages/editor/src/decision-state.ts b/packages/editor/src/decision-state.ts index cf241c0d8..fc58d9126 100644 --- a/packages/editor/src/decision-state.ts +++ b/packages/editor/src/decision-state.ts @@ -18,7 +18,7 @@ export function countUnanswered( return entries.reduce( (total, entry) => { let status = cardMeta?.get(entry.id)?.status ?? cardStatus(entry.value); - if (status === "decided" || status === "discarded") return total; + if (status === "decided" || status === "discarded" || status === "expired") return total; if (status === "reopened") return total + entry.value.questions.length; return total + entry.value.questions.filter(question => question.answer === undefined).length; diff --git a/packages/editor/src/decisions.test.tsx b/packages/editor/src/decisions.test.tsx index 46e2c68df..76d909940 100644 --- a/packages/editor/src/decisions.test.tsx +++ b/packages/editor/src/decisions.test.tsx @@ -6,15 +6,31 @@ import { Decisions } from "./decisions"; import { QuestionnaireStore } from "./questionnaires"; import type { MotionDisclosureContract } from "./disclosure-motion"; +import type { QuestionnaireEntry } from "./questionnaires"; let original = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); +const RESOLVED: QuestionnaireEntry = { + id: "questionnaire", + value: { + id: "questionnaire", + questions: [{ + id: "question", + header: "Question", + prompt: "What did the room decide?", + multiple: false, + options: [], + answer: "Done", + }], + }, +}; + afterEach(() => { if (original) Object.defineProperty(globalThis, "localStorage", original); else delete (globalThis as { localStorage?: unknown }).localStorage; }); -function markup(stored?: string): string { +function markup(stored?: string, entries = [RESOLVED]): string { let values = new Map(); if (stored) values.set("chopin:decisions:resolved", stored); Object.defineProperty(globalThis, "localStorage", { @@ -26,23 +42,7 @@ function markup(stored?: string): string { }); let store = new QuestionnaireStore(); - store.set({ - entries: [{ - id: "questionnaire", - value: { - id: "questionnaire", - questions: [{ - id: "question", - header: "Question", - prompt: "What did the room decide?", - multiple: false, - options: [], - answer: "Done", - }], - }, - }], - hasPlanContent: true, - }); + store.set({ entries, hasPlanContent: true }); let motion: MotionDisclosureContract = { className: "motion-collapse", closeDuration: 250, @@ -64,3 +64,31 @@ test("resolved history starts collapsed and restores an explicit open preference expect(restored).toContain('data-feedback-icon="open"'); expect(restored).toContain('data-motion-feedback="icon"'); }); + +test("an expired card is listed with the resolved cards and says nobody answered", () => { + let expired: QuestionnaireEntry = { + id: "expired", + value: { + id: "expired", + status: "expired", + at: "2026-09-28T10:30:00.000Z", + questions: [{ + id: "confirm", + header: "Confirm", + prompt: "Ship the migration?", + multiple: false, + options: [{ id: "yes", label: "Yes" }], + }], + }, + }; + let history = markup("true", [expired, RESOLVED]); + expect(history).toMatch(/2<\/span>resolved<\/span>/); + expect(history).toContain('data-plan-sidecar-questionnaire="expired"'); + expect(history).toContain("Ship the migration?"); + expect(history).toContain( + "Nobody answered within 30 minutes. The Planner will use its best judgement for this decision.", + ); + for (let control of ["Save answer", "; let resolved = answers(value); let current = meta?.status ?? cardStatus(value); let pointing = { places, onQuestionEnter, onQuestionLeave, onQuestionSelect }; @@ -513,6 +514,20 @@ export function carriedByMarkers( ); } +/** Host input nobody answered in time: settled like an answer, with nothing to submit. */ +function Expired({ value }: { value: Questionnaire }) { + return ( + + + + ); +} + function InlineQuestionnaire({ value }: { value: Questionnaire }) { let options = useCellValue(widgets$); let meta = useCardMeta(options.cardMeta, value.id); diff --git a/packages/protocol/question.d.ts b/packages/protocol/question.d.ts index ba1a8bc4c..9f183b42d 100644 --- a/packages/protocol/question.d.ts +++ b/packages/protocol/question.d.ts @@ -56,13 +56,15 @@ export declare namespace Question { question: string; options: Option[]; multiple: boolean; + /** Host dialogs preserve raw text, including an explicitly submitted empty answer. */ + verbatim?: true; }; export type Definition = { questions: Item[]; }; - export type CardStatus = "open" | "decided" | "reopened" | "discarded"; + export type CardStatus = "open" | "decided" | "reopened" | "discarded" | "expired"; export type OptionOrigin = { origin: "chat" | "planner" | "human"; @@ -240,7 +242,7 @@ export declare namespace Question { | { ok: false; reason: "resolved"; - status: "answered" | "cancelled"; + status: Status; resolver: string; answers?: Answer[]; } @@ -260,7 +262,7 @@ export declare namespace Question { | { ok: false; reason: "resolved"; - status: "answered" | "cancelled"; + status: Status; resolver: string; answers?: Answer[]; } @@ -356,10 +358,20 @@ export declare namespace Question { by: string; }; + /** + * How a questionnaire closed. + * + * `cancelled` takes the card out of the document: a member declined it or + * its asker withdrew it. `expired` is host input nobody answered within its + * time limit; the card stays in the document, marked expired, and its asker + * proceeds without an answer. + */ + export type Status = "answered" | "cancelled" | "expired"; + /** The questionnaire is closed. Nobody may answer it further. */ export type Resolved = KIND<"question:resolved"> & { id: string; - status: "answered" | "cancelled"; + status: Status; resolver: string; answers?: Answer[]; }; diff --git a/packages/question/src/answer.ts b/packages/question/src/answer.ts index 20a2c12a2..37392fd1d 100644 --- a/packages/question/src/answer.ts +++ b/packages/question/src/answer.ts @@ -37,7 +37,10 @@ export function derive(definition: Definition, drafts: Drafts): Outcome { } if (draft!.mode === "custom") { - answers.push({ question: question.question, custom: draft!.custom.trim() }); + answers.push({ + question: question.question, + custom: question.verbatim ? draft!.custom : draft!.custom.trim(), + }); continue; } diff --git a/packages/question/src/draft.ts b/packages/question/src/draft.ts index 943111576..ff690f19f 100644 --- a/packages/question/src/draft.ts +++ b/packages/question/src/draft.ts @@ -39,7 +39,9 @@ export function create(definition: Definition): Model { } questions[question.id] = crdt.schema.obj({ - mode: crdt.schema.val(crdt.schema.con("choices")), + mode: crdt.schema.val( + crdt.schema.con(question.verbatim && !question.options.length ? "custom" : "choices"), + ), choice: crdt.schema.val(crdt.schema.con(null)), options: crdt.schema.obj(options), // A CRDT string, so two people typing a custom answer merge rather @@ -147,7 +149,7 @@ export function read(model: Model, definition: Definition): Drafts { let custom = node.get("custom"); if (!(custom instanceof crdt.StrNode)) reject(`${question.id}.custom must be a CRDT string`); let value = (custom as crdt.StrNode).view(); - if (value.length > limits.MAX_CUSTOM) { + if (!question.verbatim && value.length > limits.MAX_CUSTOM) { reject(`${question.id}.custom exceeds ${limits.MAX_CUSTOM} characters`); } @@ -160,7 +162,7 @@ export function read(model: Model, definition: Definition): Drafts { /** Whether a question has enough of an answer to submit. */ export function answered(question: Item, draft: Draft | undefined): boolean { if (!draft) return false; - if (draft.mode === "custom") return !!draft.custom.trim(); + if (draft.mode === "custom") return question.verbatim || !!draft.custom.trim(); if (question.multiple) return question.options.some(option => draft.options[option.id]); return question.options.some(option => option.id === draft.choice); } diff --git a/packages/question/src/limits.ts b/packages/question/src/limits.ts index 2390e242b..f273cdbe9 100644 --- a/packages/question/src/limits.ts +++ b/packages/question/src/limits.ts @@ -2,7 +2,9 @@ * Bounds on a questionnaire. * * Enforced by the VM when the agent asks a question and again on every edit. - * Clients apply the same numbers so a rejection is never a surprise. + * Clients apply the same numbers so a rejection is never a surprise. Verbatim + * host dialogs skip the per-field counts and lengths; the byte limits below + * still apply to them. */ export const MAX_QUESTIONS = 10; @@ -28,3 +30,6 @@ export const MAX_MODEL_BYTES = 256 * 1024; /** Longest a tool call id may be, since it identifies the request. */ export const MAX_CALL_ID = 256; + +/** How long host input waits for an answer before its card expires unanswered. */ +export const INPUT_EXPIRY_MS = 30 * 60 * 1_000; diff --git a/packages/question/src/question.test.ts b/packages/question/src/question.test.ts index f31d9855f..9f96f1ce5 100644 --- a/packages/question/src/question.test.ts +++ b/packages/question/src/question.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; import { derive, incomplete, summarize } from "./answer"; -import { answered, apply, assertPatch, crdt, create, read } from "./draft"; +import { answered, apply, assertPatch, crdt, create, read, restore } from "./draft"; import * as limits from "./limits"; import { identified } from "./index"; import { appendOption, normalize, QuestionError } from "./schema"; @@ -41,6 +41,19 @@ describe("normalize", () => { expect(definition.questions[0]!.header).toBe("Rollout"); expect(definition.questions[0]!.options[1]!.description).toBe(""); }); + it("supports verbatim host dialogs without choices, including empty text replies", () => { + let definition = normalize(tool({ header: " Title ", options: [] }), { verbatim: true }); + expect(definition.questions[0]!.header).toBe(" Title "); + let drafts = read(create(definition), definition); + expect(drafts.q0!.mode).toBe("custom"); + for (let custom of ["", " ", " answer\n"]) { + drafts.q0!.custom = custom; + expect(derive(definition, drafts)).toEqual({ + ok: true, + answers: [{ question: "How should we deploy?", custom }], + }); + } + }); it("rejects unknown fields instead of ignoring them", () => { expect(() => normalize({ questions: [], extra: 1 })).toThrow(QuestionError); @@ -55,8 +68,54 @@ describe("normalize", () => { it("enforces the documented limits", () => { expect(() => normalize({ questions: Array.from({ length: 11 }, () => tool().questions[0]) })) .toThrow(/at most 10 questions/); - expect(() => normalize(tool({ header: "x".repeat(limits.MAX_HEADER + 1) }))) - .toThrow(/exceeds 80 characters/); + let many = Array.from({ length: limits.MAX_OPTIONS + 1 }, (_, index) => ({ + label: `${index}`, + description: "", + })); + for ( + let [override, message] of [ + [{ header: "x".repeat(limits.MAX_HEADER + 1) }, /exceeds 80 characters/], + [{ question: "x".repeat(limits.MAX_QUESTION + 1) }, /exceeds 1000 characters/], + [{ options: many }, /at most 20 options/], + [{ options: [{ label: "x".repeat(limits.MAX_LABEL + 1), description: "" }] }, /label/], + [ + { options: [{ label: "a", description: "x".repeat(limits.MAX_DESCRIPTION + 1) }] }, + /description exceeds 1000 characters/, + ], + ] as const + ) expect(() => normalize(tool(override))).toThrow(message); + let model = create(normalize(tool())); + model.api.val(["q0", "mode"]).set("custom"); + model.api.str(["q0", "custom"]).ins(0, "x".repeat(limits.MAX_CUSTOM + 1)); + expect(() => read(model, normalize(tool()))).toThrow(/custom exceeds 4000 characters/); + }); + + it("bounds verbatim host input only by the shared draft's byte limits", () => { + let question = { + header: "x".repeat(limits.MAX_HEADER + 1), + question: "x".repeat(limits.MAX_QUESTION + 1), + multiple: false, + options: Array.from({ length: limits.MAX_OPTIONS + 1 }, (_, index) => ({ + label: `${index}`.padEnd(limits.MAX_LABEL + 1, " "), + description: "x".repeat(limits.MAX_DESCRIPTION + 1), + })), + }; + let definition = normalize({ + questions: Array.from({ length: limits.MAX_QUESTIONS + 1 }, () => question), + }, { verbatim: true }); + expect(definition.questions).toHaveLength(limits.MAX_QUESTIONS + 1); + expect(definition.questions[0]).toMatchObject({ + header: question.header, + question: question.question, + options: question.options, + }); + let custom = "x".repeat(limits.MAX_CUSTOM + 1); + let model = create(definition); + model.api.val(["q0", "mode"]).set("custom"); + model.api.str(["q0", "custom"]).ins(0, custom); + expect(read(model, definition).q0!.custom).toBe(custom); + model.api.str(["q0", "custom"]).ins(0, "x".repeat(limits.MAX_MODEL_BYTES)); + expect(() => restore([...model.toBinary()], definition)).toThrow(/256 KiB limit/); }); it("requires multiple to be a boolean", () => { diff --git a/packages/question/src/react/question-view.test.ts b/packages/question/src/react/question-view.test.ts index 0587907f5..36f9574f8 100644 --- a/packages/question/src/react/question-view.test.ts +++ b/packages/question/src/react/question-view.test.ts @@ -3,6 +3,7 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { currentQuestion, QuestionView } from "./question-view"; +import { create, limits, normalize, read } from "../index"; test("a replacement definition falls back before rendering when its active question disappears", () => { let storage = { @@ -225,3 +226,53 @@ test("Next waits for an answer to the current question, but not in a read-only v "Next", )).not.toContain("disabled"); }); + +test("a host dialog with no options is answered by adding one, without a free-text box", () => { + let definition = normalize({ + questions: [{ header: "Input", question: "Write the brief", options: [], multiple: false }], + }, { verbatim: true }); + let markup = renderToStaticMarkup(createElement(QuestionView, { + definition, + drafts: read(create(definition), definition), + onChange() {}, + onAddOption: async () => ({ ok: true as const }), + })); + expect(markup).toContain("Write the brief"); + expect(markup).toContain("Add an option"); + expect(markup).not.toContain(" { + let definition = normalize({ + questions: [{ + header: "Confirm", + question: "Ship the migration?", + options: [{ label: "Yes", description: "" }], + multiple: false, + }], + }); + let render = (status: "expired" | "cancelled") => + renderToStaticMarkup(createElement(QuestionView, { + definition, + drafts: {}, + status, + resolver: "chopin", + onChange() {}, + onSubmit() {}, + onCancel() {}, + })); + let note = `Nobody answered within ${limits.INPUT_EXPIRY_MS / 60_000} minutes. ` + + "The Planner will use its best judgement for this decision."; + let expired = render("expired"); + expect(limits.INPUT_EXPIRY_MS).toBe(30 * 60 * 1_000); + expect(expired).toContain( + "Nobody answered within 30 minutes. The Planner will use its best judgement for this decision.", + ); + expect(expired).toContain(note); + expect(expired).toContain("Ship the migration?"); + expect(expired).not.toContain("Cancelled"); + for (let control of [" Promise; disabled?: boolean; submitting?: boolean; - status?: "open" | "answered" | "cancelled" | "discarded"; + status?: "open" | Question.Status | "discarded"; /** Shown instead of controls once the questionnaire has resolved. */ answers?: Answer[]; resolver?: string; @@ -559,6 +560,25 @@ function Callout( ); } +const EXPIRED_NOTE = `Nobody answered within ${ + INPUT_EXPIRY_MS / 60_000 +} minutes. The Planner will use its best judgement for this decision.`; + +/** + * Host input whose time ran out. Unlike a cancelled card it stays in the + * document, so it keeps what was asked beside the note saying nobody answered. + */ +function Expired({ definition }: { definition: Definition }) { + return ( +
+ {definition.questions.map(question => ( +

{question.question}

+ ))} +

{EXPIRED_NOTE}

+
+ ); +} + export function QuestionView(props: QuestionViewProps) { let { definition, @@ -661,14 +681,17 @@ export function QuestionView(props: QuestionViewProps) { ); } - // A cancelled questionnaire has no answers, so it must be matched on status - // alone — falling through would offer an editable form for a dead question. - if (status === "cancelled") { + // A cancelled or expired questionnaire has no answers, so it must be matched + // on status alone — falling through would offer an editable form for a dead + // question. + if (status === "cancelled" || status === "expired") { return (
{single && } {aside} - + {status === "expired" + ? + : }
); } diff --git a/packages/question/src/react/questionnaire-controller.ts b/packages/question/src/react/questionnaire-controller.ts index a7ac5fb1e..d1850743c 100644 --- a/packages/question/src/react/questionnaire-controller.ts +++ b/packages/question/src/react/questionnaire-controller.ts @@ -156,8 +156,11 @@ export class QuestionnaireController { } if (patch.custom !== undefined) { let value = doc.api.str([question, "custom"]); - value.del(0, value.length()); - value.ins(0, patch.custom as string); + let text = patch.custom as string; + // json-joy 17 binary-encodes a zero-length delete so that the receiver decodes + // garbage that swallows the following insert, and it throws on an empty insert. + if (value.length() > 0) value.del(0, value.length()); + if (text) value.ins(0, text); } }); }; diff --git a/packages/question/src/react/use-questionnaire.test.ts b/packages/question/src/react/use-questionnaire.test.ts index c67da249b..445ac3a02 100644 --- a/packages/question/src/react/use-questionnaire.test.ts +++ b/packages/question/src/react/use-questionnaire.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { create, decision, normalize } from "../index"; +import { apply, create, decision, normalize, read } from "../index"; import { DEFINITION } from "./use-questionnaire.test-fixtures"; import { FocusReporter, QuestionnaireController } from "./use-questionnaire"; +import type { Definition } from "../index"; import type { Transport } from "./use-questionnaire"; const QUESTIONNAIRE = normalize({ @@ -48,6 +49,12 @@ function transport(definition = DEFINITION) { } if (kind === "question:edit") { edits++; + // The server's own gate: an edit counts only if its wire bytes apply. + let outcome = apply(model, definition, payload.patch as number[]); + if (!outcome.ok) { + return { open: true, accepted: false, revision: edits, message: outcome.message }; + } + model = outcome.model; return { open: true, accepted: true, applied: false, revision: edits }; } if (kind === "question:submit") { @@ -81,9 +88,22 @@ function transport(definition = DEFINITION) { submitRevisions: () => submitRevisions, presence: () => presence, frames: () => frames, + drafts: () => read(model, definition), }; } +async function open(definition: Definition) { + let bridge = transport(definition); + let controller = new QuestionnaireController(bridge.value, "question-1", definition, true); + let off = controller.subscribe(() => {}); + await new Promise(resolve => setTimeout(resolve, 0)); + return { bridge, controller, off }; +} + +function settle() { + return new Promise(resolve => setTimeout(resolve, 0)); +} + describe("QuestionnaireController", () => { it("rejects a questionnaire returned for an independent decision record", () => { let bridge = transport(QUESTIONNAIRE); @@ -252,3 +272,77 @@ describe("FocusReporter", () => { expect(sent.slice(3)).toEqual(["b", "c"]); }); }); + +describe("custom answer edits", () => { + let text = " pasted in one input,\nwith spacing kept "; + + for (let verbatim of [false, true]) { + it(`stores one input into an empty custom answer (verbatim: ${verbatim})`, async () => { + let definition = normalize({ + questions: [{ + header: "Scope", + question: "Which areas?", + multiple: true, + options: [{ label: "Server", description: "" }, { label: "Web", description: "" }], + }], + }, { verbatim }); + let { bridge, controller, off } = await open(definition); + + controller.change("q0", { mode: "custom" }); + await settle(); + controller.change("q0", { custom: text }); + await settle(); + + expect(controller.getSnapshot().error).toBeUndefined(); + expect(bridge.drafts().q0).toMatchObject({ mode: "custom", custom: text }); + off(); + }); + } + + it("stores a paste into a host dialog that has only a custom answer", async () => { + let definition = normalize({ + questions: [{ header: "Editor", question: "Edit the notes", multiple: false, options: [] }], + }, { verbatim: true }); + let { bridge, controller, off } = await open(definition); + let long = "notes line\n".repeat(500); + + controller.change("q0", { custom: long }); + await settle(); + + expect(bridge.drafts().q0?.custom).toBe(long); + off(); + }); + + it("replaces a non-empty custom answer", async () => { + let { bridge, controller, off } = await open(DEFINITION); + + controller.change("q0", { mode: "custom" }); + await settle(); + controller.change("q0", { custom: "first" }); + await settle(); + expect(bridge.drafts().q0?.custom).toBe("first"); + controller.change("q0", { custom: text }); + await settle(); + + expect(bridge.drafts().q0?.custom).toBe(text); + off(); + }); + + it("submits a verbatim answer that was typed and then cleared", async () => { + let definition = normalize({ + questions: [{ header: "Input", question: "Name?", multiple: false, options: [] }], + }, { verbatim: true }); + let { bridge, controller, off } = await open(definition); + + controller.change("q0", { custom: "typed" }); + await settle(); + controller.change("q0", { custom: "" }); + await settle(); + expect(bridge.drafts().q0?.custom).toBe(""); + + controller.submit(); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(bridge.submits()).toBe(1); + off(); + }); +}); diff --git a/packages/question/src/schema.ts b/packages/question/src/schema.ts index 40dd78833..1d41866ef 100644 --- a/packages/question/src/schema.ts +++ b/packages/question/src/schema.ts @@ -45,8 +45,15 @@ function exact(value: Record, keys: string[], name: string): vo } } -function text(value: unknown, name: string, max: number, optional = false): string { +function text( + value: unknown, + name: string, + max: number, + optional = false, + verbatim = false, +): string { if (typeof value !== "string") fail(`${name} must be text`); + if (verbatim) return value; let result = value.trim(); if (!optional && !result) fail(`${name} is required`); if (result.length > max) fail(`${name} exceeds ${max} characters`); @@ -54,9 +61,15 @@ function text(value: unknown, name: string, max: number, optional = false): stri } /** A stored definition keeps its original string, so enforce the bound on that string. */ -function storedText(value: unknown, name: string, max: number, optional = false): void { - text(value, name, max, optional); - if ((value as string).length > max) fail(`${name} exceeds ${max} characters`); +function storedText( + value: unknown, + name: string, + max: number, + optional = false, + verbatim = false, +): void { + text(value, name, max, optional, verbatim); + if (!verbatim && (value as string).length > max) fail(`${name} exceeds ${max} characters`); } /** @@ -66,16 +79,20 @@ function storedText(value: unknown, name: string, max: number, optional = false) * unique within one call. Durable plan questionnaires re-key them to ULIDs on * the way in, since those do have to survive rewrites. * + * A verbatim definition is a host dialog the runtime already permitted: it + * keeps its text exactly and is bounded by the draft and document byte limits + * rather than by the `ask` tool's per-field contract. + * * @throws {QuestionError} */ -export function normalize(raw: unknown): Definition { +export function normalize(raw: unknown, { verbatim = false } = {}): Definition { let args = record(raw, "Question tool input"); exact(args, ["questions"], "Question tool input"); if (!Array.isArray(args.questions) || args.questions.length === 0) { fail("At least one question is required"); } - if (args.questions.length > limits.MAX_QUESTIONS) { + if (!verbatim && args.questions.length > limits.MAX_QUESTIONS) { fail(`A questionnaire can contain at most ${limits.MAX_QUESTIONS} questions`); } @@ -83,10 +100,10 @@ export function normalize(raw: unknown): Definition { let raw = record(source, `Question ${index + 1}`); exact(raw, ["header", "question", "options", "multiple"], `Question ${index + 1}`); - if (!Array.isArray(raw.options) || raw.options.length === 0) { + if (!Array.isArray(raw.options) || (!verbatim && raw.options.length === 0)) { fail(`Question ${index + 1} requires at least one option`); } - if (raw.options.length > limits.MAX_OPTIONS) { + if (!verbatim && raw.options.length > limits.MAX_OPTIONS) { fail(`Question ${index + 1} can contain at most ${limits.MAX_OPTIONS} options`); } if (typeof raw.multiple !== "boolean") { @@ -102,12 +119,15 @@ export function normalize(raw: unknown): Definition { option.label, `Question ${index + 1} option ${position + 1} label`, limits.MAX_LABEL, + verbatim, + verbatim, ), description: text( option.description, `Question ${index + 1} option ${position + 1} description`, limits.MAX_DESCRIPTION, true, + verbatim, ), }; }); @@ -117,10 +137,23 @@ export function normalize(raw: unknown): Definition { return { id: `q${index}`, - header: text(raw.header, `Question ${index + 1} header`, limits.MAX_HEADER), - question: text(raw.question, `Question ${index + 1}`, limits.MAX_QUESTION), + header: text( + raw.header, + `Question ${index + 1} header`, + limits.MAX_HEADER, + verbatim, + verbatim, + ), + question: text( + raw.question, + `Question ${index + 1}`, + limits.MAX_QUESTION, + verbatim, + verbatim, + ), options, multiple: raw.multiple, + ...(verbatim ? { verbatim: true as const } : {}), }; }); @@ -137,6 +170,9 @@ export function identified(raw: unknown): Definition { if ( !Array.isArray(source.questions) || source.questions.length === 0 || source.questions.length > limits.MAX_QUESTIONS + && !source.questions.every(question => + (question as { verbatim?: unknown })?.verbatim === true + ) ) { fail("Questionnaire definition has an invalid question count"); } @@ -144,18 +180,25 @@ export function identified(raw: unknown): Definition { let optionIds = new Set(); for (let [index, candidate] of source.questions.entries()) { let question = record(candidate, `Question ${index + 1}`); - exact(question, ["id", "header", "question", "options", "multiple"], `Question ${index + 1}`); + let verbatim = "verbatim" in question; + if (verbatim && question.verbatim !== true) fail(`Question ${index + 1} has invalid fields`); + exact( + question, + ["id", "header", "question", "options", "multiple", ...(verbatim ? ["verbatim"] : [])], + `Question ${index + 1}`, + ); storedText(question.id, `Question ${index + 1} id`, limits.MAX_CALL_ID); let id = question.id as string; if (questionIds.has(id)) fail("Questionnaire has duplicate question IDs"); questionIds.add(id); - storedText(question.header, `Question ${index + 1} header`, limits.MAX_HEADER); - storedText(question.question, `Question ${index + 1}`, limits.MAX_QUESTION); + storedText(question.header, `Question ${index + 1} header`, limits.MAX_HEADER, false, verbatim); + storedText(question.question, `Question ${index + 1}`, limits.MAX_QUESTION, false, verbatim); if ( typeof question.multiple !== "boolean" || !Array.isArray(question.options) - || question.options.length === 0 && (source.questions.length !== 1 || question.multiple) - || question.options.length > limits.MAX_OPTIONS + || question.options.length === 0 && !verbatim + && (source.questions.length !== 1 || question.multiple) + || !verbatim && question.options.length > limits.MAX_OPTIONS ) { fail(`Question ${index + 1} has invalid options or multiple value`); } @@ -166,12 +209,19 @@ export function identified(raw: unknown): Definition { let optionId = option.id as string; if (optionIds.has(optionId)) fail("Questionnaire has duplicate option IDs"); optionIds.add(optionId); - storedText(option.label, `Question ${index + 1} option label`, limits.MAX_LABEL); + storedText( + option.label, + `Question ${index + 1} option label`, + limits.MAX_LABEL, + false, + verbatim, + ); storedText( option.description, `Question ${index + 1} option description`, limits.MAX_DESCRIPTION, true, + verbatim, ); } } diff --git a/scripts/design-contract/exceptions/dynamic-editor.json b/scripts/design-contract/exceptions/dynamic-editor.json index 090f2017a..53aa415f9 100644 --- a/scripts/design-contract/exceptions/dynamic-editor.json +++ b/scripts/design-contract/exceptions/dynamic-editor.json @@ -389,7 +389,7 @@ 1 ] ], - "sourceHash": "a4b50f4686c6d44d1e034183504093a8c486ec5d848e964db121d4d587f0ab20" + "sourceHash": "190cce4ad05296adc8c945d21755b3b26804b7542b7b30cda9667ba1e77e98cb" }, { "file": "packages/editor/src/widgets/code-view.tsx", @@ -452,7 +452,7 @@ 1 ] ], - "sourceHash": "a4b50f4686c6d44d1e034183504093a8c486ec5d848e964db121d4d587f0ab20" + "sourceHash": "190cce4ad05296adc8c945d21755b3b26804b7542b7b30cda9667ba1e77e98cb" }, { "file": "packages/editor/src/resolved-layer.tsx", diff --git a/scripts/design-contract/exceptions/dynamic-packages.json b/scripts/design-contract/exceptions/dynamic-packages.json index 8ca79a380..94afedaf6 100644 --- a/scripts/design-contract/exceptions/dynamic-packages.json +++ b/scripts/design-contract/exceptions/dynamic-packages.json @@ -32,7 +32,7 @@ 1 ] ], - "sourceHash": "44f865f2e4bef65df792e5731cd26161197067f1b57e80c99b24ddd9ed161983" + "sourceHash": "6ffb7d6df2fc865f4eb0803a0e79fa348663f78f57e7040567d5b45146037a2c" }, { "file": "packages/question/src/react/resolved-actions.tsx",