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