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
22 changes: 22 additions & 0 deletions docs/external-control-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,28 @@ Air-created Git worktrees are supported because each ACP request's absolute `cwd
Session title and update metadata are advisory state for the active ACP process. Text, thought, tool-call, and tool-result history is replayed on load, but historical binary image bytes are not replayed.

See [Environment Variables](./environment-variables.md#11-acp-permission-handling) for supported values and precedence.
## Paseo custom agent

[Paseo](https://github.com/getpaseo/paseo) registers GJC as a generic ACP provider through its custom provider configuration. Add this entry to `$PASEO_HOME/config.json` (default `~/.paseo/config.json`); Paseo then lists **Gajae Code** in its provider picker with GJC's model catalog and Default/Plan modes:

```json
{
"version": 1,
"agents": {
"providers": {
"gjc": {
"extends": "acp",
"label": "Gajae Code",
"command": ["gjc", "acp"]
}
}
}
}
```

GJC's ACP session configuration carries the spec-defined `category` on the Mode, Model, and Thinking select options (`mode`, `model`, `thought_level`), which lets ACP clients such as Paseo discover models and thinking levels without provider-specific metadata. The model catalog is filtered to providers with usable stored credentials (`providers.list/active`), falling back to the full catalog on session hosts that do not expose that query.

Sessions launched through an ACP client (e.g. `paseo run --provider gjc/...`) are broker-managed and appear in ACP `session/list`, so Paseo's import flow can attach them. Interactive `gjc` sessions host their own SDK endpoint and are not broker-registered, so they are not listed by ACP clients; use the GJC SDK/notifications surface to control those sessions.

## ACP conformance and Air release gates

Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## [Unreleased]

### Fixed
- ACP session configuration now emits the spec-defined `category` field on the Mode, Model, and Thinking select options (`mode`, `model`, `thought_level`), so standards-compliant ACP clients such as Paseo discover models, modes, and thinking levels instead of an empty model picker (#3922).
- The ACP session model catalog is now filtered to active providers via `providers.list/active`, falling back to the full catalog on older session hosts, so ACP clients no longer list models for providers without usable credentials (#3922).

- `/model` reasoning menu header now shows the highlighted reasoning level (not the model id), seeds the cursor from the role badge when re-editing the same model, and uses a provider-neutral label for `max` instead of "Opus maximum reasoning" (#3847).
- Resume listing now reverse-scans for buried but canonically valid `header_patch` titles, so a persisted manual title remains visible in the picker after later transcript growth instead of falling back to an empty/line-1 projection (#3633).
Expand Down

Large diffs are not rendered by default.

130 changes: 123 additions & 7 deletions packages/coding-agent/src/modes/acp/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,8 +403,8 @@ function receivedSdkEvent(frame: JsonObject): ReceivedSdkEvent | undefined {
}

const ACP_CONFIG_OPTIONS = [
{ id: MODEL_CONFIG_ID, name: "Model", options: [] },
{ id: THINKING_CONFIG_ID, name: "Thinking", options: [] },
{ id: MODEL_CONFIG_ID, name: "Model", category: "model", options: [] },
{ id: THINKING_CONFIG_ID, name: "Thinking", category: "thought_level", options: [] },
Comment thread
snowykr marked this conversation as resolved.
{
id: "steeringMode",
name: "Steering queue",
Expand Down Expand Up @@ -466,25 +466,135 @@ function modelPresetConfigOptions(query: unknown, current: string): { value: str
return [...options].map(([value, name]) => ({ value, name }));
}

function modelConfigOptions(query: unknown, current: string | undefined): { value: string; name: string }[] {
function modelConfigOptions(
query: unknown,
current: string | undefined,
activeProviders?: ReadonlySet<string>,
): { value: string; name: string }[] {
const options = new Map<string, string>();
for (const item of pageItems(query)) {
const model = object(item);
if (!model || typeof model.provider !== "string" || typeof model.id !== "string") continue;
if (activeProviders !== undefined && !activeProviders.has(model.provider)) continue;
Comment thread
snowykr marked this conversation as resolved.
const value = `${model.provider}/${model.id}`;
options.set(value, typeof model.name === "string" ? model.name : value);
}
if (current && !options.has(current)) options.set(current, current);
return [...options].map(([value, name]) => ({ value, name }));
}
const MAX_ACTIVE_PROVIDER_PAGES = 100;

/**
* Unsupported-query compatibility fallback. Session hosts without
* `providers.list/active` reject the unknown named query as either
* `operation_not_session_owned` (host knows the registry but not the query)
* or `invalid_request` (pre-Q29 host that predates the registry entry). Both
* keep the full catalog authoritative on the first page; every other failure
* mode fails closed so the active-provider contract is never silently
* widened.
*/
function isUnsupportedQueryError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
((error as { code?: unknown }).code === "operation_not_session_owned" ||
(error as { code?: unknown }).code === "invalid_request")
);
}

function queryPage(query: unknown): { items?: unknown; complete?: unknown; continuationCursor?: unknown } | undefined {
const response = object(query);
const result = object(response?.result) ?? response;
return object(result?.page);
}

/**
* Collect every page of `providers.list/active` (Q29, GJC >= 0.12.8) and
* return the providers with usable stored credentials or credentialless
* connection kinds, mirroring the TUI model picker's
* `modelRegistry.getAvailable()`. The openwebui-gjc-adapter applies the same
* filter to `/v1/models`. Q29 pages are byte-bounded and can span multiple
* pages when custom provider ids inflate the payload, so all pages are
* consumed before the provider set is built. Returns undefined only when the
* session host rejects the query with `operation_not_session_owned`; any
* other query failure or malformed page is thrown.
*/
export async function collectActiveProviderIds(
adapter: Pick<AcpSdkAdapter, "query">,
): Promise<ReadonlySet<string> | undefined> {
const providers = new Set<string>();
let cursor: string | undefined;
for (let pageCount = 0; pageCount < MAX_ACTIVE_PROVIDER_PAGES; pageCount++) {
let response: unknown;
try {
response = await adapter.query("providers.list/active", {}, cursor);
} catch (error) {
if (cursor === undefined && isUnsupportedQueryError(error)) return undefined;
throw error;
}
const page = queryPage(response);
if (!page) throw new AcpSdkAdapterError("protocol_error", "providers.list/active returned no page.");
const items = page.items;
if (!Array.isArray(items))
throw new AcpSdkAdapterError("protocol_error", "providers.list/active returned a malformed page.");
for (const item of items) {
const record = object(item);
if (!record || typeof record.provider !== "string") continue;
const connectionKind = record.connectionKind;
if (connectionKind === "credential" || connectionKind === "credentialless") providers.add(record.provider);
}
if (page.complete === true) return providers;
if (typeof page.continuationCursor !== "string")
throw new AcpSdkAdapterError(
"protocol_error",
"providers.list/active page is incomplete without a continuation cursor.",
);
cursor = page.continuationCursor;
}
throw new AcpSdkAdapterError("protocol_error", "providers.list/active exceeded the page budget.");
}
const MAX_MODEL_CATALOG_PAGES = 100;

/**
* Collect every page of `models.list/current` (Q10) into one catalog so the
* active-provider filter never drops models that only appear on later pages.
* The SDK pages Q10 at a fixed byte target (256 KiB), which a fully
* configured catalog can exceed. Returns the same
* `{ result: { page: { items } } }` envelope `pageItems` consumes.
*/
export async function collectModelCatalog(adapter: Pick<AcpSdkAdapter, "query">): Promise<unknown> {
const items: unknown[] = [];
let cursor: string | undefined;
for (let pageCount = 0; pageCount < MAX_MODEL_CATALOG_PAGES; pageCount++) {
const response = await adapter.query("models.list/current", {}, cursor);
const page = queryPage(response);
if (!page) throw new AcpSdkAdapterError("protocol_error", "models.list/current returned no page.");
if (!Array.isArray(page.items))
throw new AcpSdkAdapterError("protocol_error", "models.list/current returned a malformed page.");
items.push(...page.items);
if (page.complete === true) return { result: { page: { items } } };
if (typeof page.continuationCursor !== "string")
throw new AcpSdkAdapterError(
"protocol_error",
"models.list/current page is incomplete without a continuation cursor.",
);
cursor = page.continuationCursor;
}
throw new AcpSdkAdapterError("protocol_error", "models.list/current exceeded the page budget.");
}

const THINKING_CONFIG_OPTIONS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"].map(value => ({
value,
name: value,
}));

/** Maps live canonical SDK config and the selected model catalog into the ACP 1.2.1 session state surface. */
export function acpSessionStateFromConfig(query: unknown, modelCatalogQuery?: unknown, modelPreset?: string) {
export function acpSessionStateFromConfig(
query: unknown,
modelCatalogQuery?: unknown,
modelPreset?: string,
activeProviders?: ReadonlySet<string>,
) {
const values = configValues(query);
const useModelPresets = modelPreset !== undefined;
const currentModeId = values.get(MODE_CONFIG_ID) === ACP_PLAN_MODE_ID ? ACP_PLAN_MODE_ID : ACP_DEFAULT_MODE_ID;
Expand All @@ -493,6 +603,7 @@ export function acpSessionStateFromConfig(query: unknown, modelCatalogQuery?: un
{
id: MODE_CONFIG_ID,
name: "Mode",
category: "mode" as const,
type: "select" as const,
currentValue: currentModeId,
options: [
Expand All @@ -510,7 +621,7 @@ export function acpSessionStateFromConfig(query: unknown, modelCatalogQuery?: un
option.id === MODEL_CONFIG_ID
? useModelPresets
? modelPresetConfigOptions(modelCatalogQuery, value)
: modelConfigOptions(modelCatalogQuery, value)
: modelConfigOptions(modelCatalogQuery, value, activeProviders)
: option.id === THINKING_CONFIG_ID
? THINKING_CONFIG_OPTIONS
: [...option.options];
Expand Down Expand Up @@ -1799,8 +1910,13 @@ export class AcpAgent implements Agent {
const modelPreset = this.#startupOptions?.modelPreset;
const [config, modelCatalog] = await Promise.all([
record.adapter.query("config.list/get"),
record.adapter.query(modelPreset === undefined ? "models.list/current" : "models.profiles.list"),
modelPreset === undefined ? collectModelCatalog(record.adapter) : record.adapter.query("models.profiles.list"),
Comment thread
snowykr marked this conversation as resolved.
]);
// Resolve usable providers in parallel. Only an older session host that
// rejects `providers.list/active` with `operation_not_session_owned`
// falls back to the full catalog; operational failures fail closed so
// the active-provider contract is not silently widened.
const activeProviders = modelPreset === undefined ? await collectActiveProviderIds(record.adapter) : undefined;
record.authFailure = undefined;
if (modelPreset !== undefined) {
const activePreset = configValues(config).get(MODEL_PRESET_CONFIG_KEY);
Expand All @@ -1815,7 +1931,7 @@ export class AcpAgent implements Agent {
throw new AcpSdkAdapterError("authentication_failed", record.authFailure);
}
}
return acpSessionStateFromConfig(config, modelCatalog, modelPreset);
return acpSessionStateFromConfig(config, modelCatalog, modelPreset, activeProviders);
}

async #publishAvailableCommands(id: string, adapter: AcpSdkAdapter): Promise<void> {
Expand Down
135 changes: 133 additions & 2 deletions packages/coding-agent/test/acp-startup-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
acpSessionStateFromConfig,
applyAcpPermissionMode,
applyAcpStartupOptions,
collectActiveProviderIds,
collectModelCatalog,
createAcpReverseConnection,
paginateAcpSessions,
} from "../src/modes/acp/acp-agent";
Expand Down Expand Up @@ -191,21 +193,150 @@ test("ACP reports model presets when --mpreset is provided", () => {
expect(state.modes.currentModeId).toBe("plan");
expect(state.configOptions).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "mode", currentValue: "plan" }),
expect.objectContaining({ id: "mode", category: "mode", currentValue: "plan" }),
expect.objectContaining({
id: "model",
name: "Preset",
category: "model",
currentValue: "opus-codex",
options: [
{ value: "codex-medium", name: "Codex Medium" },
{ value: "opus-codex", name: "Opus Codex" },
],
}),
expect.objectContaining({ id: "thinking", currentValue: "high" }),
expect.objectContaining({ id: "thinking", category: "thought_level", currentValue: "high" }),
expect.objectContaining({ id: "steeringMode", currentValue: "one-at-a-time" }),
]),
);
});
test("ACP filters the model catalog to active providers and keeps the current model", () => {
const state = acpSessionStateFromConfig(
{
result: {
page: {
items: [
{
mode: "default",
model: "opencode-go/deepseek-v4-flash",
thinking: "high",
},
],
},
},
},
{
result: {
page: {
items: [
{ provider: "opencode-go", id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" },
{ provider: "openai-codex", id: "gpt-5.6", name: "GPT 5.6" },
{ provider: "anthropic", id: "claude-opus", name: "Claude Opus" },
],
},
},
},
undefined,
new Set(["opencode-go", "anthropic"]),
);
const modelOption = state.configOptions.find(option => option.id === "model");
expect(modelOption?.options).toEqual([
{ value: "opencode-go/deepseek-v4-flash", name: "DeepSeek V4 Flash" },
{ value: "anthropic/claude-opus", name: "Claude Opus" },
]);
// Undefined active providers (older session host) keeps the full catalog.
const unfiltered = acpSessionStateFromConfig(
{ result: { page: { items: [{ model: "openai-codex/gpt-5.6" }] } } },
{
result: {
page: {
items: [{ provider: "openai-codex", id: "gpt-5.6", name: "GPT 5.6" }],
},
},
},
);
const unfilteredModel = unfiltered.configOptions.find(option => option.id === "model");
expect(unfilteredModel?.options).toEqual([{ value: "openai-codex/gpt-5.6", name: "GPT 5.6" }]);
});
test("ACP collects every active-provider page and filters by connection kind", async () => {
const pages = [
{
id: "1",
ok: true,
page: {
items: [
{ provider: "opencode-go", connectionKind: "credential" },
{ provider: "openai-codex", connectionKind: "none" },
{ provider: "litellm", connectionKind: "credentialless" },
],
complete: false,
continuationCursor: "cursor-2",
},
},
{
id: "2",
ok: true,
page: {
items: [{ provider: "anthropic", connectionKind: "credential" }],
complete: true,
},
},
];
const adapter = {
query: async (_query: string, _input: unknown, cursor?: string) => (cursor === "cursor-2" ? pages[1] : pages[0]),
} as never;
await expect(collectActiveProviderIds(adapter)).resolves.toEqual(new Set(["opencode-go", "litellm", "anthropic"]));
});

test("ACP fails open only for an unsupported providers.list/active query", async () => {
const unsupported = {
query: async () => {
throw Object.assign(new Error("not installed"), { code: "operation_not_session_owned" });
},
} as never;
await expect(collectActiveProviderIds(unsupported)).resolves.toBeUndefined();
const preQ29 = {
query: async () => {
throw Object.assign(new Error("unknown query"), { code: "invalid_request" });
},
} as never;
await expect(collectActiveProviderIds(preQ29)).resolves.toBeUndefined();
const operational = {
query: async () => {
throw Object.assign(new Error("timed out"), { code: "timeout" });
},
} as never;
await expect(collectActiveProviderIds(operational)).rejects.toThrow("timed out");
});
test("ACP collects every model-catalog page before filtering", async () => {
const pages = [
{
id: "1",
ok: true,
page: {
items: [
{ provider: "opencode-go", id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" },
{ provider: "openai-codex", id: "gpt-5.6", name: "GPT 5.6" },
],
complete: false,
continuationCursor: "cursor-2",
},
},
{
id: "2",
ok: true,
page: {
items: [{ provider: "anthropic", id: "claude-opus", name: "Claude Opus" }],
complete: true,
},
},
];
const adapter = {
query: async (_query: string, _input: unknown, cursor?: string) => (cursor === "cursor-2" ? pages[1] : pages[0]),
} as never;
const catalog = await collectModelCatalog(adapter);
const items = (catalog as { result: { page: { items: unknown[] } } }).result.page.items;
expect(items.map(item => (item as { id: string }).id)).toEqual(["deepseek-v4-flash", "gpt-5.6", "claude-opus"]);
});

test("ACP hides unavailable presets but retains an unavailable active preset", () => {
const profiles = {
Expand Down
Loading
Loading