refactor(session): simplify pending withdrawal

This commit is contained in:
Kit Langton 2026-07-30 16:13:05 -04:00
commit d7ffc7fec1
12 changed files with 143 additions and 130 deletions

View file

@ -31,7 +31,7 @@ export function createV2SessionReducer() {
return result([...source]) return result([...source])
case "session.input.withdrawn": case "session.input.withdrawn":
pending.delete(key(sessionID, event.data.inputID)) pending.delete(key(sessionID, event.data.inputID))
return result([...source]) return
case "session.input.promoted": { case "session.input.promoted": {
const input = pending.get(key(sessionID, event.data.inputID)) const input = pending.get(key(sessionID, event.data.inputID))
pending.delete(key(sessionID, event.data.inputID)) pending.delete(key(sessionID, event.data.inputID))

View file

@ -249,44 +249,38 @@ export type Endpoint5_21Input = { readonly sessionID: Session.ID }
export type Endpoint5_21Output = ReadonlyArray<SessionPending.Info> export type Endpoint5_21Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E> export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_22Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID } export type Endpoint5_22Input = { readonly sessionID: Session.ID }
export type Endpoint5_22Output = boolean export type Endpoint5_22Output = ReadonlyArray<InstructionEntry.Info>
export type SessionPendingWithdrawOperation<E = never> = ( export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_22Input, input: Endpoint5_22Input,
) => Effect.Effect<Endpoint5_22Output, E> ) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_23Input = { readonly sessionID: Session.ID } export type Endpoint5_23Input = {
export type Endpoint5_23Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_23Input,
) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly key: InstructionEntry.Key readonly key: InstructionEntry.Key
readonly value: Schema.Json readonly value: Schema.Json
} }
export type Endpoint5_24Output = void export type Endpoint5_23Output = void
export type SessionInstructionsEntryPutOperation<E = never> = ( export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_23Input,
) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_24Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_24Input, input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E> ) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key } export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_25Output = void export type Endpoint5_25Output = { readonly text: string }
export type SessionInstructionsEntryRemoveOperation<E = never> = ( export type SessionGenerateOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
input: Endpoint5_25Input,
) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly prompt: string } export type Endpoint5_26Input = {
export type Endpoint5_26Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_27Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined readonly follow?: boolean | undefined
} }
export type Endpoint5_27Output = export type Endpoint5_26Output =
| ( | (
| { | {
readonly id: Event.ID readonly id: Event.ID
@ -842,19 +836,25 @@ export type Endpoint5_27Output =
} }
) )
| EventLog.Synced | EventLog.Synced
export type SessionLogOperation<E = never> = (input: Endpoint5_27Input) => Stream.Stream<Endpoint5_27Output, E> export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_28Input = { readonly sessionID: Session.ID } export type Endpoint5_28Input = { readonly sessionID: Session.ID }
export type Endpoint5_28Output = void export type Endpoint5_28Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E> export type SessionBackgroundOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID } export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_29Output = void export type Endpoint5_29Output = SessionMessage.Info
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E> export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID } export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_30Output = SessionMessage.Info export type Endpoint5_30Output = boolean
export type SessionMessageOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E> export type SessionPendingWithdrawOperation<E = never> = (
input: Endpoint5_30Input,
) => Effect.Effect<Endpoint5_30Output, E>
export interface SessionApi<E = never> { export interface SessionApi<E = never> {
readonly list: SessionListOperation<E> readonly list: SessionListOperation<E>

View file

@ -492,7 +492,7 @@ const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21I
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) => const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
preserveEffect<Endpoint5_22Output>()( preserveEffect<Endpoint5_22Output>()(
raw["session.pending.withdraw"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe( raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
), ),
@ -500,37 +500,29 @@ const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22I
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) => const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
preserveEffect<Endpoint5_23Output>()( preserveEffect<Endpoint5_23Output>()(
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<Endpoint5_24Output>()(
raw["session.instructions.entry.put"]({ raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] }, params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] }, payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) => const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_25Output>()( preserveEffect<Endpoint5_24Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
), ),
) )
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) => const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_26Output>()( preserveEffect<Endpoint5_25Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe( raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
), ),
) )
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) => const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveStream<Endpoint5_27Output>()( preserveStream<Endpoint5_26Output>()(
Stream.unwrap( Stream.unwrap(
raw["session.log"]({ raw["session.log"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
@ -542,19 +534,27 @@ const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27I
), ),
) )
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) => const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()( preserveEffect<Endpoint5_28Output>()(
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) => const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()( preserveEffect<Endpoint5_29Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
) )
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) => const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()( preserveEffect<Endpoint5_30Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( raw["session.pending.withdraw"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
), ),
@ -580,13 +580,13 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
wait: Endpoint5_16(raw), wait: Endpoint5_16(raw),
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) }, revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
context: Endpoint5_20(raw), context: Endpoint5_20(raw),
pending: { list: Endpoint5_21(raw), withdraw: Endpoint5_22(raw) }, pending: { list: Endpoint5_21(raw), withdraw: Endpoint5_30(raw) },
instructions: { entry: { list: Endpoint5_23(raw), put: Endpoint5_24(raw), remove: Endpoint5_25(raw) } }, instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
generate: Endpoint5_26(raw), generate: Endpoint5_25(raw),
log: Endpoint5_27(raw), log: Endpoint5_26(raw),
interrupt: Endpoint5_28(raw), interrupt: Endpoint5_27(raw),
background: Endpoint5_29(raw), background: Endpoint5_28(raw),
message: Endpoint5_30(raw), message: Endpoint5_29(raw),
}) })
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) => const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>

View file

@ -54,8 +54,6 @@ import type {
SessionContextOutput, SessionContextOutput,
SessionPendingListInput, SessionPendingListInput,
SessionPendingListOutput, SessionPendingListOutput,
SessionPendingWithdrawInput,
SessionPendingWithdrawOutput,
SessionInstructionsEntryListInput, SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput, SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput, SessionInstructionsEntryPutInput,
@ -72,6 +70,8 @@ import type {
SessionBackgroundOutput, SessionBackgroundOutput,
SessionMessageInput, SessionMessageInput,
SessionMessageOutput, SessionMessageOutput,
SessionPendingWithdrawInput,
SessionPendingWithdrawOutput,
MessageListInput, MessageListInput,
MessageListOutput, MessageListOutput,
ModelListInput, ModelListInput,

View file

@ -3170,13 +3170,6 @@ export type SessionPendingListInput = { readonly sessionID: { readonly sessionID
export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"] export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["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 SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"] export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"]
@ -3226,6 +3219,13 @@ export type SessionMessageInput = {
export type SessionMessageOutput = { data: SessionMessageInfo }["data"] export type SessionMessageOutput = { data: SessionMessageInfo }["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 MessageListInput = { export type MessageListInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"] readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly limit?: { readonly limit?: {

View file

@ -1,6 +1,6 @@
export * as SessionPending from "./pending" export * as SessionPending from "./pending"
import { and, asc, eq, or } from "drizzle-orm" import { and, asc, eq, or, sql } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect" import { DateTime, Effect, Schema } from "effect"
import { import {
Compaction, Compaction,
@ -113,20 +113,24 @@ const admittedFromHistory = Effect.fn("SessionPending.admittedFromHistory")(func
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
id: SessionMessage.ID, id: SessionMessage.ID,
) { ) {
const rows = yield* db const row = yield* db
.select() .select()
.from(EventTable) .from(EventTable)
.where(and(eq(EventTable.aggregate_id, sessionID), eq(EventTable.type, admittedEventType))) .where(
.all() and(
eq(EventTable.aggregate_id, sessionID),
eq(EventTable.type, admittedEventType),
sql`json_extract(${EventTable.data}, '$.inputID') = ${id}`,
),
)
.limit(1)
.get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
for (const row of rows) { if (row) {
const decoded = decodeAdmittedEvent(row.data) const decoded = decodeAdmittedEvent(row.data)
if (decoded._tag !== "Some" || decoded.value.inputID !== id) continue if (decoded._tag !== "Some" || decoded.value.inputID !== id)
const base = { return yield* Effect.die(new LifecycleConflict({ id }))
id, const base = { id, sessionID, timeCreated: DateTime.makeUnsafe(row.created) }
sessionID,
timeCreated: DateTime.makeUnsafe(row.created),
}
return decoded.value.input.type === "user" return decoded.value.input.type === "user"
? User.make({ ...base, ...decoded.value.input }) ? User.make({ ...base, ...decoded.value.input })
: Synthetic.make({ ...base, ...decoded.value.input }) : Synthetic.make({ ...base, ...decoded.value.input })
@ -164,16 +168,22 @@ const wasWithdrawn = Effect.fn("SessionPending.wasWithdrawn")(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
id: SessionMessage.ID, id: SessionMessage.ID,
) { ) {
const rows = yield* db const row = yield* db
.select({ data: EventTable.data }) .select({ data: EventTable.data })
.from(EventTable) .from(EventTable)
.where(and(eq(EventTable.aggregate_id, sessionID), eq(EventTable.type, withdrawnEventType))) .where(
.all() and(
eq(EventTable.aggregate_id, sessionID),
eq(EventTable.type, withdrawnEventType),
sql`json_extract(${EventTable.data}, '$.inputID') = ${id}`,
),
)
.limit(1)
.get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
return rows.some((row) => { if (!row) return false
const decoded = decodeWithdrawnEvent(row.data) const decoded = decodeWithdrawnEvent(row.data)
return decoded._tag === "Some" && decoded.value.inputID === id return decoded._tag === "Some" && decoded.value.inputID === id
})
}) })
export const admit = Effect.fn("SessionPending.admit")(function* ( export const admit = Effect.fn("SessionPending.admit")(function* (
@ -341,14 +351,13 @@ export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompac
* message insert inside the same event transaction; the deleted row is what * message insert inside the same event transaction; the deleted row is what
* makes the table pending-only. * makes the table pending-only.
*/ */
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* ( const consumeInput = Effect.fn("SessionPending.consumeInput")(function* (
db: DatabaseService, db: DatabaseService,
input: { input: {
readonly id: SessionMessage.ID readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
}, },
) { ) {
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const deleted = yield* db const deleted = yield* db
.delete(SessionPendingTable) .delete(SessionPendingTable)
.where(and(eq(SessionPendingTable.id, input.id), eq(SessionPendingTable.session_id, input.sessionID))) .where(and(eq(SessionPendingTable.id, input.id), eq(SessionPendingTable.session_id, input.sessionID)))
@ -361,6 +370,17 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
return stored return stored
}) })
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
},
) {
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return yield* consumeInput(db, input)
})
export const projectWithdrawn = Effect.fn("SessionPending.projectWithdrawn")(function* ( export const projectWithdrawn = Effect.fn("SessionPending.projectWithdrawn")(function* (
db: DatabaseService, db: DatabaseService,
input: { input: {
@ -368,16 +388,7 @@ export const projectWithdrawn = Effect.fn("SessionPending.projectWithdrawn")(fun
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
}, },
) { ) {
const deleted = yield* db return yield* consumeInput(db, input)
.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* ( export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (

View file

@ -494,22 +494,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(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( .add(
HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", { HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", {
params: { sessionID: Session.ID }, params: { sessionID: Session.ID },
@ -644,6 +628,22 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(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.",
}),
),
)
.annotateMerge( .annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
title: "session", title: "session",

View file

@ -188,6 +188,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
index.set(item.id, messages.length) index.set(item.id, messages.length)
messages.push(item) messages.push(item)
}, },
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
for (let position = start; position < messages.length; position++) {
const item = messages[position]
if (item) index.set(item.id, position)
}
},
activeAssistant(messages: SessionMessageInfo[]) { activeAssistant(messages: SessionMessageInfo[]) {
const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed) const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed)
return item?.type === "assistant" ? item : undefined return item?.type === "assistant" ? item : undefined
@ -395,8 +401,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
existing.time.created = event.created existing.time.created = event.created
draft.splice(position, 1) draft.splice(position, 1)
draft.push(existing) draft.push(existing)
index.clear() message.reindex(draft, index, position)
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
}) })
setStore( setStore(
"session", "session",
@ -445,15 +450,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const position = index.get(event.data.inputID) const position = index.get(event.data.inputID)
if (position === undefined) return if (position === undefined) return
draft.splice(position, 1) draft.splice(position, 1)
index.clear() index.delete(event.data.inputID)
draft.forEach((message, indexValue) => index.set(message.id, indexValue)) message.reindex(draft, index, position)
}) })
setStore( if (store.session.input[event.data.sessionID]?.includes(event.data.inputID))
"session", setStore(
"input", "session",
event.data.sessionID, "input",
(store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID), event.data.sessionID,
) (store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID),
)
break break
} }
case "session.instructions.updated": case "session.instructions.updated":

View file

@ -46,7 +46,6 @@ export function DialogMessage(props: {
void undoMessage(client.api, { void undoMessage(client.api, {
sessionID: props.sessionID, sessionID: props.sessionID,
messageID: props.messageID, messageID: props.messageID,
pending: data.session.input.has(props.sessionID, props.messageID),
}).catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) }).catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
dialog.clear() dialog.clear()
}, },

View file

@ -594,7 +594,6 @@ export function Session() {
void undoMessage(client.api, { void undoMessage(client.api, {
sessionID: route.sessionID, sessionID: route.sessionID,
messageID: message.id, messageID: message.id,
pending: data.session.input.has(route.sessionID, message.id),
}).catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) }).catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
prompt()?.set({ prompt()?.set({
...projectedPromptInput(message), ...projectedPromptInput(message),

View file

@ -2,10 +2,9 @@ import type { OpenCodeClient } from "@opencode-ai/client"
export function undoMessage( export function undoMessage(
client: OpenCodeClient, client: OpenCodeClient,
input: { readonly sessionID: string; readonly messageID: string; readonly pending: boolean }, input: { readonly sessionID: string; readonly messageID: string },
) { ) {
const revert = () => client.session.revert.stage(input).then(() => undefined) const revert = () => client.session.revert.stage(input).then(() => undefined)
if (!input.pending) return revert()
return client.session.pending return client.session.pending
.withdraw({ sessionID: input.sessionID, inputID: input.messageID }) .withdraw({ sessionID: input.sessionID, inputID: input.messageID })
.then((withdrawn) => (withdrawn ? undefined : revert())) .then((withdrawn) => (withdrawn ? undefined : revert()))

View file

@ -3,10 +3,9 @@ import { OpenCode } from "@opencode-ai/client"
import { undoMessage } from "../../../src/routes/session/undo" import { undoMessage } from "../../../src/routes/session/undo"
test.each([ test.each([
{ pending: true, withdrawn: true, expected: ["withdraw"] }, { withdrawn: true, expected: ["withdraw"] },
{ pending: true, withdrawn: false, expected: ["withdraw", "revert"] }, { withdrawn: false, expected: ["withdraw", "revert"] },
{ pending: false, withdrawn: false, expected: ["revert"] }, ])("routes undo for withdrawn=$withdrawn", async ({ withdrawn, expected }) => {
])("routes undo for pending=$pending withdrawn=$withdrawn", async ({ pending, withdrawn, expected }) => {
const calls: string[] = [] const calls: string[] = []
const client = OpenCode.make({ const client = OpenCode.make({
baseUrl: "http://localhost:3000", baseUrl: "http://localhost:3000",
@ -21,7 +20,7 @@ test.each([
), ),
}) })
await undoMessage(client, { sessionID: "ses_test", messageID: "msg_user", pending }) await undoMessage(client, { sessionID: "ses_test", messageID: "msg_user" })
expect(calls).toEqual([...expected]) expect(calls).toEqual([...expected])
}) })