Skip to content
Open
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
39 changes: 39 additions & 0 deletions packages/app/src/context/permission-auto-respond.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,45 @@ describe("autoRespondsPermission", () => {

expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
})

test("inherits an ancestor's directory-scoped auto-accept across a grandchild", () => {
const directory = "/tmp/project"
// root → child → grandchild lineage
const sessions = [
session({ id: "root" }),
session({ id: "child", parentID: "root" }),
session({ id: "grandchild", parentID: "child" }),
]
const autoAccept = {
[`${base64Encode(directory)}/root`]: true,
}

expect(autoRespondsPermission(autoAccept, sessions, permission("grandchild"), directory)).toBe(true)
})

test("inherits a root session's legacy auto-accept key from a grandchild", () => {
const sessions = [
session({ id: "root" }),
session({ id: "child", parentID: "root" }),
session({ id: "grandchild", parentID: "child" }),
]

expect(autoRespondsPermission({ root: true }, sessions, permission("grandchild"), "/tmp/project")).toBe(true)
})

test("requires approval for a grandchild with no ancestor override", () => {
const sessions = [
session({ id: "root" }),
session({ id: "child", parentID: "root" }),
session({ id: "grandchild", parentID: "child" }),
session({ id: "unrelated" }),
]
const autoAccept = {
unrelated: true,
}

expect(autoRespondsPermission(autoAccept, sessions, permission("grandchild"), "/tmp/project")).toBe(false)
})
})

describe("isDirectoryAutoAccepting", () => {
Expand Down
47 changes: 38 additions & 9 deletions packages/opencode/src/cli/cmd/run/stream.transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
// The tick counter prevents stale idle events from resolving the wrong turn.
// We also re-check live session status before resolving an idle event so a
// delayed idle from an older turn cannot complete a newer busy turn.
import type { Event, GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2"
import type { Event, GlobalEvent, OpencodeClient, Session } from "@opencode-ai/sdk/v2"
import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect"
import { makeRuntime } from "@/effect/run-service"
import {
Expand Down Expand Up @@ -388,6 +388,42 @@ function traceTabs(trace: Trace | undefined, prev: FooterSubagentTab[], next: Fo
}
}

const collectDescendantSessions = Effect.fn("RunStreamTransport.collectDescendantSessions")(function* (
sdk: OpencodeClient,
rootSessionID: string,
) {
const visited = new Set<string>([rootSessionID])
const descendants: Session[] = []
let frontier = [rootSessionID]

while (frontier.length > 0) {
const levels = yield* Effect.all(
frontier.map((sessionID) =>
Effect.promise(() => sdk.session.children({ sessionID })).pipe(
Effect.map((item) => item.data ?? []),
Effect.orElseSucceed(() => []),
),
),
{ concurrency: "unbounded" },
)

frontier = []
for (const children of levels) {
for (const child of children) {
if (visited.has(child.id)) {
continue
}

visited.add(child.id)
descendants.push(child)
frontier.push(child.id)
}
}
}

return descendants
})

function createLayer(input: StreamInput) {
return Layer.fresh(
Layer.effect(
Expand Down Expand Up @@ -684,14 +720,7 @@ function createLayer(input: StreamInput) {
: Math.max(input.replayLimit, SUBAGENT_BOOTSTRAP_LIMIT)
: SUBAGENT_BOOTSTRAP_LIMIT,
),
Effect.promise(() =>
input.sdk.session.children({
sessionID: input.sessionID,
}),
).pipe(
Effect.map((item) => item.data ?? []),
Effect.orElseSucceed(() => []),
),
collectDescendantSessions(input.sdk, input.sessionID),
Effect.promise(() => input.sdk.permission.list()).pipe(
Effect.map((item) => item.data ?? []),
Effect.orElseSucceed(() => []),
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/cli/cmd/run/subagent-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,7 @@ export function reduceSubagentData(input: {
}) {
const event = input.event

let changed = false
if (event.type === "message.part.updated") {
const part = event.properties.part
if (part.sessionID === input.sessionID) {
Expand All @@ -807,6 +808,10 @@ export function reduceSubagentData(input: {

return syncTaskTab(input.data, part)
}

if (part.type === "tool" && knownSession(input.data, part.sessionID)) {
changed = syncTaskTab(input.data, part)
}
}

const sessionID =
Expand Down Expand Up @@ -871,6 +876,6 @@ export function reduceSubagentData(input: {
event,
thinking: input.thinking,
limits: input.limits,
}) || cancelled
}) || cancelled || changed
)
}
126 changes: 126 additions & 0 deletions packages/opencode/test/cli/run/stream.transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1399,6 +1399,132 @@ describe("run stream transport", () => {
}
})

test("bootstraps grandchild tabs and permissions from a nested subagent chain", async () => {
const src = eventFeed()
const ui = footer()
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
messages: async ({ sessionID }) => {
if (sessionID === "session-1") {
return ok([
assistantMessage({
sessionID: "session-1",
id: "msg-1",
parts: [
runningTool({
sessionID: "session-1",
messageID: "msg-1",
id: "task-1",
callID: "call-1",
tool: "task",
body: {
description: "Explore run folder",
subagent_type: "explore",
},
metadata: {
sessionId: "child-1",
},
}),
],
}),
])
}

if (sessionID === "child-1") {
return ok([
assistantMessage({
sessionID: "child-1",
id: "msg-child-1",
parts: [
runningTool({
sessionID: "child-1",
messageID: "msg-child-1",
id: "task-2",
callID: "call-2",
tool: "task",
body: {
description: "Grep for callers",
subagent_type: "explore",
},
metadata: {
sessionId: "grandchild-1",
},
}),
],
}),
])
}

return ok([
assistantMessage({
sessionID: "grandchild-1",
id: "msg-grandchild-1",
parts: [
runningTool({
sessionID: "grandchild-1",
messageID: "msg-grandchild-1",
id: "grep-1",
callID: "call-grep-1",
tool: "grep",
body: {
pattern: "callers",
},
}),
],
}),
])
},
children: async ({ sessionID }) => {
if (sessionID === "session-1") return ok([child("child-1")])
if (sessionID === "child-1") return ok([child("grandchild-1")])
return ok([])
},
permissions: async () =>
ok([
{
id: "perm-grandchild-1",
sessionID: "grandchild-1",
permission: "grep",
patterns: ["callers"],
metadata: {},
always: [],
tool: {
messageID: "msg-grandchild-1",
callID: "call-grep-1",
},
},
]),
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})

try {
const boot = await waitFor(() => {
const item = ui.events.findLast((event) => event.type === "stream.subagent")
const state = item?.type === "stream.subagent" ? item.state : undefined
return state?.tabs.some((tab) => tab.sessionID === "grandchild-1") &&
state.permissions.some((req) => req.id === "perm-grandchild-1")
? state
: undefined
})

expect(boot.tabs.map((tab) => tab.sessionID).sort()).toEqual(["child-1", "grandchild-1"])
expect(boot.permissions).toEqual([
expect.objectContaining({
id: "perm-grandchild-1",
sessionID: "grandchild-1",
}),
])
} finally {
src.close()
await transport.close()
}
})

test("bootstraps child session output before selection", async () => {
const ui = footer()
const transport = await createSessionTransport({
Expand Down
60 changes: 60 additions & 0 deletions packages/opencode/test/cli/run/subagent-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,4 +544,64 @@ describe("run subagent data", () => {
}),
])
})

test("registers a grandchild tab when a known child session spawns a nested task", () => {
const data = createSubagentData()

bootstrapSubagentData({
data,
messages: [taskMessage("child-1", "running")],
children: [{ id: "child-1" }],
permissions: [],
questions: [],
})

const changed = reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "part-grandchild-1",
sessionID: "child-1",
messageID: "msg-child-1",
type: "tool",
callID: "call-grandchild-1",
tool: "task",
state: {
status: "running",
input: {
description: "Grep for callers",
subagent_type: "explore",
},
metadata: {
sessionId: "grandchild-1",
},
time: { start: 1 },
},
},
},
})

expect(changed).toBe(true)

const tabs = snapshotSubagentData(data).tabs
expect(tabs.map((tab) => tab.sessionID).sort()).toEqual(["child-1", "grandchild-1"])

reduce(data, {
type: "permission.asked",
properties: {
id: "perm-grandchild-1",
sessionID: "grandchild-1",
permission: "grep",
patterns: ["callers"],
metadata: {},
always: [],
tool: {
messageID: "msg-grandchild-1",
callID: "call-grep-1",
},
},
})

expect(snapshotSubagentData(data).permissions.map((item) => item.id)).toEqual(["perm-grandchild-1"])
})
})
Loading
Loading