Skip to content
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
},
"dependencies": {
"@acp-kit/core": "^0.10.2",
"@agentclientprotocol/sdk": "^1.1.0",
"@agentclientprotocol/sdk": "^1.3.0",
"@commander-js/extra-typings": "^15.0.0",
"@cyrus/connections": "workspace:*",
"@cyrus/database": "workspace:*",
Expand Down
52 changes: 51 additions & 1 deletion apps/cli/src/core/acp/events.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { mapRuntimeSessionEvent } from "./events";
import { mapApprovalRequest, mapRuntimeSessionEvent } from "./events";

describe("mapRuntimeSessionEvent", () => {
test("maps token deltas", () => {
Expand Down Expand Up @@ -41,3 +41,53 @@ describe("mapRuntimeSessionEvent", () => {
]);
});
});

describe("mapApprovalRequest", () => {
test("does not throw on a raw ACP diff content item without patch/additions/deletions", () => {
const event = mapApprovalRequest({
sessionId: "session-1",
toolCallId: "tool-1",
toolName: "edit",
title: "Edit file.txt",
input: {},
options: [
{ optionId: "reject", name: "Deny", kind: "reject_once" },
{ optionId: "allow", name: "Allow Once", kind: "allow_once" },
],
raw: {
sessionId: "session-1",
toolCall: {
toolCallId: "tool-1",
title: "Edit file.txt",
kind: "edit",
content: [
{
type: "diff",
path: "/tmp/file.txt",
oldText: "old\n",
newText: "new\n",
},
],
},
},
} as never);

expect(event).toMatchObject({
type: "approval_request",
request: {
toolCall: {
toolCallId: "tool-1",
content: [
expect.objectContaining({
type: "diff",
path: "/tmp/file.txt",
additions: expect.any(Number),
deletions: expect.any(Number),
patch: expect.any(String),
}),
],
},
},
});
});
});
1 change: 1 addition & 0 deletions apps/cli/src/core/acp/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export function mapApprovalRequest(
fields.title ||
rawToolCall.title ||
"Permission required",
content: enrichDiffContent(fields.content),
},
options: (request.options ?? []).map((option) => ({
optionId: option.optionId ?? option.kind ?? "deny",
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/core/acp/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from "@acp-kit/core";
import type { AgentEvent } from "@cyrus/schemas/rtc/chat";
import { mapApprovalRequest } from "./events";
import { createWireErrorLogger } from "./logger";

export type TurnBinding = {
threadId: string;
Expand Down Expand Up @@ -180,6 +181,7 @@ export function createInteractiveHost(
requestPermission: (request) =>
interactivePending.requestPermission(request),
onAgentExit,
wireMiddleware: [createWireErrorLogger()],
};
}

Expand Down
113 changes: 113 additions & 0 deletions apps/cli/src/core/acp/logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, spyOn, test } from "bun:test";
import { log } from "evlog";
import { createWireErrorLogger } from "./logger";

describe("createWireErrorLogger", () => {
test("logs the originating request when a response carries an error", async () => {
const errorSpy = spyOn(log, "error").mockImplementation(() => undefined);
const middleware = createWireErrorLogger();
const next = async () => undefined;

await middleware(
{
direction: "in",
frame: {
jsonrpc: "2.0",
id: 1,
method: "session/request_permission",
params: { toolCall: { toolCallId: "tool-1" } },
},
},
next
);
await middleware(
{
direction: "out",
frame: {
jsonrpc: "2.0",
id: 1,
error: { code: -32_602, message: "Invalid params" },
},
},
next
);

expect(errorSpy).toHaveBeenCalledWith(
expect.objectContaining({
kind: "acp_wire_request_error",
method: "session/request_permission",
params: { toolCall: { toolCallId: "tool-1" } },
error: { code: -32_602, message: "Invalid params" },
})
);
errorSpy.mockRestore();
});

test("an incoming response to our own outgoing request doesn't evict an unrelated pending agent request sharing the same id", async () => {
const errorSpy = spyOn(log, "error").mockImplementation(() => undefined);
const middleware = createWireErrorLogger();
const next = async () => undefined;

await middleware(
{
direction: "in",
frame: {
jsonrpc: "2.0",
id: 1,
method: "session/request_permission",
params: { toolCall: { toolCallId: "tool-1" } },
},
},
next
);
await middleware(
{
direction: "out",
frame: { jsonrpc: "2.0", id: 1, method: "session/prompt", params: {} },
},
next
);
await middleware(
{ direction: "in", frame: { jsonrpc: "2.0", id: 1, result: {} } },
next
);
await middleware(
{
direction: "out",
frame: {
jsonrpc: "2.0",
id: 1,
error: { code: -32_602, message: "Invalid params" },
},
},
next
);

expect(errorSpy).toHaveBeenCalledWith(
expect.objectContaining({
method: "session/request_permission",
params: { toolCall: { toolCallId: "tool-1" } },
})
);
errorSpy.mockRestore();
});

test("never drops a frame, even without a matching request", async () => {
const errorSpy = spyOn(log, "error").mockImplementation(() => undefined);
const middleware = createWireErrorLogger();
let nextCalls = 0;
const next = () => {
nextCalls++;
return Promise.resolve();
};

await middleware({ direction: "in", frame: { foo: "bar" } }, next);
await middleware(
{ direction: "out", frame: { jsonrpc: "2.0", id: 99, error: {} } },
next
);

expect(nextCalls).toBe(2);
errorSpy.mockRestore();
});
});
50 changes: 50 additions & 0 deletions apps/cli/src/core/acp/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { WireMiddleware } from "@acp-kit/core";
import { log } from "evlog";

type JsonRpcFrameId = string | number;

function hasFrameId(frame: unknown): frame is { id: JsonRpcFrameId } {
return (
typeof frame === "object" &&
frame !== null &&
"id" in frame &&
(typeof frame.id === "string" || typeof frame.id === "number")
);
}

export function createWireErrorLogger(): WireMiddleware {
const pending = new Map<
JsonRpcFrameId,
{ method: string; params: unknown }
>();

return (ctx, next) => {
const frame = ctx.frame;
if (!hasFrameId(frame)) return next();

if (
ctx.direction === "in" &&
"method" in frame &&
typeof frame.method === "string"
) {
pending.set(frame.id, {
method: frame.method,
params: "params" in frame ? frame.params : undefined,
});
} else if (ctx.direction === "out" && "error" in frame) {
const request = pending.get(frame.id);
log.error({
kind: "acp_wire_request_error",
id: frame.id,
method: request?.method,
params: request?.params,
error: frame.error,
});
pending.delete(frame.id);
} else if (ctx.direction === "out" && "result" in frame) {
pending.delete(frame.id);
}

return next();
};
}
11 changes: 10 additions & 1 deletion apps/cli/src/core/agents/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export type AgentCatalog = {
configOptions: SessionConfigOption[];
};

type RawSessionModel = {
modelId: string;
name: string;
description?: string | null;
_meta?: Record<string, unknown> | null;
};

export function catalogFromSession(session: RuntimeSession): AgentCatalog {
const { session: meta } = session.transcript;
return {
Expand All @@ -24,7 +31,9 @@ export function catalogFromSession(session: RuntimeSession): AgentCatalog {
export function modelsFromSession(session: RuntimeSession): ModelOption[] {
const { session: meta } = session.transcript;
if (hasNativeModelSelection(session)) {
return (meta.models?.availableModels ?? []).map((model) => ({
const availableModels = (meta.models?.availableModels ??
[]) as RawSessionModel[];
return availableModels.map((model) => ({
id: model.modelId,
name: model.name,
description: model.description ?? null,
Expand Down
9 changes: 5 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@
}
},
"type": "module",
"overrides": {
"@agentclientprotocol/sdk": "^1.3.0"
},
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
Expand Down