diff --git a/packages/app/src/context/server-session-v2-reducer.test.ts b/packages/app/src/context/server-session-v2-reducer.test.ts index 00cc37cf52..270c529305 100644 --- a/packages/app/src/context/server-session-v2-reducer.test.ts +++ b/packages/app/src/context/server-session-v2-reducer.test.ts @@ -153,4 +153,42 @@ describe("v2 session reducer", () => { expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] }) }) + + test("forgets withdrawn input before a later promotion", () => { + const reducer = createV2SessionReducer() + reducer.reduce( + [], + event({ + ...base, + id: "evt_admitted", + type: "session.input.admitted", + data: { + sessionID: "ses_1", + inputID: "msg_user", + input: { type: "user", delivery: "steer", data: { text: "hello" } }, + }, + }), + ) + reducer.reduce( + [], + event({ + ...base, + id: "evt_withdrawn", + type: "session.input.withdrawn", + data: { sessionID: "ses_1", inputID: "msg_user" }, + }), + ) + + const result = reducer.reduce( + [], + event({ + ...base, + id: "evt_promoted", + type: "session.input.promoted", + data: { sessionID: "ses_1", inputID: "msg_user" }, + }), + ) + + expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] }) + }) }) diff --git a/packages/app/src/context/server-session-v2-reducer.ts b/packages/app/src/context/server-session-v2-reducer.ts index b34ab3985e..53dc389b1e 100644 --- a/packages/app/src/context/server-session-v2-reducer.ts +++ b/packages/app/src/context/server-session-v2-reducer.ts @@ -29,6 +29,9 @@ export function createV2SessionReducer() { case "session.input.admitted": pending.set(key(sessionID, event.data.inputID), event.data.input) return result([...source]) + case "session.input.withdrawn": + pending.delete(key(sessionID, event.data.inputID)) + return result([...source]) case "session.input.promoted": { const input = pending.get(key(sessionID, event.data.inputID)) pending.delete(key(sessionID, event.data.inputID)) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 80df5a87e4..731444a177 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -249,38 +249,44 @@ export type Endpoint5_21Input = { readonly sessionID: Session.ID } export type Endpoint5_21Output = ReadonlyArray export type SessionPendingListOperation = (input: Endpoint5_21Input) => Effect.Effect -export type Endpoint5_22Input = { readonly sessionID: Session.ID } -export type Endpoint5_22Output = ReadonlyArray -export type SessionInstructionsEntryListOperation = ( +export type Endpoint5_22Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID } +export type Endpoint5_22Output = boolean +export type SessionPendingWithdrawOperation = ( input: Endpoint5_22Input, ) => Effect.Effect -export type Endpoint5_23Input = { +export type Endpoint5_23Input = { readonly sessionID: Session.ID } +export type Endpoint5_23Output = ReadonlyArray +export type SessionInstructionsEntryListOperation = ( + input: Endpoint5_23Input, +) => Effect.Effect + +export type Endpoint5_24Input = { readonly sessionID: Session.ID readonly key: InstructionEntry.Key readonly value: Schema.Json } -export type Endpoint5_23Output = void -export type SessionInstructionsEntryPutOperation = ( - input: Endpoint5_23Input, -) => Effect.Effect - -export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key } export type Endpoint5_24Output = void -export type SessionInstructionsEntryRemoveOperation = ( +export type SessionInstructionsEntryPutOperation = ( input: Endpoint5_24Input, ) => Effect.Effect -export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly prompt: string } -export type Endpoint5_25Output = { readonly text: string } -export type SessionGenerateOperation = (input: Endpoint5_25Input) => Effect.Effect +export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key } +export type Endpoint5_25Output = void +export type SessionInstructionsEntryRemoveOperation = ( + input: Endpoint5_25Input, +) => Effect.Effect -export type Endpoint5_26Input = { +export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly prompt: string } +export type Endpoint5_26Output = { readonly text: string } +export type SessionGenerateOperation = (input: Endpoint5_26Input) => Effect.Effect + +export type Endpoint5_27Input = { readonly sessionID: Session.ID readonly after?: Event.Seq | undefined readonly follow?: boolean | undefined } -export type Endpoint5_26Output = +export type Endpoint5_27Output = | ( | { readonly id: Event.ID @@ -370,6 +376,15 @@ export type Endpoint5_26Output = readonly input: SessionPending.Message } } + | { + readonly id: Event.ID + readonly created: DateTime.Utc + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.input.withdrawn" + readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } + readonly location?: Location.Ref | undefined + readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID } + } | { readonly id: Event.ID readonly created: DateTime.Utc @@ -827,19 +842,19 @@ export type Endpoint5_26Output = } ) | EventLog.Synced -export type SessionLogOperation = (input: Endpoint5_26Input) => Stream.Stream - -export type Endpoint5_27Input = { readonly sessionID: Session.ID } -export type Endpoint5_27Output = void -export type SessionInterruptOperation = (input: Endpoint5_27Input) => Effect.Effect +export type SessionLogOperation = (input: Endpoint5_27Input) => Stream.Stream export type Endpoint5_28Input = { readonly sessionID: Session.ID } export type Endpoint5_28Output = void -export type SessionBackgroundOperation = (input: Endpoint5_28Input) => Effect.Effect +export type SessionInterruptOperation = (input: Endpoint5_28Input) => Effect.Effect -export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID } -export type Endpoint5_29Output = SessionMessage.Info -export type SessionMessageOperation = (input: Endpoint5_29Input) => Effect.Effect +export type Endpoint5_29Input = { readonly sessionID: Session.ID } +export type Endpoint5_29Output = void +export type SessionBackgroundOperation = (input: Endpoint5_29Input) => Effect.Effect + +export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID } +export type Endpoint5_30Output = SessionMessage.Info +export type SessionMessageOperation = (input: Endpoint5_30Input) => Effect.Effect export interface SessionApi { readonly list: SessionListOperation @@ -865,7 +880,10 @@ export interface SessionApi { readonly commit: SessionRevertCommitOperation } readonly context: SessionContextOperation - readonly pending: { readonly list: SessionPendingListOperation } + readonly pending: { + readonly list: SessionPendingListOperation + readonly withdraw: SessionPendingWithdrawOperation + } readonly instructions: { readonly entry: { readonly list: SessionInstructionsEntryListOperation diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 7706c9aa9b..4985abc549 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -76,6 +76,8 @@ import type { Endpoint5_28Output, Endpoint5_29Input, Endpoint5_29Output, + Endpoint5_30Input, + Endpoint5_30Output, Endpoint6_0Input, Endpoint6_0Output, Endpoint7_0Input, @@ -490,7 +492,7 @@ const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21I const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) => preserveEffect()( - raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( + raw["session.pending.withdraw"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ), @@ -498,29 +500,37 @@ const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22I const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) => preserveEffect()( + raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ), + ) + +const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) => + preserveEffect()( raw["session.instructions.entry.put"]({ params: { sessionID: input["sessionID"], key: input["key"] }, payload: { value: input["value"] }, }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) => - preserveEffect()( +const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) => + preserveEffect()( raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( Effect.mapError(mapClientError), ), ) -const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) => - preserveEffect()( +const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) => + preserveEffect()( raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ), ) -const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) => - preserveStream()( +const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) => + preserveStream()( Stream.unwrap( raw["session.log"]({ params: { sessionID: input["sessionID"] }, @@ -532,18 +542,18 @@ const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26I ), ) -const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) => - preserveEffect()( - raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), - ) - const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) => preserveEffect()( - raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), + raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), ) const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) => preserveEffect()( + raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), + ) + +const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) => + preserveEffect()( raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -570,13 +580,13 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({ wait: Endpoint5_16(raw), revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) }, context: Endpoint5_20(raw), - pending: { list: Endpoint5_21(raw) }, - instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } }, - generate: Endpoint5_25(raw), - log: Endpoint5_26(raw), - interrupt: Endpoint5_27(raw), - background: Endpoint5_28(raw), - message: Endpoint5_29(raw), + pending: { list: Endpoint5_21(raw), withdraw: Endpoint5_22(raw) }, + instructions: { entry: { list: Endpoint5_23(raw), put: Endpoint5_24(raw), remove: Endpoint5_25(raw) } }, + generate: Endpoint5_26(raw), + log: Endpoint5_27(raw), + interrupt: Endpoint5_28(raw), + background: Endpoint5_29(raw), + message: Endpoint5_30(raw), }) const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) => diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index aa0563f948..16a15c5162 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -54,6 +54,8 @@ import type { SessionContextOutput, SessionPendingListInput, SessionPendingListOutput, + SessionPendingWithdrawInput, + SessionPendingWithdrawOutput, SessionInstructionsEntryListInput, SessionInstructionsEntryListOutput, SessionInstructionsEntryPutInput, @@ -732,6 +734,17 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), + withdraw: (input: SessionPendingWithdrawInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionPendingWithdrawOutput }>( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/withdraw`, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), }, instructions: { entry: { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 8c8b6295a6..1f12ed8caa 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -637,6 +637,16 @@ export type SessionInputPromoted = { data: { sessionID: string; inputID: string } } +export type SessionInputWithdrawn = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.input.withdrawn" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string; inputID: string } +} + export type SessionExecutionStarted = { id: string created: number @@ -2157,6 +2167,7 @@ export type SessionEventDurable = | SessionForked | SessionInputPromoted | SessionInputAdmitted + | SessionInputWithdrawn | SessionExecutionStarted | SessionExecutionSucceeded | SessionExecutionFailed @@ -2252,6 +2263,7 @@ export type V2Event = | SessionForked | SessionInputPromoted | SessionInputAdmitted + | SessionInputWithdrawn | SessionExecutionStarted | SessionExecutionSucceeded | SessionExecutionFailed @@ -3158,6 +3170,13 @@ export type SessionPendingListInput = { readonly sessionID: { readonly sessionID export type SessionPendingListOutput = { data: Array }["data"] +export type SessionPendingWithdrawInput = { + readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"] + readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"] +} + +export type SessionPendingWithdrawOutput = { data: boolean }["data"] + export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionInstructionsEntryListOutput = { data: Array }["data"] diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index b38b141cbc..5ae2dc4f02 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -300,7 +300,7 @@ test("session instructions methods use the public HTTP contract", async () => { ]) }) -test("session.pending.list uses the public HTTP contract", async () => { +test("session.pending uses the public HTTP contract", async () => { const requests: Array<{ method: string; url: string }> = [] const pending = [ { @@ -317,14 +317,22 @@ test("session.pending.list uses the public HTTP contract", async () => { fetch: async (input, init) => { const request = input instanceof Request ? input : new Request(input, init) requests.push({ method: request.method, url: request.url }) - return Response.json({ data: pending }) + return Response.json({ data: request.method === "GET" ? pending : true }) }, }) const result = await client.session.pending.list({ sessionID: "ses_test" }) + const withdrawn = await client.session.pending.withdraw({ sessionID: "ses_test", inputID: "msg_pending" }) expect(result).toEqual(pending) - expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }]) + expect(withdrawn).toBe(true) + expect(requests).toEqual([ + { method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }, + { + method: "POST", + url: "http://localhost:3000/api/session/ses_test/pending/msg_pending/withdraw", + }, + ]) }) test("event.subscribe exposes the Promise event stream wire projection", async () => { diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index bf1b38eaaf..2dff9630ca 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -209,6 +209,10 @@ export interface Interface { * unhandled compaction barriers. */ readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect + readonly withdraw: (input: { + sessionID: SessionSchema.ID + inputID: SessionMessage.ID + }) => Effect.Effect /** * Durable, ordered session log read. Replays durable session bus after * the exclusive `after` cursor, emits a `Synced` marker at the captured @@ -540,6 +544,10 @@ const layer = Layer.effect( yield* result.get(sessionID) return yield* SessionPending.list(db, sessionID) }), + withdraw: Effect.fn("Session.withdraw")(function* (input) { + yield* result.get(input.sessionID) + return yield* SessionPending.withdraw(db, bus, input) + }), log: (input) => Stream.unwrap( result diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 6d9c4723ff..84c6512694 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -175,6 +175,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.forked": () => Effect.void, "session.input.promoted": () => Effect.void, "session.input.admitted": () => Effect.void, + "session.input.withdrawn": () => Effect.void, "session.execution.started": () => Effect.void, "session.execution.succeeded": () => clearCurrentRetry, "session.execution.failed": () => clearCurrentRetry, diff --git a/packages/core/src/session/pending.ts b/packages/core/src/session/pending.ts index 986d624a6f..2b7d7c303a 100644 --- a/packages/core/src/session/pending.ts +++ b/packages/core/src/session/pending.ts @@ -38,10 +38,15 @@ const encodeUser = Schema.encodeSync(UserData) const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData) const encodeSynthetic = Schema.encodeSync(SyntheticData) const decodeAdmittedEvent = Schema.decodeUnknownOption(SessionEvent.InputAdmitted.data) +const decodeWithdrawnEvent = Schema.decodeUnknownOption(SessionEvent.InputWithdrawn.data) const admittedEventType = Bus.versionedType( SessionEvent.InputAdmitted.type, SessionEvent.InputAdmitted.durable.version, ) +const withdrawnEventType = Bus.versionedType( + SessionEvent.InputWithdrawn.type, + SessionEvent.InputWithdrawn.durable.version, +) const inboxLocks = KeyedMutex.makeUnsafe() export class LifecycleConflict extends Schema.TaggedErrorClass()( @@ -103,26 +108,11 @@ export const compaction = Effect.fn("SessionPending.compaction")(function* ( return entry.type === "compaction" ? entry : undefined }) -/** - * Reconstruct the admitted record for a pending row that was already consumed - * by promotion. The projected `session_message` row proves promotion happened; - * the durable `session.input.admitted` event retains the exact admitted - * message, including delivery. - */ -const promotedFromHistory = Effect.fn("SessionPending.promotedFromHistory")(function* ( +const admittedFromHistory = Effect.fn("SessionPending.admittedFromHistory")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, id: SessionMessage.ID, ) { - const message = yield* db - .select() - .from(SessionMessageTable) - .where(eq(SessionMessageTable.id, id)) - .get() - .pipe(Effect.orDie) - if (message === undefined) return undefined - if (message.session_id !== sessionID || (message.type !== "user" && message.type !== "synthetic")) - return yield* Effect.die(new LifecycleConflict({ id })) const rows = yield* db .select() .from(EventTable) @@ -146,6 +136,46 @@ const promotedFromHistory = Effect.fn("SessionPending.promotedFromHistory")(func return yield* Effect.die(new LifecycleConflict({ id })) }) +/** + * Reconstruct the admitted record for a pending row that was already consumed + * by promotion. The projected `session_message` row proves promotion happened; + * the durable `session.input.admitted` event retains the exact admitted + * message, including delivery. + */ +const promotedFromHistory = Effect.fn("SessionPending.promotedFromHistory")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + id: SessionMessage.ID, +) { + const message = yield* db + .select() + .from(SessionMessageTable) + .where(eq(SessionMessageTable.id, id)) + .get() + .pipe(Effect.orDie) + if (message === undefined) return undefined + if (message.session_id !== sessionID || (message.type !== "user" && message.type !== "synthetic")) + return yield* Effect.die(new LifecycleConflict({ id })) + return yield* admittedFromHistory(db, sessionID, id) +}) + +const wasWithdrawn = Effect.fn("SessionPending.wasWithdrawn")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + id: SessionMessage.ID, +) { + const rows = yield* db + .select({ data: EventTable.data }) + .from(EventTable) + .where(and(eq(EventTable.aggregate_id, sessionID), eq(EventTable.type, withdrawnEventType))) + .all() + .pipe(Effect.orDie) + return rows.some((row) => { + const decoded = decodeWithdrawnEvent(row.data) + return decoded._tag === "Some" && decoded.value.inputID === id + }) +}) + export const admit = Effect.fn("SessionPending.admit")(function* ( db: DatabaseService, bus: Bus.Interface, @@ -162,6 +192,8 @@ export const admit = Effect.fn("SessionPending.admit")(function* ( } const promoted = yield* promotedFromHistory(db, request.sessionID, request.id) if (promoted !== undefined) return promoted + if (yield* wasWithdrawn(db, request.sessionID, request.id)) + return yield* admittedFromHistory(db, request.sessionID, request.id) return yield* bus .publish(SessionEvent.InputAdmitted, { inputID: request.id, @@ -329,6 +361,25 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct return stored }) +export const projectWithdrawn = Effect.fn("SessionPending.projectWithdrawn")(function* ( + db: DatabaseService, + input: { + readonly id: SessionMessage.ID + readonly sessionID: SessionSchema.ID + }, +) { + const deleted = yield* db + .delete(SessionPendingTable) + .where(and(eq(SessionPendingTable.id, input.id), eq(SessionPendingTable.session_id, input.sessionID))) + .returning() + .get() + .pipe(Effect.orDie) + if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + const stored = fromRow(deleted) + if (stored.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: input.id })) + return stored +}) + export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* ( db: DatabaseService, input: { readonly sessionID: SessionSchema.ID }, @@ -406,6 +457,30 @@ export const equivalent = ( return false } +export const withdraw = Effect.fn("SessionPending.withdraw")(function* ( + db: DatabaseService, + bus: Bus.Interface, + input: { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID }, +) { + return yield* inboxLocks.withLock(input.sessionID)( + Effect.gen(function* () { + const pending = yield* find(db, input.inputID) + if (!pending) return yield* wasWithdrawn(db, input.sessionID, input.inputID) + if (pending.sessionID !== input.sessionID || pending.type === "compaction") return false + yield* bus + .publish(SessionEvent.InputWithdrawn, input) + .pipe( + Effect.catchDefect((defect) => + wasWithdrawn(db, input.sessionID, input.inputID).pipe( + Effect.flatMap((withdrawn) => (withdrawn ? Effect.void : Effect.die(defect))), + ), + ), + ) + return true + }), + ) +}) + const publish = Effect.fn("SessionPending.publish")(function* ( db: DatabaseService, bus: Bus.Interface, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 64ab79dea8..414683dc43 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -665,6 +665,12 @@ const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + yield* bus.project(SessionEvent.InputWithdrawn, (event) => + SessionPending.projectWithdrawn(db, { + id: event.data.inputID, + sessionID: event.data.sessionID, + }), + ) yield* bus.project(SessionEvent.Compaction.Admitted, (event) => Effect.gen(function* () { if (event.durable === undefined) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 9f30efe9c0..363c09332a 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -996,6 +996,52 @@ describe("Session.pending", () => { }), ) + it.effect("withdraws an interrupted input before promotion without resurrecting exact retries", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + + const admitted = yield* session.prompt({ + id: SessionMessage.ID.make("msg_withdrawn"), + sessionID, + text: "Withdraw me", + resume: false, + }) + + yield* session.interrupt(sessionID) + expect(yield* session.withdraw({ sessionID, inputID: admitted.id })).toBe(true) + expect(yield* session.pending(sessionID)).toEqual([]) + expect(yield* session.messages({ sessionID })).toEqual([]) + expect(yield* session.withdraw({ sessionID, inputID: admitted.id })).toBe(true) + + const retried = yield* session.prompt({ + id: admitted.id, + sessionID, + text: "Withdraw me", + resume: false, + }) + expect(retried.id).toBe(admitted.id) + expect(yield* session.pending(sessionID)).toEqual([]) + expect(yield* eventCount(Bus.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1) + expect(yield* eventCount(Bus.versionedType(SessionEvent.InputWithdrawn.type, 1))).toBe(1) + }), + ) + + it.effect("leaves promoted input for revert when withdrawal loses the race", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const bus = yield* Bus.Service + const { db } = yield* Database.Service + const admitted = yield* session.prompt({ sessionID, text: "Promote me", resume: false }) + + yield* SessionPending.promote(db, bus, sessionID, "steer") + + expect(yield* session.withdraw({ sessionID, inputID: admitted.id })).toBe(false) + expect(yield* session.messages({ sessionID })).toMatchObject([{ id: admitted.id, type: "user" }]) + }), + ) + it.effect("lists an unhandled compaction barrier until it settles", () => Effect.gen(function* () { yield* setup diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index be963b8a69..61eeff3bce 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -494,6 +494,22 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.post("session.pending.withdraw", "/api/session/:sessionID/pending/:inputID/withdraw", { + params: { sessionID: Session.ID, inputID: SessionMessage.ID }, + success: Schema.Struct({ data: Schema.Boolean }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.pending.withdraw", + summary: "Withdraw pending session input", + description: + "Withdraw one admitted input before promotion. Returns true when the input was withdrawn or had already been withdrawn, and false when it is no longer pending so callers can fall back to reverting projected history.", + }), + ), + ) .add( HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", { params: { sessionID: Session.ID }, diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 4a00ae4b4b..cf5f318c9f 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -155,6 +155,16 @@ export const InputAdmitted = Event.durable({ }) export type InputAdmitted = typeof InputAdmitted.Type +export const InputWithdrawn = Event.durable({ + type: "session.input.withdrawn", + ...options, + schema: { + ...Base, + inputID: SessionMessage.ID, + }, +}) +export type InputWithdrawn = typeof InputWithdrawn.Type + export namespace Execution { export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base }) export type Started = typeof Started.Type @@ -556,6 +566,7 @@ export const Definitions = Event.inventory( Forked, InputPromoted, InputAdmitted, + InputWithdrawn, Execution.Started, Execution.Succeeded, Execution.Failed, diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index bd08c653a5..5a26debd46 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -108,6 +108,7 @@ describe("public event manifest", () => { "session.forked.2", "session.input.promoted.1", "session.input.admitted.1", + "session.input.withdrawn.1", "session.execution.started.1", "session.execution.succeeded.1", "session.execution.failed.1", diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 1f022483c3..b4f0f295f4 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -608,6 +608,23 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.pending.withdraw", + Effect.fn(function* (ctx) { + return { + data: yield* session.withdraw(ctx.params).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ), + } + }), + ) .handle( "session.instructions.entry.list", Effect.fn(function* (ctx) { diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index af023c2c9f..1bd684c02c 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -438,6 +438,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) }) break + case "session.input.withdrawn": { + removePending(event.data.sessionID, event.data.inputID) + if (store.session.input[event.data.sessionID]?.includes(event.data.inputID)) + message.update(event.data.sessionID, (draft, index) => { + const position = index.get(event.data.inputID) + if (position === undefined) return + draft.splice(position, 1) + index.clear() + draft.forEach((message, indexValue) => index.set(message.id, indexValue)) + }) + setStore( + "session", + "input", + event.data.sessionID, + (store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID), + ) + break + } case "session.instructions.updated": const instructions = event.metadata?.instructions if ( diff --git a/packages/tui/src/mini/stream-v2.subagent.ts b/packages/tui/src/mini/stream-v2.subagent.ts index 6f1fdf56c7..ffe21cb5d7 100644 --- a/packages/tui/src/mini/stream-v2.subagent.ts +++ b/packages/tui/src/mini/stream-v2.subagent.ts @@ -643,6 +643,10 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac if (event.data.input.type === "user") child.prompts.set(event.data.inputID, event.data.input.data.text) return } + if (event.type === "session.input.withdrawn") { + child.prompts.delete(event.data.inputID) + return + } if (event.type === "session.input.promoted") { const prompt = child.prompts.get(event.data.inputID) if (prompt === undefined) return diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index e58811ec20..5009064c80 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -911,6 +911,12 @@ export async function createSessionTransport(input: StreamInput): Promise toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) + void undoMessage(client.api, { + sessionID: props.sessionID, + messageID: props.messageID, + pending: data.session.input.has(props.sessionID, props.messageID), + }).catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) dialog.clear() }, }, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 07dd461a1f..fbd24ea18c 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -64,6 +64,7 @@ import { useToast } from "../../ui/toast" import stripAnsi from "strip-ansi" import { usePromptRef } from "../../context/prompt" import { projectedPromptInput } from "../../prompt/codec" +import { undoMessage } from "./undo" import { useEpilogue } from "../../context/epilogue" import { normalizePath } from "../../util/path" import { PermissionPrompt } from "./permission" @@ -590,9 +591,11 @@ export function Session() { dialog.clear() return } - void client.api.session.revert - .stage({ sessionID: route.sessionID, messageID: message.id }) - .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) + void undoMessage(client.api, { + sessionID: route.sessionID, + messageID: message.id, + pending: data.session.input.has(route.sessionID, message.id), + }).catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) prompt()?.set({ ...projectedPromptInput(message), pasted: [], diff --git a/packages/tui/src/routes/session/undo.ts b/packages/tui/src/routes/session/undo.ts new file mode 100644 index 0000000000..6a65daa0ed --- /dev/null +++ b/packages/tui/src/routes/session/undo.ts @@ -0,0 +1,12 @@ +import type { OpenCodeClient } from "@opencode-ai/client" + +export function undoMessage( + client: OpenCodeClient, + input: { readonly sessionID: string; readonly messageID: string; readonly pending: boolean }, +) { + const revert = () => client.session.revert.stage(input).then(() => undefined) + if (!input.pending) return revert() + return client.session.pending + .withdraw({ sessionID: input.sessionID, inputID: input.messageID }) + .then((withdrawn) => (withdrawn ? undefined : revert())) +} diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index c814f57679..958d2f88da 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -853,6 +853,65 @@ test("completes exploration when a queued prompt is promoted", async () => { } }) +test("removes optimistic input when it is withdrawn", async () => { + const events = createEventStream() + const sessionID = "session-withdrawal" + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) + }, events) + let data!: ReturnType + let client!: ReturnType + + function Probe() { + data = useData() + client = useClient() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => client.connection.status() === "connected") + emitEvent(events, { + id: "evt_prompt_admitted", + created: 1, + type: "session.input.admitted", + durable: durable(sessionID, 1), + data: { + sessionID, + inputID: "message-user", + input: { type: "user", data: { text: "Never mind" }, delivery: "steer" }, + }, + }) + await wait(() => data.session.message.get(sessionID, "message-user") !== undefined) + expect(data.session.input.has(sessionID, "message-user")).toBe(true) + + emitEvent(events, { + id: "evt_prompt_withdrawn", + created: 2, + type: "session.input.withdrawn", + durable: durable(sessionID, 2), + data: { sessionID, inputID: "message-user" }, + }) + + await wait(() => data.session.message.get(sessionID, "message-user") === undefined) + expect(data.session.input.has(sessionID, "message-user")).toBe(false) + expect(data.session.pending.list(sessionID)).toEqual([]) + } finally { + app.renderer.destroy() + } +}) + test("classifies live tool rows independently of their call ID", async () => { const events = createEventStream() const sessionID = "session-tool-call-id" diff --git a/packages/tui/test/cli/tui/undo.test.ts b/packages/tui/test/cli/tui/undo.test.ts new file mode 100644 index 0000000000..a2e52e9996 --- /dev/null +++ b/packages/tui/test/cli/tui/undo.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test" +import { OpenCode } from "@opencode-ai/client" +import { undoMessage } from "../../../src/routes/session/undo" + +test.each([ + { pending: true, withdrawn: true, expected: ["withdraw"] }, + { pending: true, withdrawn: false, expected: ["withdraw", "revert"] }, + { pending: false, withdrawn: false, expected: ["revert"] }, +])("routes undo for pending=$pending withdrawn=$withdrawn", async ({ pending, withdrawn, expected }) => { + const calls: string[] = [] + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: Object.assign( + async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init) + const operation = request.url.endsWith("/withdraw") ? "withdraw" : "revert" + calls.push(operation) + return Response.json({ data: operation === "withdraw" ? withdrawn : { messageID: "msg_user" } }) + }, + { preconnect: fetch.preconnect }, + ), + }) + + await undoMessage(client, { sessionID: "ses_test", messageID: "msg_user", pending }) + + expect(calls).toEqual([...expected]) +})