Skip to content

Commit 67c79bc

Browse files
committed
fix(tui): publish synthetic reject event when ask is interrupted
When the underlying "ask" effect for a Permission or Question prompt is interrupted (tool cancelled, session ended, parent killed, scope torn down) rather than completing via a normal reply/reject, the Effect.ensuring finalizer deleted the pending entry but never published a corresponding terminal bus event. The TUI tracks prompt state via these events, so the orphaned request stayed in sync.data.permission / sync.data.question forever. Pressing Allow/Reject/typing a custom answer / pressing Esc all issued SDK calls against a requestID that the server no longer had in pending — the handler logs "reply for unknown request" and returns 200 OK silently with no event, so the TUI never dismissed and the prompt became completely unresponsive (every option re-rendered the same view, even exit was unreachable while the prompt held the input). Fix: replace the unconditional pending.delete in the finalizer with an Effect.gen that checks pending.has(id). The reply and reject paths delete the entry themselves before publishing, so this guard fires only on the interrupt path. When it does, delete the entry and publish a synthetic terminal event so the TUI dismisses the orphan: - question/index.ts publishes Event.Rejected - permission/index.ts publishes Event.Replied with reply: "reject" (Permission has no dedicated cancellation event; "reject" is the accepted Reply value that signals dismissal) Adds regression tests in both packages that fork the ask effect, wait for it to be pending, interrupt the fiber, and assert the bus emits the expected terminal event and the pending list is empty.
1 parent 7703786 commit 67c79bc

4 files changed

Lines changed: 117 additions & 3 deletions

File tree

packages/opencode/src/permission/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,20 @@ export const layer = Layer.effect(
204204
yield* bus.publish(Event.Asked, info)
205205
return yield* Effect.ensuring(
206206
Deferred.await(deferred),
207-
Effect.sync(() => {
207+
Effect.gen(function* () {
208+
// If the entry is still here, this finalizer is running because ask was
209+
// interrupted (tool cancelled, session ended, parent killed). The reply
210+
// path deletes the entry itself before publishing Event.Replied, so we
211+
// only fire a synthetic "reject" here for the orphan case — otherwise
212+
// the TUI's sync.data.permission never receives a terminal event and the
213+
// prompt stays visible forever with no way to dismiss it.
214+
if (!pending.has(id)) return
208215
pending.delete(id)
216+
yield* bus.publish(Event.Replied, {
217+
sessionID: info.sessionID,
218+
requestID: info.id,
219+
reply: "reject",
220+
})
209221
}),
210222
)
211223
})

packages/opencode/src/question/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,19 @@ export const layer = Layer.effect(
173173

174174
return yield* Effect.ensuring(
175175
Deferred.await(deferred),
176-
Effect.sync(() => {
176+
Effect.gen(function* () {
177+
// If the entry is still here, this finalizer is running because ask was
178+
// interrupted (tool cancelled, session ended, parent killed). The reply
179+
// and reject paths delete the entry themselves before publishing their
180+
// event, so we only fire Rejected here for the orphan case — otherwise
181+
// the TUI's sync.data.question never receives a terminal event and the
182+
// prompt stays visible forever.
183+
if (!pending.has(id)) return
177184
pending.delete(id)
185+
yield* bus.publish(Event.Rejected, {
186+
sessionID: info.sessionID,
187+
requestID: info.id,
188+
})
178189
}),
179190
)
180191
})

packages/opencode/test/permission/next.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -951,6 +951,57 @@ it.instance(
951951
{ git: true },
952952
)
953953

954+
it.live("interrupt - publishes Replied(reject) so TUI orphans get dismissed", () =>
955+
Effect.gen(function* () {
956+
const dir = yield* tmpdirScoped({ git: true })
957+
const store = yield* InstanceStore.Service
958+
return yield* store.provide(
959+
{ directory: dir },
960+
Effect.gen(function* () {
961+
const bus = yield* Bus.Service
962+
const received: { sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }[] = []
963+
const unsub = yield* bus.subscribeCallback(Permission.Event.Replied, (event) => {
964+
received.push(event.properties)
965+
})
966+
967+
try {
968+
const fiber = yield* ask({
969+
id: PermissionID.make("per_interrupt"),
970+
sessionID: SessionID.make("session_interrupt"),
971+
permission: "read",
972+
patterns: [".env.template"],
973+
metadata: {},
974+
always: ["*"],
975+
ruleset: [],
976+
}).pipe(Effect.forkScoped)
977+
978+
yield* waitForPending(1)
979+
980+
// Simulate the tool execution being interrupted before the user replies
981+
// (session ended, parent killed, scope torn down, etc.). Without the
982+
// finalizer publishing a synthetic reject, the TUI's sync.data.permission
983+
// would keep showing the prompt forever.
984+
yield* Fiber.interrupt(fiber)
985+
986+
// Give the finalizer a moment to publish.
987+
yield* Effect.sleep("20 millis")
988+
989+
expect(received).toEqual([
990+
{
991+
sessionID: SessionID.make("session_interrupt"),
992+
requestID: PermissionID.make("per_interrupt"),
993+
reply: "reject",
994+
},
995+
])
996+
expect(yield* list()).toHaveLength(0)
997+
} finally {
998+
unsub()
999+
}
1000+
}),
1001+
)
1002+
}),
1003+
)
1004+
9541005
it.live("permission requests stay isolated by directory", () =>
9551006
Effect.gen(function* () {
9561007
const one = yield* tmpdirScoped({ git: true })

packages/opencode/test/question/question.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import { testEffect } from "../lib/effect"
1010
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
1111
import { Bus } from "../../src/bus"
1212

13+
const bus = Bus.layer
1314
const it = testEffect(
14-
Layer.mergeAll(Question.layer.pipe(Layer.provideMerge(Bus.layer)), CrossSpawnSpawner.defaultLayer),
15+
Layer.mergeAll(Question.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer),
1516
)
1617

1718
const askEffect = Effect.fn("QuestionTest.ask")(function* (input: {
@@ -272,6 +273,45 @@ it.instance(
272273
{ git: true },
273274
)
274275

276+
it.instance(
277+
"interrupt - publishes Rejected event so TUI orphans get dismissed",
278+
() =>
279+
Effect.gen(function* () {
280+
const bus = yield* Bus.Service
281+
const received: { sessionID: SessionID; requestID: QuestionID }[] = []
282+
const off = yield* bus.subscribeCallback(Question.Event.Rejected, (evt) => {
283+
received.push({ sessionID: evt.properties.sessionID, requestID: evt.properties.requestID })
284+
})
285+
286+
const fiber = yield* askEffect({
287+
sessionID: SessionID.make("ses_test"),
288+
questions: [
289+
{
290+
question: "What would you like to do?",
291+
header: "Action",
292+
options: [{ label: "Option 1", description: "First option" }],
293+
},
294+
],
295+
}).pipe(Effect.forkScoped)
296+
297+
const pending = yield* waitForPending(1)
298+
const requestID = pending[0].id
299+
300+
// Simulate the tool execution being interrupted before the user replies
301+
// (session ended, parent killed, scope torn down, etc.).
302+
yield* Fiber.interrupt(fiber)
303+
304+
// Wait briefly for finalizer to publish the synthetic Rejected event.
305+
yield* Effect.sleep("10 millis")
306+
off()
307+
308+
expect(received).toEqual([{ sessionID: SessionID.make("ses_test"), requestID }])
309+
const after = yield* listEffect
310+
expect(after.length).toBe(0)
311+
}),
312+
{ git: true },
313+
)
314+
275315
// multiple questions tests
276316

277317
it.instance(

0 commit comments

Comments
 (0)