diff --git a/README.md b/README.md index 991b925..061dcfd 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,16 @@ Sessions are written as JSONL event logs under `.fledgling/sessions` by default. The agent advertises ACP `loadSession` support. Loading a session rebuilds the user/assistant message history from stored events and replays that transcript to the ACP client. +## ACP and MCP capability contract + +Fledgling uses ACP for agent orchestration: initialization, sessions, prompt turns, cancellation, session updates, and session loading. + +Workspace capabilities are MCP-first. File reads, file writes, directory listing, text search, and command execution are exposed through MCP tools, including the `firstPartyWorkspace` MCP server. Fledgling does not currently use ACP host filesystem or terminal APIs as workspace backends. + +ACP host `readTextFile`, `writeTextFile`, and terminal integration may be added later for hosts that only expose those APIs. Permission prompts for MCP-backed writes and commands are also future work. + ## Current Limitations - Prompt input is treated mostly as text; rich ACP prompt parts are not yet preserved semantically. -- Workspace access is provided through MCP tools, not ACP host filesystem or terminal APIs. - ACP authentication, session modes, model selection, and permission prompts are still minimal or stubbed. - Tool result context hints are recorded but not yet used for full context-store replay. diff --git a/packages/acp-agent/src/agent-prompt-cancellation.test.ts b/packages/acp-agent/src/agent-prompt-cancellation.test.ts index d79add5..4143fc6 100644 --- a/packages/acp-agent/src/agent-prompt-cancellation.test.ts +++ b/packages/acp-agent/src/agent-prompt-cancellation.test.ts @@ -53,6 +53,21 @@ describe("FledglingAgent prompt cancellation", () => { } }); + it("advertises only the minimum supported ACP agent capabilities", async () => { + const { agent } = await createTestAgent(); + + const response = await agent.initialize({ protocolVersion: 1, clientCapabilities: {} } as never); + + expect(typeof response.protocolVersion).toBe("number"); + expect(response.agentCapabilities).toEqual({ + loadSession: true, + mcpCapabilities: { + http: true, + sse: true + } + }); + }); + it("streams text responses and persists user and assistant messages", async () => { const { agent, sessionId, streamText, sessionFile, sessionUpdates } = await createTestAgent(); streamText.mockReturnValueOnce(createImmediateStream([{ type: "text-delta", text: "hello" }])); @@ -115,6 +130,42 @@ describe("FledglingAgent prompt cancellation", () => { ); }); + it("does not use ACP host filesystem or permission methods during prompt flow", async () => { + const { manager: sessionManager, sessionFile, tempDir: createdTempDir } = await createTempSessionManager(); + const sessionUpdates: FakeSessionUpdate[] = []; + const hostMethods = createFailingHostMethods(); + const streamText = vi.fn(() => createImmediateStream([{ type: "text-delta", text: "mcp-first" }])); + const { FledglingAgent } = await import("./agent.js"); + const agent = new FledglingAgent( + { + ...createFakeConnection(sessionUpdates), + ...hostMethods + } as never, + { + toolProvider: { + createSessionTools: vi.fn(async () => ({ clients: [], tools: {} })) + }, + sessionManager, + modelTurnRunner: { + runModelTurn: streamText + } + } satisfies FledglingAgentDependencies + ); + const session = await agent.newSession({ cwd: createdTempDir, mcpServers: [] }); + + await expect(agent.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hi" }] })).resolves.toEqual({ + stopReason: "end_turn" + }); + + expect(hostMethods.requestPermission).not.toHaveBeenCalled(); + expect(hostMethods.readTextFile).not.toHaveBeenCalled(); + expect(hostMethods.writeTextFile).not.toHaveBeenCalled(); + expect(await loadStoredMessages(sessionFile)).toEqual([ + ["message.user", "hi"], + ["message.assistant", "mcp-first"] + ]); + }); + it("normalizes model start failures into diagnostics and durable errors", async () => { const { agent, sessionId, streamText, sessionFile, sessionUpdates } = await createTestAgent(); streamText.mockImplementationOnce(() => { @@ -426,6 +477,24 @@ function createFakeConnection(sessionUpdates: FakeSessionUpdate[] = []): { sessi }; } +function createFailingHostMethods(): { + readonly requestPermission: ReturnType; + readonly readTextFile: ReturnType; + readonly writeTextFile: ReturnType; +} { + return { + requestPermission: vi.fn(async () => { + throw new Error("ACP host permission should not be used"); + }), + readTextFile: vi.fn(async () => { + throw new Error("ACP host readTextFile should not be used"); + }), + writeTextFile: vi.fn(async () => { + throw new Error("ACP host writeTextFile should not be used"); + }) + }; +} + async function createTempSessionManager(): Promise<{ readonly manager: FileSystemSessionManager; readonly sessionFile: string; diff --git a/packages/acp-agent/tools/smoke-client.mjs b/packages/acp-agent/tools/smoke-client.mjs index 2df1180..7caf13d 100644 --- a/packages/acp-agent/tools/smoke-client.mjs +++ b/packages/acp-agent/tools/smoke-client.mjs @@ -44,14 +44,18 @@ class SmokeClient { } async requestPermission() { + // The smoke host implements the ACP client surface, but Fledgling's current + // workspace path is MCP-first and should not call these host methods. return { outcome: { outcome: "cancelled" } }; } async writeTextFile() { + // Inert ACP filesystem stub; workspace writes are provided by MCP tools. return {}; } async readTextFile() { + // Inert ACP filesystem stub; workspace reads are provided by MCP tools. return { content: "" }; } } diff --git a/packages/acp-host-log/src/index.ts b/packages/acp-host-log/src/index.ts index fd8737d..ccd40a8 100644 --- a/packages/acp-host-log/src/index.ts +++ b/packages/acp-host-log/src/index.ts @@ -56,16 +56,20 @@ class LoggingHost implements acp.Client { } public async requestPermission(params: acp.RequestPermissionRequest): Promise { + // This host implements the ACP client surface for logging, but Fledgling's + // current workspace path is MCP-first and should not call this method. emitTranscript("session/request_permission", { params }); return { outcome: { outcome: "cancelled" } }; } public async writeTextFile(params: acp.WriteTextFileRequest): Promise { + // Inert ACP filesystem stub; workspace writes are provided by MCP tools. emitTranscript("fs/write_text_file", { params }); return {}; } public async readTextFile(params: acp.ReadTextFileRequest): Promise { + // Inert ACP filesystem stub; workspace reads are provided by MCP tools. emitTranscript("fs/read_text_file", { params }); return { content: "" }; } diff --git a/packages/web-demo/src/acp-demo.ts b/packages/web-demo/src/acp-demo.ts index 19d8be0..3e8d3d9 100644 --- a/packages/web-demo/src/acp-demo.ts +++ b/packages/web-demo/src/acp-demo.ts @@ -21,14 +21,18 @@ class DemoClient { } public async requestPermission(): Promise { + // The demo client implements the ACP client surface, but workspace access in + // this app is provided by browser MCP workspace tools. return { outcome: { outcome: "cancelled" } }; } public async writeTextFile(): Promise { + // Inert ACP filesystem stub; workspace writes are provided by MCP tools. return {}; } public async readTextFile(): Promise { + // Inert ACP filesystem stub; workspace reads are provided by MCP tools. return { content: "" }; } }