mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-21 20:50:09 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe5241fdb8 |
@@ -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"])
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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\`);`,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user