Skip to content
Closed
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
8 changes: 8 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
# Changelog

## [Unreleased]
### Fixed

- ACP `session/request_permission` responses are now normalized from the spec-shaped `RequestPermissionResponse` (`{ outcome: { outcome, optionId } }`) into the SDK's flat permission-decision contract before reaching the permission provider. Standards-compliant ACP clients such as Paseo can now authorize permission-gated shell/eval and destructive file operations (`bash`, `monitor`, `eval`, `delete`, `move`, and `edit` only for delete/move operations) without an invalid-response failure; `write` and ordinary edits remain ungated. Nested and flat selected/cancelled responses are accepted by reconstructing the canonical SDK decision fields, while malformed or unknown decisions fail closed.

### Added

- Added first-class `cline-pass` and `commandcode-goat` provider presets with documented API endpoints, environment-variable credentials, non-hardcoded live model discovery from models.dev and the Command Code Provider API, and prefix-based Claude routing.

### Fixed

- `smithery-env-trust.test.ts` raises the per-test child-process timeout from 30s to 60s so CI contention cannot fail at the previous 30s cap (Dev CI run 31128319216 timed out at 30004ms).
- `smithery-env-trust.test.ts` now sets a 30s per-test timeout on all five child-process-spawning trust-boundary tests, preventing CI flake when the Bun child-process spawn + env-file-parse chain exceeds the default 5s budget under parallel shard contention (Dev CI run 31102063678).

## [0.12.15] - 2026-08-06

## [0.12.14] - 2026-08-06
Expand Down
15 changes: 14 additions & 1 deletion packages/coding-agent/src/modes/acp/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -785,13 +785,26 @@ export function createAcpReverseConnection(connection: AgentSideConnection, sess
const rawRequest = (connection as unknown as Record<string, unknown>).request;
if (typeof rawRequest !== "function")
throw new AcpSdkAdapterError("acp_reverse_unavailable", "ACP reverse request surface is unavailable.");
return await (
const result = await (
rawRequest as (
method: string,
input: JsonObject,
options?: { cancellationSignal?: AbortSignal },
) => Promise<unknown>
).call(connection, name, { ...params, sessionId }, options);
// ACP clients answer `session/request_permission` with the spec-shaped
// `RequestPermissionResponse` `{ outcome: { outcome, optionId } }`, while the
// SDK permission-provider contract is the flat decision `{ outcome, optionId }`.
// Normalize the outer wrapper (accepting the flat legacy shape as well) so
// permission-gated tool calls resolve instead of failing as an invalid response.
const response = object(result);
if (name === "session/request_permission" && response) {
const decision = object(response.outcome) ?? response;
if (decision.outcome === "cancelled") return { outcome: "cancelled" };
if (decision.outcome === "selected" && typeof decision.optionId === "string")
return { outcome: "selected", optionId: decision.optionId };
}
return result;
},
};
}
Expand Down
56 changes: 56 additions & 0 deletions packages/coding-agent/src/slash-commands/notify-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, test } from "bun:test";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Split unrelated test maintenance from the ACP fix

This ACP response-normalization commit also adds /notify routing coverage and changes unrelated provider-discovery, onboarding-wizard, and Smithery-timeout tests. Bundling these independent changes makes the ACP fix harder to review, revert, or cherry-pick safely; move them into separate logical commits as required by the repository contract.

AGENTS.md reference: AGENTS.md:L152-L155

Useful? React with 👍 / 👎.

import { lookupBuiltinSlashCommand } from "./builtin-registry";

/**
* Contract: /notify on|off is session-local and extension-owned.
* The builtin must always pass the raw command text through as a prompt so it
* cannot shadow the live per-session `api.registerCommand("notify")` control —
* whether or not a lazy/native command is currently installed in the fixture.
* See builtin-registry.ts notify handler comments.
*/
function runtimeWithExtension(commandInstalled: boolean) {
const output: string[] = [];
return {
runtime: {
session: commandInstalled
? { extensionRunner: { getCommand: () => ({ name: "notify" }) } }
: { extensionRunner: { getCommand: () => undefined } },
settings: {},
cwd: "/tmp",
output: async (message: string) => {
output.push(message);
},
} as never,
output,
};
}

describe("/notify SDK-only routing", () => {
test("always pass-through on/off when no lazy command is installed", async () => {
const command = lookupBuiltinSlashCommand("notify");
if (!command?.handle) throw new Error("notify builtin handler missing");
const { runtime, output } = runtimeWithExtension(false);
expect(await command.handle({ name: "notify", args: "on", text: "/notify on" }, runtime)).toEqual({
prompt: "/notify on",
});
expect(output).toEqual([]);
expect(await command.handle({ name: "notify", args: "off", text: "/notify off" }, runtime)).toEqual({
prompt: "/notify off",
});
expect(output).toEqual([]);
});

test("always pass-through on/off when a registered native/session command is present", async () => {
const command = lookupBuiltinSlashCommand("notify");
if (!command?.handle) throw new Error("notify builtin handler missing");
const { runtime, output } = runtimeWithExtension(true);
expect(await command.handle({ name: "notify", args: "on", text: "/notify on" }, runtime)).toEqual({
prompt: "/notify on",
});
expect(output).toEqual([]);
expect(await command.handle({ name: "notify", args: "off", text: "/notify off" }, runtime)).toEqual({
prompt: "/notify off",
});
expect(output).toEqual([]);
});
});
21 changes: 20 additions & 1 deletion packages/coding-agent/test/acp-client-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe("ACP client bridge permission requests", () => {
_meta: { gjc: { permissionHandling: "prompt" } },
});

await bridge.requestPermission!(
const outcome = await bridge.requestPermission!(
{
toolCallId: "call-1",
toolName: "bash",
Expand All @@ -43,6 +43,25 @@ describe("ACP client bridge permission requests", () => {
rawInput: { command: "echo hi" },
content: [{ type: "content", content: { type: "text", text: "$ echo hi" } }],
});
expect(outcome).toEqual({ outcome: "selected", optionId: "allow_once", kind: "allow_once" });
});

it("returns cancelled ACP permission outcomes through the typed client bridge contract", async () => {
const connection = {
async requestPermission() {
return { outcome: { outcome: "cancelled" as const } };
},
} as unknown as AgentSideConnection;
const bridge = createAcpClientBridge(connection, "session-1", {
_meta: { gjc: { permissionHandling: "prompt" } },
});

expect(
await bridge.requestPermission!(
{ toolCallId: "call-2", toolName: "bash", title: "cancel", status: "pending" },
[{ optionId: "reject_once", name: "Reject once", kind: "reject_once" }],
),
).toEqual({ outcome: "cancelled" });
});

it("only enables ACP permission requests in prompt mode", () => {
Expand Down
24 changes: 24 additions & 0 deletions packages/coding-agent/test/acp-startup-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ test("ACP reverse requests use canonical names, session scope, and cancellation"
const reverse = createAcpReverseConnection(connection, "session-1");
const requests = [
["request", { toolCallId: "call-1", sessionId: "spoofed-session" }],
["permission.request", { toolCallId: "call-2", sessionId: "spoofed-session" }],
["fs.readTextFile", { path: "/workspace/README.md" }],
["fs.writeTextFile", { path: "/workspace/README.md", content: "updated" }],
["terminal.create", { command: "printf", args: ["ok"] }],
Expand All @@ -78,6 +79,7 @@ test("ACP reverse requests use canonical names, session scope, and cancellation"

expect(calls).toEqual([
["session/request_permission", { toolCallId: "call-1", sessionId: "session-1" }, { cancellationSignal: signal }],
["session/request_permission", { toolCallId: "call-2", sessionId: "session-1" }, { cancellationSignal: signal }],
["fs/read_text_file", { path: "/workspace/README.md", sessionId: "session-1" }, { cancellationSignal: signal }],
[
"fs/write_text_file",
Expand All @@ -93,6 +95,28 @@ test("ACP reverse requests use canonical names, session scope, and cancellation"
]);
expect(typedCalls).toEqual([]);
});
test("ACP reverse permission aliases normalize nested and flat outcomes into the SDK decision contract", async () => {
for (const method of ["request", "permission.request"] as const) {
for (const [response, expected] of [
[
{ outcome: { outcome: "selected", optionId: "allow_once" } },
{ outcome: "selected", optionId: "allow_once" },
],
[{ outcome: { outcome: "cancelled" } }, { outcome: "cancelled" }],
[
{ outcome: "selected", optionId: "allow_always" },
{ outcome: "selected", optionId: "allow_always" },
],
[{ outcome: "cancelled" }, { outcome: "cancelled" }],
] as const) {
const connection = {
request: async () => response,
} as unknown as AgentSideConnection;
const reverse = createAcpReverseConnection(connection, "session-1");
expect(await reverse.request?.(method, { toolCallId: "call-1" })).toEqual(expected);
}
}
});

test("ACP maps non-prompt permission handling to the SDK allow policy", async () => {
const modes: string[] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "bu
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "@gajae-code/ai";
import type { ModelRegistry, ProviderDiscoveryState } from "@gajae-code/coding-agent/config/model-registry";
import { ModelRegistry as ModelRegistryImpl } from "@gajae-code/coding-agent/config/model-registry";
import { Settings } from "@gajae-code/coding-agent/config/settings";
Expand Down Expand Up @@ -84,7 +85,7 @@ describe("issue #970 custom provider discovery", () => {
}
});

test("discovers custom openai-compatible models and lets YAML models override discovered fields", async () => {
test("preserves same-id YAML fields and discovered-only model overrides", async () => {
fs.writeFileSync(
modelsPath,
[
Expand All @@ -101,6 +102,11 @@ describe("issue #970 custom provider discovery", () => {
" name: Qwen3.6",
" contextWindow: 128000",
" maxTokens: 8192",
" modelOverrides:",
" issue-3954-override-context:",
" contextWindow: 64000",
" issue-3954-override-max:",
" maxTokens: 4096",
].join("\n"),
);

Expand All @@ -112,17 +118,32 @@ describe("issue #970 custom provider discovery", () => {
const headers = init?.headers as Headers | Record<string, string> | undefined;
const authHeader = headers instanceof Headers ? headers.get("Authorization") : headers?.Authorization;
expect(authHeader).toBe("Bearer sk-1234");
return new Response(JSON.stringify({ data: [{ id: "qwen3.6" }, { id: "deepseek-r1" }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
return new Response(
JSON.stringify({
data: [
{ id: "qwen3.6", context_length: 256000 },
{ id: "issue-3954-override-context", context_length: 512000 },
{ id: "issue-3954-override-max", context_length: 384000 },
{ id: "issue-3954-uncatalogued" },
],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
});

const registry = new ModelRegistryImpl(authStorage, modelsPath);
await registry.refreshProvider("vllm");

const providerModels = registry.getAll().filter(model => model.provider === "vllm");
expect(providerModels.map(model => model.id).sort()).toEqual(["deepseek-r1", "qwen3.6"]);
expect(providerModels.map(model => model.id).sort()).toEqual([
"issue-3954-override-context",
"issue-3954-override-max",
"issue-3954-uncatalogued",
"qwen3.6",
]);
expect(registry.getProviderDiscoveryState("vllm")?.status).toBe("ok");

const qwen = registry.find("vllm", "qwen3.6");
Expand All @@ -132,12 +153,17 @@ describe("issue #970 custom provider discovery", () => {
expect(qwen?.contextWindow).toBe(128000);
expect(qwen?.maxTokens).toBe(8192);

const deepseek = registry.find("vllm", "deepseek-r1");
expect(deepseek?.api).toBe("openai-completions");
expect(deepseek?.provider).toBe("vllm");
expect(deepseek?.name).toBe("deepseek-r1");
expect(deepseek?.contextWindow).toBe(128000);
expect(deepseek?.maxTokens).toBe(8192);
const contextOverride = registry.find("vllm", "issue-3954-override-context");
expect(contextOverride?.contextWindow).toBe(64000);
expect(contextOverride?.maxTokens).toBe(UNK_MAX_TOKENS);

const maxTokensOverride = registry.find("vllm", "issue-3954-override-max");
expect(maxTokensOverride?.contextWindow).toBe(384000);
expect(maxTokensOverride?.maxTokens).toBe(4096);

const uncatalogued = registry.find("vllm", "issue-3954-uncatalogued");
expect(uncatalogued?.contextWindow).toBe(UNK_CONTEXT_WINDOW);
expect(uncatalogued?.maxTokens).toBe(UNK_MAX_TOKENS);
});

test("shows a provider-tab hint when discovery succeeds but returns zero models", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ describe("provider onboarding wizard red-team", () => {
apiKeyEnv: "EMPTY_MODELS_KEY",
models: ["", " ", ","],
}),
).rejects.toThrow("At least one model id is required");
).rejects.toThrow("At least one model id or model discovery is required");
});

it("requires force for an existing provider and overwrites when force is confirmed", async () => {
Expand Down
Loading
Loading