Compare commits

...

1 Commits

Author SHA1 Message Date
LukeParkerDev fe5241fdb8 fix(core): make inbox reordering durable and atomic 2026-08-25 13:31:06 +10:00
31 changed files with 1101 additions and 77 deletions
@@ -13,23 +13,34 @@ type InboxRow = {
id: string
sessionID: string
timeCreated: number
type: "user"
type: "user" | "synthetic"
payload: { text: string; metadata?: Record<string, unknown> }
delivery: "steer" | "queue"
}
function createQueueMock(seed: string[]) {
function createQueueMock(seed: string[], synthetic?: number) {
const rows: InboxRow[] = seed.map((text, index) => ({
id: `inb_seed_${index + 1}`,
id: `msg_seed_${index + 1}`,
sessionID,
timeCreated: 1700000000000 + index,
type: "user",
payload: { text },
delivery: "queue",
}))
if (synthetic !== undefined) {
rows.splice(synthetic, 0, {
id: "msg_seed_synthetic",
sessionID,
timeCreated: 1700000000000 + synthetic,
type: "synthetic",
payload: { text: "synthetic queued work" },
delivery: "queue",
})
}
const events: OpenCodeEvent[] = []
const prompts: Record<string, unknown>[] = []
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
const reorders: string[][] = []
const log: string[] = []
let sequence = 0
const emit = (type: OpenCodeEvent["type"], data: OpenCodeEvent["data"]) => {
@@ -46,13 +57,14 @@ function createQueueMock(seed: string[]) {
rows,
prompts,
changes,
reorders,
log,
events: () => events.splice(0),
onPrompt: (input: { sessionID: string; body: Record<string, unknown> }) => {
prompts.push(input.body)
log.push(`prompt:${String(input.body.delivery ?? "steer")}`)
const row: InboxRow = {
id: typeof input.body.id === "string" ? input.body.id : `inb_mock_${sequence}`,
id: typeof input.body.id === "string" ? input.body.id : `msg_mock_${sequence}`,
sessionID: input.sessionID,
timeCreated: Date.now(),
type: "user",
@@ -87,6 +99,15 @@ function createQueueMock(seed: string[]) {
delivery: "steer",
})
},
onInboxReorder: (input: { sessionID: string; inboxIDs: readonly string[] }) => {
const users = rows.filter((row) => row.type === "user" && row.delivery === "queue")
expect(input.inboxIDs.toSorted()).toEqual(users.map((row) => row.id).toSorted())
reorders.push([...input.inboxIDs])
log.push("reorder")
const ordered = input.inboxIDs.flatMap((id) => users.filter((row) => row.id === id)).values()
rows.splice(0, rows.length, ...rows.map((row) => (users.includes(row) ? (ordered.next().value ?? row) : row)))
emit("session.inbox.reordered", { sessionID: input.sessionID, inboxIDs: [...input.inboxIDs] })
},
}
}
@@ -134,6 +155,7 @@ async function openSession(page: Page, mock: ReturnType<typeof createQueueMock>,
inbox: () => mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })),
onPrompt: mock.onPrompt,
onInboxChange: mock.onInboxChange,
onInboxReorder: mock.onInboxReorder,
events: mock.events,
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -161,7 +183,7 @@ test("follow-up preference controls Enter while Mod+Enter uses the alternate del
await expect(view.input).toHaveText("")
})
test("dragging reorders queued prompts", async ({ page }) => {
test("dragging reorders stable queue IDs without cloning or cancelling prompts", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
const view = await openSession(page, mock)
await expect(view.rows).toHaveCount(3)
@@ -180,20 +202,14 @@ test("dragging reorders queued prompts", async ({ page }) => {
"third queued prompt",
"first queued prompt",
])
expect(mock.prompts.map((prompt) => prompt.text)).toEqual([
"second queued prompt",
"third queued prompt",
"first queued prompt",
])
expect(mock.changes).toEqual([
{ inboxID: "inb_seed_1", action: "cancel" },
{ inboxID: "inb_seed_2", action: "cancel" },
{ inboxID: "inb_seed_3", action: "cancel" },
])
expect(mock.reorders).toEqual([["msg_seed_2", "msg_seed_3", "msg_seed_1"]])
expect(mock.rows.map((row) => row.id)).toEqual(["msg_seed_2", "msg_seed_3", "msg_seed_1"])
expect(mock.prompts).toEqual([])
expect(mock.changes).toEqual([])
})
test("editing restores the existing draft and replaces only the original queue position", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "tighten the error copy", "third queued prompt"])
const mock = createQueueMock(["first queued prompt", "tighten the error copy", "third queued prompt"], 2)
const view = await openSession(page, mock)
const original = view.rows.getByText("tighten the error copy", { exact: true })
await expect(original).toBeVisible()
@@ -215,12 +231,19 @@ test("editing restores the existing draft and replaces only the original queue p
"third queued prompt",
])
await expect(view.input).toHaveText("my in-progress draft")
expect(mock.prompts.map((prompt) => prompt.text)).toEqual([
"tighten the error copy and add a retry hint",
"tighten the error copy and add a retry hint",
"third queued prompt",
expect(mock.prompts).toHaveLength(1)
expect(mock.prompts[0]).toMatchObject({
text: "tighten the error copy and add a retry hint",
delivery: "queue",
resume: false,
})
expect(mock.changes).toEqual([{ inboxID: "msg_seed_2", action: "cancel" }])
expect(mock.reorders).toEqual([["msg_seed_1", mock.prompts[0]?.id, "msg_seed_3", "msg_seed_2"]])
expect(mock.rows.map((row) => row.id)).toEqual([
"msg_seed_1",
mock.prompts[0]?.id,
"msg_seed_synthetic",
"msg_seed_3",
])
expect(mock.prompts.every((prompt) => prompt.delivery === "queue" && prompt.resume === false)).toBe(true)
expect(mock.changes.map((change) => change.action)).toEqual(["cancel", "cancel", "cancel"])
expect(mock.log[0]).toBe("prompt:queue")
expect(mock.log).toEqual(["prompt:queue", "reorder", "cancel:msg_seed_2"])
})
+10 -2
View File
@@ -1,3 +1,4 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Schema, SchemaGetter } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
@@ -197,13 +198,20 @@ const Group = HttpApiGroup.make("mock")
)
.add(
HttpApiEndpoint.delete("sessionInboxCancel", "/api/session/:sessionID/inbox/:inboxID", {
params: { ...SessionParams, inboxID: Schema.String },
params: { ...SessionParams, inboxID: SessionMessage.ID },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionInboxSteer", "/api/session/:sessionID/inbox/:inboxID/steer", {
params: { ...SessionParams, inboxID: Schema.String },
params: { ...SessionParams, inboxID: SessionMessage.ID },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionInboxReorder", "/api/session/:sessionID/inbox/reorder", {
params: SessionParams,
payload: Schema.Struct({ inboxIDs: Schema.Array(SessionMessage.ID) }),
success: NoContent,
}),
)
+6 -1
View File
@@ -38,6 +38,7 @@ export interface MockServerConfig {
inbox?: unknown[] | (() => unknown[])
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => void
onInboxReorder?: (input: { sessionID: string; inboxIDs: readonly string[] }) => void
}
type MockStreamWindow = Window & {
@@ -408,7 +409,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
config.onPrompt?.({ sessionID: ctx.params.sessionID, body })
return {
data: {
id: typeof body.id === "string" ? body.id : `inb_mock_${Date.now()}`,
id: typeof body.id === "string" ? body.id : `msg_mock_${Date.now()}`,
sessionID: ctx.params.sessionID,
timeCreated: Date.now(),
type: "user",
@@ -431,6 +432,10 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "steer" }),
).pipe(Effect.andThen(noContent)),
sessionInboxReorder: (ctx) =>
Effect.sync(() =>
config.onInboxReorder?.({ sessionID: ctx.params.sessionID, inboxIDs: ctx.payload.inboxIDs }),
).pipe(Effect.andThen(noContent)),
sessionSwitchAgent: () => noContent,
sessionSwitchModel: () => noContent,
sessionPermission: (ctx) => {
+15 -35
View File
@@ -67,38 +67,6 @@ export function createSessionQueue(input: {
})
}
const rewrite = async (inboxIDs: string[]) => {
const pending = await server.api.session.inbox.list({ sessionID: input.sessionID })
if (pending.some((item) => item.delivery === "queue" && item.type !== "user"))
throw new Error("Queued control items block reordering")
const current = pending.filter((item): item is QueuedPrompt => item.type === "user" && item.delivery === "queue")
const ordered = inboxIDs.flatMap((id) => current.filter((item) => item.id === id))
if (ordered.length !== current.length) throw new Error("Queued prompts changed before reordering")
const changed = ordered.findIndex((item, index) => item.id !== current[index]?.id)
if (changed < 0) return
// Existing inbox APIs cannot reorder rows, so replace only the changed suffix.
for (const item of ordered.slice(changed)) {
await data.session.prompt({
sessionID: input.sessionID,
text: item.payload.text,
files: item.payload.files?.map((file) => ({
uri: `data:${file.mime};base64,${file.data}`,
name: file.name,
description: file.description,
mention: file.mention,
})),
agents: item.payload.agents,
skills: item.payload.skills,
metadata: item.payload.metadata,
delivery: "queue",
resume: false,
})
}
for (const item of current.slice(changed)) {
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: item.id })
}
}
const steer = (id: string) => {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox.steer({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
@@ -109,7 +77,14 @@ export function createSessionQueue(input: {
}
const reorder = (inboxIDs: string[]) => {
if (state.busy) return Promise.resolve()
return run(() => rewrite(inboxIDs))
return run(async () => {
await data.session.pending.settled(input.sessionID)
const visible = new Set(queued().map((item) => item.id))
await server.api.session.inbox.reorder({
sessionID: input.sessionID,
inboxIDs: inboxIDs.filter((id) => visible.has(id)),
})
})
}
const edit = (id: string) => {
@@ -157,15 +132,20 @@ export function createSessionQueue(input: {
const inboxIDs = queued().map((entry) => entry.id)
void run(async () => {
const replacement = await editedPromptInput(input.sessionID, location().directory, item, prompt, text)
// Admit before cancelling so a failed replacement never discards the original.
// Admit and reorder before cancelling so failures never discard the original.
const admitted = await data.session.prompt({
...replacement,
delivery,
...(delivery === "queue" ? { resume: false } : {}),
})
if (delivery === "queue") {
await server.api.session.inbox.reorder({
sessionID: input.sessionID,
inboxIDs: [...inboxIDs.map((id) => (id === editing.id ? admitted.id : id)), editing.id],
})
}
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: editing.id })
cancelEdit()
if (delivery === "queue") await rewrite(inboxIDs.map((id) => (id === editing.id ? admitted.id : id)))
})
}
const editFirst = () => {
+19
View File
@@ -340,6 +340,15 @@ export type SessionInboxListOperation<E = never> = (
input: SessionInboxListInput,
) => Effect.Effect<SessionInboxListOutput, E>
export type SessionInboxReorderInput = {
readonly sessionID: Session.ID
readonly inboxIDs: ReadonlyArray<SessionMessage.ID>
}
export type SessionInboxReorderOutput = void
export type SessionInboxReorderOperation<E = never> = (
input: SessionInboxReorderInput,
) => Effect.Effect<SessionInboxReorderOutput, E>
export type SessionInboxCancelInput = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type SessionInboxCancelOutput = void
export type SessionInboxCancelOperation<E = never> = (
@@ -541,6 +550,15 @@ export type SessionLogOutput =
readonly delivery: SessionInbox.Delivery
}
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.reordered"
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 inboxIDs: ReadonlyArray<SessionMessage.ID> }
}
| {
readonly id: Event.ID
readonly created: number
@@ -1055,6 +1073,7 @@ export interface SessionApi<E = never> {
readonly context: SessionContextOperation<E>
readonly inbox: {
readonly list: SessionInboxListOperation<E>
readonly reorder: SessionInboxReorderOperation<E>
readonly cancel: SessionInboxCancelOperation<E>
readonly steer: SessionInboxSteerOperation<E>
readonly queue: SessionInboxQueueOperation<E>
@@ -64,6 +64,8 @@ import type {
SessionContextOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxReorderInput,
SessionInboxReorderOutput,
SessionInboxCancelInput,
SessionInboxCancelOutput,
SessionInboxSteerInput,
@@ -541,6 +543,14 @@ const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: S
),
)
const EndpointSessionInboxReorder = (raw: RawClient["server.session"]) => (input: SessionInboxReorderInput) =>
preserveEffect<SessionInboxReorderOutput>()(
raw["session.inbox.reorder"]({
params: { sessionID: input["sessionID"] },
payload: { inboxIDs: input["inboxIDs"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointSessionInboxCancel = (raw: RawClient["server.session"]) => (input: SessionInboxCancelInput) =>
preserveEffect<SessionInboxCancelOutput>()(
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
@@ -674,6 +684,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
context: EndpointSessionContext(raw),
inbox: {
list: EndpointSessionInboxList(raw),
reorder: EndpointSessionInboxReorder(raw),
cancel: EndpointSessionInboxCancel(raw),
steer: EndpointSessionInboxSteer(raw),
queue: EndpointSessionInboxQueue(raw),
@@ -58,6 +58,8 @@ import type {
SessionContextOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxReorderInput,
SessionInboxReorderOutput,
SessionInboxCancelInput,
SessionInboxCancelOutput,
SessionInboxSteerInput,
@@ -778,6 +780,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
reorder: (input: SessionInboxReorderInput, requestOptions?: RequestOptions) =>
request<SessionInboxReorderOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/reorder`,
body: { inboxIDs: input["inboxIDs"] },
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
cancel: (input: SessionInboxCancelInput, requestOptions?: RequestOptions) =>
request<SessionInboxCancelOutput>(
{
@@ -617,6 +617,16 @@ export type SessionInboxDeliveryChanged = {
data: { sessionID: string; inboxID: string; delivery: SessionInboxDelivery }
}
export type SessionInboxReordered = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.inbox.reordered"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inboxIDs: Array<string> }
}
export type SessionExecutionStarted = {
id: string
created: number
@@ -1991,6 +2001,7 @@ export type SessionEventDurable =
| SessionInboxEnqueued
| SessionInboxCancelled
| SessionInboxDeliveryChanged
| SessionInboxReordered
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -2083,6 +2094,7 @@ export type V2Event =
| SessionInboxEnqueued
| SessionInboxCancelled
| SessionInboxDeliveryChanged
| SessionInboxReordered
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -3874,6 +3886,13 @@ export type SessionInboxListInput = { readonly sessionID: { readonly sessionID:
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
export type SessionInboxReorderInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly inboxIDs: { readonly inboxIDs: ReadonlyArray<string> }["inboxIDs"]
}
export type SessionInboxReorderOutput = void
export type SessionInboxCancelInput = {
readonly sessionID: { readonly sessionID: string; readonly inboxID: string }["sessionID"]
readonly inboxID: { readonly sessionID: string; readonly inboxID: string }["inboxID"]
+24
View File
@@ -639,6 +639,27 @@ export function createData(config: CreateDataInput) {
case "session.inbox.delivery.changed":
updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery)
return
case "session.inbox.reordered": {
const pending = store.session.pending[event.data.sessionID]
if (!pending) return
const queued = new Map(
pending.filter((item) => item.type === "user" && item.delivery === "queue").map((item) => [item.id, item]),
)
const reordered = new Map(
event.data.inboxIDs.flatMap((id) => {
const item = queued.get(id)
return item ? [[id, item] as const] : []
}),
)
const items = reordered.values()
setStore(
"session",
"pending",
event.data.sessionID,
pending.map((item) => (reordered.has(item.id) ? (items.next().value ?? item) : item)),
)
return
}
case "session.inbox.cancelled": {
retractLocal(event.data.sessionID, event.data.inboxID)
return
@@ -1181,6 +1202,9 @@ export function createData(config: CreateDataInput) {
list(sessionID: string) {
return store.session.pending[sessionID] ?? []
},
settled(sessionID: string) {
return sending.get(sessionID) ?? Promise.resolve()
},
sync(sessionID: string) {
return sync.run(`session.pending:${sessionID}`, async () => {
const pending = await api().session.inbox.list({ sessionID })
+8 -3
View File
@@ -435,12 +435,12 @@ test("session.inbox.list uses the public HTTP contract", async () => {
})
test("session.inbox mutations use the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const requests: Request[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ method: request.method, url: request.url })
requests.push(request)
return new Response(null, { status: 204 })
},
})
@@ -448,12 +448,17 @@ test("session.inbox mutations use the public HTTP contract", async () => {
await client.session.inbox.cancel({ sessionID: "ses_test", inboxID: "msg_cancel" })
await client.session.inbox.steer({ sessionID: "ses_test", inboxID: "msg_steer" })
await client.session.inbox.queue({ sessionID: "ses_test", inboxID: "msg_queue" })
expect(
await client.session.inbox.reorder({ sessionID: "ses_test", inboxIDs: ["msg_second", "msg_first"] }),
).toBeUndefined()
expect(requests).toEqual([
expect(requests.map((request) => ({ method: request.method, url: request.url }))).toEqual([
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/inbox/msg_cancel" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/inbox/msg_steer/steer" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/inbox/msg_queue/queue" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/inbox/reorder" },
])
expect(await requests[3]?.json()).toEqual({ inboxIDs: ["msg_second", "msg_first"] })
})
test("event.subscribe exposes the Promise event stream wire projection", async () => {
+132
View File
@@ -72,6 +72,138 @@ test("revalidates after an event overtakes an active session read", async () =>
}
})
test("reorders queued user inbox items without moving unrelated or optimistic rows", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const pending = [
{ id: "msg_first", type: "user", payload: { text: "First" }, delivery: "queue" },
{ id: "msg_control", type: "compaction", payload: {}, delivery: "queue" },
{ id: "msg_steer", type: "user", payload: { text: "Steer" }, delivery: "steer" },
{ id: "msg_synthetic", type: "synthetic", payload: { text: "Synthetic" }, delivery: "queue" },
{ id: "msg_second", type: "user", payload: { text: "Second" }, delivery: "queue" },
].map((item, index) => ({ ...item, sessionID: "ses_refresh", timeCreated: index }))
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
if (request.url.endsWith("/inbox")) return Response.json({ data: pending })
if (request.url.endsWith("/prompt"))
return Response.json({
data: {
id: "msg_optimistic",
sessionID: "ses_refresh",
type: "user",
data: { text: "Optimistic" },
delivery: "queue",
timeCreated: 5,
},
})
throw new Error(`Unexpected request: ${request.url}`)
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
try {
await setup.data.session.pending.sync("ses_refresh")
const optimistic = setup.data.session.prompt({
sessionID: "ses_refresh",
id: "msg_optimistic",
text: "Optimistic",
delivery: "queue",
})
const reordered: OpenCodeEvent = {
id: "evt_reordered",
created: 6,
type: "session.inbox.reordered",
durable: { aggregateID: "ses_refresh", seq: 1, version: 1 },
data: { sessionID: "ses_refresh", inboxIDs: ["msg_second", "msg_first"] },
}
listeners.forEach((listener) => listener({ name: reordered.type, details: reordered }))
expect(setup.data.session.pending.list("ses_refresh").map((item) => item.id)).toEqual([
"msg_second",
"msg_control",
"msg_steer",
"msg_synthetic",
"msg_first",
"msg_optimistic",
])
await optimistic
} finally {
setup.dispose()
}
})
test("waits for optimistic pending prompts to be admitted", async () => {
const release = Promise.withResolvers<void>()
let admitted = false
let settled = false
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
if (!request.url.endsWith("/prompt")) throw new Error(`Unexpected request: ${request.url}`)
await release.promise
admitted = true
return Response.json({
data: {
id: "msg_pending",
sessionID: "ses_refresh",
type: "user",
data: { text: "Pending" },
delivery: "queue",
timeCreated: 1,
},
})
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: { on: () => () => {}, listen: () => () => {} },
}),
dispose,
}))
try {
const prompt = setup.data.session.prompt({
sessionID: "ses_refresh",
id: "msg_pending",
text: "Pending",
delivery: "queue",
})
const pending = setup.data.session.pending.settled("ses_refresh").then(() => {
settled = true
})
expect(setup.data.session.pending.list("ses_refresh").map((item) => item.id)).toEqual(["msg_pending"])
await Bun.sleep(0)
expect(admitted).toBe(false)
expect(settled).toBe(false)
release.resolve()
await pending
expect(admitted).toBe(true)
expect(settled).toBe(true)
await prompt
} finally {
setup.dispose()
}
})
test("reports optimistic sessions as creating until the request settles", async () => {
const release = Promise.withResolvers<void>()
const api = OpenCode.make({
+34 -2
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
"id": "ba9e3e23-6bcb-4570-aaea-cb1e02e2f1cf",
"prevIds": ["be60f352-8da1-40e1-8d70-dc41121cfbc5"],
"ddl": [
{
"name": "account_state",
@@ -910,6 +910,16 @@
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "order_seq",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "integer",
"notNull": true,
@@ -1848,6 +1858,28 @@
"entityType": "indexes",
"table": "session_inbox"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
},
{
"value": "delivery",
"isExpression": false
},
{
"value": "coalesce(\"order_seq\", \"enqueued_seq\")",
"isExpression": true
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_inbox_session_delivery_order_idx",
"entityType": "indexes",
"table": "session_inbox"
},
{
"columns": [
{
+2
View File
@@ -45,6 +45,7 @@ import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
import m46 from "./migration/20260825004258_session_inbox_order.js"
export const migrations = [
m00,
@@ -93,4 +94,5 @@ export const migrations = [
m43,
m44,
m45,
m46,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,16 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260825004258_session_inbox_order",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_inbox\` ADD \`order_seq\` integer;`)
yield* tx.run(
`CREATE INDEX \`session_inbox_session_delivery_order_idx\` ON \`session_inbox\` (\`session_id\`,\`delivery\`,coalesce("order_seq", "enqueued_seq"));`,
)
})
},
}
export default migration
+4
View File
@@ -150,6 +150,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
\`payload\` text NOT NULL,
\`delivery\` text NOT NULL,
\`enqueued_seq\` integer NOT NULL,
\`order_seq\` integer,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_inbox_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
);
@@ -246,6 +247,9 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
yield* tx.run(
`CREATE INDEX \`session_inbox_session_delivery_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`delivery\`,\`enqueued_seq\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_inbox_session_delivery_order_idx\` ON \`session_inbox\` (\`session_id\`,\`delivery\`,coalesce("order_seq", "enqueued_seq"));`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_inbox_session_enqueued_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`enqueued_seq\`);`,
)
+19 -1
View File
@@ -198,13 +198,17 @@ export interface Interface {
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
/**
* Durable admitted session work not yet visible in projected history,
* ordered by admission. Includes unpromoted user and synthetic inputs and
* ordered by effective inbox position. Includes unpromoted user and synthetic inputs and
* unhandled compaction barriers.
*/
readonly inbox: (sessionID: SessionSchema.ID) => Effect.Effect<SessionInbox.Info[], NotFoundError>
readonly cancelInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
readonly steerInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
readonly queueInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
readonly reorderInbox: (input: {
readonly sessionID: SessionSchema.ID
readonly inboxIDs: ReadonlyArray<SessionMessage.ID>
}) => Effect.Effect<void, NotFoundError | InboxConflictError>
/**
* Durable, ordered session log read. Replays durable session bus after
* the exclusive `after` cursor, emits a `Synced` marker at the captured
@@ -571,6 +575,20 @@ const layer = Layer.effect(
cancelInbox: Effect.fn("Session.cancelInbox")((input) => mutatePending(input, SessionInbox.cancel)),
steerInbox: Effect.fn("Session.steerInbox")((input) => mutatePending(input, SessionInbox.steer, true)),
queueInbox: Effect.fn("Session.queueInbox")((input) => mutatePending(input, SessionInbox.queue)),
reorderInbox: Effect.fn("Session.reorderInbox")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* result.get(input.sessionID)
yield* SessionInbox.reorder(db, bus, input).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict
? new InboxConflictError({ sessionID: input.sessionID, inboxID: defect.id })
: Effect.die(defect),
),
)
}),
),
),
log: (input) =>
Stream.unwrap(
result
+72 -4
View File
@@ -1,6 +1,6 @@
export * as SessionInbox from "./inbox.js"
import { and, asc, eq, or } from "drizzle-orm"
import { and, asc, eq, or, sql } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import {
Compaction,
@@ -312,12 +312,50 @@ export const projectDeliveryChanged = Effect.fn("SessionInbox.projectDeliveryCha
}),
)
export const projectReordered = Effect.fn("SessionInbox.projectReordered")(function* (
db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID; readonly inboxIDs: ReadonlyArray<SessionMessage.ID> },
) {
const queued = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, input.sessionID), eq(SessionInboxTable.delivery, "queue")))
.orderBy(asc(sql`coalesce(${SessionInboxTable.order_seq}, ${SessionInboxTable.enqueued_seq})`))
.all()
.pipe(Effect.orDie)
const barrier = queued.find((row) => row.type === "compaction" || row.type === "move")
if (barrier) return yield* Effect.die(new LifecycleConflict({ id: barrier.id }))
const users = queued.filter((row) => row.type === "user")
const duplicate = input.inboxIDs.find((id, index) => input.inboxIDs.indexOf(id) !== index)
if (duplicate) return yield* Effect.die(new LifecycleConflict({ id: duplicate }))
const invalid = input.inboxIDs.find((id) => !users.some((row) => row.id === id))
if (invalid) return yield* Effect.die(new LifecycleConflict({ id: invalid }))
const missing = users.find((row) => !input.inboxIDs.includes(row.id))
if (missing) return yield* Effect.die(new LifecycleConflict({ id: missing.id }))
yield* Effect.forEach(
input.inboxIDs,
(id, index) =>
db
.update(SessionInboxTable)
.set({ order_seq: users[index]?.order_seq ?? users[index]?.enqueued_seq })
.where(and(eq(SessionInboxTable.id, id), eq(SessionInboxTable.session_id, input.sessionID)))
.run()
.pipe(Effect.orDie),
{ discard: true },
)
})
export const list = Effect.fn("SessionInbox.list")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const rows = yield* db
.select()
.from(SessionInboxTable)
.where(eq(SessionInboxTable.session_id, sessionID))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.orderBy(
asc(
sql`case when ${SessionInboxTable.delivery} = 'queue' then coalesce(${SessionInboxTable.order_seq}, ${SessionInboxTable.enqueued_seq}) else ${SessionInboxTable.enqueued_seq} end`,
),
asc(SessionInboxTable.enqueued_seq),
)
.all()
.pipe(Effect.orDie)
return rows.map(fromRow)
@@ -343,7 +381,11 @@ export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function*
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, delivery)))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.orderBy(
delivery === "steer"
? asc(SessionInboxTable.enqueued_seq)
: asc(sql`coalesce(${SessionInboxTable.order_seq}, ${SessionInboxTable.enqueued_seq})`),
)
.limit(1)
.get()
.pipe(Effect.orDie)
@@ -414,6 +456,32 @@ export const queue = Effect.fn("SessionInbox.queue")((bus: Bus.Interface, input:
),
)
export const reorder = Effect.fn("SessionInbox.reorder")(
(
db: DatabaseService,
bus: Bus.Interface,
input: { readonly sessionID: SessionSchema.ID; readonly inboxIDs: ReadonlyArray<SessionMessage.ID> },
) =>
serialized(
input.sessionID,
Effect.gen(function* () {
const queued = yield* db
.select({ id: SessionInboxTable.id, type: SessionInboxTable.type })
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, input.sessionID), eq(SessionInboxTable.delivery, "queue")))
.orderBy(asc(sql`coalesce(${SessionInboxTable.order_seq}, ${SessionInboxTable.enqueued_seq})`))
.all()
.pipe(Effect.orDie)
const barrier = queued.find((row) => row.type === "compaction" || row.type === "move")
if (barrier) return yield* Effect.die(new LifecycleConflict({ id: barrier.id }))
const users = queued.filter((row) => row.type === "user")
if (users.length === input.inboxIDs.length && users.every((row, index) => row.id === input.inboxIDs[index]))
return
yield* bus.publish(SessionEvent.InboxReordered, input)
}),
).pipe(Effect.asVoid),
)
const publish = Effect.fn("SessionInbox.publish")(function* (
db: DatabaseService,
bus: Bus.Interface,
@@ -469,7 +537,7 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "queue")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.orderBy(asc(sql`coalesce(${SessionInboxTable.order_seq}, ${SessionInboxTable.enqueued_seq})`))
.limit(1)
.get()
.pipe(Effect.orDie)
@@ -122,6 +122,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.inbox.enqueued": () => Effect.void,
"session.inbox.cancelled": () => Effect.void,
"session.inbox.delivery.changed": () => Effect.void,
"session.inbox.reordered": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
+6
View File
@@ -625,6 +625,12 @@ const layer = Layer.effectDiscard(
delivery: event.data.delivery,
}),
)
yield* bus.project(SessionEvent.InboxReordered, (event) =>
SessionInbox.projectReordered(db, {
sessionID: event.data.sessionID,
inboxIDs: event.data.inboxIDs,
}),
)
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => projectIdle(db, event))
+6
View File
@@ -134,12 +134,18 @@ export const SessionInboxTable = sqliteTable(
payload: text({ mode: "json" }).$type<UserPayload | SyntheticPayload | CompactionPayload | MovePayload>().notNull(),
delivery: text().$type<SessionInbox.Delivery>().notNull(),
enqueued_seq: integer().notNull(),
order_seq: integer(),
time_created: integer()
.notNull()
.$default(() => Date.now()),
},
(table) => [
index("session_inbox_session_delivery_seq_idx").on(table.session_id, table.delivery, table.enqueued_seq),
index("session_inbox_session_delivery_order_idx").on(
table.session_id,
table.delivery,
sql`coalesce(${table.order_seq}, ${table.enqueued_seq})`,
),
uniqueIndex("session_inbox_session_enqueued_seq_idx").on(table.session_id, table.enqueued_seq),
],
)
@@ -18,6 +18,7 @@ import workspaceMigration from "@opencode-ai/core/database/migration/20260808023
import executionClaimsMigration from "@opencode-ai/core/database/migration/20260811161259_execution_claim_attempts"
import sessionInboxMigration from "@opencode-ai/core/database/migration/20260812181746_session_inbox"
import sessionViewedStateMigration from "@opencode-ai/core/database/migration/20260819222447_session_viewed_state"
import sessionInboxOrderMigration from "@opencode-ai/core/database/migration/20260825004258_session_inbox_order"
import { Global } from "@opencode-ai/util/global"
const run = <A, E>(
@@ -240,8 +241,21 @@ describe("DatabaseMigration", () => {
executionClaimsMigration,
sessionInboxMigration,
worktreeMigration,
sessionInboxOrderMigration,
])
expect(
yield* db.all(sql`
EXPLAIN QUERY PLAN
SELECT id FROM session_inbox
WHERE session_id = 'session' AND delivery = 'queue'
ORDER BY coalesce(order_seq, enqueued_seq)
`),
).toEqual([
expect.objectContaining({
detail: expect.stringContaining("USING INDEX session_inbox_session_delivery_order_idx"),
}),
])
expect(yield* db.get(sql`SELECT id, resume_attempts FROM session_v2`)).toEqual({
id: "session",
resume_attempts: 0,
+216 -2
View File
@@ -24,6 +24,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionInboxTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Image } from "@opencode-ai/core/image"
@@ -76,8 +77,7 @@ const locations = Layer.effect(
Layer.mock(Snapshot.Service, {
capture: () =>
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () =>
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () => (ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready"))),
}),
Layer.succeed(
PluginSupervisor.Service,
@@ -1093,6 +1093,220 @@ describe("Session.inbox", () => {
}),
)
it.effect("rejects reordering an unknown session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
expect(
yield* session.reorderInbox({ sessionID: Session.ID.make("ses_missing"), inboxIDs: [] }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.NotFoundError" })
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxReordered.type, 1))).toBe(0)
}),
)
it.effect("does not publish reorder events for empty or unchanged queued users", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const type = Bus.versionedType(SessionEvent.InboxReordered.type, 1)
yield* session.reorderInbox({ sessionID, inboxIDs: [] })
yield* session.synthetic({ sessionID, text: "Stationary completion", delivery: "queue", resume: false })
yield* session.reorderInbox({ sessionID, inboxIDs: [] })
const first = yield* session.prompt({ sessionID, text: "First queued", delivery: "queue", resume: false })
const second = yield* session.prompt({ sessionID, text: "Second queued", delivery: "queue", resume: false })
yield* session.reorderInbox({ sessionID, inboxIDs: [first.id, second.id] })
expect(yield* eventCount(type)).toBe(0)
yield* session.reorderInbox({ sessionID, inboxIDs: [second.id, first.id] })
yield* session.reorderInbox({ sessionID, inboxIDs: [second.id, first.id] })
expect(yield* eventCount(type)).toBe(1)
}),
)
it.effect("reorders queued users without changing admission slots, identities, or execution", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const database = yield* Database.Service
const first = yield* session.prompt({ sessionID, text: "First queued", delivery: "queue", resume: false })
const steer = yield* session.prompt({ sessionID, text: "Keep this steer in place", resume: false })
const second = yield* session.prompt({ sessionID, text: "Second queued", delivery: "queue", resume: false })
const third = yield* session.prompt({ sessionID, text: "Third queued", delivery: "queue", resume: false })
const before = yield* database.db
.select()
.from(SessionInboxTable)
.where(eq(SessionInboxTable.session_id, sessionID))
.all()
.pipe(Effect.orDie)
wakeCalls.length = 0
yield* session.reorderInbox({ sessionID, inboxIDs: [third.id, first.id, second.id] })
expect(yield* session.inbox(sessionID)).toMatchObject([
{ id: third.id, payload: { text: "Third queued" } },
{ id: steer.id, payload: { text: "Keep this steer in place" } },
{ id: first.id, payload: { text: "First queued" } },
{ id: second.id, payload: { text: "Second queued" } },
])
const after = yield* database.db
.select()
.from(SessionInboxTable)
.where(eq(SessionInboxTable.session_id, sessionID))
.all()
.pipe(Effect.orDie)
expect(after.map((row) => [row.id, row.enqueued_seq]).toSorted()).toEqual(
before.map((row) => [row.id, row.enqueued_seq]).toSorted(),
)
expect(after.find((row) => row.id === third.id)?.order_seq).toBe(
before.find((row) => row.id === first.id)?.enqueued_seq,
)
expect(after.find((row) => row.id === steer.id)?.order_seq).toBeNull()
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxReordered.type, 1))).toBe(1)
}),
)
it.effect("keeps queued synthetic input in its original slot when users are reordered", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const { db } = yield* Database.Service
const first = yield* session.prompt({ sessionID, text: "First queued", delivery: "queue", resume: false })
const synthetic = yield* session.synthetic({
sessionID,
text: "Stationary completion",
delivery: "queue",
resume: false,
})
const second = yield* session.prompt({ sessionID, text: "Second queued", delivery: "queue", resume: false })
const third = yield* session.prompt({ sessionID, text: "Third queued", delivery: "queue", resume: false })
yield* session.reorderInbox({ sessionID, inboxIDs: [third.id, first.id, second.id] })
expect((yield* session.inbox(sessionID)).map((item) => item.id)).toEqual([
third.id,
synthetic.id,
first.id,
second.id,
])
expect(
yield* db
.select({ order: SessionInboxTable.order_seq })
.from(SessionInboxTable)
.where(eq(SessionInboxTable.id, synthetic.id))
.get()
.pipe(Effect.orDie),
).toEqual({ order: null })
expect(
yield* session
.reorderInbox({ sessionID, inboxIDs: [third.id, synthetic.id, first.id, second.id] })
.pipe(Effect.flip),
).toMatchObject({ _tag: "Session.InboxConflictError", sessionID, inboxID: synthetic.id })
}),
)
it.effect("promotes queued users in their reordered sequence with stable message IDs", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const bus = yield* Bus.Service
const database = yield* Database.Service
const first = yield* session.prompt({ sessionID, text: "First queued", delivery: "queue", resume: false })
const second = yield* session.prompt({ sessionID, text: "Second queued", delivery: "queue", resume: false })
const third = yield* session.prompt({ sessionID, text: "Third queued", delivery: "queue", resume: false })
yield* session.reorderInbox({ sessionID, inboxIDs: [third.id, first.id, second.id] })
expect(yield* SessionInbox.nextPromotable(database.db, sessionID, "input")).toMatchObject({ id: third.id })
yield* SessionInbox.promote(database.db, bus, sessionID, "input")
yield* SessionInbox.promote(database.db, bus, sessionID, "input")
yield* SessionInbox.promote(database.db, bus, sessionID, "input")
expect((yield* session.messages({ sessionID, order: "asc" })).map((message) => message.id)).toEqual([
third.id,
first.id,
second.id,
])
expect(yield* session.inbox(sessionID)).toEqual([])
}),
)
it.effect("promotes reordered users changed to steers in admission order before control barriers", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const first = yield* session.prompt({ sessionID, text: "Before compaction", delivery: "queue", resume: false })
const barrier = yield* session.compact({ sessionID })
const second = yield* session.prompt({ sessionID, text: "After compaction", delivery: "queue", resume: false })
yield* session.reorderInbox({ sessionID, inboxIDs: [second.id, first.id] })
yield* session.steerInbox({ sessionID, inboxID: second.id })
yield* session.steerInbox({ sessionID, inboxID: first.id })
expect((yield* session.inbox(sessionID)).map((item) => item.id)).toEqual([first.id, barrier.id, second.id])
expect(yield* SessionInbox.nextPromotable(db, sessionID, "steer")).toMatchObject({ id: first.id })
expect(yield* SessionInbox.promote(db, bus, sessionID, "steer")).toBe(1)
expect((yield* session.messages({ sessionID, order: "asc" })).map((message) => message.id)).toEqual([first.id])
expect(yield* SessionInbox.nextPromotable(db, sessionID, "steer")).toMatchObject({ id: barrier.id })
expect(yield* SessionInbox.promote(db, bus, sessionID, "steer")).toBe(0)
expect(yield* SessionInbox.find(db, second.id)).toMatchObject({ id: second.id, delivery: "steer" })
}),
)
it.effect("rejects invalid queued-user permutations and queued control barriers transactionally", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const first = yield* session.prompt({ sessionID, text: "First queued", delivery: "queue", resume: false })
const second = yield* session.prompt({ sessionID, text: "Second queued", delivery: "queue", resume: false })
const unknown = SessionMessage.ID.make("msg_unknown_queued")
expect(
yield* session.reorderInbox({ sessionID, inboxIDs: [first.id, first.id] }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.InboxConflictError", sessionID, inboxID: first.id })
expect(yield* session.reorderInbox({ sessionID, inboxIDs: [second.id] }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.InboxConflictError",
sessionID,
inboxID: first.id,
})
expect(yield* session.reorderInbox({ sessionID, inboxIDs: [first.id, unknown] }).pipe(Effect.flip)).toMatchObject(
{ _tag: "Session.InboxConflictError", sessionID, inboxID: unknown },
)
expect((yield* session.inbox(sessionID)).map((item) => item.id)).toEqual([first.id, second.id])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxReordered.type, 1))).toBe(0)
const compaction = yield* session.compact({ sessionID, delivery: "queue" })
expect(
yield* session.reorderInbox({ sessionID, inboxIDs: [first.id, second.id] }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.InboxConflictError", sessionID, inboxID: compaction.id })
expect((yield* session.inbox(sessionID)).map((item) => item.id)).toEqual([first.id, second.id, compaction.id])
yield* session.cancelInbox({ sessionID, inboxID: compaction.id })
const move = yield* SessionInbox.admit(db, bus, {
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
projectID: Project.ID.global,
},
delivery: "queue",
},
})
expect(
yield* session.reorderInbox({ sessionID, inboxIDs: [second.id, first.id] }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.InboxConflictError", sessionID, inboxID: move.id })
expect((yield* session.inbox(sessionID)).map((item) => item.id)).toEqual([first.id, second.id, move.id])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxReordered.type, 1))).toBe(0)
}),
)
it.effect("lists admitted work in admission order until promotion", () =>
Effect.gen(function* () {
yield* setup
+87 -1
View File
@@ -2982,10 +2982,96 @@
}
}
},
"description": "List durable enqueued session work not yet delivered, ordered by enqueue sequence. Includes user, synthetic, compaction, and move items.",
"description": "List durable enqueued session work not yet delivered, ordered by effective delivery order. Includes user, synthetic, compaction, and move items.",
"summary": "List session inbox"
}
},
"/api/session/{sessionID}/inbox/reorder": {
"post": {
"tags": ["session"],
"operationId": "v2.session.inbox.reorder",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConflictErrorEncoded"
}
}
}
}
},
"description": "Provide a complete permutation of pending queued user prompts. Synthetic items retain their position; compaction and move controls block reordering. Does not wake execution.",
"summary": "Reorder session inbox",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"inboxIDs": {
"type": "array",
"items": {
"type": "string",
"pattern": "^msg_"
}
}
},
"required": ["inboxIDs"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session/{sessionID}/inbox/{inboxID}": {
"delete": {
"tags": ["session"],
+16 -1
View File
@@ -531,7 +531,22 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
identifier: "v2.session.inbox.list",
summary: "List session inbox",
description:
"List durable enqueued session work not yet delivered, ordered by enqueue sequence. Includes user, synthetic, compaction, and move items.",
"List durable enqueued session work not yet delivered, ordered by effective delivery order. Includes user, synthetic, compaction, and move items.",
}),
),
)
.add(
HttpApiEndpoint.post("session.inbox.reorder", "/api/session/:sessionID/inbox/reorder", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ inboxIDs: Schema.Array(SessionMessage.ID) }),
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.inbox.reorder",
summary: "Reorder session inbox",
description:
"Provide a complete permutation of pending queued user prompts. Synthetic items retain their position; compaction and move controls block reordering. Does not wake execution.",
}),
),
)
+8
View File
@@ -201,6 +201,13 @@ export const InboxDeliveryChanged = Event.durable({
})
export type InboxDeliveryChanged = typeof InboxDeliveryChanged.Type
export const InboxReordered = Event.durable({
type: "session.inbox.reordered",
...options,
schema: { ...Base, inboxIDs: Schema.Array(SessionMessage.ID) },
})
export type InboxReordered = typeof InboxReordered.Type
export namespace Execution {
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
export type Started = typeof Started.Type
@@ -606,6 +613,7 @@ export const Definitions = Event.inventory(
InboxEnqueued,
InboxCancelled,
InboxDeliveryChanged,
InboxReordered,
Execution.Started,
Execution.Succeeded,
Execution.Failed,
@@ -90,6 +90,7 @@ describe("public event manifest", () => {
"session.inbox.enqueued.1",
"session.inbox.cancelled.1",
"session.inbox.delivery.changed.1",
"session.inbox.reordered.1",
"session.execution.started.1",
"session.execution.succeeded.1",
"session.execution.failed.1",
+9
View File
@@ -529,6 +529,15 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.inbox.reorder",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.reorderInbox({ sessionID: ctx.params.sessionID, inboxIDs: ctx.payload.inboxIDs }),
"Pending input can no longer be reordered",
)
}),
)
.handle(
"session.inbox.cancel",
Effect.fn(function* (ctx) {
@@ -988,6 +988,19 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
])
return
}
if (event.type === "session.inbox.reordered") {
const queued = [...state.pending].filter(
([id, item]) => item.delivery === "queue" && event.data.inboxIDs.includes(id),
)
queued.sort(([left], [right]) => event.data.inboxIDs.indexOf(left) - event.data.inboxIDs.indexOf(right))
state.pending = new Map(
[...state.pending].map(([id, item]) =>
item.delivery === "queue" && event.data.inboxIDs.includes(id) ? (queued.shift() ?? [id, item]) : [id, item],
),
)
syncPending()
return
}
if (event.type === "session.inbox.cancelled") {
state.admitted.delete(event.data.inboxID)
if (state.pending.delete(event.data.inboxID)) syncPending()
@@ -776,6 +776,105 @@ describe("V2 mini transport", () => {
await transport.close()
})
test("reorders queued prompts without moving steers or unmatched optimistic prompts", async () => {
const events = feed()
events.push(connected())
const client = sdk({
streams: [events],
pending: {
ses_1: [
{
id: "msg_first",
sessionID: "ses_1",
timeCreated: 1,
type: "user",
payload: { text: "First" },
delivery: "queue",
},
{
id: "msg_steer",
sessionID: "ses_1",
timeCreated: 2,
type: "user",
payload: { text: "Steer" },
delivery: "steer",
},
{
id: "msg_second",
sessionID: "ses_1",
timeCreated: 3,
type: "user",
payload: { text: "Second" },
delivery: "queue",
},
],
},
})
spyOn(client.session, "prompt").mockImplementation((request) => ok(promptAdmission(request)) as never)
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
footer: ui.api,
})
await transport.admitPromptTurn(
{
agent: undefined,
model: undefined,
variant: undefined,
prompt: { messageID: "msg_optimistic", text: "Optimistic", parts: [] },
files: [],
includeFiles: false,
},
"queue",
)
const calls = ui.calls.length
const pending = () =>
ui.events.findLast((event) => event.type === "queued.prompts")?.prompts.map((prompt) => prompt.messageID)
events.push({
id: "evt_reordered",
created: 4,
type: "session.inbox.reordered",
durable: durable("ses_1", 4),
data: { sessionID: "ses_1", inboxIDs: ["msg_second", "msg_first"] },
})
while (pending()?.[0] !== "msg_second") await Bun.sleep(0)
expect(pending()).toEqual(["msg_second", "msg_first", "msg_optimistic"])
expect(ui.calls.slice(calls)).toEqual([
{
type: "event",
value: {
type: "queued.prompts",
prompts: [
expect.objectContaining({ messageID: "msg_second" }),
expect.objectContaining({ messageID: "msg_first" }),
expect.objectContaining({ messageID: "msg_optimistic" }),
],
},
},
])
expect(ui.commits).toHaveLength(0)
events.push({
id: "evt_steer_queued",
created: 5,
type: "session.inbox.delivery.changed",
durable: durable("ses_1", 5),
data: { sessionID: "ses_1", inboxID: "msg_steer", delivery: "queue" },
})
while (pending()?.length !== 4) await Bun.sleep(0)
expect(pending()).toEqual(["msg_second", "msg_steer", "msg_first", "msg_optimistic"])
expect(ui.commits).toHaveLength(0)
expect(ui.calls.slice(calls).every((call) => call.type === "event" && call.value.type === "queued.prompts")).toBe(
true,
)
await transport.close()
})
test("reports an observed execution failure before prompt promotion", async () => {
const events = feed()
events.push(connected())
+87 -1
View File
@@ -2982,10 +2982,96 @@
}
}
},
"description": "List durable enqueued session work not yet delivered, ordered by enqueue sequence. Includes user, synthetic, compaction, and move items.",
"description": "List durable enqueued session work not yet delivered, ordered by effective delivery order. Includes user, synthetic, compaction, and move items.",
"summary": "List session inbox"
}
},
"/api/session/{sessionID}/inbox/reorder": {
"post": {
"tags": ["session"],
"operationId": "v2.session.inbox.reorder",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConflictErrorEncoded"
}
}
}
}
},
"description": "Provide a complete permutation of pending queued user prompts. Synthetic items retain their position; compaction and move controls block reordering. Does not wake execution.",
"summary": "Reorder session inbox",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"inboxIDs": {
"type": "array",
"items": {
"type": "string",
"pattern": "^msg_"
}
}
},
"required": ["inboxIDs"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session/{sessionID}/inbox/{inboxID}": {
"delete": {
"tags": ["session"],
+87 -1
View File
@@ -2982,10 +2982,96 @@
}
}
},
"description": "List durable enqueued session work not yet delivered, ordered by enqueue sequence. Includes user, synthetic, compaction, and move items.",
"description": "List durable enqueued session work not yet delivered, ordered by effective delivery order. Includes user, synthetic, compaction, and move items.",
"summary": "List session inbox"
}
},
"/api/session/{sessionID}/inbox/reorder": {
"post": {
"tags": ["session"],
"operationId": "v2.session.inbox.reorder",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConflictErrorEncoded"
}
}
}
}
},
"description": "Provide a complete permutation of pending queued user prompts. Synthetic items retain their position; compaction and move controls block reordering. Does not wake execution.",
"summary": "Reorder session inbox",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"inboxIDs": {
"type": "array",
"items": {
"type": "string",
"pattern": "^msg_"
}
}
},
"required": ["inboxIDs"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session/{sessionID}/inbox/{inboxID}": {
"delete": {
"tags": ["session"],