diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 4c511849a610..8f6c97328f33 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -444,139 +444,135 @@ export const RunCommand = cmd({ async function loop() { const toggles = new Map() - try { - for await (const event of events.stream) { + for await (const event of events.stream) { + if ( + event.type === "message.updated" && + event.properties.info.role === "assistant" && + args.format !== "json" && + toggles.get("start") !== true + ) { + UI.empty() + UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) + UI.empty() + toggles.set("start", true) + } + + if (event.type === "message.part.updated") { + const part = event.properties.part + if (part.sessionID !== sessionID) continue + + if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { + if (emit("tool_use", { part })) continue + if (part.state.status === "completed") { + tool(part) + continue + } + inline({ + icon: "✗", + title: `${part.tool} failed`, + }) + UI.error(part.state.error) + } + if ( - event.type === "message.updated" && - event.properties.info.role === "assistant" && - args.format !== "json" && - toggles.get("start") !== true + part.type === "tool" && + part.tool === "task" && + part.state.status === "running" && + args.format !== "json" ) { - UI.empty() - UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) - UI.empty() - toggles.set("start", true) + if (toggles.get(part.id) === true) continue + task(props(part)) + toggles.set(part.id, true) } - if (event.type === "message.part.updated") { - const part = event.properties.part - if (part.sessionID !== sessionID) continue - - if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { - if (emit("tool_use", { part })) continue - if (part.state.status === "completed") { - tool(part) - continue - } - inline({ - icon: "✗", - title: `${part.tool} failed`, - }) - UI.error(part.state.error) - } - - if ( - part.type === "tool" && - part.tool === "task" && - part.state.status === "running" && - args.format !== "json" - ) { - if (toggles.get(part.id) === true) continue - task(props(part)) - toggles.set(part.id, true) - } + if (part.type === "step-start") { + if (emit("step_start", { part })) continue + } - if (part.type === "step-start") { - if (emit("step_start", { part })) continue - } + if (part.type === "step-finish") { + if (emit("step_finish", { part })) continue + } - if (part.type === "step-finish") { - if (emit("step_finish", { part })) continue + if (part.type === "text" && part.time?.end) { + if (emit("text", { part })) continue + const text = part.text.trim() + if (!text) continue + if (!process.stdout.isTTY) { + process.stdout.write(text + EOL) + continue } + UI.empty() + UI.println(text) + UI.empty() + } - if (part.type === "text" && part.time?.end) { - if (emit("text", { part })) continue - const text = part.text.trim() - if (!text) continue - if (!process.stdout.isTTY) { - process.stdout.write(text + EOL) - continue - } + if (part.type === "reasoning" && part.time?.end && args.thinking) { + if (emit("reasoning", { part })) continue + const text = part.text.trim() + if (!text) continue + const line = `Thinking: ${text}` + if (process.stdout.isTTY) { UI.empty() - UI.println(text) + UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) UI.empty() + continue } - - if (part.type === "reasoning" && part.time?.end && args.thinking) { - if (emit("reasoning", { part })) continue - const text = part.text.trim() - if (!text) continue - const line = `Thinking: ${text}` - if (process.stdout.isTTY) { - UI.empty() - UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) - UI.empty() - continue - } - process.stdout.write(line + EOL) - } + process.stdout.write(line + EOL) } + } - if (event.type === "session.error") { - const props = event.properties - if (props.sessionID !== sessionID || !props.error) continue - let err = String(props.error.name) - if ("data" in props.error && props.error.data && "message" in props.error.data) { - err = String(props.error.data.message) - } - error = error ? error + EOL + err : err - if (emit("error", { error: props.error })) continue - UI.error(err) + if (event.type === "session.error") { + const props = event.properties + if (props.sessionID !== sessionID || !props.error) continue + let err = String(props.error.name) + if ("data" in props.error && props.error.data && "message" in props.error.data) { + err = String(props.error.data.message) } + error = error ? error + EOL + err : err + if (emit("error", { error: props.error })) continue + UI.error(err) + } - if ( - event.type === "session.status" && - event.properties.sessionID === sessionID && - event.properties.status.type === "idle" - ) { - break - } + if ( + event.type === "session.status" && + event.properties.sessionID === sessionID && + event.properties.status.type === "idle" + ) { + break + } - if (event.type === "permission.asked") { - const permission = event.properties - if (permission.sessionID !== sessionID) continue - - if (runEventsHandle) { - if (!args["dangerously-skip-permissions"] && !jsonMode) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL + - `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, - ) - } - continue - } + if (event.type === "permission.asked") { + const permission = event.properties + if (permission.sessionID !== sessionID) continue - if (args["dangerously-skip-permissions"]) { - await sdk.permission.reply({ - requestID: permission.id, - reply: "once", - }) - } else { + if (runEventsHandle) { + if (!args["dangerously-skip-permissions"] && !jsonMode) { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, ) - await sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - }) } + continue + } + + if (args["dangerously-skip-permissions"]) { + await sdk.permission.reply({ + requestID: permission.id, + reply: "once", + }) + } else { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL + + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, + ) + await sdk.permission.reply({ + requestID: permission.id, + reply: "reject", + }) } } - } finally { - runEventsHandle?.unsubscribe() } } @@ -659,23 +655,26 @@ export const RunCommand = cmd({ }), ) - await share(sdk, sessionID) - - loop().catch((e) => { - console.error(e) - process.exit(1) - }) + try { + await share(sdk, sessionID) - if (args.command) { - await sdk.session.command({ - sessionID, - agent, - model: args.model, - command: args.command, - arguments: message, - variant: args.variant, + loop().catch((e) => { + console.error(e) + process.exit(1) }) - } else { + + if (args.command) { + await sdk.session.command({ + sessionID, + agent, + model: args.model, + command: args.command, + arguments: message, + variant: args.variant, + }) + return + } + const model = args.model ? Provider.parseModel(args.model) : undefined await sdk.session.prompt({ sessionID, @@ -684,6 +683,8 @@ export const RunCommand = cmd({ variant: args.variant, parts: [...files, { type: "text", text: message }], }) + } finally { + runEventsHandle?.unsubscribe() } } diff --git a/packages/opencode/test/cli/run-events.test.ts b/packages/opencode/test/cli/run-events.test.ts index 3af1992f0949..8d0c1bf078eb 100644 --- a/packages/opencode/test/cli/run-events.test.ts +++ b/packages/opencode/test/cli/run-events.test.ts @@ -8,7 +8,7 @@ import { Permission } from "../../src/permission" import { Session } from "../../src/session" import { Bus } from "../../src/bus" import { SessionID } from "../../src/session/schema" -import { RunEvents } from "../../src/cli/cmd/run-events" +import { MAX_LINEAGE_DEPTH, RunEvents } from "../../src/cli/cmd/run-events" const it = testEffect( Layer.mergeAll( @@ -33,6 +33,19 @@ const waitForQuestionCount = ( return yield* Effect.fail(new Error(`timed out waiting for ${count} question(s)`)) }) +const waitForPermissionCount = ( + permission: Permission.Interface, + count: number, +): Effect.Effect, Error> => + Effect.gen(function* () { + for (const _ of Array.from({ length: 100 })) { + const pending = yield* permission.list() + if (pending.length === count) return pending + yield* Effect.sleep("10 millis") + } + return yield* Effect.fail(new Error(`timed out waiting for ${count} permission(s)`)) + }) + describe("cli/run-events", () => { it.live("auto-rejects question.asked for the root session (non-attach, non-json)", () => provideTmpdirInstance(() => @@ -102,6 +115,62 @@ describe("cli/run-events", () => { ), ) + it.live("auto-rejects question.asked across a grandchild lineage walk", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const question = yield* Question.Service + const session = yield* Session.Service + const rootSessionID = SessionID.make("ses_root_grandchild_0000000000000") + const middle = yield* session.create({ parentID: rootSessionID, title: "Middle" }) + const child = yield* session.create({ parentID: middle.id, title: "Grandchild" }) + const handler = yield* RunEvents.make({ + rootSessionID, + skipPermissions: false, + jsonMode: false, + }) + + const childResult = yield* Effect.exit( + question.ask({ + sessionID: child.id, + questions: [ + { + question: "first?", + header: "h", + options: [{ label: "a", description: "a" }], + }, + ], + }), + ) + + expect(Exit.isFailure(childResult)).toBe(true) + if (Exit.isFailure(childResult)) { + expect(Cause.squash(childResult.cause)).toBeInstanceOf(Question.RejectedError) + } + + const middleResult = yield* Effect.exit( + question.ask({ + sessionID: middle.id, + questions: [ + { + question: "second?", + header: "h", + options: [{ label: "b", description: "b" }], + }, + ], + }), + ) + + expect(Exit.isFailure(middleResult)).toBe(true) + if (Exit.isFailure(middleResult)) { + expect(Cause.squash(middleResult.cause)).toBeInstanceOf(Question.RejectedError) + } + expect(handler.stats.autoRejectedQuestions).toBe(2) + + yield* Effect.sync(() => handler.unsubscribe()) + }), + ), + ) + it.live("ignores question.asked for an unrelated session tree", () => provideTmpdirInstance(() => Effect.gen(function* () { @@ -142,6 +211,53 @@ describe("cli/run-events", () => { ), ) + it.live("does not auto-reject when lineage depth exceeds MAX_LINEAGE_DEPTH", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const question = yield* Question.Service + const session = yield* Session.Service + const rootSessionID = SessionID.make("ses_root_depth_cutoff_000000000000") + const handler = yield* RunEvents.make({ + rootSessionID, + skipPermissions: false, + jsonMode: false, + }) + + const createDeepChild = (parentID: SessionID, remaining: number): Effect.Effect => { + if (remaining === 0) return Effect.succeed(parentID) + return session + .create({ parentID, title: "Depth child" }) + .pipe(Effect.flatMap((created) => createDeepChild(created.id, remaining - 1))) + } + + const deepSessionID = yield* createDeepChild(rootSessionID, MAX_LINEAGE_DEPTH + 1) + const fiber = yield* question + .ask({ + sessionID: deepSessionID, + questions: [ + { + question: "deep?", + header: "h", + options: [{ label: "n", description: "n" }], + }, + ], + }) + .pipe(Effect.forkScoped) + + const pending = yield* waitForQuestionCount(question, 1) + expect(pending[0].sessionID).toBe(deepSessionID) + expect(handler.stats.autoRejectedQuestions).toBe(0) + + yield* question.reject(pending[0].id) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError) + + yield* Effect.sync(() => handler.unsubscribe()) + }), + ), + ) + it.live("RunEvents.Config does not expose an attach field", () => Effect.sync(() => { const validConfig: RunEvents.Config = { @@ -229,6 +345,109 @@ describe("cli/run-events", () => { ), ) + it.live("does not cache unrelated walks as descendants for permission.asked", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const permission = yield* Permission.Service + const session = yield* Session.Service + const rootSessionID = SessionID.make("ses_root_cache_guard_0000000000000") + const unrelatedRootSessionID = SessionID.make("ses_unrelated_root_000000000000") + const x = yield* session.create({ parentID: unrelatedRootSessionID, title: "X" }) + const y = yield* session.create({ parentID: x.id, title: "Y" }) + const handler = yield* RunEvents.make({ + rootSessionID, + skipPermissions: false, + jsonMode: false, + }) + + const askPermission = (sessionID: SessionID) => + permission.ask({ + sessionID, + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], + }) + + const yFiber = yield* askPermission(y.id).pipe(Effect.forkScoped) + const firstPending = yield* waitForPermissionCount(permission, 1) + expect(firstPending[0].sessionID).toBe(y.id) + expect(handler.stats.autoRejectedPermissions).toBe(0) + yield* permission.reply({ requestID: firstPending[0].id, reply: "once" }) + const yExit = yield* Fiber.await(yFiber) + expect(Exit.isSuccess(yExit)).toBe(true) + + const xFiber = yield* askPermission(x.id).pipe(Effect.forkScoped) + const secondPending = yield* waitForPermissionCount(permission, 1) + expect(secondPending[0].sessionID).toBe(x.id) + expect(handler.stats.autoRejectedPermissions).toBe(0) + yield* permission.reply({ requestID: secondPending[0].id, reply: "once" }) + const xExit = yield* Fiber.await(xFiber) + expect(Exit.isSuccess(xExit)).toBe(true) + + const descendant = yield* session.create({ parentID: rootSessionID, title: "Descendant" }) + const descendantExit = yield* Effect.exit(askPermission(descendant.id)) + expect(Exit.isFailure(descendantExit)).toBe(true) + if (Exit.isFailure(descendantExit)) { + expect(Cause.squash(descendantExit.cause)).toBeInstanceOf(Permission.RejectedError) + } + expect(handler.stats.autoRejectedPermissions).toBe(1) + + yield* Effect.sync(() => handler.unsubscribe()) + }), + ), + ) + + it.live("auto-approves permission.asked for the root when skipPermissions=true", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const permission = yield* Permission.Service + const bus = yield* Bus.Service + const rootSessionID = SessionID.make("ses_root_skip_perm_root_000000000") + const replies: Array<{ sessionID: SessionID; reply: string }> = [] + const unsubscribeReply = yield* bus.subscribeCallback(Permission.Event.Replied, (evt) => { + replies.push({ sessionID: evt.properties.sessionID, reply: evt.properties.reply }) + }) + const handler = yield* RunEvents.make({ + rootSessionID, + skipPermissions: true, + jsonMode: false, + }) + + const exit = yield* Effect.exit( + permission.ask({ + sessionID: rootSessionID, + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], + }), + ) + + yield* Effect.gen(function* () { + for (const _ of Array.from({ length: 100 })) { + if (replies.length === 1) return + yield* Effect.sleep("10 millis") + } + return yield* Effect.fail(new Error("timed out waiting for permission.replied event")) + }) + + expect(Exit.isSuccess(exit)).toBe(true) + expect(handler.stats.autoRejectedPermissions).toBe(0) + expect(replies[0]?.sessionID).toBe(rootSessionID) + expect(replies[0]?.reply).toBe("once") + expect(yield* permission.list()).toHaveLength(0) + + yield* Effect.sync(() => { + unsubscribeReply() + handler.unsubscribe() + }) + }), + ), + ) + it.live("emits structured JSON event to stdout when jsonMode=true", () => provideTmpdirInstance(() => Effect.gen(function* () {