Compare commits

...

6 Commits

Author SHA1 Message Date
LukeParkerDev fe5241fdb8 fix(core): make inbox reordering durable and atomic 2026-08-25 13:31:06 +10:00
Luke Parker a02a2f5799 feat(app): queue and steer follow-up prompts (#44683) 2026-08-25 13:01:04 +10:00
Aiden Cline 63c23c98de feat(ai): parse partial tool input (#44830) 2026-08-24 21:56:53 -05:00
Dax Raad 9a90b94921 docs(sdk): align Effect and Cloudflare customization guides 2026-08-24 22:52:30 -04:00
Dax Raad f03418afde docs: improve build and SDK customization guides 2026-08-24 22:52:30 -04:00
opencode-agent[bot] 19d0009891 docs: clarify prompt data handling (#44854)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
Co-authored-by: Dax <mail@thdxr.com>
2026-08-24 22:29:36 -04:00
58 changed files with 2208 additions and 114 deletions
@@ -1,8 +1,10 @@
import { Effect } from "effect"
import { Effect, Option } from "effect"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema/index.js"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared.js"
import { parse } from "./partial-json.js"
type StreamKey = string | number
const parsePartialInput = Option.liftThrowable(parse)
/**
* One pending streamed tool call. Providers emit the tool identity and JSON
@@ -57,12 +59,15 @@ const inputStart = (tool: PendingTool) =>
providerMetadata: tool.providerMetadata,
})
const inputDelta = (tool: PendingTool, text: string) =>
LLMEvent.toolInputDelta({
const inputDelta = (tool: PendingTool, text: string) => {
const input = parsePartialInput(tool.input)
return LLMEvent.toolInputDelta({
id: tool.id,
name: tool.name,
text,
...(Option.isSome(input) ? { input: input.value } : {}),
})
}
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const raw = inputOverride ?? tool.input
+2
View File
@@ -152,6 +152,8 @@ export const ToolInputDelta = Schema.Struct({
id: ToolCallID,
name: Schema.String,
text: Schema.String,
/** Best-effort parse of all input fragments received through this delta. */
input: Schema.optional(Schema.Unknown),
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta>
@@ -585,12 +585,11 @@ describe("Anthropic Messages route", () => {
it.effect("infers empty-signature compatibility across Kimi providers", () =>
Effect.gen(function* () {
const coding = AnthropicMessages.route
.with({
provider: "kimi-for-coding",
endpoint: { baseURL: "https://compatible.test/v1/" },
auth: Auth.header("x-api-key", "test"),
})
const coding = AnthropicMessages.route.with({
provider: "kimi-for-coding",
endpoint: { baseURL: "https://compatible.test/v1/" },
auth: Auth.header("x-api-key", "test"),
})
const moonshot = AnthropicMessages.route
.with({
provider: "moonshotai",
@@ -1129,8 +1128,14 @@ describe("Anthropic Messages route", () => {
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
type: "tool-call",
@@ -475,8 +475,14 @@ describe("Bedrock Converse route", () => {
])
const events = response.events.filter((event) => event.type === "tool-input-delta")
expect(events).toEqual([
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"', input: {} },
{
type: "tool-input-delta",
id: "tool_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
])
expect(response.events.at(-1)).toMatchObject({
type: "finish",
+16 -4
View File
@@ -1164,8 +1164,14 @@ describe("OpenAI Chat route", () => {
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
type: "tool-call",
@@ -1265,8 +1271,14 @@ describe("OpenAI Chat route", () => {
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
type: "tool-call",
@@ -2806,12 +2806,14 @@ describe("OpenAI Responses route", () => {
id: "call_1",
name: "lookup",
text: '{"query"',
input: {},
},
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{
type: "tool-input-end",
+43 -2
View File
@@ -23,9 +23,11 @@ describe("ToolStream", () => {
expect(first.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
])
expect(second.events).toEqual([
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}', input: { query: "weather" } },
])
expect(second.events).toEqual([{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }])
expect(finished).toEqual({
tools: {},
events: [
@@ -36,6 +38,45 @@ describe("ToolStream", () => {
}),
)
it.effect("exposes cumulative partial string values", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: '{"query":"wea' },
"missing tool",
)
if (ToolStream.isError(result)) return yield* result
expect(result.events.at(-1)).toEqual({
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"wea',
input: { query: "wea" },
})
}),
)
it.effect("omits partial input when the accumulated value cannot be parsed", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: "x" },
"missing tool",
)
if (ToolStream.isError(result)) return yield* result
expect(result.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x" },
])
}),
)
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
Effect.gen(function* () {
const first = ToolStream.appendOrStart(
@@ -0,0 +1,249 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/SessionQueueRegression"
const projectID = "proj_session_queue_regression"
const sessionID = "ses_session_queue_regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
type InboxRow = {
id: string
sessionID: string
timeCreated: number
type: "user" | "synthetic"
payload: { text: string; metadata?: Record<string, unknown> }
delivery: "steer" | "queue"
}
function createQueueMock(seed: string[], synthetic?: number) {
const rows: InboxRow[] = seed.map((text, index) => ({
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"]) => {
sequence += 1
events.push({
id: `evt_queue_${sequence}`,
type,
created: Date.now(),
durable: { aggregateID: sessionID, seq: sequence, version: 1 },
data,
} as OpenCodeEvent)
}
return {
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 : `msg_mock_${sequence}`,
sessionID: input.sessionID,
timeCreated: Date.now(),
type: "user",
payload: {
text: typeof input.body.text === "string" ? input.body.text : "",
...(input.body.metadata === undefined ? {} : { metadata: input.body.metadata as Record<string, unknown> }),
},
delivery: input.body.delivery === "queue" ? "queue" : "steer",
}
rows.push(row)
emit("session.inbox.enqueued", {
sessionID: input.sessionID,
inboxID: row.id,
item: { type: "user", payload: row.payload, delivery: row.delivery },
})
},
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => {
changes.push({ inboxID: input.inboxID, action: input.action })
log.push(`${input.action}:${input.inboxID}`)
const index = rows.findIndex((row) => row.id === input.inboxID)
const row = rows[index]
if (!row) return
if (input.action === "cancel") {
rows.splice(index, 1)
emit("session.inbox.cancelled", { sessionID: input.sessionID, inboxID: input.inboxID })
return
}
row.delivery = "steer"
emit("session.inbox.delivery.changed", {
sessionID: input.sessionID,
inboxID: input.inboxID,
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] })
},
}
}
async function openSession(page: Page, mock: ReturnType<typeof createQueueMock>, followUpBehavior?: "queue" | "steer") {
if (followUpBehavior) {
await page.addInitScript(
(behavior) => localStorage.setItem("settings.v3", JSON.stringify({ general: { followUpBehavior: behavior } })),
followUpBehavior,
)
}
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "session-queue-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "queue-model": { id: "queue-model", name: "Queue Model", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "queue-model" },
},
sessions: [
{
id: sessionID,
slug: "session-queue-regression",
projectID,
directory,
title: "Session queue regression",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
sessionStatus: () => ({ [sessionID]: { type: "running" } }),
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}`)
const composer = page.locator('[data-component="composer"]')
await expectAppVisible(composer)
return {
composer,
input: composer.locator('[data-component="composer-editor"]'),
rows: page.locator('[data-component="session-queue-row"]'),
}
}
test("follow-up preference controls Enter while Mod+Enter uses the alternate delivery", async ({ page }) => {
const mock = createQueueMock([])
const view = await openSession(page, mock, "queue")
await view.input.fill("queue this follow-up")
await expect(view.composer.locator('[data-action="composer-alternate-delivery"]')).toContainText("Steer")
await view.input.press("Enter")
await expect(view.rows.getByText("queue this follow-up", { exact: true })).toBeVisible()
await view.input.fill("steer this correction")
await view.input.press("ControlOrMeta+Enter")
await expect.poll(() => mock.prompts.map((prompt) => prompt.delivery)).toEqual(["queue", "steer"])
await expect(view.input).toHaveText("")
})
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)
const first = view.rows.filter({ hasText: "first queued prompt" })
const third = view.rows.filter({ hasText: "third queued prompt" })
await first.getByRole("button", { name: "Reorder queued prompt" }).hover()
await page.mouse.down()
const target = await third.boundingBox()
if (!target) throw new Error("The target queue row is not visible")
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 })
await page.mouse.up()
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
"second queued prompt",
"third queued prompt",
"first queued prompt",
])
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"], 2)
const view = await openSession(page, mock)
const original = view.rows.getByText("tighten the error copy", { exact: true })
await expect(original).toBeVisible()
await view.input.fill("my in-progress draft")
await original.click()
await expect(view.input).toHaveText("tighten the error copy")
await view.input.press("Escape")
await expect(view.input).toHaveText("my in-progress draft")
await original.click()
await expect(view.input).toHaveText("tighten the error copy")
await view.input.fill("tighten the error copy and add a retry hint")
await view.input.press("Enter")
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
"first queued prompt",
"tighten the error copy and add a retry hint",
"third queued prompt",
])
await expect(view.input).toHaveText("my in-progress draft")
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.log).toEqual(["prompt:queue", "reorder", "cancel:msg_seed_2"])
})
+41
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"
@@ -174,6 +175,46 @@ const Group = HttpApiGroup.make("mock")
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionPrompt", "/api/session/:sessionID/prompt", {
params: SessionParams,
payload: JsonPayload,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionSwitchAgent", "/api/session/:sessionID/agent", {
params: SessionParams,
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionSwitchModel", "/api/session/:sessionID/model", {
params: SessionParams,
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.delete("sessionInboxCancel", "/api/session/:sessionID/inbox/:inboxID", {
params: { ...SessionParams, inboxID: SessionMessage.ID },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionInboxSteer", "/api/session/:sessionID/inbox/:inboxID/steer", {
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,
}),
)
.add(
HttpApiEndpoint.get("sessionPermission", "/api/session/:sessionID/permission", {
params: SessionParams,
+41 -1
View File
@@ -35,6 +35,10 @@ export interface MockServerConfig {
fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
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 & {
@@ -397,7 +401,43 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
sessionFormReply: () => noContent,
sessionFormCancel: () => noContent,
sessionBackground: () => noContent,
sessionInbox: () => Effect.succeed({ data: [] }),
sessionInbox: () =>
Effect.sync(() => ({ data: typeof config.inbox === "function" ? config.inbox() : (config.inbox ?? []) })),
sessionPrompt: (ctx) =>
Effect.sync(() => {
const body = record(ctx.payload) ? ctx.payload : {}
config.onPrompt?.({ sessionID: ctx.params.sessionID, body })
return {
data: {
id: typeof body.id === "string" ? body.id : `msg_mock_${Date.now()}`,
sessionID: ctx.params.sessionID,
timeCreated: Date.now(),
type: "user",
payload: {
text: typeof body.text === "string" ? body.text : "",
...(body.files === undefined ? {} : { files: body.files }),
...(body.agents === undefined ? {} : { agents: body.agents }),
...(body.skills === undefined ? {} : { skills: body.skills }),
...(body.metadata === undefined ? {} : { metadata: body.metadata }),
},
delivery: body.delivery === "queue" ? "queue" : "steer",
},
}
}),
sessionInboxCancel: (ctx) =>
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }),
).pipe(Effect.andThen(noContent)),
sessionInboxSteer: (ctx) =>
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) => {
const permissions =
typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
+20
View File
@@ -39,6 +39,26 @@ export type ComposerSelection = {
variant?: string
}
export type ComposerDelivery = "steer" | "queue"
// Contract between the composer and the session prompt queue. The session
// owns the queue (pending inbox items); the composer only asks which delivery
// a submit should use and delegates edit confirmation while a queued prompt
// is loaded in the editor.
export type ComposerQueue = {
count: Accessor<number>
// Delivery a plain submit uses right now.
delivery: Accessor<ComposerDelivery>
// Delivery offered on Mod+Enter and the toolbar hint button; undefined hides the hint.
alternate: Accessor<ComposerDelivery | undefined>
// Inbox ID of the queued prompt currently loaded in the composer for editing.
editing: Accessor<string | undefined>
confirmEdit: (delivery: ComposerDelivery) => void
cancelEdit: () => void
// Loads the first queued prompt into the composer. Returns false when the queue is empty.
editFirst: () => boolean
}
export type ComposerSession = {
id: string
directory: string
+2 -1
View File
@@ -8,7 +8,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ComposerEditor } from "./editor/editor"
import { ModelSelectorPopover } from "@/providers/models/select-dialog"
import { DialogSelectModelUnpaid } from "@/providers/models/unpaid"
import { useCommand } from "@/shell/commands/command"
import { formatKeybind, useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import type { ComposerModel } from "./model"
@@ -32,6 +32,7 @@ export function Composer(props: {
modelControlsVisible={!props.model.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
alternateKeybind={[formatKeybind("mod", language.t), formatKeybind("enter", language.t)]}
modelControl={
<ComposerModelControl
loading={props.model.model.loading}
+50 -2
View File
@@ -45,6 +45,7 @@ export type ComposerEditorProps = {
modelControlsVisible?: boolean
attachKeybind?: string[]
attachShortcut?: string
alternateKeybind?: string[]
}
export function ComposerEditor(props: ComposerEditorProps) {
@@ -177,10 +178,15 @@ export function ComposerEditor(props: ComposerEditorProps) {
}}
onKeyDown={(event) => {
if (props.controller.onKeyDown(event)) return
const mod = event.metaKey || event.ctrlKey
if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) {
if (view.submit.queue?.editFirst()) event.preventDefault()
return
}
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault()
if (event.repeat) return
props.controller.submit()
props.controller.submit(mod ? { alternate: true } : undefined)
}
}}
onKeyUp={updateCursor}
@@ -248,6 +254,12 @@ export function ComposerEditor(props: ComposerEditorProps) {
</Show>
</Show>
</div>
<Show when={state.mode === "normal"}>
<ComposerEditorAlternateDelivery
controller={props.controller}
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
/>
</Show>
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
@@ -255,7 +267,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
accent={props.accentSubmit}
sendLabel={i18n.t("ui.promptInput.send")}
stopLabel={i18n.t("ui.promptInput.stop")}
onSubmit={props.controller.submit}
onSubmit={() => props.controller.submit()}
onStop={props.controller.stop}
/>
</div>
@@ -691,6 +703,42 @@ export function ComposerEditorPopover(props: {
)
}
// "Steer ⌘⏎" / "Queue ⌘⏎" hint next to the submit button: submits with the
// delivery opposite to what plain Enter does. Visible only while the queue
// exposes an alternate (turn running and composer holding a value), so it
// disappears on its own when the current turn ends.
function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorModel; keybind: string[] }) {
const i18n = useI18n()
const view = props.controller.view
const action = createMemo(() => {
const queue = view.submit.queue
if (!queue || !props.controller.canSubmit()) return undefined
if (queue.editing()) return "steer" as const
return queue.alternate()
})
return (
<Show when={action()} keyed>
{(delivery) => (
<Tooltip placement="top" inactive={delivery !== "steer"} value={i18n.t("ui.promptInput.steerHint")}>
<Button
data-action="composer-alternate-delivery"
type="button"
variant="ghost-muted"
size="small"
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530]"
onClick={() => props.controller.submit({ alternate: true })}
>
{delivery === "steer" ? i18n.t("ui.promptInput.steer") : i18n.t("ui.promptInput.queue")}
<span class="hidden sm:block">
<Keybind keys={props.keybind} variant="neutral" />
</span>
</Button>
</Tooltip>
)}
</Show>
)
}
export function ComposerEditorSubmitButton(props: {
mode: ComposerMode
stopping: boolean
@@ -19,6 +19,7 @@ import {
type ComposerInteractionEvent,
} from "../suggestions/machine"
import { clonePrompt, promptLength } from "../prompt-parts"
import type { ComposerQueue } from "../adapter"
export type ComposerSelectControl = {
options: Accessor<ComposerOption[]>
@@ -37,7 +38,8 @@ export type ComposerEditorView = {
submit: {
stopping: Accessor<boolean>
working?: Accessor<boolean>
onSubmit: () => void
queue?: ComposerQueue
onSubmit: (options?: { alternate?: boolean }) => void
onStop: () => void
}
shell?: {
@@ -212,6 +214,11 @@ export function createComposerEditor(input: {
)
}
if (handled) return true
if (event.key === "Escape" && input.view.submit.queue?.editing()) {
event.preventDefault()
input.view.submit.queue.cancelEdit()
return true
}
const stop =
input.view.submit.working?.() &&
((event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "g") ||
@@ -354,8 +361,8 @@ export function createComposerEditor(input: {
openShell() {
dispatch({ type: "mode.shell" })
},
submit() {
input.view.submit.onSubmit()
submit(options?: { alternate?: boolean }) {
input.view.submit.onSubmit(options)
dispatch({ type: "popover.close" })
},
stop() {
+24 -4
View File
@@ -16,7 +16,7 @@ import { createSessionTabs } from "@/session/helpers"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "@/runtime/server/errors"
import { Skill } from "@opencode-ai/schema/skill"
import type { ComposerAdapter, ComposerControls } from "./adapter"
import type { ComposerAdapter, ComposerControls, ComposerQueue } from "./adapter"
import type { ImageAttachmentPart } from "./state"
import type { PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
@@ -27,7 +27,7 @@ export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
}
export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
export function createComposerModel(adapter: ComposerAdapter, options?: { queue?: ComposerQueue }): ComposerModel {
const sdk = useWorkspaceLocation()
const data = useData()
const files = useFile()
@@ -80,7 +80,11 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
})
const stopping = createMemo(() => adapter.working() && blank())
const placeholder = () =>
composerPlaceholder(mode(), (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never))
composerPlaceholder(
mode(),
(key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
adapter.working() || (options?.queue?.count() ?? 0) > 0,
)
const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
@@ -253,6 +257,11 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
resetHistory: () => controller.resetHistory(),
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
closePopover: () => controller.dispatch({ type: "popover.close" }),
delivery: (alternate) => {
const queue = options?.queue
if (!queue) return "steer"
return (alternate ? queue.alternate() : queue.delivery()) ?? "steer"
},
notify: {
missingSelection: () =>
showToast({
@@ -360,7 +369,18 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
submit: {
stopping,
working: adapter.working,
onSubmit: () => void submission.submit(new Event("submit")),
queue: options?.queue,
onSubmit: (submitOptions) => {
const queue = options?.queue
// Confirming an edit re-admits the queued prompt instead of sending
// the composer value as a new prompt. Enter keeps it queued in
// place; the alternate action sends it as a steer.
if (queue?.editing()) {
queue.confirmEdit(submitOptions?.alternate ? "steer" : "queue")
return
}
void submission.submit(new Event("submit"), submitOptions)
},
onStop: () => void submission.stop(),
},
},
@@ -12,4 +12,12 @@ describe("Composer placeholder", () => {
test("uses the command and context hint in normal mode", () => {
expect(composerPlaceholder("normal", t)).toBe("ui.promptInput.placeholder.normal/@")
})
test("uses the follow-up copy while a turn runs or prompts are queued", () => {
expect(composerPlaceholder("normal", t, true)).toBe("ui.promptInput.placeholder.followUp/@")
})
test("keeps the shell placeholder while a turn runs", () => {
expect(composerPlaceholder("shell", t, true)).toBe("prompt.placeholder.shell:git status")
})
})
+2
View File
@@ -1,7 +1,9 @@
export function composerPlaceholder(
mode: "normal" | "shell",
t: (key: string, params?: Record<string, string>) => string,
followUp?: boolean,
) {
if (mode === "shell") return t("prompt.placeholder.shell", { example: "git status" })
if (followUp) return t("ui.promptInput.placeholder.followUp", { slash: "/", at: "@" })
return t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
}
+35 -23
View File
@@ -5,7 +5,7 @@ import type { Accessor } from "solid-js"
import type { PromptHistoryComment } from "./history/entry"
import type { ImageAttachmentPart, Prompt } from "./state"
import { clonePrompt, promptLength } from "./prompt-parts"
import type { ComposerAdapter, ComposerSelection, ComposerSession } from "./adapter"
import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSession } from "./adapter"
import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
@@ -21,7 +21,7 @@ type ComposerSubmission = {
text: string
images: ImageAttachmentPart[]
selection: ComposerSelection
delivery: "steer"
delivery: ComposerDelivery
}
type ComposerSubmitInput = {
@@ -33,6 +33,7 @@ type ComposerSubmitInput = {
resetHistory: () => void
setMode: (mode: "normal" | "shell") => void
closePopover: () => void
delivery?: (alternate: boolean) => ComposerDelivery
notify: {
missingSelection: () => void
failed: (kind: "shell" | "command" | "prompt", error: unknown) => void
@@ -45,7 +46,7 @@ type ComposerSubmitInput = {
}
export function createComposerSubmit(input: ComposerSubmitInput) {
const submit = async (event: globalThis.Event) => {
const submit = async (event: globalThis.Event, options?: { alternate?: boolean }) => {
event.preventDefault()
const submission = createComposerSubmission({
@@ -56,7 +57,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
selection: item.selection ? { ...item.selection } : undefined,
})),
})
const value = readSubmission(input, submission.prompt, submission.context)
const value = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
if (!value) {
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
return
@@ -113,7 +114,10 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
void sendCommand(session, value, command).catch((error) =>
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(session, { ...value, delivery: "steer" }, command).catch((error) =>
failSubmission(input, session, "command", error, restore, value.id),
)
return
@@ -157,6 +161,7 @@ function readSubmission(
input: ComposerSubmitInput,
prompt: Prompt,
context: ComposerSubmission["context"],
alternate: boolean,
): ComposerSubmission | undefined {
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
const mode = input.mode()
@@ -195,7 +200,7 @@ function readSubmission(
model: { modelID: model.id, providerID: model.provider.id },
variant,
},
delivery: "steer",
delivery: input.delivery?.(alternate) ?? "steer",
}
}
@@ -285,23 +290,30 @@ async function sendCommand(
async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
const request = await buildSubmissionRequest(session, value)
const current = session.current()
if (current?.agent !== value.selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
}
if (
current?.model?.providerID !== value.selection.model.providerID ||
current.model.id !== value.selection.model.modelID ||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
) {
await session.api.switchModel({
sessionID: session.id,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
})
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
// selection applies now; a queued follow-up must not reconfigure the turn it
// waits behind, so it runs with the session selection at delivery time (the
// intended selection stays recorded in its metadata).
if (value.delivery === "steer") {
const current = session.current()
if (current?.agent !== value.selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
}
if (
current?.model?.providerID !== value.selection.model.providerID ||
current.model.id !== value.selection.model.modelID ||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
) {
await session.api.switchModel({
sessionID: session.id,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
})
}
}
const admission = {
+13
View File
@@ -677,6 +677,14 @@ export const dict = {
"session.background.subagent.one": "{{count}} subagent",
"session.background.subagent.other": "{{count}} subagents",
"command.session.background": "Move to background",
"session.queue.count.one": "{{count}} queued",
"session.queue.count.other": "{{count}} queued",
"session.queue.steer": "Steer",
"session.queue.send": "Send",
"session.queue.steerTooltip": "Send without interrupting",
"session.queue.remove": "Remove",
"session.queue.reorder": "Reorder queued prompt",
"session.queue.attachments": "+ attachments",
"session.timeline.notice.finished": "{{actor}} finished",
"session.timeline.notice.failed": "{{actor}} failed",
"session.timeline.notice.cancelled": "{{actor}} cancelled",
@@ -961,6 +969,11 @@ export const dict = {
"settings.general.row.showCustomAgents.title": "Show agent",
"settings.general.row.showCustomAgents.description":
"Switch between agents in the composer. When hidden, defaults to Build agent.",
"settings.general.row.followUpBehavior.title": "Follow-up behavior",
"settings.general.row.followUpBehavior.description":
"Choose whether to queue follow-ups or steer the current turn. Use {{keybind}} to switch.",
"settings.general.row.followUpBehavior.queue": "Queue",
"settings.general.row.followUpBehavior.steer": "Steer",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
@@ -0,0 +1,186 @@
import { createMemo, For, Show } from "solid-js"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { arrayMove } from "@dnd-kit/helpers"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import type { SessionQueueView } from "./queue"
// Pullout above the composer listing the prompts queued behind the current
// turn. The panel slides under the composer card (negative margin, opaque
// composer background) so the two read as one attached surface.
export function SessionQueuePanel(props: { queue: SessionQueueView }) {
const language = useLanguage()
const count = () => props.queue.rows().length
let listRef!: HTMLDivElement
return (
<Show when={count() > 0}>
<div
data-component="session-queue-panel"
class="relative z-0 -mb-3 rounded-xl bg-v2-background-bg-base px-1.5 pt-1.5 pb-[18px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)]"
>
<Show when={count() > 3}>
<div class="px-1.5 pb-px text-[11px] font-[530] uppercase leading-[var(--line-height-tight)] tracking-[0.05px] text-v2-text-text-muted [font-variant-numeric:tabular-nums]">
{language.plural("session.queue.count", count())}
</div>
</Show>
<DragDropProvider
sensors={(defaults) => [
...defaults.filter((sensor) => sensor !== PointerSensor),
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
}),
]}
modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragEnd={(event) => {
const source = event.operation.source
if (event.canceled || !isSortable(source)) return
if (source.initialIndex === source.index) return
void props.queue.reorder(
arrayMove(
props.queue.rows().map((row) => row.id),
source.initialIndex,
source.index,
),
)
}}
>
{/* Keyed on row IDs so store updates move row elements instead of
remounting them, which would kill an in-flight drag. */}
<div
ref={listRef}
class="flex flex-col gap-px"
classList={{ "max-h-[131px] overflow-y-auto": count() > 3 }}
>
<For each={props.queue.rows().map((row) => row.id)}>
{(id, index) => <SessionQueueRow queue={props.queue} id={id} index={index()} />}
</For>
</div>
</DragDropProvider>
</div>
</Show>
)
}
function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: number }) {
const language = useLanguage()
const row = createMemo(() => props.queue.rows().find((entry) => entry.id === props.id))
const editing = () => props.queue.editing() === props.id
// While the turn is stopped the queue stays parked, so the first prompt
// shows its actions without hover and its label reads Send: that is how a
// parked queue resumes.
const active = () => !props.queue.working() && props.index === 0
const sortable = useSortable({
get id() {
return props.id
},
get index() {
return props.index
},
get disabled() {
return props.queue.busy()
},
})
return (
<Show when={row()} keyed>
{(entry) => (
<div
ref={sortable.ref}
data-component="session-queue-row"
class="group/queue-row flex items-center justify-between gap-2 rounded-md py-1 ps-1 pe-2"
classList={{
"bg-v2-overlay-simple-overlay-hover": editing(),
"opacity-60": sortable.isDragSource(),
}}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<button
ref={sortable.handleRef}
type="button"
class="grid shrink-0 cursor-grab touch-none grid-cols-2 gap-x-[2px] gap-y-[2.25px] p-1"
aria-label={language.t("session.queue.reorder")}
>
<For each={Array.from({ length: 6 })}>
{() => <span class="size-[2px] bg-v2-background-bg-layer-04" />}
</For>
</button>
<div class="flex min-w-0 flex-col">
<button
type="button"
data-action="session-queue-edit"
dir="auto"
disabled={props.queue.busy()}
class="max-w-full min-w-0 self-start truncate rounded-sm text-start text-[13px] font-[440] leading-[var(--line-height-compact)]"
classList={{
"text-v2-text-text-faint": editing(),
"cursor-text text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover": !editing(),
}}
onClick={() => props.queue.edit(props.id)}
>
{entry.text || (entry.attachments ? language.t("session.queue.attachments") : "")}
</button>
<Show when={entry.attachments && entry.text}>
<span class="text-[13px] font-[440] leading-[var(--line-height-compact)] text-v2-text-text-muted">
{language.t("session.queue.attachments")}
</span>
</Show>
</div>
</div>
<div
data-slot="session-queue-actions"
class="flex shrink-0 items-center gap-1.5"
classList={{
"opacity-0 focus-within:opacity-100 group-hover/queue-row:opacity-100 [@media(hover:none)]:opacity-100":
!active() && !editing(),
"pointer-events-none": props.queue.busy(),
}}
>
<Show when={!editing()}>
<Tooltip
placement="top"
inactive={!props.queue.working()}
value={language.t("session.queue.steerTooltip")}
>
<Button
data-action="session-queue-steer"
type="button"
size="small"
variant="ghost-muted"
icon="arrow-up"
disabled={props.queue.busy()}
class="text-v2-text-text-muted ![font-weight:530]"
onClick={() => void props.queue.steer(props.id)}
>
{props.queue.working() ? language.t("session.queue.steer") : language.t("session.queue.send")}
</Button>
</Tooltip>
</Show>
<Tooltip placement="top" value={language.t("session.queue.remove")}>
<IconButton
data-action="session-queue-remove"
type="button"
size="small"
variant="ghost-muted"
icon={<Icon name="outline-xmark" />}
disabled={props.queue.busy()}
aria-label={language.t("session.queue.remove")}
onClick={() => void props.queue.remove(props.id)}
/>
</Tooltip>
</div>
</div>
)}
</Show>
)
}
+255
View File
@@ -0,0 +1,255 @@
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionInboxInfo } from "@opencode-ai/client/promise"
import type { ComposerDelivery } from "@/composer/adapter"
import type { ComposerModel } from "@/composer/model"
import type { ComposerStateTarget } from "@/composer/submission-state"
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
import { clonePrompt, promptLength } from "@/composer/prompt-parts"
import { buildPromptRequest } from "@/composer/request"
import { blobDataUrl } from "@/runtime/persistence/drafts"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
export type QueuedPrompt = Extract<SessionInboxInfo, { type: "user" }>
type EditStash = {
prompt: Prompt
cursor: number
mode: "normal" | "shell"
retry: ReturnType<ComposerStateTarget["retry"]["current"]>
}
export function createSessionQueue(input: {
sessionID: string
draft: ComposerStateTarget
working: Accessor<boolean>
behavior: Accessor<ComposerDelivery>
composer: Accessor<ComposerModel | undefined>
}) {
const data = useData()
const server = useServerSDK()
const location = useWorkspaceLocation()
const language = useLanguage()
const [state, setState] = createStore<{ editing?: { id: string; stash: EditStash }; busy: boolean }>({ busy: false })
const queued = createMemo(() =>
data.session.pending
.list(input.sessionID)
.filter((item): item is QueuedPrompt => item.type === "user" && item.delivery === "queue"),
)
const rows = createMemo(() =>
queued().map((item) => ({
id: item.id,
text: queuedPromptText(item),
attachments: (item.payload.files?.length ?? 0) > 0,
})),
)
createEffect(() => {
const editing = state.editing
if (!editing || state.busy || queued().some((item) => item.id === editing.id)) return
setState("editing", undefined)
})
onCleanup(() => cancelEdit())
const notify = () => showToast({ title: language.t("common.requestFailed") })
const run = (work: () => Promise<unknown>) => {
setState("busy", true)
return work()
.catch(() => notify())
.finally(async () => {
await data.session.pending.sync(input.sessionID).catch(() => undefined)
setState("busy", false)
})
}
const steer = (id: string) => {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox.steer({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
}
const remove = (id: string) => {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
}
const reorder = (inboxIDs: string[]) => {
if (state.busy) return Promise.resolve()
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) => {
if (state.busy) return false
if (state.editing?.id === id) return true
const item = queued().find((entry) => entry.id === id)
if (!item) return false
if (state.editing) cancelEdit()
const draft = input.draft.current()
setState("editing", {
id,
stash: {
prompt: clonePrompt(draft),
cursor: input.draft.cursor() ?? promptLength(draft),
mode: input.draft.mode.current(),
retry: input.draft.retry.current(),
},
})
const text = queuedPromptText(item)
input.composer()?.dispatch({ type: "mode.normal" })
input.draft.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
input.composer()?.restoreFocus(text.length)
return true
}
const cancelEdit = () => {
const editing = state.editing
if (!editing) return
setState("editing", undefined)
// Mode first, then prompt, then retry: mode and prompt writes both clear
// the retry marker.
input.composer()?.dispatch({ type: editing.stash.mode === "shell" ? "mode.shell" : "mode.normal" })
input.draft.set(editing.stash.prompt, editing.stash.cursor)
if (editing.stash.retry) input.draft.retry.set(editing.stash.retry)
input.composer()?.restoreFocus(editing.stash.cursor)
}
const confirmEdit = (delivery: ComposerDelivery) => {
const editing = state.editing
if (!editing || state.busy) return
const prompt = clonePrompt(input.draft.current())
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
if (!text.trim() && !prompt.some((part) => part.type === "image")) return cancelEdit()
const item = queued().find((entry) => entry.id === editing.id)
const pristine = item && text.trim() === queuedPromptText(item) && !prompt.some((part) => part.type === "image")
if (pristine && delivery === "queue") return cancelEdit()
const inboxIDs = queued().map((entry) => entry.id)
void run(async () => {
const replacement = await editedPromptInput(input.sessionID, location().directory, item, prompt, text)
// 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()
})
}
const editFirst = () => {
const first = queued()[0]
if (!first) return false
return edit(first.id)
}
return {
count: () => queued().length,
delivery: () => (input.working() ? input.behavior() : "steer"),
alternate: () => {
if (state.editing) return "steer"
if (!input.working()) return undefined
return input.behavior() === "queue" ? "steer" : "queue"
},
editing: () => state.editing?.id,
confirmEdit,
cancelEdit,
editFirst,
rows,
busy: () => state.busy,
working: input.working,
steer,
remove,
edit,
reorder,
}
}
export type SessionQueue = ReturnType<typeof createSessionQueue>
// The slice of the queue the panel renders and drives.
export type SessionQueueView = Pick<
SessionQueue,
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "edit" | "reorder"
>
export function queuedPromptText(item: QueuedPrompt) {
const display = item.payload.metadata?.["displayText"]
return typeof display === "string" && display.length > 0 ? display : item.payload.text
}
// Confirming an edit submits the current composer content as the replacement:
// mentions and images added during the edit are parsed like a normal
// submission, the original's stored attachments are preserved, and the
// review-comment notes appended to the original's model-visible text survive.
// Ambient composer context (open review comments) stays out: it belongs to
// the next fresh prompt, not to a queued edit.
async function editedPromptInput(
sessionID: string,
directory: string,
item: QueuedPrompt | undefined,
prompt: Prompt,
text: string,
) {
const images = await Promise.all(
prompt
.filter((part): part is ImageAttachmentPart => part.type === "image")
.map(async (part) => ({ ...part, dataUrl: await blobDataUrl(part.blob, part.mime) })),
)
const request = buildPromptRequest({ prompt, context: [], images, text, sessionDirectory: directory })
const payload = item?.payload
const display = item ? queuedPromptText(item) : ""
const notes = payload && display && payload.text.startsWith(display) ? payload.text.slice(display.length) : ""
const mention = (value: { start: number; end: number; text: string } | undefined) => {
if (!value) return undefined
const start = text.indexOf(value.text)
if (start < 0) return undefined
return { text: value.text, start, end: start + value.text.length }
}
// Structured mentions degrade to plain text in the editor, so an original
// agent or skill reference survives the edit as long as its mention text
// still appears; newly typed structured mentions come from the request.
const agents = [
...(payload?.agents?.filter(
(agent) =>
agent.mention &&
text.includes(agent.mention.text) &&
!request.agents.some((entry) => entry.name === agent.name),
) ?? []),
...request.agents,
]
const skills = [
...(payload?.skills?.filter(
(skill) =>
skill.mention && text.includes(skill.mention.text) && !request.skills.some((entry) => entry.id === skill.id),
) ?? []),
...request.skills,
]
return {
sessionID,
text: request.text + notes,
files: [
...(payload?.files?.map((file) => ({
uri: `data:${file.mime};base64,${file.data}`,
name: file.name,
description: file.description,
mention: mention(file.mention),
})) ?? []),
...request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
],
agents: agents.map((agent) => ({ name: agent.name, mention: mention(agent.mention) })),
skills: skills.map((skill) => ({ id: skill.id, mention: mention(skill.mention) })),
metadata: { ...payload?.metadata, displayText: request.displayText },
}
}
+28 -3
View File
@@ -6,7 +6,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate } from "@solidjs/router"
import { createEffect, on, onMount } from "solid-js"
import { Composer } from "@/composer/composer"
import { createComposerModel } from "@/composer/model"
import { createComposerModel, type ComposerModel } from "@/composer/model"
import { useComposerState } from "@/composer/persistence"
import { createComposerControls } from "@/composer/selection"
import { setCursorPosition } from "@/composer/editor/dom"
@@ -27,8 +27,11 @@ import { createSessionRevert } from "../revert"
import { SessionComposerRegion } from "./session-composer-region"
import { createSessionComposerRegionController } from "./session-composer-region-controller"
import { createActiveComposerAdapter } from "./adapter"
import { createSessionQueue } from "./queue"
import { SessionQueuePanel } from "./queue-panel"
import { resolveSessionComposerSelection } from "./selection"
import { createSessionRequestModel } from "../requests/model"
import { useSettings } from "@/settings/model"
export function createActiveSessionRegion(input: {
session: SessionModel
@@ -216,6 +219,7 @@ export function ActiveSessionComposerRegion(props: {
accentSubmit: boolean
onResponseSubmit: () => void
}) {
const settings = useSettings()
const region = createSessionComposerRegionController({
state: props.model.region.state,
parentID: props.session.data.parentID,
@@ -231,11 +235,32 @@ export function ActiveSessionComposerRegion(props: {
submitted: props.model.submitted,
setEditor: props.model.input.setPromptRef,
})
const composer = createComposerModel(adapter)
let composer: ComposerModel | undefined
const queue = createSessionQueue({
sessionID: requireSessionID(props.session),
draft: adapter.state,
working: adapter.working,
behavior: settings.general.followUpBehavior,
composer: () => composer,
})
composer = createComposerModel(adapter, { queue })
return (
<SessionComposerRegion
controller={region}
composer={<Composer model={composer} borderUnderlay accentSubmit={props.accentSubmit} />}
composer={
<div class="relative">
<SessionQueuePanel queue={queue} />
<div class="relative z-10">
<Composer model={composer} borderUnderlay accentSubmit={props.accentSubmit} />
</div>
</div>
}
/>
)
}
function requireSessionID(session: SessionModel) {
const id = session.identity.params.id
if (!id) throw new Error("Active Composer requires a Session ID")
return id
}
+22
View File
@@ -55,6 +55,28 @@ export function createSessionRevert(input: {
await server.api.session.interrupt({ sessionID }).catch(() => undefined)
}
if (!(await request(() => server.api.session.revert.stage({ sessionID, messageID: message.id })))) return
// Reverting to a previous prompt discards the pending queue (and pending
// steers): they were written against the history being rewound. Cancel
// the authoritative inbox merged with the local snapshot, fire-and-forget
// so a slow request cannot delay restoring the composer. The cutoff keeps
// the asynchronous sweep away from prompts admitted after the revert; an
// old admission still in flight when the list is fetched can survive it,
// and fully closing that race needs a server-side revert-discards-inbox
// rule.
const cutoff = Date.now()
const local = data.session.pending
.list(sessionID)
.filter((item) => item.type === "user")
.map((item) => item.id)
void server.api.session.inbox
.list({ sessionID })
.then((rows) => rows.filter((row) => row.type === "user" && row.timeCreated <= cutoff).map((row) => row.id))
.catch(() => [])
.then((authoritative) => {
new Set([...local, ...authoritative]).forEach(
(inboxID) => void server.api.session.inbox.cancel({ sessionID, inboxID }).catch(() => undefined),
)
})
restore(target, message)
owner.run(() => input.setActiveMessage(previous))
}
+37 -1
View File
@@ -7,7 +7,13 @@ import { TextInput } from "@opencode-ai/ui/text-input"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useUpdaterAction } from "@/shell/updates/action"
import { type TerminalPlacement, type WorkspaceDefaultDestination, useSettings } from "@/settings/model"
import {
type FollowUpBehavior,
type TerminalPlacement,
type WorkspaceDefaultDestination,
useSettings,
} from "@/settings/model"
import { formatKeybind } from "@/shell/commands/command"
import { ExternalLink } from "@/runtime/platform/external-link"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
@@ -148,6 +154,35 @@ const TerminalPlacementSetting: Component = () => {
)
}
const FollowUpBehaviorSetting: Component = () => {
const language = useLanguage()
const settings = useSettings()
const options = createMemo((): { value: FollowUpBehavior; label: string }[] => [
{ value: "queue", label: language.t("settings.general.row.followUpBehavior.queue") },
{ value: "steer", label: language.t("settings.general.row.followUpBehavior.steer") },
])
return (
<SettingsRow
title={language.t("settings.general.row.followUpBehavior.title")}
description={language.t("settings.general.row.followUpBehavior.description", {
keybind: formatKeybind("mod+enter", language.t),
})}
>
<Select
data-action="settings-follow-up-behavior"
options={options()}
current={options().find((option) => option.value === settings.general.followUpBehavior())}
value={(option) => option.value}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && settings.general.setFollowUpBehavior(option.value)}
/>
</SettingsRow>
)
}
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
const language = useLanguage()
return (
@@ -294,6 +329,7 @@ export const SettingsGeneral: Component<{
<ShellSetting controller={shell} />
<TerminalPlacementSetting />
<FollowUpBehaviorSetting />
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
+7
View File
@@ -7,6 +7,7 @@ import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
export type WorkspaceLastUsed = "local" | "workspace"
export type TerminalPlacement = "side" | "bottom"
export type FollowUpBehavior = "queue" | "steer"
export interface NotificationSettings {
agent: boolean
@@ -39,6 +40,7 @@ export interface Settings {
showCustomAgents: boolean
mobileTitlebarPosition: "top" | "bottom"
terminalPlacement: TerminalPlacement
followUpBehavior: FollowUpBehavior
}
appearance: {
fontSize: number
@@ -126,6 +128,7 @@ const defaultSettings: Settings = {
showCustomAgents: false,
mobileTitlebarPosition: "top",
terminalPlacement: "side",
followUpBehavior: "steer",
},
appearance: {
fontSize: 14,
@@ -256,6 +259,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setTerminalPlacement(value: TerminalPlacement) {
setStore("general", "terminalPlacement", value)
},
followUpBehavior: withFallback(() => store.general?.followUpBehavior, defaultSettings.general.followUpBehavior),
setFollowUpBehavior(value: FollowUpBehavior) {
setStore("general", "followUpBehavior", value)
},
},
visibility: {
fileTree: showFileTree,
+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({
@@ -237,9 +237,8 @@ export default function PrivacyPolicy() {
</td>
<td>
<ul>
<li>Providing, Customizing and Improving the Services</li>
<li>Marketing the Services</li>
<li>Corresponding with You</li>
<li>Passing through to upstream provider to provide services</li>
<li>Not stored</li>
</ul>
</td>
<td>
+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())
+4
View File
@@ -131,6 +131,7 @@ const source = {
"ui.promptInput.label": "Prompt",
"ui.promptInput.placeholder.shell": "Enter shell command...",
"ui.promptInput.placeholder.normal": "Ask anything, {{slash}} for commands, {{at}} for context...",
"ui.promptInput.placeholder.followUp": "Add follow-up, {{slash}} for commands, {{at}} for context...",
"ui.promptInput.add": "Add images and files",
"ui.promptInput.attachments": "Images and files",
"ui.promptInput.context": "Context",
@@ -140,6 +141,9 @@ const source = {
"ui.promptInput.chooseVariant": "Choose model variant",
"ui.promptInput.send": "Send",
"ui.promptInput.stop": "Stop",
"ui.promptInput.steer": "Steer",
"ui.promptInput.queue": "Queue",
"ui.promptInput.steerHint": "Send without interrupting",
"ui.tabs.close": "Close tab",
+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"],
@@ -2,6 +2,10 @@
title: "Intro"
---
OpenCode is used by millions every day. Build on top of it to create your own
applications, integrations, and agent experiences without starting from
scratch.
<CardGroup cols={1}>
<Card title="Extend OpenCode" href="/build/plugins">
Build plugins that add tools, integrations, commands, agents, and custom behavior while keeping the rest of OpenCode
@@ -46,16 +46,6 @@ constructor(state: DurableObjectState) {
}
```
Configuration is a typed JavaScript object, and plugins are imported values bundled with the Worker.
```ts
await OpenCodeWorkerd.create({
storage: state.storage,
config: { default_agent: "build" },
plugins: [myPlugin],
})
```
Wrangler selects OpenCode's Workerd-safe implementations through the `workerd` package condition. Cloudflare may evict
a Durable Object without running cleanup, so correctness does not depend on `close()` being called.
@@ -65,17 +55,33 @@ a Durable Object without running cleanup, so correctness does not depend on `clo
}
```
## Effect
## Customize
Use the Effect-native entrypoint from `@opencode-ai/sdk/workerd/effect`.
Customize your OpenCode instance by registering plugins bundled with your
Worker. Pass plugins to `OpenCodeWorkerd.create()` to customize agents, models,
tools, and other behavior:
```ts
import { OpenCodeWorkerd } from "@opencode-ai/sdk/workerd/effect"
import { Effect } from "effect"
import { Plugin } from "@opencode-ai/plugin"
import { OpenCodeWorkerd } from "@opencode-ai/sdk/workerd"
const program = Effect.scoped(
Effect.gen(function* () {
return yield* OpenCodeWorkerd.create({ storage: state.storage })
}),
)
const plugin = Plugin.define({
id: "customize-agent",
async setup(ctx) {
await ctx.agent.transform((agents) => {
agents.update("build", (agent) => {
agent.description = "Builds features and fixes bugs for our team"
})
})
},
})
await OpenCodeWorkerd.create({
storage: state.storage,
config: { default_agent: "build" },
plugins: [plugin],
})
```
See the [full plugins documentation](/build/plugins) for plugin hooks,
transforms, tools, and the complete plugin context.
@@ -56,31 +56,33 @@ yield* opencode.events.subscribe().pipe(
)
```
## Register plugins
## Customize
Register Effect plugins through the embedded host. Their registrations remain scoped to the host.
Customize your OpenCode instance by registering Effect plugins. Use the embedded
host to customize agents, models, tools, and other behavior:
```ts
import { Plugin } from "@opencode-ai/plugin/effect"
import { Effect } from "effect"
const plugin = Plugin.define({
id: "example",
id: "customize-agent",
effect: (ctx) =>
Effect.gen(function* () {
const storage = ctx.storage
yield* storage.set("embedded", true)
const agent = ctx.agent
yield* agent.transform((agents) => {
agents.update("build", (agent) => {
agent.description = "Builds features and fixes bugs for our team"
})
})
}),
})
yield* opencode.plugin(plugin)
```
See the [Effect plugins guide](/build/plugins/effect) for the complete plugin context.
```ts
yield* opencode.plugin.list()
```
See the [full Effect plugins documentation](/build/plugins/effect) for plugin
hooks, transforms, tools, and the complete plugin context.
## Layer
@@ -50,25 +50,31 @@ for await (const event of opencode.events.subscribe()) {
Pass an `AbortSignal` through the generated request options, or leave an
iteration to cancel its response body.
## Register plugins
## Customize
Pass initial Promise plugins to `OpenCode.create()`. Embedded plugins use the
same discovery and Location-scoped activation path as configured plugins.
Customize your OpenCode instance by registering plugins. Pass plugins to
`OpenCode.create()` to customize agents, models, tools, and other behavior when
the embedded host starts:
```ts
const plugin = {
id: "example",
import { Plugin } from "@opencode-ai/plugin"
import { OpenCode } from "@opencode-ai/sdk"
const plugin = Plugin.define({
id: "customize-agent",
async setup(ctx) {
await ctx.agent.transform((agents) => {
// Modify the Location's agent catalog.
agents.update("build", (agent) => {
agent.description = "Builds features and fixes bugs for our team"
})
})
},
}
})
await using opencode = await OpenCode.create({ plugins: [plugin] })
```
Call `await opencode.plugin(plugin)` to register another plugin after startup.
See the [Plugins guide](/build/plugins) for the plugin context and available
hooks.
See the [full plugins documentation](/build/plugins) for plugin hooks,
transforms, tools, and the complete plugin context.