From 1d89e911e8bcd80416461c7be6c48b9d7ce0896b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 19 Aug 2026 21:56:40 -0400 Subject: [PATCH] refactor(core): make prompt ID reuse idempotent (#43548) --- .changeset/idempotent-prompt-reuse.md | 5 ++ AGENTS.md | 2 +- packages/core/src/session.ts | 14 +++--- packages/core/src/session/inbox.ts | 17 ------- packages/core/test/session-prompt.test.ts | 58 +++++++++++------------ 5 files changed, 41 insertions(+), 55 deletions(-) create mode 100644 .changeset/idempotent-prompt-reuse.md diff --git a/.changeset/idempotent-prompt-reuse.md b/.changeset/idempotent-prompt-reuse.md new file mode 100644 index 0000000000..acd696491a --- /dev/null +++ b/.changeset/idempotent-prompt-reuse.md @@ -0,0 +1,5 @@ +--- +"@opencode-ai/core": patch +--- + +Prompt and synthetic inbox ID reuse is now idempotent: reusing an ID within the same Session succeeds and returns the first admission, ignoring the retried payload, metadata, and delivery mode. Previously reuse with a differing payload failed with a conflict. Cross-Session and cross-type reuse still fail, and control items keep their operation-specific conflict behavior. diff --git a/AGENTS.md b/AGENTS.md index 8cbc83706d..5d871e7046 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,7 +176,7 @@ const table = sqliteTable("session", { - Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views. - Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work. -- Reusing a Session ID adopts the existing Session. While a user or synthetic inbox item is pending, reusing its ID reconciles only when Session, type, complete payload, metadata, and delivery match; conflicting reuse fails. Once delivered, retry reconciliation for those message-producing items uses the projected message and does not require retained enqueue history or the original delivery mode. Control items keep their operation-specific conflict behavior. +- Reusing a Session ID adopts the existing Session. Reusing a user or synthetic inbox item ID is idempotent when Session and type match: the first admission wins and the retried payload, metadata, and delivery mode are ignored, whether the item is still pending or already delivered (reconciled from the projected message without retained enqueue history). Cross-Session or cross-type reuse fails. Control items keep their operation-specific conflict behavior. - Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session. - Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. - Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop. diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index d0e6a14875..506caffb65 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -608,10 +608,9 @@ const layer = Layer.effect( : Effect.die(defect), ), ) - if ( - admitted.type !== "user" || - !SessionInbox.equivalent(admitted, { sessionID: input.sessionID, item: admittedInput }) - ) + // First admission wins: same-session reuse is idempotent and ignores the + // retried payload, metadata, and delivery mode. + if (admitted.type !== "user" || admitted.sessionID !== input.sessionID) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) if (input.resume !== false) { if (activeShells.has(admitted.sessionID)) return admitted @@ -883,10 +882,9 @@ const layer = Layer.effect( : Effect.die(defect), ), ) - if ( - admitted.type !== "synthetic" || - !SessionInbox.equivalent(admitted, { sessionID: input.sessionID, item: admittedInput }) - ) + // First admission wins: same-session reuse is idempotent and ignores the + // retried payload, metadata, and delivery mode. + if (admitted.type !== "synthetic" || admitted.sessionID !== input.sessionID) return yield* new SyntheticConflictError({ sessionID: input.sessionID, inputID }) if (input.resume !== false && !(yield* result.get(input.sessionID)).revert) yield* execution.wake(input.sessionID) diff --git a/packages/core/src/session/inbox.ts b/packages/core/src/session/inbox.ts index e41c3db66b..11d1a4ccec 100644 --- a/packages/core/src/session/inbox.ts +++ b/packages/core/src/session/inbox.ts @@ -379,23 +379,6 @@ export const has = Effect.fn("SessionInbox.has")(function* ( return row !== undefined }) -export const equivalent = (input: Info, expected: { readonly sessionID: SessionSchema.ID; readonly item: Item }) => { - if ( - input.type !== expected.item.type || - input.delivery !== expected.item.delivery || - input.sessionID !== expected.sessionID - ) - return false - if (input.type === "user" && expected.item.type === "user") - return JSON.stringify(encodeUser(input.payload)) === JSON.stringify(encodeUser(expected.item.payload)) - if (input.type === "synthetic" && expected.item.type === "synthetic") - return JSON.stringify(encodeSynthetic(input.payload)) === JSON.stringify(encodeSynthetic(expected.item.payload)) - if (input.type === "compaction" && expected.item.type === "compaction") return true - if (input.type === "move" && expected.item.type === "move") - return JSON.stringify(encodeMove(input.payload)) === JSON.stringify(encodeMove(expected.item.payload)) - return false -} - const publishMutation = (input: PendingRef, effect: Effect.Effect) => serialized(input.sessionID, effect).pipe(Effect.asVoid) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 3d4df854a6..d6b81dd2a5 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -652,53 +652,52 @@ describe("Session.prompt", () => { }), ) - it.effect("rejects reuse of one ID with a different prompt", () => + it.effect("keeps the first admission when one ID is reused with a different prompt", () => Effect.gen(function* () { yield* setup const session = yield* Session.Service - yield* session.prompt({ + const first = yield* session.prompt({ sessionID, id: messageID, text: "Fix the failing tests", }) - const failure = yield* session - .prompt({ - sessionID, - id: messageID, - text: "Delete the failing tests", - resume: false, - }) - .pipe(Effect.flip) + const retried = yield* session.prompt({ + sessionID, + id: messageID, + text: "Delete the failing tests", + resume: false, + }) - expect(failure._tag).toBe("Session.PromptConflictError") + expect(retried).toEqual(first) + expect(retried.payload.text).toBe("Fix the failing tests") expect(yield* session.messages({ sessionID })).toHaveLength(0) expect(yield* admittedCount).toBe(1) }), ) - it.effect("rejects reuse of one ID with a different delivery mode", () => + it.effect("keeps the first admission's delivery mode when one ID is reused with another", () => Effect.gen(function* () { yield* setup const session = yield* Session.Service - yield* session.prompt({ + const first = yield* session.prompt({ id: messageID, sessionID, text: "Fix the failing tests", resume: false, }) - const failure = yield* session - .prompt({ - id: messageID, - sessionID, - text: "Fix the failing tests", - delivery: "queue", - resume: false, - }) - .pipe(Effect.flip) + const retried = yield* session.prompt({ + id: messageID, + sessionID, + text: "Fix the failing tests", + delivery: "queue", + resume: false, + }) - expect(failure._tag).toBe("Session.PromptConflictError") + expect(retried).toEqual(first) + expect(retried.delivery).toBe("steer") + expect(yield* admittedCount).toBe(1) }), ) @@ -914,7 +913,7 @@ describe("Session.prompt", () => { }), ) - it.effect("treats prompt metadata as durable retry identity", () => + it.effect("keeps the first admission's metadata when one ID is reused with other metadata", () => Effect.gen(function* () { yield* setup const session = yield* Session.Service @@ -928,11 +927,12 @@ describe("Session.prompt", () => { const first = yield* session.prompt(input) const retried = yield* session.prompt(input) - const failure = yield* session.prompt({ ...input, metadata: { source: "plugin" } }).pipe(Effect.flip) + const differing = yield* session.prompt({ ...input, metadata: { source: "plugin" } }) expect(retried).toEqual(first) + expect(differing).toEqual(first) expect(first.payload.metadata).toEqual({ source: "api" }) - expect(failure._tag).toBe("Session.PromptConflictError") + expect(yield* admittedCount).toBe(1) }), ) @@ -978,7 +978,7 @@ describe("Session.prompt", () => { }), ) - it.effect("reconciles exact synthetic retries and rejects conflicting reuse", () => + it.effect("reconciles synthetic retries from the promoted message regardless of payload", () => Effect.gen(function* () { yield* setup const session = yield* Session.Service @@ -991,11 +991,11 @@ describe("Session.prompt", () => { }) yield* SessionInbox.promote(database.db, bus, sessionID, "steer") const promotedRetry = yield* session.synthetic(input) - const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip) + const differing = yield* session.synthetic({ ...input, text: "Different completion" }) expect(entries[1]).toEqual(entries[0]) expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", payload: { text: "Completed" } }) - expect(failure).toMatchObject({ _tag: "Session.SyntheticConflictError", sessionID, inputID: messageID }) + expect(differing).toMatchObject({ id: messageID, type: "synthetic", payload: { text: "Completed" } }) expect(yield* admittedCount).toBe(0) expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxEnqueued.type, 1))).toBe(1) }),