mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-05 16:41:01 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca357ee2a0 |
@@ -1,4 +1,4 @@
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import type { AgentSideConnection, SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type {
|
||||
Event,
|
||||
EventMessagePartDelta,
|
||||
@@ -29,6 +29,13 @@ type GlobalEventEnvelope = {
|
||||
type GlobalEventStream = {
|
||||
stream: AsyncIterable<GlobalEventEnvelope>
|
||||
}
|
||||
type ChildSession = {
|
||||
id: string
|
||||
parentID: string
|
||||
rootID: string
|
||||
depth: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPSession.Interface }) {
|
||||
const subscription = new Subscription(input)
|
||||
@@ -42,6 +49,7 @@ export class Subscription {
|
||||
private readonly toolStarts = new Set<string>()
|
||||
private readonly connectionWaiters = new Set<() => void>()
|
||||
private readonly idleWaiters = new Map<string, Set<ReturnType<typeof signal>>>()
|
||||
private readonly children = new Map<string, ChildSession>()
|
||||
private readonly permission: ACPPermission.Handler
|
||||
private connected = false
|
||||
private started = false
|
||||
@@ -92,12 +100,30 @@ export class Subscription {
|
||||
|
||||
async handle(event: Event) {
|
||||
switch (event.type) {
|
||||
case "session.created":
|
||||
await this.registerChild(event.properties.info)
|
||||
return
|
||||
case "session.deleted":
|
||||
this.children.delete(event.properties.sessionID)
|
||||
return
|
||||
case "session.status":
|
||||
if (event.properties.status.type === "idle") this.idle(event.properties.sessionID)
|
||||
return
|
||||
case "permission.asked":
|
||||
this.permission.handle(event)
|
||||
case "permission.asked": {
|
||||
const target = await this.resolveSession(event.properties.sessionID)
|
||||
if (!target) return
|
||||
this.permission.handle(event, {
|
||||
sessionId: target.session.id,
|
||||
cwd: target.session.cwd,
|
||||
...(target.child
|
||||
? {
|
||||
toolCallPrefix: target.child.id,
|
||||
titlePrefix: target.child.title,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
case "message.part.updated":
|
||||
return this.handlePartUpdated(event)
|
||||
case "message.part.delta":
|
||||
@@ -112,7 +138,7 @@ export class Subscription {
|
||||
for (const part of message.parts) {
|
||||
await this.recordFetchedPart(message.info.sessionID, message, part)
|
||||
if (part.type === "tool") {
|
||||
await this.handleToolPart(message.info.sessionID, part, cwd ?? process.cwd())
|
||||
await this.handleToolPart(message.info.sessionID, message.info.sessionID, part, cwd ?? process.cwd())
|
||||
continue
|
||||
}
|
||||
await this.replayContentPart(message, part)
|
||||
@@ -190,13 +216,13 @@ export class Subscription {
|
||||
|
||||
private async handlePartUpdated(event: EventMessagePartUpdated) {
|
||||
const part = event.properties.part
|
||||
const sessionId = part.sessionID || event.properties.sessionID
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(sessionId))
|
||||
if (!session) return
|
||||
const sourceSessionId = part.sessionID || event.properties.sessionID
|
||||
const target = await this.resolveSession(sourceSessionId)
|
||||
if (!target) return
|
||||
|
||||
await Effect.runPromise(
|
||||
this.input.session.recordPartMetadata({
|
||||
sessionId: session.id,
|
||||
sessionId: target.session.id,
|
||||
messageId: part.messageID,
|
||||
partId: part.id,
|
||||
partType: part.type,
|
||||
@@ -207,18 +233,18 @@ export class Subscription {
|
||||
}),
|
||||
)
|
||||
if (part.type === "tool") {
|
||||
await this.handleToolPart(session.id, part, session.cwd)
|
||||
await this.handleToolPart(target.session.id, sourceSessionId, part, target.session.cwd, target.child)
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePartDelta(event: EventMessagePartDelta) {
|
||||
const props = event.properties
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(props.sessionID))
|
||||
if (!session) return
|
||||
const target = await this.resolveSession(props.sessionID)
|
||||
if (!target) return
|
||||
|
||||
const known = await Effect.runPromise(
|
||||
this.input.session.tryGetPartMetadata({
|
||||
sessionId: session.id,
|
||||
sessionId: target.session.id,
|
||||
messageId: props.messageID,
|
||||
partId: props.partID,
|
||||
}),
|
||||
@@ -226,12 +252,18 @@ export class Subscription {
|
||||
const metadata =
|
||||
known?.role && known.partType
|
||||
? known
|
||||
: await this.fetchPartMetadata(session.id, session.cwd, props.messageID, props.partID)
|
||||
: await this.fetchPartMetadata(
|
||||
props.sessionID,
|
||||
target.session.id,
|
||||
target.session.cwd,
|
||||
props.messageID,
|
||||
props.partID,
|
||||
)
|
||||
if (metadata?.role !== "assistant") return
|
||||
if (metadata.partType === "text" && props.field === "text" && metadata.ignored !== true) {
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId: session.id,
|
||||
update: {
|
||||
await this.update(
|
||||
target.session.id,
|
||||
{
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: props.messageID,
|
||||
content: {
|
||||
@@ -239,14 +271,15 @@ export class Subscription {
|
||||
text: props.delta,
|
||||
},
|
||||
},
|
||||
})
|
||||
target.child,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (metadata.partType === "reasoning" && props.field === "text") {
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId: session.id,
|
||||
update: {
|
||||
await this.update(
|
||||
target.session.id,
|
||||
{
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: props.messageID,
|
||||
content: {
|
||||
@@ -254,15 +287,22 @@ export class Subscription {
|
||||
text: props.delta,
|
||||
},
|
||||
},
|
||||
})
|
||||
target.child,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchPartMetadata(sessionId: string, cwd: string, messageId: string, partId: string) {
|
||||
private async fetchPartMetadata(
|
||||
sourceSessionId: string,
|
||||
targetSessionId: string,
|
||||
cwd: string,
|
||||
messageId: string,
|
||||
partId: string,
|
||||
) {
|
||||
const message = await this.input.sdk.session
|
||||
.message(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
sessionID: sourceSessionId,
|
||||
messageID: messageId,
|
||||
directory: cwd,
|
||||
},
|
||||
@@ -274,7 +314,7 @@ export class Subscription {
|
||||
|
||||
const part = message.parts.find((item) => item.id === partId)
|
||||
if (!part) return
|
||||
return await this.recordFetchedPart(sessionId, message, part)
|
||||
return await this.recordFetchedPart(targetSessionId, message, part)
|
||||
}
|
||||
|
||||
private async recordFetchedPart(sessionId: string, message: SessionMessageResponse, part: Part) {
|
||||
@@ -292,23 +332,30 @@ export class Subscription {
|
||||
)
|
||||
}
|
||||
|
||||
private async handleToolPart(sessionId: string, part: ToolPart, cwd: string) {
|
||||
await this.toolStart(sessionId, part, cwd)
|
||||
private async handleToolPart(
|
||||
sessionId: string,
|
||||
sourceSessionId: string,
|
||||
part: ToolPart,
|
||||
cwd: string,
|
||||
child?: ChildSession,
|
||||
) {
|
||||
const key = toolKey(sourceSessionId, part.callID)
|
||||
await this.toolStart(sessionId, key, part, cwd, child)
|
||||
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
this.shellSnapshots.delete(key)
|
||||
return
|
||||
|
||||
case "running":
|
||||
await this.runningTool(sessionId, part, cwd)
|
||||
await this.runningTool(sessionId, key, part, cwd, child)
|
||||
return
|
||||
|
||||
case "completed":
|
||||
this.clearTool(part.callID)
|
||||
await this.input.connection.sessionUpdate({
|
||||
this.clearTool(key)
|
||||
await this.update(
|
||||
sessionId,
|
||||
update: {
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
@@ -317,14 +364,15 @@ export class Subscription {
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
})
|
||||
child,
|
||||
)
|
||||
return
|
||||
|
||||
case "error":
|
||||
this.clearTool(part.callID)
|
||||
await this.input.connection.sessionUpdate({
|
||||
this.clearTool(key)
|
||||
await this.update(
|
||||
sessionId,
|
||||
update: {
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
@@ -333,20 +381,21 @@ export class Subscription {
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
})
|
||||
child,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private async runningTool(sessionId: string, part: ToolPart, cwd: string) {
|
||||
private async runningTool(sessionId: string, key: string, part: ToolPart, cwd: string, child?: ChildSession) {
|
||||
if (part.state.status !== "running") return
|
||||
|
||||
const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined
|
||||
if (output !== undefined) {
|
||||
if (this.shellSnapshots.get(part.callID) === output) {
|
||||
await this.input.connection.sessionUpdate({
|
||||
if (this.shellSnapshots.get(key) === output) {
|
||||
await this.update(
|
||||
sessionId,
|
||||
update: {
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
...duplicateRunningToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
@@ -355,15 +404,16 @@ export class Subscription {
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
})
|
||||
child,
|
||||
)
|
||||
return
|
||||
}
|
||||
this.shellSnapshots.set(part.callID, output)
|
||||
this.shellSnapshots.set(key, output)
|
||||
}
|
||||
|
||||
await this.input.connection.sessionUpdate({
|
||||
await this.update(
|
||||
sessionId,
|
||||
update: {
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
@@ -373,15 +423,16 @@ export class Subscription {
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
})
|
||||
child,
|
||||
)
|
||||
}
|
||||
|
||||
private async toolStart(sessionId: string, part: ToolPart, cwd: string) {
|
||||
if (this.toolStarts.has(part.callID)) return
|
||||
this.toolStarts.add(part.callID)
|
||||
await this.input.connection.sessionUpdate({
|
||||
private async toolStart(sessionId: string, key: string, part: ToolPart, cwd: string, child?: ChildSession) {
|
||||
if (this.toolStarts.has(key)) return
|
||||
this.toolStarts.add(key)
|
||||
await this.update(
|
||||
sessionId,
|
||||
update: {
|
||||
{
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: part.callID,
|
||||
@@ -390,13 +441,67 @@ export class Subscription {
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
child,
|
||||
)
|
||||
}
|
||||
|
||||
private clearTool(key: string) {
|
||||
this.toolStarts.delete(key)
|
||||
this.shellSnapshots.delete(key)
|
||||
}
|
||||
|
||||
private async registerChild(info: Extract<Event, { type: "session.created" }>["properties"]["info"]) {
|
||||
if (!info.parentID) return
|
||||
const parent = this.children.get(info.parentID)
|
||||
const rootID = parent?.rootID ?? (await Effect.runPromise(this.input.session.tryGet(info.parentID)))?.id
|
||||
if (!rootID) return
|
||||
this.children.set(info.id, {
|
||||
id: info.id,
|
||||
parentID: info.parentID,
|
||||
rootID,
|
||||
depth: (parent?.depth ?? 0) + 1,
|
||||
title: info.title,
|
||||
})
|
||||
}
|
||||
|
||||
private clearTool(toolCallId: string) {
|
||||
this.toolStarts.delete(toolCallId)
|
||||
this.shellSnapshots.delete(toolCallId)
|
||||
private async resolveSession(sessionId: string) {
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(sessionId))
|
||||
if (session) return { session, child: undefined }
|
||||
const child = this.children.get(sessionId)
|
||||
if (!child) return
|
||||
const root = await Effect.runPromise(this.input.session.tryGet(child.rootID))
|
||||
if (!root) return
|
||||
return { session: root, child }
|
||||
}
|
||||
|
||||
private update(sessionId: string, update: SessionUpdate, child?: ChildSession) {
|
||||
return this.input.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: child ? projectChildUpdate(update, child) : update,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function toolKey(sessionId: string, toolCallId: string) {
|
||||
return `${sessionId}:${toolCallId}`
|
||||
}
|
||||
|
||||
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
|
||||
const projected = { ...update }
|
||||
projected._meta = {
|
||||
...projected._meta,
|
||||
"opencode/child-session": {
|
||||
id: child.id,
|
||||
parentID: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
},
|
||||
}
|
||||
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
|
||||
projected.toolCallId = `${child.id}:${projected.toolCallId}`
|
||||
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
function signal() {
|
||||
|
||||
@@ -16,6 +16,12 @@ import { Effect } from "effect"
|
||||
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
||||
type Reply = "once" | "always" | "reject"
|
||||
type Connection = Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
||||
export type Context = {
|
||||
sessionId: string
|
||||
cwd: string
|
||||
toolCallPrefix?: string
|
||||
titlePrefix?: string
|
||||
}
|
||||
|
||||
const permissionOptions: PermissionOption[] = [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
@@ -34,11 +40,11 @@ export class Handler {
|
||||
},
|
||||
) {}
|
||||
|
||||
handle(event: PermissionEvent) {
|
||||
handle(event: PermissionEvent, context?: Context) {
|
||||
const permission = event.properties
|
||||
const previous = this.queues.get(permission.sessionID) ?? Promise.resolve()
|
||||
const next = previous
|
||||
.then(() => this.process(event))
|
||||
.then(() => this.process(event, context))
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (this.queues.get(permission.sessionID) === next) {
|
||||
@@ -48,28 +54,31 @@ export class Handler {
|
||||
this.queues.set(permission.sessionID, next)
|
||||
}
|
||||
|
||||
private async process(event: PermissionEvent) {
|
||||
private async process(event: PermissionEvent, context?: Context) {
|
||||
const permission = event.properties
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(permission.sessionID))
|
||||
if (!session) return
|
||||
const registered = context ? undefined : await Effect.runPromise(this.input.session.tryGet(permission.sessionID))
|
||||
const target = context ?? (registered ? { sessionId: registered.id, cwd: registered.cwd } : undefined)
|
||||
if (!target) return
|
||||
|
||||
if (!this.input.connection.requestPermission) {
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
await this.reply(permission.id, "reject", target.cwd)
|
||||
return
|
||||
}
|
||||
|
||||
const toolCallId = permission.tool?.callID ?? permission.id
|
||||
const result = await this.input.connection
|
||||
.requestPermission({
|
||||
sessionId: permission.sessionID,
|
||||
sessionId: target.sessionId,
|
||||
toolCall: await permissionToolCall({
|
||||
toolCallId: permission.tool?.callID ?? permission.id,
|
||||
toolCallId: target.toolCallPrefix ? `${target.toolCallPrefix}:${toolCallId}` : toolCallId,
|
||||
toolName: permission.permission,
|
||||
input: permission.metadata,
|
||||
titlePrefix: target.titlePrefix,
|
||||
}),
|
||||
options: permissionOptions,
|
||||
})
|
||||
.catch(async () => {
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
await this.reply(permission.id, "reject", target.cwd)
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -77,15 +86,15 @@ export class Handler {
|
||||
|
||||
const reply = selectedReply(result)
|
||||
if (reply !== "once" && reply !== "always") {
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
await this.reply(permission.id, "reject", target.cwd)
|
||||
return
|
||||
}
|
||||
|
||||
if (permission.permission === "edit") {
|
||||
await this.writeProposedEdit(session.id, permission.metadata).catch(() => {})
|
||||
await this.writeProposedEdit(target.sessionId, permission.metadata).catch(() => {})
|
||||
}
|
||||
|
||||
await this.reply(permission.id, reply, session.cwd)
|
||||
await this.reply(permission.id, reply, target.cwd)
|
||||
}
|
||||
|
||||
private async reply(requestID: string, reply: Reply, directory: string) {
|
||||
@@ -119,6 +128,7 @@ async function permissionToolCall(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly input: ToolInput
|
||||
readonly titlePrefix?: string
|
||||
}): Promise<ToolCallUpdate> {
|
||||
const toolCall = pendingToolCall({
|
||||
toolCallId: input.toolCallId,
|
||||
@@ -131,11 +141,18 @@ async function permissionToolCall(input: {
|
||||
const content = await permissionContent(input.toolName, input.input)
|
||||
return {
|
||||
...toolCall,
|
||||
title: prefixedTitle(input.titlePrefix, toolCall.title),
|
||||
locations: permissionLocations(input.toolName, input.input),
|
||||
...(content.length ? { content } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function prefixedTitle(prefix: string | undefined, title: string | undefined) {
|
||||
if (!prefix) return title
|
||||
if (!title) return prefix
|
||||
return `${prefix}: ${title}`
|
||||
}
|
||||
|
||||
function permissionTitle(toolName: string, input: ToolInput) {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
switch (tool) {
|
||||
|
||||
@@ -167,6 +167,21 @@ function toolUpdated(part: ToolPart): Event {
|
||||
}
|
||||
}
|
||||
|
||||
function sessionCreated(sessionID: string, parentID: string, title: string): Event {
|
||||
return {
|
||||
id: `evt_${sessionID}_created`,
|
||||
type: "session.created",
|
||||
properties: {
|
||||
sessionID,
|
||||
info: {
|
||||
id: sessionID,
|
||||
parentID,
|
||||
title,
|
||||
},
|
||||
},
|
||||
} as Event
|
||||
}
|
||||
|
||||
function assistantMessage(sessionID: string, messageID: string, partID: string, type: DeltaPartType) {
|
||||
return {
|
||||
info: {
|
||||
@@ -319,6 +334,63 @@ async function createKnownSession(
|
||||
}
|
||||
|
||||
describe("acp event routing", () => {
|
||||
it("projects nested subagent messages onto the root session", async () => {
|
||||
const harness = createHarness({
|
||||
msg_grandchild: assistantMessage("ses_grandchild", "msg_grandchild", "part_grandchild", "text"),
|
||||
})
|
||||
await Effect.runPromise(harness.session.create({ id: "ses_root", cwd: "/workspace" }))
|
||||
await harness.subscription.handle(sessionCreated("ses_child", "ses_root", "Explore"))
|
||||
await harness.subscription.handle(sessionCreated("ses_grandchild", "ses_child", "Research"))
|
||||
|
||||
await harness.subscription.handle(partUpdated("ses_grandchild", "msg_grandchild", "part_grandchild", "text"))
|
||||
await harness.subscription.handle(textDelta("ses_grandchild", "msg_grandchild", "part_grandchild", "nested result"))
|
||||
|
||||
expect(harness.updates).toEqual([
|
||||
{
|
||||
sessionId: "ses_root",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_grandchild",
|
||||
content: { type: "text", text: "nested result" },
|
||||
_meta: {
|
||||
"opencode/child-session": {
|
||||
id: "ses_grandchild",
|
||||
parentID: "ses_child",
|
||||
depth: 2,
|
||||
title: "Research",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("namespaces subagent tool calls without colliding with root tools", async () => {
|
||||
const harness = createHarness()
|
||||
await Effect.runPromise(harness.session.create({ id: "ses_root", cwd: "/workspace" }))
|
||||
await harness.subscription.handle(sessionCreated("ses_child", "ses_root", "Explore"))
|
||||
|
||||
await harness.subscription.handle(toolUpdated(runningTool("ses_root", "call_shared", "root")))
|
||||
await harness.subscription.handle(toolUpdated(runningTool("ses_child", "call_shared", "child")))
|
||||
|
||||
const starts = toolUpdates(harness.updates).filter((item) => item.update.sessionUpdate === "tool_call")
|
||||
expect(starts.map((item) => item.update.toolCallId)).toEqual(["call_shared", "ses_child:call_shared"])
|
||||
expect(starts[1]).toMatchObject({
|
||||
sessionId: "ses_root",
|
||||
update: {
|
||||
title: "Explore: printf hello",
|
||||
_meta: {
|
||||
"opencode/child-session": {
|
||||
id: "ses_child",
|
||||
parentID: "ses_root",
|
||||
depth: 1,
|
||||
title: "Explore",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("routes message.part.delta by sessionID without cross-session pollution", async () => {
|
||||
const harness = createHarness()
|
||||
await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
|
||||
|
||||
@@ -122,6 +122,21 @@ function permissionAsked(
|
||||
} as PermissionEvent
|
||||
}
|
||||
|
||||
function sessionCreated(sessionID: string, parentID: string, title: string): Event {
|
||||
return {
|
||||
id: `evt_${sessionID}_created`,
|
||||
type: "session.created",
|
||||
properties: {
|
||||
sessionID,
|
||||
info: {
|
||||
id: sessionID,
|
||||
parentID,
|
||||
title,
|
||||
},
|
||||
},
|
||||
} as Event
|
||||
}
|
||||
|
||||
function textDelta(sessionID: string, messageID: string, partID: string, delta: string) {
|
||||
return {
|
||||
id: `evt_${sessionID}_${messageID}_${partID}`,
|
||||
@@ -156,6 +171,29 @@ async function tempFile(name: string, content: string) {
|
||||
}
|
||||
|
||||
describe("acp permissions", () => {
|
||||
it("routes subagent permissions through the root session", async () => {
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_root")
|
||||
await harness.subscription.handle(sessionCreated("ses_child", "ses_root", "Explore"))
|
||||
|
||||
harness.subscription.handle(
|
||||
permissionAsked("ses_child", "perm_child", {
|
||||
tool: { messageID: "msg_child", callID: "call_child" },
|
||||
}),
|
||||
)
|
||||
|
||||
await pollUntil(() => harness.replies.length === 1, "subagent permission was never replied")
|
||||
|
||||
expect(harness.requests[0]).toMatchObject({
|
||||
sessionId: "ses_root",
|
||||
toolCall: {
|
||||
toolCallId: "ses_child:call_child",
|
||||
title: "Explore: printf hello",
|
||||
},
|
||||
})
|
||||
expect(harness.replies).toEqual([{ requestID: "perm_child", reply: "once", directory: "/workspace" }])
|
||||
})
|
||||
|
||||
it("sends requestPermission and replies with the selected outcome", async () => {
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
Reference in New Issue
Block a user