From 8b31dea11bed54f86c41217759159c415611346c Mon Sep 17 00:00:00 2001 From: nikita-ashihmin Date: Thu, 6 Aug 2026 17:01:56 +0400 Subject: [PATCH] feat: expose provider-neutral ACP goal extension (#964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - advertise a provider-neutral goal extension in `InitializeResponse._meta.goal` - map Claude SDK `active_goal` messages to `session_info_update._meta.goal` - expose the shared `_session/goal` control method with the truthfully supported `clear` action - document the extension as a candidate shape for a future first-class ACP goal API ## Architecture A goal is persistent **session state**, not the lifecycle of one `session/prompt` request. `goal.status === "active"` says that the objective may drive more work; it does not say that a prompt is executing now. This keeps two independent axes: 1. **Goal state** is published asynchronously as session metadata and may survive multiple prompt turns. 2. **Prompt activity** follows the backend's actual running/quiescent boundaries. While a turn runs, clients use advertised steering or prompt queueing. Once the backend is quiescent, an ordinary `session/prompt` is valid even if the goal remains active. The metadata is deliberately provider-neutral: ```json { "_meta": { "goal": { "version": 1, "controlMethod": "_session/goal", "actions": ["clear"] } } } ``` State is emitted as `session_info_update._meta.goal`; `null` clears it. The common snapshot vocabulary supports `objective`, common statuses, timestamps in Unix milliseconds, budgets/usage, iteration count, and a continuation reason. Each implementation emits only fields it actually knows. This shape is intentionally close to a future typed ACP capability/update so promotion out of `_meta` would not require a provider-specific migration. Control actions are capability-negotiated. The shared vocabulary reserves `set`, `pause`, `resume`, and `clear`, but an agent advertises only the subset it can implement with correct semantics. ## Claude mapping The Claude runtime already emits internal `active_goal` messages, although that message is not currently included in the public `SDKMessage` union. The adapter handles it before the exhaustive switch: - `condition` → `objective` - `iterations` → `iterations` - `set_at` → `createdAt` - `last_reason` → `lastReason` - `value: null` → `goal: null` Provider bookkeeping such as `tokens_at_start` is intentionally not exposed. Goal updates do not settle, extend, or otherwise mutate ACP prompt lifecycle. Claude's existing Stop hook remains authoritative for whether the backend itself continues working. Only `clear` is advertised for out-of-band control. It submits `/goal clear` through the existing session prompt queue, preserving the adapter's established ordering and turn ownership. Setting a goal remains available through `/goal ` but is not advertised as a control action because it starts the long-running Stop-hook workflow. The companion Codex implementation is agentclientprotocol/codex-acp#371. ## Verification - `npm run test:run` — 692 passed, 20 skipped - `npm run build` - `npm run check` --- README.md | 1 + docs/goal-extension.md | 55 +++++++++++++++++ src/acp-agent.ts | 44 ++++++++++++++ src/goal-extension.ts | 64 ++++++++++++++++++++ src/tests/acp-agent.test.ts | 114 ++++++++++++++++++++++++++++++++++++ 5 files changed, 278 insertions(+) create mode 100644 docs/goal-extension.md create mode 100644 src/goal-extension.ts diff --git a/README.md b/README.md index 0c6c899c..09ea678e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https - Interactive (and background) terminals - Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands) - Client MCP servers +- Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md) Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/). diff --git a/docs/goal-extension.md b/docs/goal-extension.md new file mode 100644 index 00000000..ee377425 --- /dev/null +++ b/docs/goal-extension.md @@ -0,0 +1,55 @@ +# Goal extension + +This document defines a provider-neutral experimental ACP extension implemented by `claude-agent-acp`. It is intentionally shaped like a possible future first-class ACP API: implementations publish `_meta.goal`, not provider-specific metadata such as `_meta.claudeCode.goal`. + +## Capability negotiation + +An agent advertises support in its `initialize` response: + +```json +{ + "_meta": { + "goal": { + "version": 1, + "controlMethod": "_session/goal", + "actions": ["clear"] + } + } +} +``` + +`actions` is the implementation-supported subset of `set`, `pause`, `resume`, and `clear`. Clients must not infer support for an action that is not advertised. The control request contains `sessionId` and `action`; a future version may add action-specific fields such as an objective for `set`. + +## Session state + +The current snapshot is published in `session_info_update._meta.goal`. Clearing a goal publishes `goal: null`. + +```json +{ + "objective": "Ship the change", + "status": "active", + "iterations": 3, + "lastReason": "Tests still need work", + "createdAt": 1710000000123, + "controlMethod": "_session/goal" +} +``` + +Common statuses are `active`, `paused`, `blocked`, `limited`, and `complete`. Optional fields allow implementations to report budgets, usage, iteration count, and the last continuation reason. Timestamps are Unix milliseconds. + +## Lifecycle architecture + +A goal belongs to the ACP session, not to an individual `session/prompt` request. Goal activity and prompt activity are independent: + +- `status: active` means the persistent objective can drive more work; it does not mean an ACP prompt is currently executing. +- A prompt completes when its current backend turn reaches a quiescent boundary, even when the goal remains active. +- A later autonomous cycle may publish more session updates outside that completed prompt. +- While a turn is running, clients use steering or prompt queueing when advertised. While the session is quiescent, clients may send an ordinary `session/prompt`. + +This separation prevents a persistent goal from monopolizing the session's prompt slot and lets clients model “working now” independently from “objective remains active.” + +## Claude mapping + +Claude's session-scoped `/goal` command installs a Stop hook. The runtime emits internal `active_goal` messages when that hook updates or clears its state; the adapter maps them into the neutral snapshot before the exhaustive SDK message switch. `condition`, `iterations`, `set_at`, and `last_reason` become `objective`, `iterations`, `createdAt`, and `lastReason`. Provider-only bookkeeping such as `tokens_at_start` is not exposed. + +Only `clear` is advertised for out-of-band control. The adapter submits `/goal clear` through the existing session prompt queue, preserving the same ordering and lifecycle rules as other Claude commands. Setting a goal remains available through `/goal ` and is not advertised as an extension action because it starts Claude's long-running Stop-hook workflow. diff --git a/src/acp-agent.ts b/src/acp-agent.ts index ff7262ca..7c95bf93 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -77,6 +77,7 @@ import { Query, query, SDKAssistantMessageError, + SDKActiveGoalMessage, SDKMessage, SDKMessageOrigin, SDKPartialAssistantMessage, @@ -84,6 +85,16 @@ import { SlashCommand, ThinkingConfig, } from "@anthropic-ai/claude-agent-sdk"; +import { + GOAL_ACTIONS, + GOAL_CONTROL_METHOD, + GOAL_EXTENSION_VERSION, + GoalCapability, + GoalRequest, + GoalControlResponse, + parseGoalRequest, + toGoalSnapshot, +} from "./goal-extension.js"; import { ContentBlockParam } from "@anthropic-ai/sdk/resources"; import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs"; import { execFile } from "node:child_process"; @@ -1596,6 +1607,11 @@ export class ClaudeAcpAgent { steering: { supported: true, }, + goal: { + version: GOAL_EXTENSION_VERSION, + controlMethod: GOAL_CONTROL_METHOD, + actions: [...GOAL_ACTIONS], + } satisfies GoalCapability, }, }; } @@ -1894,6 +1910,14 @@ export class ClaudeAcpAgent { return response; } + async goal(params: GoalRequest): Promise { + await this.prompt({ + sessionId: params.sessionId, + prompt: [{ type: "text", text: "/goal clear" }], + }); + return {}; + } + /** Steer the session per the ACP steering wire protocol: inject a follow-up * message into the turn that is currently running. If that turn already * settled, the established default starts a new detached turn; Hosts may opt @@ -2635,6 +2659,21 @@ export class ClaudeAcpAgent { continue; } + // `active_goal` is emitted by the Claude runtime but is not currently + // included in the public SDKMessage union. Handle it before the + // exhaustive switch and publish only the provider-neutral ACP shape. + if ((message as { type: string }).type === "active_goal") { + const activeGoal = message as unknown as SDKActiveGoalMessage; + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "session_info_update", + _meta: { goal: toGoalSnapshot(activeGoal) }, + }, + }); + continue; + } + switch (message.type) { case "system": switch (message.subtype) { @@ -7879,6 +7918,11 @@ export function runAcp() { .onRequest(STEER_METHOD, { parse: parseSteerRequest }, (ctx) => agent.steer(ctx.params), ) + .onRequest( + GOAL_CONTROL_METHOD, + { parse: parseGoalRequest }, + (ctx) => agent.goal(ctx.params), + ) .connect(stream); agent = new ClaudeAcpAgent(new ClientConnection(connection.client)); diff --git a/src/goal-extension.ts b/src/goal-extension.ts new file mode 100644 index 00000000..45394935 --- /dev/null +++ b/src/goal-extension.ts @@ -0,0 +1,64 @@ +import { RequestError } from "@agentclientprotocol/sdk"; +import type { SDKActiveGoalMessage } from "@anthropic-ai/claude-agent-sdk"; + +export const GOAL_EXTENSION_VERSION = 1 as const; +export const GOAL_CONTROL_METHOD = "_session/goal"; + +export const GOAL_ACTIONS = ["clear"] as const; +export type GoalAction = (typeof GOAL_ACTIONS)[number]; + +export type GoalCapability = { + version: typeof GOAL_EXTENSION_VERSION; + controlMethod: typeof GOAL_CONTROL_METHOD; + actions: GoalAction[]; +}; + +export type GoalStatus = "active" | "paused" | "blocked" | "limited" | "complete"; + +export type GoalSnapshot = { + objective: string; + status: GoalStatus; + iterations?: number; + lastReason?: string | null; + createdAt?: number; + updatedAt?: number; + tokenBudget?: number | null; + tokensUsed?: number; + timeUsedSeconds?: number; + controlMethod: typeof GOAL_CONTROL_METHOD; +}; + +export type GoalRequest = { + sessionId: string; + action: GoalAction; +}; + +export type GoalControlResponse = Record; + +export function parseGoalRequest(params: unknown): GoalRequest { + if (!params || typeof params !== "object") { + throw RequestError.invalidParams(undefined, "goal params must be an object"); + } + const { sessionId, action } = params as Record; + if (typeof sessionId !== "string" || sessionId.length === 0) { + throw RequestError.invalidParams(undefined, "goal params require a non-empty sessionId"); + } + if (!GOAL_ACTIONS.includes(action as GoalAction)) { + throw RequestError.invalidParams(undefined, 'goal action must be "clear"'); + } + return { sessionId, action: action as GoalAction }; +} + +export function toGoalSnapshot(message: SDKActiveGoalMessage): GoalSnapshot | null { + if (message.value === null) { + return null; + } + return { + objective: message.value.condition.trim(), + status: "active", + iterations: message.value.iterations, + lastReason: message.value.last_reason ?? null, + createdAt: message.value.set_at, + controlMethod: GOAL_CONTROL_METHOD, + }; +} diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 88420c47..b70c5e93 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -54,6 +54,7 @@ import { SDKAssistantMessage, } from "@anthropic-ai/claude-agent-sdk"; import { randomUUID } from "crypto"; +import { GOAL_CONTROL_METHOD, parseGoalRequest, toGoalSnapshot } from "../goal-extension.js"; vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => { const actual = await importOriginal(); @@ -3299,6 +3300,93 @@ describe("stop reason propagation", () => { expect(errors.filter((e) => e.includes("Unexpected case"))).toEqual([]); }); + it("publishes active_goal as provider-neutral session state without changing turn settlement", async () => { + const updates: any[] = []; + const mockClient = { + sessionUpdate: async (notification: any) => updates.push(notification), + } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + + injectGeneratorSession(agent, (input) => { + async function* messageGenerator() { + const iter = input[Symbol.asyncIterator](); + const { value: userMessage } = await iter.next(); + yield userEcho(userMessage); + yield { + type: "active_goal", + value: { + condition: " Finish the migration ", + iterations: 3, + set_at: 1710000000123, + tokens_at_start: 42, + last_reason: "Tests still need work", + }, + uuid: randomUUID(), + session_id: "test-session", + }; + yield createResultMessage({ subtype: "success", stop_reason: "end_turn", is_error: false }); + yield { + type: "active_goal", + value: null, + uuid: randomUUID(), + session_id: "test-session", + }; + yield { type: "system", subtype: "session_state_changed", state: "idle" }; + } + return messageGenerator(); + }); + + await expect( + agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }), + ).resolves.toEqual(expect.objectContaining({ stopReason: "end_turn" })); + expect(updates).toContainEqual({ + sessionId: "test-session", + update: { + sessionUpdate: "session_info_update", + _meta: { + goal: { + objective: "Finish the migration", + status: "active", + iterations: 3, + lastReason: "Tests still need work", + createdAt: 1710000000123, + controlMethod: GOAL_CONTROL_METHOD, + }, + }, + }, + }); + expect(updates).toContainEqual({ + sessionId: "test-session", + update: { sessionUpdate: "session_info_update", _meta: { goal: null } }, + }); + expect(updates.some((notification) => notification.update?._meta?.claudeCode?.goal)).toBe( + false, + ); + }); + + it("maps the SDK goal shape without exposing provider fields", () => { + expect( + toGoalSnapshot({ + type: "active_goal", + value: { + condition: "Goal", + iterations: 1, + set_at: 1710000000000, + tokens_at_start: 99, + }, + uuid: randomUUID(), + session_id: "test-session", + }), + ).toEqual({ + objective: "Goal", + status: "active", + iterations: 1, + lastReason: null, + createdAt: 1710000000000, + controlMethod: GOAL_CONTROL_METHOD, + }); + }); + it("settles a no-echo command result (e.g. /compact) by promoting the head turn", async () => { // Regression: /compact never echoes a user message carrying the prompt's // uuid (its only user messages are the generated summary and a @@ -9905,6 +9993,32 @@ describe("turn steering (_session/steering)", () => { expect((response._meta as any)?.steering).toEqual({ supported: true, }); + expect((response._meta as any)?.goal).toEqual({ + version: 1, + controlMethod: GOAL_CONTROL_METHOD, + actions: ["clear"], + }); + }); + + it("submits clear through the session prompt queue", async () => { + const agent = createMockAgent(); + const prompt = vi.spyOn(agent, "prompt").mockResolvedValue({ stopReason: "end_turn" }); + + await expect(agent.goal({ sessionId: "test-session", action: "clear" })).resolves.toEqual({}); + expect(prompt).toHaveBeenCalledWith({ + sessionId: "test-session", + prompt: [{ type: "text", text: "/goal clear" }], + }); + }); + + it("validates goal control requests", () => { + expect(parseGoalRequest({ sessionId: "test-session", action: "clear" })).toEqual({ + sessionId: "test-session", + action: "clear", + }); + expect(() => parseGoalRequest({ sessionId: "test-session", action: "pause" })).toThrow( + 'goal action must be "clear"', + ); }); it("preserves startedNewTurn by default when no turn is in flight", async () => {