Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions common/reviews/agent-core.public.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<acp.AuthenticateResponse>;
authenticate(params: acp.AuthenticateRequest): Promise<acp.AuthenticateResponse>;
cancel(_params: acp.CancelNotification): Promise<void>;
closeAllSessions(reason: string): Promise<void>;
initialize(_params: acp.InitializeRequest): Promise<acp.InitializeResponse>;
loadSession(params: acp.LoadSessionRequest): Promise<acp.LoadSessionResponse>;
newSession(params: acp.NewSessionRequest): Promise<acp.NewSessionResponse>;
prompt(params: acp.PromptRequest): Promise<acp.PromptResponse>;
setSessionMode(_params: acp.SetSessionModeRequest): Promise<acp.SetSessionModeResponse>;
setSessionMode(params: acp.SetSessionModeRequest): Promise<acp.SetSessionModeResponse>;
}

// @public
Expand Down
8 changes: 7 additions & 1 deletion common/reviews/common.public.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;

Expand Down
154 changes: 152 additions & 2 deletions packages/acp-agent/src/agent-prompt-cancellation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -166,6 +243,75 @@ 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.replace_range",
"workspace.run_command",
"external_mutate"
]);
Comment thread
nick-pape marked this conversation as resolved.
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(() => {
Expand Down Expand Up @@ -425,7 +571,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;
Expand All @@ -440,7 +586,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: {
Expand Down Expand Up @@ -469,6 +615,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<void> } {
return {
async sessionUpdate(params: FakeSessionUpdate): Promise<void> {
Expand Down
Loading
Loading