FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

fix(tui): show permission prompts from nested subagent chains by NaturalSelect · Pull Request #36046 · anomalyco/opencode · GitHub

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension .ts  (6) .tsx  (1) All 2 file types selected
Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,45 @@ describe("autoRespondsPermission", () => {

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

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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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

Back | FazBrowse Home | New Git URL