mirror of
https://github.com/Heretek-AI/opencode.git
synced 2026-11-10 02:11:36 -10:00
741 lines
28 KiB
TypeScript
741 lines
28 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import type { AnyRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
|
import { createTwoFilesPatch } from "diff"
|
|
import { Cause } from "effect"
|
|
import fs from "node:fs/promises"
|
|
import path from "node:path"
|
|
import { tmpdir } from "../fixture/tmpdir"
|
|
import {
|
|
childCreated,
|
|
delivered,
|
|
durableEvent,
|
|
interrupted,
|
|
permissionAsked,
|
|
startSession,
|
|
startWire,
|
|
stepEnded,
|
|
succeeded,
|
|
textDelta,
|
|
toolCalled,
|
|
toolStarted,
|
|
toolSucceeded,
|
|
turn,
|
|
type Wire,
|
|
} from "./wire-fixture"
|
|
|
|
const allowOnce = () => ({ outcome: { outcome: "selected", optionId: "once" } }) as const
|
|
|
|
describe("acp permissions over the wire", () => {
|
|
test("forwards allow-once and allow-always selections to the server", async () => {
|
|
await using acp = await startSession({
|
|
onPrompt: ({ sessionID, id }) =>
|
|
turn(
|
|
sessionID,
|
|
id,
|
|
toolStarted(sessionID, "call_once", "shell"),
|
|
toolCalled(sessionID, "call_once", { command: "printf hello" }),
|
|
toolStarted(sessionID, "call_always", "read"),
|
|
toolCalled(sessionID, "call_always", { path: "/workspace/file.ts" }),
|
|
permissionAsked(sessionID, "perm_once", {
|
|
action: "shell",
|
|
metadata: { command: "printf hello" },
|
|
source: { type: "tool", messageID: "msg_allow", id: "call_once" },
|
|
}),
|
|
permissionAsked(sessionID, "perm_always", {
|
|
action: "read",
|
|
metadata: { path: "/workspace/file.ts" },
|
|
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
|
|
}),
|
|
),
|
|
permission: (request) => ({
|
|
outcome: { outcome: "selected", optionId: request.toolCall.toolCallId === "call_once" ? "once" : "always" },
|
|
}),
|
|
})
|
|
|
|
await acp.prompt(acp.sessionId, "hello")
|
|
|
|
expect(acp.permissions[0]).toMatchObject({
|
|
sessionId: acp.sessionId,
|
|
toolCall: {
|
|
toolCallId: "call_once",
|
|
status: "pending",
|
|
title: "printf hello",
|
|
kind: "execute",
|
|
locations: [{ path: "/workspace" }],
|
|
rawInput: { command: "printf hello", cwd: "/workspace" },
|
|
},
|
|
options: [
|
|
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
|
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
|
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
|
],
|
|
})
|
|
expect(acp.permissions[1]).toMatchObject({
|
|
sessionId: acp.sessionId,
|
|
toolCall: {
|
|
toolCallId: "call_always",
|
|
status: "pending",
|
|
title: "/workspace/file.ts",
|
|
kind: "read",
|
|
locations: [{ path: "/workspace/file.ts" }],
|
|
rawInput: { path: "/workspace/file.ts" },
|
|
},
|
|
})
|
|
expect(decisions(acp)).toEqual([
|
|
["perm_once", "once"],
|
|
["perm_always", "always"],
|
|
])
|
|
})
|
|
|
|
test("preserves external directory permission context", async () => {
|
|
await using acp = await startSession({
|
|
onPrompt: ({ sessionID, id }) =>
|
|
turn(
|
|
sessionID,
|
|
id,
|
|
permissionAsked(sessionID, "perm_external", {
|
|
action: "external_directory",
|
|
metadata: { filepath: "/tmp/outside/a.ts", parentDir: "/tmp/outside" },
|
|
}),
|
|
),
|
|
permission: allowOnce,
|
|
})
|
|
|
|
await acp.prompt(acp.sessionId, "hello")
|
|
|
|
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
|
title: "/tmp/outside",
|
|
locations: [{ path: "/tmp/outside/a.ts" }],
|
|
})
|
|
expect(acp.permissions[0]?.toolCall).not.toHaveProperty("rawInput")
|
|
})
|
|
|
|
test("locates path resources without glob patterns when the ask has no tool locations", async () => {
|
|
await using acp = await startSession({
|
|
onPrompt: ({ sessionID, id }) =>
|
|
turn(
|
|
sessionID,
|
|
id,
|
|
permissionAsked(sessionID, "perm_external", {
|
|
action: "external_directory",
|
|
resources: ["/tmp/outside/*", "/tmp/other/*", "/tmp/outside/*"],
|
|
metadata: {},
|
|
}),
|
|
permissionAsked(sessionID, "perm_read", {
|
|
action: "read",
|
|
resources: ["src/[slug].ts", "src/{a,b}.ts", "**/*.ts", "src/?.ts"],
|
|
}),
|
|
permissionAsked(sessionID, "perm_search", { action: "websearch", resources: ["acp spec"] }),
|
|
),
|
|
permission: allowOnce,
|
|
})
|
|
|
|
await acp.prompt(acp.sessionId, "hello")
|
|
|
|
expect(acp.permissions.map((request) => request.toolCall.locations)).toEqual([
|
|
[{ path: "/tmp/outside" }, { path: "/tmp/other" }],
|
|
[{ path: path.resolve("/workspace", "src/[slug].ts") }, { path: path.resolve("/workspace", "src/{a,b}.ts") }],
|
|
[],
|
|
])
|
|
})
|
|
|
|
test("asks under the permission ID when ACP does not track the asking tool call", async () => {
|
|
await using acp = await startSession({
|
|
onPrompt: ({ sessionID, id }) =>
|
|
turn(
|
|
sessionID,
|
|
id,
|
|
permissionAsked(sessionID, "perm_untracked", {
|
|
source: { type: "tool", messageID: "msg_tools", id: "call_untracked" },
|
|
}),
|
|
),
|
|
permission: allowOnce,
|
|
})
|
|
|
|
await acp.prompt(acp.sessionId, "hello")
|
|
|
|
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_untracked"])
|
|
expect(acp.permissions[0]?.toolCall).not.toHaveProperty("rawInput")
|
|
})
|
|
|
|
test("asks about a tracked tool call with its own input", async () => {
|
|
await using acp = await startSession({
|
|
onPrompt: ({ sessionID, id }) =>
|
|
turn(
|
|
sessionID,
|
|
id,
|
|
toolStarted(sessionID, "call_read", "read"),
|
|
toolCalled(sessionID, "call_read", { path: "/workspace/file.ts" }),
|
|
permissionAsked(sessionID, "perm_read", {
|
|
action: "read",
|
|
metadata: { files: ["file.ts"] },
|
|
source: { type: "tool", messageID: "msg_read", id: "call_read" },
|
|
}),
|
|
),
|
|
permission: allowOnce,
|
|
})
|
|
|
|
await acp.prompt(acp.sessionId, "hello")
|
|
|
|
const running = acp.updates.flatMap((item) =>
|
|
item.update.sessionUpdate === "tool_call_update" && item.update.status === "in_progress"
|
|
? [item.update.rawInput]
|
|
: [],
|
|
)
|
|
expect(running).toEqual([{ path: "/workspace/file.ts" }])
|
|
expect(acp.permissions[0]?.toolCall).toMatchObject({ toolCallId: "call_read", rawInput: running[0] })
|
|
})
|
|
|
|
test("routes foreground child permissions through the parent ACP session", async () => {
|
|
await using acp = await startSession({
|
|
onPrompt: ({ sessionID, id }) =>
|
|
turn(
|
|
sessionID,
|
|
id,
|
|
childCreated("ses_child", sessionID, "Review code"),
|
|
durableEvent("session.execution.started", { sessionID: "ses_child" }),
|
|
toolStarted("ses_child", "call_child", "read"),
|
|
toolCalled("ses_child", "call_child", { path: "/workspace/child.ts" }),
|
|
permissionAsked("ses_child", "perm_child", {
|
|
action: "read",
|
|
source: { type: "tool", messageID: "msg_child", id: "call_child" },
|
|
}),
|
|
succeeded("ses_child"),
|
|
),
|
|
permission: allowOnce,
|
|
})
|
|
|
|
await acp.prompt(acp.sessionId, "hello")
|
|
|
|
const childMeta = {
|
|
"opencode/child-session": { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Review code" },
|
|
}
|
|
expect(acp.permissions).toHaveLength(1)
|
|
expect(acp.permissions[0]).toMatchObject({
|
|
sessionId: acp.sessionId,
|
|
toolCall: { toolCallId: "ses_child:call_child", title: "Review code: /workspace/child.ts", _meta: childMeta },
|
|
})
|
|
expect(
|
|
acp.updates.flatMap((item) => (item.update.sessionUpdate === "tool_call" ? [item.update._meta] : [])),
|
|
).toEqual([childMeta])
|
|
expect(acp.server.replies).toEqual([{ sessionID: "ses_child", requestID: "perm_child", decision: "once" }])
|
|
})
|
|
|
|
test("asks for a background child's permission after the parent turn ends without the child capability", async () => {
|
|
await using acp = await startSession({
|
|
onPrompt: ({ sessionID, id }) => turn(sessionID, id, childCreated("ses_background", sessionID, "Research")),
|
|
permission: allowOnce,
|
|
})
|
|
|
|
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
|
acp.server.send(
|
|
durableEvent("session.execution.started", { sessionID: "ses_background" }),
|
|
permissionAsked("ses_background", "perm_background", {
|
|
action: "read",
|
|
metadata: { path: "/workspace/notes.md" },
|
|
}),
|
|
)
|
|
await acp.until(() => acp.server.replies.length === 1, "background permission reply")
|
|
|
|
const childMeta = {
|
|
"opencode/child-session": { id: "ses_background", parentID: acp.sessionId, depth: 1, title: "Research" },
|
|
}
|
|
expect(acp.permissions).toMatchObject([
|
|
{
|
|
sessionId: acp.sessionId,
|
|
toolCall: {
|
|
toolCallId: "ses_background:perm_background",
|
|
title: "Research: /workspace/notes.md",
|
|
_meta: childMeta,
|
|
},
|
|
},
|
|
])
|
|
expect(acp.server.replies).toEqual([
|
|
{ sessionID: "ses_background", requestID: "perm_background", decision: "once" },
|
|
])
|
|
})
|
|
|
|
test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
|
|
await using acp = await startSession({
|
|
onPrompt: ({ sessionID, id }) =>
|
|
turn(
|
|
sessionID,
|
|
id,
|
|
permissionAsked(sessionID, "perm_selected_reject"),
|
|
permissionAsked(sessionID, "perm_cancelled"),
|
|
permissionAsked(sessionID, "perm_failed"),
|
|
),
|
|
permission(request) {
|
|
if (request.toolCall.toolCallId === "perm_selected_reject") {
|
|
return { outcome: { outcome: "selected", optionId: "reject" } }
|
|
}
|
|
if (request.toolCall.toolCallId === "perm_cancelled") return { outcome: { outcome: "cancelled" } }
|
|
throw new Error("client permission UI failed")
|
|
},
|
|
})
|
|
|
|
expect(await acp.prompt(acp.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
|
|
expect(decisions(acp)).toEqual([
|
|
["perm_selected_reject", "reject"],
|
|
["perm_cancelled", "reject"],
|
|
["perm_failed", "reject"],
|
|
])
|
|
})
|
|
|
|
test("logs a failed server reply and still answers later asks", async () => {
|
|
await using acp = await startSession({
|
|
onPrompt: ({ sessionID, id }) =>
|
|
turn(sessionID, id, permissionAsked(sessionID, "perm_failed"), permissionAsked(sessionID, "perm_next")),
|
|
fetch: (request) =>
|
|
request.path.endsWith("/permission/perm_failed/reply") ? new Response(null, { status: 500 }) : undefined,
|
|
permission: allowOnce,
|
|
})
|
|
|
|
expect(await acp.prompt(acp.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
|
|
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_failed", "perm_next"])
|
|
expect(decisions(acp)).toEqual([["perm_next", "once"]])
|
|
expect(acp.logs.map((log) => ({ message: log.message, cause: Cause.squash(log.cause) }))).toMatchObject([
|
|
{ message: ["ACP permission reply failed"], cause: { _tag: "ClientError" } },
|
|
])
|
|
})
|
|
|
|
test("serializes permission requests and replies within one session", async () => {
|
|
const releaseFirst = Promise.withResolvers<RequestPermissionResponse>()
|
|
await using acp = await startSession({
|
|
onPrompt: ({ sessionID, id }) =>
|
|
turn(sessionID, id, permissionAsked(sessionID, "perm_1"), permissionAsked(sessionID, "perm_2")),
|
|
permission: (request) =>
|
|
request.toolCall.toolCallId === "perm_1"
|
|
? releaseFirst.promise
|
|
: { outcome: { outcome: "selected", optionId: "always" } },
|
|
})
|
|
|
|
const prompt = acp.prompt(acp.sessionId, "hello")
|
|
await acp.until(() => acp.permissions.length === 1, "first permission")
|
|
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
|
|
expect(acp.server.replies).toEqual([])
|
|
|
|
releaseFirst.resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
|
await prompt
|
|
|
|
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1", "perm_2"])
|
|
expect(decisions(acp)).toEqual([
|
|
["perm_1", "once"],
|
|
["perm_2", "always"],
|
|
])
|
|
})
|
|
|
|
test("keeps streaming other children while one child's permission is pending", async () => {
|
|
const release = Promise.withResolvers<RequestPermissionResponse>()
|
|
await using acp = await startSession({
|
|
onPrompt: ({ sessionID, id }) =>
|
|
turn(
|
|
sessionID,
|
|
id,
|
|
childCreated("ses_a", sessionID, "A"),
|
|
childCreated("ses_b", sessionID, "B"),
|
|
permissionAsked("ses_a", "perm_a"),
|
|
textDelta("ses_b", "msg_b", "still streaming"),
|
|
succeeded("ses_b"),
|
|
succeeded("ses_a"),
|
|
),
|
|
permission: () => release.promise,
|
|
})
|
|
|
|
const prompt = acp.prompt(acp.sessionId, "hello")
|
|
await acp.waitForUpdate((item) => item.update.sessionUpdate === "agent_message_chunk", "child B's chunk")
|
|
await acp.until(() => acp.permissions.length === 1, "child A's permission")
|
|
|
|
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["ses_a:perm_a"])
|
|
expect(acp.server.replies).toEqual([])
|
|
release.resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
|
expect((await prompt).stopReason).toBe("end_turn")
|
|
expect(decisions(acp)).toEqual([["perm_a", "once"]])
|
|
})
|
|
|
|
test("does not let one session's blocked permission stall another session", async () => {
|
|
const releaseBlocked = Promise.withResolvers<RequestPermissionResponse>()
|
|
await using acp = await startWire({ onPrompt: () => undefined, permission: () => releaseBlocked.promise })
|
|
await acp.initialize()
|
|
const blockedSession = await acp.newSession()
|
|
const freeSession = await acp.newSession()
|
|
|
|
const blocked = acp.prompt(blockedSession.sessionId, "hello")
|
|
const free = acp.prompt(freeSession.sessionId, "hello")
|
|
const [blockedPrompt, freePrompt] = await acp.until(
|
|
() => acp.server.prompts.length === 2 && acp.server.prompts,
|
|
"both prompt submissions",
|
|
)
|
|
acp.server.send(
|
|
delivered(blockedPrompt.sessionID, blockedPrompt.id),
|
|
delivered(freePrompt.sessionID, freePrompt.id),
|
|
permissionAsked(blockedPrompt.sessionID, "perm_blocked"),
|
|
textDelta(freePrompt.sessionID, "msg_free", "session B continued"),
|
|
stepEnded(freePrompt.sessionID, "msg_free"),
|
|
succeeded(freePrompt.sessionID),
|
|
succeeded(blockedPrompt.sessionID),
|
|
)
|
|
await acp.until(() => acp.permissions.length === 1, "blocked permission")
|
|
|
|
expect(await free).toMatchObject({ stopReason: "end_turn" })
|
|
expect(acp.updates).toContainEqual({
|
|
sessionId: freePrompt.sessionID,
|
|
update: {
|
|
sessionUpdate: "agent_message_chunk",
|
|
messageId: "msg_free",
|
|
content: { type: "text", text: "session B continued" },
|
|
},
|
|
})
|
|
expect(acp.server.replies).toEqual([])
|
|
|
|
releaseBlocked.resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
|
expect(await blocked).toMatchObject({ stopReason: "end_turn" })
|
|
expect(decisions(acp)).toEqual([["perm_blocked", "once"]])
|
|
})
|
|
|
|
test("cancelling the turn cancels its pending permission request and rejects the permission", async () => {
|
|
await using acp = await startSession({
|
|
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), permissionAsked(sessionID, "perm_cancel")],
|
|
onPermissionReply: ({ sessionID }) => [interrupted(sessionID)],
|
|
permission: (_request, signal) =>
|
|
new Promise((resolve) => {
|
|
signal.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), { once: true })
|
|
}),
|
|
})
|
|
|
|
const prompt = acp.prompt(acp.sessionId, "hello")
|
|
await acp.until(() => acp.permissions.length === 1, "permission request")
|
|
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
|
|
|
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
|
expect(decisions(acp)).toEqual([["perm_cancel", "reject"]])
|
|
const asked = acp.received.find(
|
|
(message): message is AnyRequest =>
|
|
"method" in message && "id" in message && message.method === "session/request_permission",
|
|
)
|
|
expect(acp.received).toContainEqual({
|
|
jsonrpc: "2.0",
|
|
method: "$/cancel_request",
|
|
params: { requestId: asked?.id },
|
|
})
|
|
})
|
|
|
|
test("rejects asks queued behind a cancelled one without sending them to the client", async () => {
|
|
await using acp = await startSession({
|
|
onPrompt: ({ sessionID, id }) => [
|
|
delivered(sessionID, id),
|
|
permissionAsked(sessionID, "perm_pending"),
|
|
permissionAsked(sessionID, "perm_queued"),
|
|
],
|
|
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
|
|
permission: (_request, signal) =>
|
|
new Promise((resolve) => {
|
|
signal.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), { once: true })
|
|
}),
|
|
})
|
|
|
|
const prompt = acp.prompt(acp.sessionId, "hello")
|
|
await acp.until(() => acp.permissions.length === 1, "permission request")
|
|
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
|
|
|
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
|
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_pending"])
|
|
expect(decisions(acp)).toEqual([
|
|
["perm_pending", "reject"],
|
|
["perm_queued", "reject"],
|
|
])
|
|
})
|
|
})
|
|
|
|
describe("acp edit previews over the wire", () => {
|
|
test("previews core's edit diff during approval", async () => {
|
|
await using dir = await tmpdir()
|
|
const file = path.join(dir.path, "file.ts")
|
|
await fs.writeFile(file, "one\r\ntwo\r\n")
|
|
await using acp = await startWire({
|
|
onPrompt: ({ sessionID, id }) =>
|
|
turn(
|
|
sessionID,
|
|
id,
|
|
toolStarted(sessionID, "call_edit", "edit"),
|
|
toolCalled(sessionID, "call_edit", { path: "file.ts", oldString: "one\ntwo", newString: "one\nthree" }),
|
|
permissionAsked(sessionID, "perm_edit", {
|
|
action: "edit",
|
|
resources: ["file.ts"],
|
|
metadata: { files: [fileDiff("file.ts", "one\r\ntwo\r\n", "one\r\nthree\r\n")] },
|
|
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
|
|
}),
|
|
),
|
|
permission: allowOnce,
|
|
})
|
|
await acp.initialize()
|
|
const session = await acp.newSession(dir.path)
|
|
|
|
await acp.prompt(session.sessionId, "hello")
|
|
|
|
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
|
title: "file.ts",
|
|
kind: "edit",
|
|
locations: [{ path: file }],
|
|
content: [{ type: "diff", path: file, oldText: "one\r\ntwo\r\n", newText: "one\r\nthree\r\n" }],
|
|
})
|
|
})
|
|
|
|
test("previews each file in a patch", async () => {
|
|
await using dir = await tmpdir()
|
|
await Promise.all([
|
|
fs.writeFile(path.join(dir.path, "first.ts"), "one\n"),
|
|
fs.writeFile(path.join(dir.path, "second.ts"), "alpha\n"),
|
|
])
|
|
const patchText = [
|
|
"*** Begin Patch",
|
|
"*** Update File: first.ts",
|
|
"@@",
|
|
"-one",
|
|
"+two",
|
|
"*** Update File: second.ts",
|
|
"@@",
|
|
"-alpha",
|
|
"+beta",
|
|
"*** End Patch",
|
|
].join("\n")
|
|
await using acp = await startWire({
|
|
onPrompt: ({ sessionID, id }) => [
|
|
delivered(sessionID, id),
|
|
toolStarted(sessionID, "call_patch", "patch"),
|
|
toolCalled(sessionID, "call_patch", { patchText }),
|
|
permissionAsked(sessionID, "perm_patch", {
|
|
action: "edit",
|
|
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
|
|
}),
|
|
],
|
|
onPermissionReply: async ({ sessionID }) => {
|
|
await Promise.all([
|
|
fs.writeFile(path.join(dir.path, "first.ts"), "two\n"),
|
|
fs.writeFile(path.join(dir.path, "second.ts"), "beta\n"),
|
|
])
|
|
return [
|
|
toolSucceeded(sessionID, "call_patch", { files: [{ file: "first.ts" }, { file: "second.ts" }] }, "patched"),
|
|
succeeded(sessionID),
|
|
]
|
|
},
|
|
permission: allowOnce,
|
|
})
|
|
await acp.initialize()
|
|
const session = await acp.newSession(dir.path)
|
|
|
|
await acp.prompt(session.sessionId, "hello")
|
|
|
|
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
|
title: "2 files",
|
|
kind: "edit",
|
|
locations: [{ path: path.join(dir.path, "first.ts") }, { path: path.join(dir.path, "second.ts") }],
|
|
content: [
|
|
{ type: "diff", path: path.join(dir.path, "first.ts"), oldText: "one\n", newText: "two\n" },
|
|
{ type: "diff", path: path.join(dir.path, "second.ts"), oldText: "alpha\n", newText: "beta\n" },
|
|
],
|
|
})
|
|
})
|
|
|
|
test("previews added files as new and deleted files as empty", async () => {
|
|
await using dir = await tmpdir()
|
|
await fs.writeFile(path.join(dir.path, "gone.ts"), "gone\n")
|
|
const patchText = ["*** Begin Patch", "*** Add File: added.ts", "+one", "*** End Patch"].join("\n")
|
|
await using acp = await startWire({
|
|
onPrompt: ({ sessionID, id }) =>
|
|
turn(
|
|
sessionID,
|
|
id,
|
|
toolStarted(sessionID, "call_write", "write"),
|
|
toolCalled(sessionID, "call_write", { path: "written.ts", content: "two\n" }),
|
|
permissionAsked(sessionID, "perm_write", {
|
|
action: "edit",
|
|
metadata: { files: [fileDiff("written.ts", "", "two\n", "added")] },
|
|
source: { type: "tool", messageID: "msg_write", id: "call_write" },
|
|
}),
|
|
toolStarted(sessionID, "call_patch", "patch"),
|
|
toolCalled(sessionID, "call_patch", { patchText }),
|
|
permissionAsked(sessionID, "perm_patch", {
|
|
action: "edit",
|
|
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
|
|
}),
|
|
permissionAsked(sessionID, "perm_delete", {
|
|
action: "edit",
|
|
metadata: { files: [fileDiff("gone.ts", "gone\n", "", "deleted")] },
|
|
}),
|
|
),
|
|
permission: allowOnce,
|
|
})
|
|
await acp.initialize()
|
|
const session = await acp.newSession(dir.path)
|
|
|
|
await acp.prompt(session.sessionId, "hello")
|
|
|
|
expect(acp.permissions.map((request) => request.toolCall.content)).toEqual([
|
|
[{ type: "diff", path: path.join(dir.path, "written.ts"), oldText: null, newText: "two\n" }],
|
|
[{ type: "diff", path: path.join(dir.path, "added.ts"), oldText: null, newText: "one\n" }],
|
|
[{ type: "diff", path: path.join(dir.path, "gone.ts"), oldText: "gone\n", newText: "" }],
|
|
])
|
|
})
|
|
|
|
test("asks without previews when core's diff no longer applies or the file cannot be read", async () => {
|
|
await using dir = await tmpdir()
|
|
await fs.writeFile(path.join(dir.path, "file.ts"), "changed\n")
|
|
await fs.mkdir(path.join(dir.path, "folder"))
|
|
await using acp = await startWire({
|
|
onPrompt: ({ sessionID, id }) =>
|
|
turn(
|
|
sessionID,
|
|
id,
|
|
permissionAsked(sessionID, "perm_stale", {
|
|
action: "edit",
|
|
resources: ["file.ts"],
|
|
metadata: { files: [fileDiff("file.ts", "one\n", "two\n")] },
|
|
}),
|
|
permissionAsked(sessionID, "perm_folder", {
|
|
action: "edit",
|
|
resources: ["folder"],
|
|
metadata: { files: [fileDiff("folder", "one\n", "two\n")] },
|
|
}),
|
|
),
|
|
permission: allowOnce,
|
|
})
|
|
await acp.initialize()
|
|
const session = await acp.newSession(dir.path)
|
|
|
|
await acp.prompt(session.sessionId, "hello")
|
|
|
|
expect(acp.permissions.map((request) => request.toolCall)).toMatchObject([
|
|
{ kind: "edit", locations: [{ path: path.join(dir.path, "file.ts") }] },
|
|
{ kind: "edit", locations: [{ path: path.join(dir.path, "folder") }] },
|
|
])
|
|
expect(acp.permissions.map((request) => request.toolCall.content)).toEqual([undefined, undefined])
|
|
expect(decisions(acp)).toEqual([
|
|
["perm_stale", "once"],
|
|
["perm_folder", "once"],
|
|
])
|
|
})
|
|
|
|
test("asks without previews when a patch does not apply to the current file", async () => {
|
|
await using dir = await tmpdir()
|
|
await fs.writeFile(path.join(dir.path, "first.ts"), "changed\n")
|
|
const patchText = ["*** Begin Patch", "*** Update File: first.ts", "@@", "-one", "+two", "*** End Patch"].join("\n")
|
|
await using acp = await startWire({
|
|
onPrompt: ({ sessionID, id }) => [
|
|
delivered(sessionID, id),
|
|
toolStarted(sessionID, "call_patch", "patch"),
|
|
toolCalled(sessionID, "call_patch", { patchText }),
|
|
permissionAsked(sessionID, "perm_patch", {
|
|
action: "edit",
|
|
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
|
|
}),
|
|
],
|
|
onPermissionReply: ({ sessionID }) => [succeeded(sessionID)],
|
|
permission: allowOnce,
|
|
})
|
|
await acp.initialize()
|
|
const session = await acp.newSession(dir.path)
|
|
|
|
await acp.prompt(session.sessionId, "hello")
|
|
|
|
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
|
kind: "edit",
|
|
locations: [{ path: path.join(dir.path, "first.ts") }],
|
|
})
|
|
expect(acp.permissions[0]?.toolCall.content).toBeUndefined()
|
|
expect(decisions(acp)).toEqual([["perm_patch", "once"]])
|
|
})
|
|
|
|
test("reports the same absolute locations for a moved file in the permission and tool updates", async () => {
|
|
await using dir = await tmpdir()
|
|
await fs.writeFile(path.join(dir.path, "old.ts"), "one\n")
|
|
const patchText = [
|
|
"*** Begin Patch",
|
|
"*** Update File: old.ts",
|
|
"*** Move to: new.ts",
|
|
"@@",
|
|
"-one",
|
|
"+two",
|
|
"*** End Patch",
|
|
].join("\n")
|
|
await using acp = await startWire({
|
|
onPrompt: ({ sessionID, id }) => [
|
|
delivered(sessionID, id),
|
|
toolStarted(sessionID, "call_move", "patch"),
|
|
toolCalled(sessionID, "call_move", { patchText }),
|
|
permissionAsked(sessionID, "perm_move", {
|
|
action: "edit",
|
|
source: { type: "tool", messageID: "msg_move", id: "call_move" },
|
|
}),
|
|
],
|
|
onPermissionReply: ({ sessionID }) => [
|
|
toolSucceeded(sessionID, "call_move", {}, "patched"),
|
|
succeeded(sessionID),
|
|
],
|
|
permission: allowOnce,
|
|
})
|
|
await acp.initialize()
|
|
const session = await acp.newSession(dir.path)
|
|
|
|
await acp.prompt(session.sessionId, "hello")
|
|
|
|
const locations = [{ path: path.join(dir.path, "old.ts") }, { path: path.join(dir.path, "new.ts") }]
|
|
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
|
locations,
|
|
content: [{ type: "diff", path: path.join(dir.path, "new.ts"), oldText: "one\n", newText: "two\n" }],
|
|
})
|
|
expect(
|
|
acp.updates.flatMap((item) =>
|
|
item.update.sessionUpdate === "tool_call_update" && item.update.toolCallId === "call_move"
|
|
? [[item.update.status, item.update.locations]]
|
|
: [],
|
|
),
|
|
).toEqual([
|
|
["in_progress", locations],
|
|
["completed", locations],
|
|
])
|
|
})
|
|
|
|
test("does not echo completed edits to a client that advertises writeTextFile", async () => {
|
|
await using dir = await tmpdir()
|
|
const file = path.join(dir.path, "file.ts")
|
|
await fs.writeFile(file, "after")
|
|
await using acp = await startWire({
|
|
onPrompt: ({ sessionID, id }) =>
|
|
turn(
|
|
sessionID,
|
|
id,
|
|
toolStarted(sessionID, "call_edit", "edit"),
|
|
toolCalled(sessionID, "call_edit", { filePath: file, oldString: "before", newString: "after" }),
|
|
toolSucceeded(sessionID, "call_edit", { files: [{ file }] }, "edited"),
|
|
),
|
|
})
|
|
await acp.initialize({ writeTextFile: true })
|
|
const session = await acp.newSession(dir.path)
|
|
|
|
expect(await acp.prompt(session.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
|
|
expect(acp.writes).toEqual([])
|
|
expect(
|
|
acp.updates.flatMap((item) =>
|
|
item.update.sessionUpdate === "tool_call_update" && item.update.status === "completed" ? [item.update] : [],
|
|
),
|
|
).toMatchObject([
|
|
{
|
|
toolCallId: "call_edit",
|
|
content: [
|
|
{ type: "content", content: { type: "text", text: "edited" } },
|
|
{ type: "diff", path: file, oldText: "before", newText: "after" },
|
|
],
|
|
},
|
|
])
|
|
})
|
|
})
|
|
|
|
function decisions(acp: Wire) {
|
|
return acp.server.replies.map((reply) => [reply.requestID, reply.decision])
|
|
}
|
|
|
|
function fileDiff(file: string, before: string, after: string, status: "added" | "deleted" | "modified" = "modified") {
|
|
return { file, patch: createTwoFilesPatch(file, file, before, after), additions: 1, deletions: 1, status }
|
|
}
|