From ef8e0f60374d2f16895f2b8737e739284ab46ab5 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:00:52 -0500 Subject: [PATCH 1/2] Implement ACP auth and session modes --- README.md | 4 +- common/reviews/agent-core.public.api.md | 4 +- common/reviews/common.public.api.md | 8 +- .../src/agent-prompt-cancellation.test.ts | 153 +++++++++++++++++- packages/agent-core/src/agent.ts | 104 +++++++++++- packages/common/src/session-events.ts | 12 ++ 6 files changed, 272 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 061dcfd..bddd2cc 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,10 @@ Workspace capabilities are MCP-first. File reads, file writes, directory listing 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. +Fledgling advertises no ACP authentication methods. Sessions support `read` and `write` modes; `write` is the default, while `read` hides known first-party workspace mutation and command tools from model turns. + ## Current Limitations - Prompt input is treated mostly as text; rich ACP prompt parts are not yet preserved semantically. -- ACP authentication, session modes, model selection, and permission prompts are still minimal or stubbed. +- ACP 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/common/reviews/agent-core.public.api.md b/common/reviews/agent-core.public.api.md index c7a41ea..ee09ed6 100644 --- a/common/reviews/agent-core.public.api.md +++ b/common/reviews/agent-core.public.api.md @@ -47,14 +47,14 @@ export function extractPromptText(params: PromptRequest): string; // @public export class FledglingAgent implements acp.Agent { constructor(connection: acp.AgentSideConnection, dependencies: FledglingAgentDependencies); - authenticate(_params: acp.AuthenticateRequest): Promise; + authenticate(params: acp.AuthenticateRequest): Promise; cancel(_params: acp.CancelNotification): Promise; closeAllSessions(reason: string): Promise; initialize(_params: acp.InitializeRequest): Promise; loadSession(params: acp.LoadSessionRequest): Promise; newSession(params: acp.NewSessionRequest): Promise; prompt(params: acp.PromptRequest): Promise; - setSessionMode(_params: acp.SetSessionModeRequest): Promise; + setSessionMode(params: acp.SetSessionModeRequest): Promise; } // @public diff --git a/common/reviews/common.public.api.md b/common/reviews/common.public.api.md index c8fca9f..52634eb 100644 --- a/common/reviews/common.public.api.md +++ b/common/reviews/common.public.api.md @@ -78,7 +78,7 @@ export type SessionErrorEvent = SessionEventBase & { }; // @public -export type SessionEvent = SessionCreatedEvent | SessionLoadedEvent | UserMessageEvent | AssistantMessageEvent | ToolCallEvent | ToolResultEvent | SessionErrorEvent | CompactionEvent; +export type SessionEvent = SessionCreatedEvent | SessionLoadedEvent | SessionModeChangedEvent | UserMessageEvent | AssistantMessageEvent | ToolCallEvent | ToolResultEvent | SessionErrorEvent | CompactionEvent; // @public export interface SessionEventBase { @@ -94,6 +94,12 @@ export type SessionLoadedEvent = SessionEventBase & { readonly source: string; }; +// @public +export type SessionModeChangedEvent = SessionEventBase & { + readonly type: "session.mode_changed"; + readonly modeId: string; +}; + // @public export const TOOL_META_KEY: string; diff --git a/packages/acp-agent/src/agent-prompt-cancellation.test.ts b/packages/acp-agent/src/agent-prompt-cancellation.test.ts index 4143fc6..2c41ab7 100644 --- a/packages/acp-agent/src/agent-prompt-cancellation.test.ts +++ b/packages/acp-agent/src/agent-prompt-cancellation.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ToolSet } from "ai"; import type { SessionEvent } from "@fledgling/common"; import { FileSystemSessionManager } from "@fledgling/session-file-system"; @@ -66,6 +67,82 @@ describe("FledglingAgent prompt cancellation", () => { sse: true } }); + expect(response.authMethods).toEqual([]); + }); + + it("rejects unsupported ACP authentication methods", async () => { + const { agent } = await createTestAgent(); + + await expect(agent.authenticate({ methodId: "token" })).rejects.toThrow( + "Unsupported ACP authentication method: token" + ); + }); + + it("returns write mode as the default mode for new sessions", async () => { + const { FledglingAgent } = await import("./agent.js"); + const { manager, tempDir: createdTempDir } = await createTempSessionManager(); + const agent = new FledglingAgent(createFakeConnection() as never, { + toolProvider: { + createSessionTools: vi.fn(async () => ({ clients: [], tools: {} })) + }, + sessionManager: manager, + modelTurnRunner: { + runModelTurn: vi.fn() + } + }); + + const response = await agent.newSession({ cwd: createdTempDir, mcpServers: [] }); + + expect(typeof response.sessionId).toBe("string"); + expect(response).toEqual({ + sessionId: response.sessionId, + modes: { + currentModeId: "write", + availableModes: [ + { + id: "read", + name: "Read", + description: "Inspect the workspace without file mutations or command execution." + }, + { + id: "write", + name: "Write", + description: "Use all available workspace tools, including writes and command execution." + } + ] + } + }); + }); + + it("sets session mode, emits a mode update, and persists the change", async () => { + const { agent, sessionId, sessionFile, sessionUpdates } = await createTestAgent(); + + await expect(agent.setSessionMode({ sessionId, modeId: "read" })).resolves.toEqual({}); + + expect(sessionUpdates).toEqual([ + { + sessionId, + update: { + sessionUpdate: "current_mode_update", + currentModeId: "read" + } + } + ]); + expect(await loadStoredEvents(sessionFile)).toEqual([ + expect.objectContaining({ type: "session.created" }), + expect.objectContaining({ type: "session.mode_changed", modeId: "read" }) + ]); + }); + + it("rejects unknown session mode requests", async () => { + const { agent, sessionId } = await createTestAgent(); + + await expect(agent.setSessionMode({ sessionId: "missing", modeId: "read" })).rejects.toThrow( + "Unknown ACP session: missing" + ); + await expect(agent.setSessionMode({ sessionId, modeId: "delete" })).rejects.toThrow( + "Unsupported ACP session mode: delete" + ); }); it("streams text responses and persists user and assistant messages", async () => { @@ -166,6 +243,74 @@ describe("FledglingAgent prompt cancellation", () => { ]); }); + it("filters known mutating workspace tools in read mode", async () => { + const tools = createNamedTools([ + "workspace_read_file", + "workspace_list_directory", + "workspace_search_text", + "workspace_write_file", + "workspace_replace_range", + "workspace_run_command", + "workspace.read_file", + "workspace.write_file", + "workspace.run_command", + "external_mutate" + ]); + const { agent, sessionId, streamText } = await createTestAgent({ tools }); + streamText.mockReturnValueOnce(createImmediateStream([])); + + await agent.setSessionMode({ sessionId, modeId: "read" }); + await expect(agent.prompt({ sessionId, prompt: [{ type: "text", text: "inspect" }] })).resolves.toEqual({ + stopReason: "end_turn" + }); + + const [[modelRequest]] = streamText.mock.calls as [[{ readonly tools: ToolSet }]]; + expect(Object.keys(modelRequest.tools).sort()).toEqual([ + "external_mutate", + "workspace.read_file", + "workspace_list_directory", + "workspace_read_file", + "workspace_search_text" + ]); + }); + + it("keeps all tools in write mode", async () => { + const tools = createNamedTools(["workspace_read_file", "workspace_write_file", "workspace_run_command"]); + const { agent, sessionId, streamText } = await createTestAgent({ tools }); + streamText.mockReturnValueOnce(createImmediateStream([])); + + await expect(agent.prompt({ sessionId, prompt: [{ type: "text", text: "change" }] })).resolves.toEqual({ + stopReason: "end_turn" + }); + + const [[modelRequest]] = streamText.mock.calls as [[{ readonly tools: ToolSet }]]; + expect(Object.keys(modelRequest.tools).sort()).toEqual([ + "workspace_read_file", + "workspace_run_command", + "workspace_write_file" + ]); + }); + + it("restores persisted mode when loading a session", async () => { + const tools = createNamedTools(["workspace_read_file", "workspace_write_file"]); + const { agent, sessionId, sessionManager, streamText, tempDir } = await createTestAgent({ tools }); + streamText.mockReturnValueOnce(createImmediateStream([])); + await sessionManager.appendEvent({ + ...sessionManager.createEventBase(sessionId), + type: "session.mode_changed", + modeId: "read" + }); + + const loadResponse = await agent.loadSession({ sessionId, cwd: tempDir, mcpServers: [] }); + expect(loadResponse.modes?.currentModeId).toBe("read"); + await expect(agent.prompt({ sessionId, prompt: [{ type: "text", text: "inspect" }] })).resolves.toEqual({ + stopReason: "end_turn" + }); + + const [[modelRequest]] = streamText.mock.calls as [[{ readonly tools: ToolSet }]]; + expect(Object.keys(modelRequest.tools)).toEqual(["workspace_read_file"]); + }); + it("normalizes model start failures into diagnostics and durable errors", async () => { const { agent, sessionId, streamText, sessionFile, sessionUpdates } = await createTestAgent(); streamText.mockImplementationOnce(() => { @@ -425,7 +570,7 @@ describe("FledglingAgent prompt cancellation", () => { await expect(agent.newSession({ cwd: sessionStore.tempDir, mcpServers: [] })).rejects.toThrow("setup failed"); }); - async function createTestAgent(): Promise<{ + async function createTestAgent(options: { readonly tools?: ToolSet } = {}): Promise<{ readonly agent: FledglingAgent; readonly sessionId: string; readonly sessionFile: string; @@ -440,7 +585,7 @@ describe("FledglingAgent prompt cancellation", () => { const { FledglingAgent } = await import("./agent.js"); const agent = new FledglingAgent(createFakeConnection(sessionUpdates) as never, { toolProvider: { - createSessionTools: vi.fn(async () => ({ clients: [], tools: {} })) + createSessionTools: vi.fn(async () => ({ clients: [], tools: options.tools ?? {} })) }, sessionManager, modelTurnRunner: { @@ -469,6 +614,10 @@ interface FakeSessionUpdate { }; } +function createNamedTools(toolNames: readonly string[]): ToolSet { + return Object.fromEntries(toolNames.map((toolName) => [toolName, {}])) as ToolSet; +} + function createFakeConnection(sessionUpdates: FakeSessionUpdate[] = []): { sessionUpdate(params: FakeSessionUpdate): Promise } { return { async sessionUpdate(params: FakeSessionUpdate): Promise { diff --git a/packages/agent-core/src/agent.ts b/packages/agent-core/src/agent.ts index 1b3fbfd..d2fc27c 100644 --- a/packages/agent-core/src/agent.ts +++ b/packages/agent-core/src/agent.ts @@ -1,5 +1,6 @@ import * as acp from "@agentclientprotocol/sdk"; import { buildContext } from "@fledgling/context-builder"; +import type { SessionEvent } from "@fledgling/common"; import type { CoreMessage, ToolSet } from "ai"; import type { FledglingAgentDependencies, IClosable } from "./interfaces.js"; @@ -25,10 +26,36 @@ interface SessionState { readonly clients: IClosable[]; readonly tools: ToolSet; readonly toolCallNames: Map; + modeId: FledglingSessionModeId; pendingPrompt: AbortController | undefined; promptQueue: Promise; } +type FledglingSessionModeId = "read" | "write"; + +const DEFAULT_SESSION_MODE_ID: FledglingSessionModeId = "write"; + +const SESSION_MODES: readonly acp.SessionMode[] = [ + { + id: "read", + name: "Read", + description: "Inspect the workspace without file mutations or command execution." + }, + { + id: "write", + name: "Write", + description: "Use all available workspace tools, including writes and command execution." + } +]; + +const MUTATING_TOOL_NAMES: ReadonlySet = new Set([ + "workspace_write_file", + "workspace_replace_range", + "workspace_run_command", + "workspace.write_file", + "workspace.run_command" +]); + /** ACP agent implementation that manages sessions, model turns, tools, and event persistence. */ export class FledglingAgent implements acp.Agent { readonly #connection: acp.AgentSideConnection; @@ -57,7 +84,8 @@ export class FledglingAgent implements acp.Agent { http: true, sse: true } - } + }, + authMethods: [] }; } @@ -74,6 +102,7 @@ export class FledglingAgent implements acp.Agent { clients, tools, toolCallNames: new Map(), + modeId: DEFAULT_SESSION_MODE_ID, pendingPrompt: undefined, promptQueue: Promise.resolve() }; @@ -87,7 +116,8 @@ export class FledglingAgent implements acp.Agent { }); return { - sessionId: session.id + sessionId: session.id, + modes: createSessionModeState(session.modeId) }; } @@ -100,6 +130,7 @@ export class FledglingAgent implements acp.Agent { mcpServers: params.mcpServers }); const existingSession = this.#sessions.get(params.sessionId); + const modeId = restoreSessionMode(events); const session: SessionState = { id: params.sessionId, cwd: params.cwd, @@ -107,6 +138,7 @@ export class FledglingAgent implements acp.Agent { clients, tools, toolCallNames: new Map(), + modeId, pendingPrompt: undefined, promptQueue: Promise.resolve() }; @@ -124,16 +156,38 @@ export class FledglingAgent implements acp.Agent { }); await replaySessionHistory(this.#connection, session); - return {}; + return { + modes: createSessionModeState(session.modeId) + }; } /** Handles ACP authentication requests. */ - public async authenticate(_params: acp.AuthenticateRequest): Promise { - return {}; + public async authenticate(params: acp.AuthenticateRequest): Promise { + throw new Error(`Unsupported ACP authentication method: ${sanitizeErrorMessage(params.methodId)}`); } /** Accepts ACP session mode updates. */ - public async setSessionMode(_params: acp.SetSessionModeRequest): Promise { + public async setSessionMode(params: acp.SetSessionModeRequest): Promise { + const session = this.#sessions.get(params.sessionId); + if (!session) { + throw new Error(`Unknown ACP session: ${sanitizeErrorMessage(params.sessionId)}`); + } + + const modeId = parseSessionModeId(params.modeId); + session.modeId = modeId; + await this.#dependencies.sessionManager.appendEvent({ + ...this.#dependencies.sessionManager.createEventBase(session.id), + type: "session.mode_changed", + modeId + }); + await this.#connection.sessionUpdate({ + sessionId: session.id, + update: { + sessionUpdate: "current_mode_update", + currentModeId: modeId + } + }); + return {}; } @@ -180,7 +234,7 @@ export class FledglingAgent implements acp.Agent { try { result = this.#dependencies.modelTurnRunner.runModelTurn({ messages: session.history, - tools: session.tools, + tools: toolsForMode(session.tools, session.modeId), abortSignal: promptController.signal }); } catch (error: unknown) { @@ -418,3 +472,39 @@ async function replaySessionHistory(connection: acp.AgentSideConnection, session }); } } + +function createSessionModeState(modeId: FledglingSessionModeId): acp.SessionModeState { + return { + currentModeId: modeId, + availableModes: [...SESSION_MODES] + }; +} + +function parseSessionModeId(modeId: string): FledglingSessionModeId { + if (modeId === "read" || modeId === "write") { + return modeId; + } + + throw new Error(`Unsupported ACP session mode: ${sanitizeErrorMessage(modeId)}`); +} + +function restoreSessionMode(events: readonly SessionEvent[]): FledglingSessionModeId { + let modeId: FledglingSessionModeId = DEFAULT_SESSION_MODE_ID; + for (const event of events) { + if (event.type === "session.mode_changed") { + modeId = parseSessionModeId(event.modeId); + } + } + + return modeId; +} + +function toolsForMode(tools: ToolSet, modeId: FledglingSessionModeId): ToolSet { + if (modeId === "write") { + return tools; + } + + return Object.fromEntries( + Object.entries(tools).filter(([toolName]) => !MUTATING_TOOL_NAMES.has(toolName)) + ) as ToolSet; +} diff --git a/packages/common/src/session-events.ts b/packages/common/src/session-events.ts index 650ed7d..b0d96f3 100644 --- a/packages/common/src/session-events.ts +++ b/packages/common/src/session-events.ts @@ -19,6 +19,7 @@ export interface ContextMessage { export type SessionEvent = | SessionCreatedEvent | SessionLoadedEvent + | SessionModeChangedEvent | UserMessageEvent | AssistantMessageEvent | ToolCallEvent @@ -74,6 +75,17 @@ export type SessionLoadedEvent = SessionEventBase & { readonly source: string; }; +/** + * Event recorded when an ACP session mode changes. + */ +export type SessionModeChangedEvent = SessionEventBase & { + /** Event discriminator. */ + readonly type: "session.mode_changed"; + + /** Active ACP session mode after the change. */ + readonly modeId: string; +}; + /** * Event containing a user message. */ From b2d60c9ac62a068f134cafe668dde743afbb3b5f Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:17:22 -0500 Subject: [PATCH 2/2] Filter replace range in read mode --- packages/acp-agent/src/agent-prompt-cancellation.test.ts | 1 + packages/agent-core/src/agent.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/acp-agent/src/agent-prompt-cancellation.test.ts b/packages/acp-agent/src/agent-prompt-cancellation.test.ts index 2c41ab7..061cdff 100644 --- a/packages/acp-agent/src/agent-prompt-cancellation.test.ts +++ b/packages/acp-agent/src/agent-prompt-cancellation.test.ts @@ -253,6 +253,7 @@ describe("FledglingAgent prompt cancellation", () => { "workspace_run_command", "workspace.read_file", "workspace.write_file", + "workspace.replace_range", "workspace.run_command", "external_mutate" ]); diff --git a/packages/agent-core/src/agent.ts b/packages/agent-core/src/agent.ts index d2fc27c..cdeffbf 100644 --- a/packages/agent-core/src/agent.ts +++ b/packages/agent-core/src/agent.ts @@ -53,6 +53,7 @@ const MUTATING_TOOL_NAMES: ReadonlySet = new Set([ "workspace_replace_range", "workspace_run_command", "workspace.write_file", + "workspace.replace_range", "workspace.run_command" ]);