Repository navigation
Description
Description
What happens. In v2.0.32, DELETE /api/session/:sessionID/inbox/:inboxID answers 204 No Content in two different situations:
- the pending item was withdrawn, so it will never reach the model;
- the item was no longer pending, for example because it had already been delivered to the session.
The handler catches the conflict and returns the same empty success (packages/server/src/handlers/session.ts, lines 551–559 at v2.0.32):
.handle(
"session.inbox.cancel",
Effect.fn(function* (ctx) {
yield* session.cancelInbox({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.InboxConflictError", () => Effect.void),
)
return HttpApiSchema.NoContent.make()
}),
)The sibling session.inbox.update handler (line 564) sends the same InboxConflictError through pendingMutation, which answers 409 Conflict. Only cancel hides it.
Why it matters. A client that offers "undo my queued message" wants to put the withdrawn text back in the input box. Between the client's inbox read and the cancel, the running turn can end and the item can be delivered. The cancel still returns 204. If the client then restores the text, the person may send the same request again, although the first copy already ran. The client cannot tell the two cases apart:
- the item is absent from a fresh inbox read in both cases;
- the transcript proves delivery only when the message is in the page the client reads.
Request. Let the cancel response say what happened. For example:
- answer 409 Conflict for an item that is no longer pending, as
session.inbox.updatealready does, and keep 204 for a real withdrawal; or - if changing the status would break existing callers, return a small body such as
{ "withdrawn": true }or{ "withdrawn": false }.
Either form lets a client restore the text only after a real withdrawal, and otherwise point the person to the delivered message.
Plugins
None
OpenCode version
2.0.32
Steps to reproduce
- Start a reply that takes a few seconds.
- While it runs, queue a follow-up message. It appears in
GET /api/session/:sessionID/inbox. - Wait until the reply ends and the follow-up appears in
GET /api/session/:sessionID/message. - Send
DELETE /api/session/:sessionID/inbox/:inboxIDfor that follow-up. The answer is 204, the same as for a real withdrawal.
Activity
opencode-agent commentedon Oct 18, 2026
Thanks for the detailed write-up and the code pointers. You're asking for
DELETE /api/session/:sessionID/inbox/:inboxIDto say whether the item was really withdrawn or was already delivered, either with a 409 likesession.inbox.updateor with a small{ "withdrawn": true | false }body. That way a client only puts the text back in the input box after a real withdrawal.The idempotent 204 on cancel was a deliberate API choice, so this is a change to the API design rather than a bug. Maintainers will consider it. Related issues and PRs:
/undo. It works around the 204 on the client by cancelling and then reading the message back to tell "withdrawn" from "delivered". It also notes that a 409 alone wouldn't cover a cancel that reaches the server before the prompt's own POST.Until the API changes, a client can do what #54163 does: after the cancel, read the session's messages for that message ID, and only put the text back if it isn't there.