Compare commits

..

1 Commits

Author SHA1 Message Date
Ryan Vogel 5e27dd7b3e feat(tui): show token throughput 2026-08-12 19:30:50 +00:00
125 changed files with 2931 additions and 3573 deletions
@@ -103,7 +103,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const base = pickerRoot(cleaned) || root() || start()
if (!base) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.api.file
.get({
.find({
location: { directory: base },
query: pickerFileSearchQuery(base, value, home()),
type: "file",
@@ -135,7 +135,7 @@ test("resolves directory autocomplete from the current browser root", async () =
const sdk = {
api: {
file: {
get: (input: { location?: { directory?: string } }) => {
find: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve({ data: [] })
},
@@ -157,7 +157,7 @@ test("keeps indexed directory results for servers that support empty search", as
const sdk = {
api: {
file: {
get: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
list: () => Promise.reject(new Error("listing should not run when search returns results")),
},
},
@@ -176,7 +176,7 @@ test("lists the default directory when empty search is unsupported", async () =>
const sdk = {
api: {
file: {
get: () => Promise.resolve({ data: [] }),
find: () => Promise.resolve({ data: [] }),
list: (input: { location?: { directory?: string } }) => {
calls.push(input.location?.directory ?? "")
return Promise.resolve({
@@ -198,7 +198,7 @@ test("matches the default directory listing when typed search is unsupported", a
const sdk = {
api: {
file: {
get: () => Promise.resolve({ data: [] }),
find: () => Promise.resolve({ data: [] }),
list: () =>
Promise.resolve({
data: [
@@ -375,7 +375,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const query = normalizePickerDrive(input.path)
if (!pathInput) {
const results = await args.sdk.api.file
.get({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.then((result) => result.data.map((entry) => entry.path))
.catch(() => [])
if (!active()) return []
+1 -1
View File
@@ -212,7 +212,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
serverSDK()
.api.file.get(
.api.file.find(
{
location: { directory: sdk().directory },
query,
@@ -18,18 +18,18 @@ describe("v2 session reducer", () => {
apply({
...base,
id: "evt_admitted",
type: "session.inbox.enqueued",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inboxID: "msg_user",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
inputID: "msg_user",
input: { type: "user", delivery: "steer", data: { text: "hello" } },
},
})
apply({
...base,
id: "evt_promoted",
type: "session.inbox.delivered",
data: { sessionID: "ses_1", inboxID: "msg_user" },
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
})
apply({
...base,
@@ -203,8 +203,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_promoted",
type: "session.inbox.delivered",
data: { sessionID: "ses_1", inboxID: "msg_user" },
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
@@ -218,11 +218,11 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_admitted",
type: "session.inbox.enqueued",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inboxID: "msg_user",
item: { type: "user", delivery: "queue", payload: { text: "cancel me" } },
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
},
}),
)
@@ -231,8 +231,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_cancelled",
type: "session.inbox.cancelled",
data: { sessionID: "ses_1", inboxID: "msg_user" },
type: "session.input.cancelled",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
@@ -240,8 +240,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_promoted",
type: "session.inbox.delivered",
data: { sessionID: "ses_1", inboxID: "msg_user" },
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
@@ -255,11 +255,11 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_admitted",
type: "session.inbox.enqueued",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inboxID: "msg_user",
item: { type: "user", delivery: "queue", payload: { text: "steer me" } },
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
},
}),
)
@@ -268,8 +268,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_steered",
type: "session.inbox.delivery.changed",
data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "steer" },
type: "session.input.steered",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
reducer.reduce(
@@ -277,8 +277,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_queued",
type: "session.inbox.delivery.changed",
data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "queue" },
type: "session.input.queued",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
@@ -287,8 +287,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_promoted",
type: "session.inbox.delivered",
data: { sessionID: "ses_1", inboxID: "msg_user" },
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
@@ -1,4 +1,4 @@
import type { OpenCodeEvent, SessionInboxItem, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise"
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
@@ -12,7 +12,7 @@ export type V2SessionReduction = {
}
export function createV2SessionReducer() {
const pending = new Map<string, SessionInboxItem>()
const pending = new Map<string, SessionPendingMessage>()
const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => {
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
@@ -26,33 +26,32 @@ export function createV2SessionReducer() {
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
switch (event.type) {
case "session.inbox.enqueued":
pending.set(key(sessionID, event.data.inboxID), event.data.item)
case "session.input.admitted":
pending.set(key(sessionID, event.data.inputID), event.data.input)
return result([...source])
case "session.inbox.cancelled":
pending.delete(key(sessionID, event.data.inboxID))
case "session.input.cancelled":
pending.delete(key(sessionID, event.data.inputID))
return
case "session.inbox.delivered": {
const input = pending.get(key(sessionID, event.data.inboxID))
pending.delete(key(sessionID, event.data.inboxID))
if (!input) return { ...result([...source]), missing: event.data.inboxID }
case "session.input.promoted": {
const input = pending.get(key(sessionID, event.data.inputID))
pending.delete(key(sessionID, event.data.inputID))
if (!input) return { ...result([...source]), missing: event.data.inputID }
if (input.type === "user")
return append({
id: event.data.inboxID,
id: event.data.inputID,
type: "user",
metadata: input.payload.metadata,
text: input.payload.text,
files: input.payload.files,
agents: input.payload.agents,
metadata: input.data.metadata,
text: input.data.text,
files: input.data.files,
agents: input.data.agents,
time: { created: event.created },
})
if (input.type !== "synthetic") return result([...source])
return append({
id: event.data.inboxID,
id: event.data.inputID,
type: "synthetic",
metadata: input.payload.metadata,
text: input.payload.text,
description: input.payload.description,
metadata: input.data.metadata,
text: input.data.text,
description: input.data.description,
time: { created: event.created },
})
}
@@ -352,7 +351,7 @@ export function createV2SessionReducer() {
metadata: event.metadata,
reason: event.data.reason,
summary: "",
recent: event.data.recent ?? "",
recent: event.data.recent,
time: { created: event.created },
})
case "session.compaction.delta":
+3 -2
View File
@@ -538,8 +538,9 @@ async function replayMessage(
}
function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
if (start.type === "input") return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
if (start.type === "compaction") return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
if (start.type === "input") return event.type === "session.input.promoted" && event.data.inputID === start.id
if (start.type === "compaction")
return event.type === "session.compaction.admitted" && event.data.inputID === start.id
return event.type === "session.skill.activated" && event.id === start.id.replace(/^msg_/, "evt_")
}
+2 -2
View File
@@ -195,8 +195,8 @@ export async function runNonInteractivePrompt(input: Input) {
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
const time = toMillis("created" in event ? event.created : undefined)
if (event.type === "session.inbox.delivered") {
if (event.data.inboxID === messageID) {
if (event.type === "session.input.promoted") {
if (event.data.inputID === messageID) {
promoted = true
prePromotionError = undefined
continue
+18 -18
View File
@@ -22,8 +22,8 @@ describe("acp event behavior", () => {
delta: "before admission",
}),
)
send(durableEvent("session.inbox.delivered", { sessionID: "ses_b", inboxID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: "input_other" }))
send(durableEvent("session.input.promoted", { sessionID: "ses_b", inputID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: "input_other" }))
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_a",
@@ -32,7 +32,7 @@ describe("acp event behavior", () => {
delta: "wrong input",
}),
)
send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: id }))
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_b",
@@ -68,7 +68,7 @@ describe("acp event behavior", () => {
fixture,
connection: recordingConnection(updates),
sessionID: "ses_a",
inboxID: "input_a",
inputID: "input_a",
})
expect(fixture.requests.slice(0, 2).map((request) => request.path)).toEqual([
@@ -99,7 +99,7 @@ describe("acp event behavior", () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
async onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_order", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_order", inputID: id }))
send(
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
@@ -148,7 +148,7 @@ describe("acp event behavior", () => {
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
} satisfies Connection
const result = turn({ fixture, connection, sessionID: "ses_order", inboxID: "input_order" })
const result = turn({ fixture, connection, sessionID: "ses_order", inputID: "input_order" })
try {
await withTimeout(firstUpdate.promise, "first ordered update was not delivered")
@@ -195,7 +195,7 @@ describe("acp event behavior", () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_child",
@@ -240,7 +240,7 @@ describe("acp event behavior", () => {
fixture,
connection: recordingConnection(updates),
sessionID: "ses_parent",
inboxID: "input_parent",
inputID: "input_parent",
})
expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate])).toEqual([
@@ -276,7 +276,7 @@ describe("acp event behavior", () => {
const completed = Promise.withResolvers<void>()
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_background",
@@ -292,7 +292,7 @@ describe("acp event behavior", () => {
fixture,
connection: recordingConnection(updates),
sessionID: "ses_parent",
inboxID: "input_parent",
inputID: "input_parent",
childSessionUpdate: async (update) => {
childUpdates.push(update)
if (update.type === "status" && update.status === "completed") completed.resolve()
@@ -370,7 +370,7 @@ describe("acp event behavior", () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_tools", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_tools", inputID: id }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
@@ -460,7 +460,7 @@ describe("acp event behavior", () => {
fixture,
connection: recordingConnection(updates),
sessionID: "ses_tools",
inboxID: "input_tools",
inputID: "input_tools",
})
expect(
@@ -597,7 +597,7 @@ describe("acp event behavior", () => {
const control: TurnControl = { cancelled: false, admission: new AbortController() }
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_cancel", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_cancel", inputID: id }))
},
onInterrupt({ sessionID, send }) {
send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
@@ -680,7 +680,7 @@ describe("acp event behavior", () => {
test("cancels unsupported session forms so execution can continue", async () => {
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_form", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_form", inputID: id }))
send(
ephemeralEvent("form.created", {
form: {
@@ -704,7 +704,7 @@ describe("acp event behavior", () => {
fixture,
connection: recordingConnection([]),
sessionID: "ses_form",
inboxID: "input_form",
inputID: "input_form",
})
expect(response.stopReason).toBe("end_turn")
@@ -730,7 +730,7 @@ function turn(input: {
readonly fixture: Fixture
readonly connection: Connection
readonly sessionID: string
readonly inboxID: string
readonly inputID: string
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
}) {
return streamTurn({
@@ -738,12 +738,12 @@ function turn(input: {
connection: input.connection,
sessionID: input.sessionID,
cwd: "/workspace",
start: { type: "input", id: input.inboxID },
start: { type: "input", id: input.inputID },
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() },
childSessionUpdate: input.childSessionUpdate,
submit: (signal) =>
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inboxID, text: "hello" }, { signal }),
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
})
}
+2 -2
View File
@@ -34,8 +34,8 @@ test("acp prompt resolves after ordered turn updates", async () => {
send(events, {
id: "evt_promoted",
created: 1,
type: "session.inbox.delivered",
data: { sessionID: "ses_test", inboxID: id },
type: "session.input.promoted",
data: { sessionID: "ses_test", inputID: id },
})
send(events, {
id: "evt_text",
@@ -38,7 +38,7 @@ describe("acp permission behavior", () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_allow", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_allow", inputID: id }))
send(
permissionAsked("ses_allow", "perm_once", {
action: "shell",
@@ -112,7 +112,7 @@ describe("acp permission behavior", () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_external", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_external", inputID: id }))
send(
permissionAsked("ses_external", "perm_external", {
action: "external_directory",
@@ -157,7 +157,7 @@ describe("acp permission behavior", () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_child",
@@ -219,7 +219,7 @@ describe("acp permission behavior", () => {
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_edit", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_edit", inputID: id }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_edit",
@@ -309,7 +309,7 @@ describe("acp permission behavior", () => {
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_patch", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_patch", inputID: id }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_patch",
@@ -389,7 +389,7 @@ describe("acp permission behavior", () => {
test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_reject", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_reject", inputID: id }))
send(permissionAsked("ses_reject", "perm_selected_reject"))
send(permissionAsked("ses_reject", "perm_cancelled"))
send(permissionAsked("ses_reject", "perm_failed"))
@@ -426,7 +426,7 @@ describe("acp permission behavior", () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_serial", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_serial", inputID: id }))
send(permissionAsked("ses_serial", "perm_1"))
send(permissionAsked("ses_serial", "perm_2"))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_serial" }))
@@ -477,8 +477,8 @@ describe("acp permission behavior", () => {
const blockedID = promptIDs.get("ses_blocked")
const freeID = promptIDs.get("ses_free")
if (!blockedID || !freeID) throw new Error("both permission test prompts must be registered")
send(durableEvent("session.inbox.delivered", { sessionID: "ses_blocked", inboxID: blockedID }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_free", inboxID: freeID }))
send(durableEvent("session.input.promoted", { sessionID: "ses_blocked", inputID: blockedID }))
send(durableEvent("session.input.promoted", { sessionID: "ses_free", inputID: freeID }))
send(permissionAsked("ses_blocked", "perm_blocked"))
send(
ephemeralEvent("session.text.delta", {
@@ -538,16 +538,16 @@ describe("acp permission behavior", () => {
})
})
function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inboxID: string, cwd = "/workspace") {
function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inputID: string, cwd = "/workspace") {
return streamTurn({
client: fixture.client,
connection,
sessionID,
cwd,
start: { type: "input", id: inboxID },
start: { type: "input", id: inputID },
writeTextFile: true,
control: { cancelled: false, admission: new AbortController() },
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inboxID, text: "hello" }, { signal }),
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
})
}
+8 -8
View File
@@ -15,8 +15,8 @@ describe("acp service prompt routing and usage", () => {
const id = requestID(request)
completeTurn(context, "ses_routes", {
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_routes", inboxID: id },
type: "session.input.promoted",
data: { sessionID: "ses_routes", inputID: id },
})
return Response.json({ data: {} })
}
@@ -33,8 +33,8 @@ describe("acp service prompt routing and usage", () => {
const id = requestID(request)
completeTurn(context, "ses_routes", {
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_routes", inboxID: id },
type: "session.compaction.admitted",
data: { sessionID: "ses_routes", inputID: id },
})
return Response.json({ data: {} })
}
@@ -95,8 +95,8 @@ describe("acp service prompt routing and usage", () => {
const id = requestID(request)
context.send({
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_usage", inboxID: id },
type: "session.input.promoted",
data: { sessionID: "ses_usage", inputID: id },
})
context.send({
id: "evt_step",
@@ -189,8 +189,8 @@ describe("acp service prompt routing and usage", () => {
const id = requestID(request)
context.send({
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_usage_failure", inboxID: id },
type: "session.input.promoted",
data: { sessionID: "ses_usage_failure", inputID: id },
})
context.send({
id: "evt_step_failure",
+10 -10
View File
@@ -28,13 +28,13 @@ function formCreated(info: FormInfo, eventLocation = location): V2Event {
return { id: `evt_${info.id}`, created: 0, type: "form.created", location: eventLocation, data: { form: info } }
}
function prompted(inboxID: string): V2Event {
function prompted(inputID: string): V2Event {
return {
id: "evt_prompted",
created: 0,
type: "session.inbox.delivered",
type: "session.input.promoted",
durable: { aggregateID: "ses_1", seq: 0, version: 1 },
data: { sessionID: "ses_1", inboxID },
data: { sessionID: "ses_1", inputID },
}
}
@@ -98,9 +98,9 @@ function executionFailed(message: string): V2Event {
}
}
function failedTool(inboxID: string): V2Event[] {
function failedTool(inputID: string): V2Event[] {
return [
prompted(inboxID),
prompted(inputID),
{
id: "evt_failed_tool_input",
created: 1,
@@ -156,10 +156,10 @@ function failedTool(inboxID: string): V2Event[] {
]
}
function successfulGrep(inboxID: string): V2Event[] {
function successfulGrep(inputID: string): V2Event[] {
const text = "Found 2 matches\n/src/a.ts:\n Line 1: needle\n/src/b.ts:\n Line 2: needle"
return [
prompted(inboxID),
prompted(inputID),
{
id: "evt_grep_input",
created: 1,
@@ -206,7 +206,7 @@ function successfulGrep(inboxID: string): V2Event[] {
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
// live events the prompt admission triggers, keyed by the generated message ID.
async function run(input: {
turn: (inboxID: string) => V2Event[]
turn: (inputID: string) => V2Event[]
pendingForms?: FormInfo[]
attached?: boolean
format?: "default" | "json"
@@ -214,7 +214,7 @@ async function run(input: {
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
messages?: (inboxID: string) => SessionMessageInfo[]
messages?: (inputID: string) => SessionMessageInfo[]
wait?: () => Promise<void>
terminalDelay?: number
}) {
@@ -515,7 +515,7 @@ describe("runNonInteractivePrompt", () => {
const rendered: SessionMessageAssistantTool[] = []
const failed: SessionMessageAssistantTool[] = []
await capture({
turn: (inboxID) => failedTool(inboxID).filter((event) => event.type !== "session.tool.progress"),
turn: (inputID) => failedTool(inputID).filter((event) => event.type !== "session.tool.progress"),
renderTool: (part) => {
rendered.push(part)
return Promise.resolve()
+2 -2
View File
@@ -30,7 +30,7 @@ import { Reference } from "@opencode-ai/schema/reference"
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionPending } from "@opencode-ai/schema/session-pending"
import { Shell } from "@opencode-ai/schema/shell"
import { Skill } from "@opencode-ai/schema/skill"
import { Vcs } from "@opencode-ai/schema/vcs"
@@ -69,7 +69,7 @@ const effectTypeReferences = [
...namespaceTypes("Reference", "@opencode-ai/schema/reference", Reference),
...namespaceTypes("Session", "@opencode-ai/schema/session", Session),
...namespaceTypes("SessionMessage", "@opencode-ai/schema/session-message", SessionMessage),
...namespaceTypes("SessionInbox", "@opencode-ai/schema/session-inbox", SessionInbox),
...namespaceTypes("SessionPending", "@opencode-ai/schema/session-pending", SessionPending),
...namespaceTypes("Shell", "@opencode-ai/schema/shell", Shell),
...namespaceTypes("Skill", "@opencode-ai/schema/skill", Skill),
...namespaceTypes("Vcs", "@opencode-ai/schema/vcs", Vcs),
+56 -43
View File
@@ -11,9 +11,9 @@ import type { RelativePath } from "@opencode-ai/schema/schema"
import type { Brand } from "effect"
import type { Model } from "@opencode-ai/schema/model"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { AgentAttachment } from "@opencode-ai/schema/prompt"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import type { Skill } from "@opencode-ai/schema/skill"
import type { Event } from "@opencode-ai/schema/event"
import type { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
@@ -170,7 +170,6 @@ export type Endpoint5_11Input = {
readonly sessionID: Session.ID
readonly directory: AbsolutePath
readonly workspaceID?: Workspace.ID | undefined
readonly delivery?: SessionInbox.Delivery | undefined
}
export type Endpoint5_11Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
@@ -183,10 +182,10 @@ export type Endpoint5_12Input = {
readonly agents?: ReadonlyArray<AgentAttachment> | undefined
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly delivery?: SessionInbox.Delivery | undefined
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_12Output = SessionInbox.User
export type Endpoint5_12Output = SessionPending.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_13Input = {
@@ -199,10 +198,10 @@ export type Endpoint5_13Input = {
readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
readonly agents?: ReadonlyArray<AgentAttachment> | undefined
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
readonly delivery?: SessionInbox.Delivery | undefined
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_13Output = SessionInbox.User
export type Endpoint5_13Output = SessionPending.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_14Input = {
@@ -220,10 +219,10 @@ export type Endpoint5_15Input = {
readonly text: string
readonly description?: string | undefined
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly delivery?: SessionInbox.Delivery | undefined
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_15Output = SessionInbox.Synthetic
export type Endpoint5_15Output = SessionPending.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_16Input = {
@@ -234,12 +233,8 @@ export type Endpoint5_16Input = {
export type Endpoint5_16Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_17Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly delivery?: SessionInbox.Delivery | undefined
}
export type Endpoint5_17Output = SessionInbox.Compaction
export type Endpoint5_17Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
export type Endpoint5_17Output = SessionPending.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
@@ -267,20 +262,22 @@ export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_24Output = void
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
export type SessionPendingCancelOperation<E = never> = (
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_25Output = void
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type SessionPendingSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_26Output = void
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type SessionPendingQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
@@ -371,7 +368,7 @@ export type Endpoint5_31Output =
readonly data: {
readonly sessionID: Session.ID
readonly location: Location.Ref
readonly projectID: Project.ID
readonly projectID?: Project.ID | undefined
readonly subpath?: RelativePath | undefined
}
}
@@ -413,45 +410,50 @@ export type Endpoint5_31Output =
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.delivered"
readonly type: "session.input.promoted"
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 inboxID: SessionMessage.ID }
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.enqueued"
readonly type: "session.input.admitted"
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 inboxID: SessionMessage.ID
readonly item: SessionInbox.Item
readonly inputID: SessionMessage.ID
readonly input: SessionPending.Message
}
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.cancelled"
readonly type: "session.input.cancelled"
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 inboxID: SessionMessage.ID }
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.delivery.changed"
readonly type: "session.input.steered"
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 inboxID: SessionMessage.ID
readonly delivery: SessionInbox.Delivery
}
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.queued"
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 inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
@@ -593,6 +595,7 @@ export type Endpoint5_31Output =
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly generated?: DateTime.Utc | undefined
readonly snapshot?: (string & Brand.Brand<"Snapshot.ID">) | undefined
readonly files?: ReadonlyArray<RelativePath> | undefined
}
@@ -617,6 +620,7 @@ export type Endpoint5_31Output =
readonly cache: { readonly read: number; readonly write: number }
}
| undefined
readonly generated?: DateTime.Utc | undefined
readonly snapshot?: (string & Brand.Brand<"Snapshot.ID">) | undefined
readonly files?: ReadonlyArray<RelativePath> | undefined
}
@@ -812,6 +816,15 @@ export type Endpoint5_31Output =
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
}
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.compaction.admitted"
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 inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
@@ -942,11 +955,11 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly inbox: {
readonly list: SessionInboxListOperation<E>
readonly cancel: SessionInboxCancelOperation<E>
readonly steer: SessionInboxSteerOperation<E>
readonly queue: SessionInboxQueueOperation<E>
readonly pending: {
readonly list: SessionPendingListOperation<E>
readonly cancel: SessionPendingCancelOperation<E>
readonly steer: SessionPendingSteerOperation<E>
readonly queue: SessionPendingQueueOperation<E>
}
readonly instructions: {
readonly entry: {
@@ -1362,11 +1375,11 @@ export type Endpoint16_1Input = {
readonly limit?: number | undefined
}
export type Endpoint16_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
export type FileGetOperation<E = never> = (input: Endpoint16_1Input) => Effect.Effect<Endpoint16_1Output, E>
export type FileFindOperation<E = never> = (input: Endpoint16_1Input) => Effect.Effect<Endpoint16_1Output, E>
export interface FileApi<E = never> {
readonly list: FileListOperation<E>
readonly get: FileGetOperation<E>
readonly find: FileFindOperation<E>
}
export type Endpoint17_0Input = {
+9 -12
View File
@@ -399,7 +399,7 @@ const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11I
preserveEffect<Endpoint5_11Output>()(
raw["session.move"]({
params: { sessionID: input["sessionID"] },
payload: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] },
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -481,10 +481,7 @@ const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16I
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
raw["session.compact"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], delivery: input["delivery"] },
}).pipe(
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -526,7 +523,7 @@ const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22I
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
preserveEffect<Endpoint5_23Output>()(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -534,21 +531,21 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
raw["session.pending.cancel"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
raw["session.pending.steer"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()(
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
raw["session.pending.queue"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
@@ -640,7 +637,7 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
wait: Endpoint5_18(raw),
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
context: Endpoint5_22(raw),
inbox: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
pending: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
generate: Endpoint5_30(raw),
log: Endpoint5_31(raw),
@@ -1034,12 +1031,12 @@ const Endpoint16_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint16_0Input
const Endpoint16_1 = (raw: RawClient["server.fs"]) => (input: Endpoint16_1Input) =>
preserveEffect<Endpoint16_1Output>()(
raw["fs.get"]({
raw["fs.find"]({
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup16 = (raw: RawClient["server.fs"]) => ({ list: Endpoint16_0(raw), get: Endpoint16_1(raw) })
const adaptGroup16 = (raw: RawClient["server.fs"]) => ({ list: Endpoint16_0(raw), find: Endpoint16_1(raw) })
const Endpoint17_0 = (raw: RawClient["server.command"]) => (input?: Endpoint17_0Input) =>
preserveEffect<Endpoint17_0Output>()(
+1 -1
View File
@@ -41,7 +41,7 @@ export { Reference } from "@opencode-ai/schema/reference"
export { WebSearch } from "@opencode-ai/schema/websearch"
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
export { Session } from "@opencode-ai/schema/session"
export { SessionInbox } from "@opencode-ai/schema/session-inbox"
export { SessionPending } from "@opencode-ai/schema/session-pending"
export { SessionMessage } from "@opencode-ai/schema/session-message"
export { Skill } from "@opencode-ai/schema/skill"
export { Prompt } from "@opencode-ai/schema/prompt"
+27 -27
View File
@@ -56,14 +56,14 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
SessionInboxCancelOutput,
SessionInboxSteerInput,
SessionInboxSteerOutput,
SessionInboxQueueInput,
SessionInboxQueueOutput,
SessionPendingListInput,
SessionPendingListOutput,
SessionPendingCancelInput,
SessionPendingCancelOutput,
SessionPendingSteerInput,
SessionPendingSteerOutput,
SessionPendingQueueInput,
SessionPendingQueueOutput,
SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput,
@@ -167,8 +167,8 @@ import type {
FileReadOutput,
FileListInput,
FileListOutput,
FileGetInput,
FileGetOutput,
FileFindInput,
FileFindOutput,
CommandListInput,
CommandListOutput,
SkillListInput,
@@ -598,7 +598,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/move`,
body: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] },
body: { directory: input["directory"], workspaceID: input["workspaceID"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
@@ -697,7 +697,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`,
body: { id: input["id"], delivery: input["delivery"] },
body: { id: input["id"] },
successStatus: 200,
declaredStatuses: [409, 404, 400, 401],
empty: false,
@@ -762,45 +762,45 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
inbox: {
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionInboxListOutput }>(
pending: {
list: (input: SessionPendingListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionPendingListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox`,
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending`,
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
cancel: (input: SessionInboxCancelInput, requestOptions?: RequestOptions) =>
request<SessionInboxCancelOutput>(
cancel: (input: SessionPendingCancelInput, requestOptions?: RequestOptions) =>
request<SessionPendingCancelOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}`,
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
steer: (input: SessionInboxSteerInput, requestOptions?: RequestOptions) =>
request<SessionInboxSteerOutput>(
steer: (input: SessionPendingSteerInput, requestOptions?: RequestOptions) =>
request<SessionPendingSteerOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}/steer`,
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/steer`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
queue: (input: SessionInboxQueueInput, requestOptions?: RequestOptions) =>
request<SessionInboxQueueOutput>(
queue: (input: SessionPendingQueueInput, requestOptions?: RequestOptions) =>
request<SessionPendingQueueOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}/queue`,
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/queue`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
@@ -1473,8 +1473,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
get: (input: FileGetInput, requestOptions?: RequestOptions) =>
request<FileGetOutput>(
find: (input: FileFindInput, requestOptions?: RequestOptions) =>
request<FileFindOutput>(
{
method: "GET",
path: `/api/fs/find`,
+185 -179
View File
@@ -130,17 +130,15 @@ export type SessionMessageCompactionCompleted = {
export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
export type SessionPendingSyntheticData = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
export type SessionInboxSyntheticPayload = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
export type SessionInboxCompactionPayload = {}
export type SessionPendingCompaction = { id: string; sessionID: string; timeCreated: number; type: "compaction" }
export type InstructionEntryKey = string
export type SessionGenerateResponse = { data: { text: string } }
export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } }
export type SessionPendingSyntheticData1 = { text: string; description?: string; metadata?: { [x: string]: any } }
export type ShellInfo = {
id: string
@@ -422,8 +420,6 @@ export type SessionMessageLocationSwitched = {
previous?: { location: LocationRef; projectID?: string; subpath?: string }
}
export type SessionInboxMovePayload = { location: LocationRef; projectID: string; subpath?: string }
export type SessionCreated = {
id: string
created: number
@@ -472,7 +468,7 @@ export type SessionMoved = {
type: "session.moved"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; location: LocationRef; projectID: string; subpath?: string }
data: { sessionID: string; location: LocationRef; projectID?: string; subpath?: string }
}
export type SessionRenamed = {
@@ -505,24 +501,44 @@ export type SessionForked = {
data: { sessionID: string; parentID: string; boundary: SessionForkBoundary; instructions?: { [x: string]: string } }
}
export type SessionInboxDelivered = {
export type SessionInputPromoted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.inbox.delivered"
type: "session.input.promoted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inboxID: string }
data: { sessionID: string; inputID: string }
}
export type SessionInboxCancelled = {
export type SessionInputCancelled = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.inbox.cancelled"
type: "session.input.cancelled"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inboxID: string }
data: { sessionID: string; inputID: string }
}
export type SessionInputSteered = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.steered"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionInputQueued = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.queued"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionExecutionStarted = {
@@ -608,6 +624,7 @@ export type SessionStepEnded = {
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
cost: MoneyUSD
tokens: TokenUsageInfo
generated?: number
snapshot?: string
files?: Array<string>
}
@@ -643,6 +660,16 @@ export type SessionToolInputEnded = {
data: { sessionID: string; assistantMessageID: string; id: string; text: string }
}
export type SessionCompactionAdmitted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.admitted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionCompactionStarted = {
id: string
created: number
@@ -1111,6 +1138,7 @@ export type SessionStepFailed = {
error: SessionStructuredError
cost?: MoneyUSD
tokens?: TokenUsageInfo
generated?: number
snapshot?: string
files?: Array<string>
}
@@ -1136,36 +1164,23 @@ export type SessionCompactionFailed = {
data: { sessionID: string; reason: "auto" | "manual"; error: SessionStructuredError; inputID?: string }
}
export type SessionInboxDeliveryChanged = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.inbox.delivery.changed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inboxID: string; delivery: SessionInboxDelivery }
}
export type SessionInboxSynthetic = {
export type SessionPendingSynthetic = {
id: string
sessionID: string
timeCreated: number
type: "synthetic"
payload: SessionInboxSyntheticPayload
delivery: SessionInboxDelivery
}
export type SessionInboxCompaction = {
id: string
sessionID: string
timeCreated: number
type: "compaction"
payload: SessionInboxCompactionPayload
delivery: SessionInboxDelivery
data: SessionPendingSyntheticData
delivery: "steer" | "queue"
}
export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue }
export type SessionPendingSyntheticMessage = {
type: "synthetic"
data: SessionPendingSyntheticData1
delivery: "steer" | "queue"
}
export type SessionShellStarted = {
id: string
created: number
@@ -1514,15 +1529,6 @@ export type VcsInfo = { branch: VcsBranch }
export type PermissionRuleset = Array<PermissionRule>
export type SessionInboxMove = {
id: string
sessionID: string
timeCreated: number
type: "move"
payload: SessionInboxMovePayload
delivery: SessionInboxDelivery
}
export type SessionInfo = {
id: string
parentID?: string
@@ -1560,7 +1566,7 @@ export type SessionMessageUser = {
type: "user"
}
export type SessionInboxUserPayload = {
export type SessionPendingUserData = {
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
@@ -1568,7 +1574,7 @@ export type SessionInboxUserPayload = {
metadata?: { [x: string]: JsonValue }
}
export type SessionInboxUserPayload1 = {
export type SessionPendingUserData1 = {
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
@@ -1880,20 +1886,16 @@ export type ConfigEntry =
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
export type SessionInboxUser = {
export type SessionPendingUser = {
id: string
sessionID: string
timeCreated: number
type: "user"
payload: SessionInboxUserPayload
delivery: SessionInboxDelivery
data: SessionPendingUserData
delivery: "steer" | "queue"
}
export type SessionInboxItem =
| { type: "user"; payload: SessionInboxUserPayload1; delivery: SessionInboxDelivery }
| { type: "synthetic"; payload: SessionInboxSyntheticPayload1; delivery: SessionInboxDelivery }
| { type: "compaction"; payload: SessionInboxCompactionPayload; delivery: SessionInboxDelivery }
| { type: "move"; payload: SessionInboxMovePayload; delivery: SessionInboxDelivery }
export type SessionPendingUserMessage = { type: "user"; data: SessionPendingUserData1; delivery: "steer" | "queue" }
export type SessionMessageAssistantTool = {
type: "tool"
@@ -1914,22 +1916,14 @@ export type FormFields = [FormField, ...Array<FormField>]
export type FormFields3 = [FormField1, ...Array<FormField1>]
export type SessionInboxInfo = SessionInboxUser | SessionInboxSynthetic | SessionInboxCompaction | SessionInboxMove
export type SessionPendingInfo = SessionPendingUser | SessionPendingSynthetic | SessionPendingCompaction
export type SessionInboxEnqueued = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.inbox.enqueued"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inboxID: string; item: SessionInboxItem }
}
export type SessionPendingMessage = SessionPendingUserMessage | SessionPendingSyntheticMessage
export type SessionMessageAssistant = {
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number; completed?: number }
time: { created: number; started?: number; generated?: number; completed?: number }
type: "assistant"
agent: string
model: ModelRef
@@ -1950,47 +1944,15 @@ export type FormInfo = { id: string; sessionID: string; title: string; metadata?
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields3 }
export type SessionEventDurable =
| SessionCreated
| SessionAgentSelected
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionDeleted
| SessionForked
| SessionInboxDelivered
| SessionInboxEnqueued
| SessionInboxCancelled
| SessionInboxDeliveryChanged
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
| SessionExecutionInterrupted
| SessionInstructionsUpdated
| SessionSynthetic
| SessionSkillActivated
| SessionShellStarted
| SessionShellEnded
| SessionStepStarted
| SessionStepEnded
| SessionStepFailed
| SessionTextStarted
| SessionTextEnded
| SessionReasoningStarted
| SessionReasoningEnded
| SessionToolInputStarted
| SessionToolInputEnded
| SessionToolCalled
| SessionToolSuccess
| SessionToolFailed
| SessionRetryScheduled
| SessionCompactionStarted
| SessionCompactionEnded
| SessionCompactionFailed
| SessionRevertStaged
| SessionRevertCleared
| SessionRevertCommitted
| SessionUsageRecorded
export type SessionInputAdmitted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.admitted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string; input: SessionPendingMessage }
}
export type SessionMessageInfo =
| SessionMessageAgentSelected
@@ -2019,7 +1981,49 @@ export type FormCreated = {
data: { form: FormInfo1 }
}
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type SessionEventDurable =
| SessionCreated
| SessionAgentSelected
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionDeleted
| SessionForked
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
| SessionExecutionInterrupted
| SessionInstructionsUpdated
| SessionSynthetic
| SessionSkillActivated
| SessionShellStarted
| SessionShellEnded
| SessionStepStarted
| SessionStepEnded
| SessionStepFailed
| SessionTextStarted
| SessionTextEnded
| SessionReasoningStarted
| SessionReasoningEnded
| SessionToolInputStarted
| SessionToolInputEnded
| SessionToolCalled
| SessionToolSuccess
| SessionToolFailed
| SessionRetryScheduled
| SessionCompactionAdmitted
| SessionCompactionStarted
| SessionCompactionEnded
| SessionCompactionFailed
| SessionRevertStaged
| SessionRevertCleared
| SessionRevertCommitted
| SessionUsageRecorded
export type SessionTransferData = { info: SessionInfo; messages: Array<SessionMessageInfo> }
@@ -2049,10 +2053,11 @@ export type V2Event =
| SessionUsageUpdated
| SessionDeleted
| SessionForked
| SessionInboxDelivered
| SessionInboxEnqueued
| SessionInboxCancelled
| SessionInboxDeliveryChanged
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -2079,6 +2084,7 @@ export type V2Event =
| SessionToolSuccess
| SessionToolFailed
| SessionRetryScheduled
| SessionCompactionAdmitted
| SessionCompactionStarted
| SessionCompactionDelta
| SessionCompactionEnded
@@ -2123,6 +2129,8 @@ export type V2Event =
| McpResourcesChanged
| V2EventServerConnected
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
@@ -2636,7 +2644,12 @@ export type SessionImportInput = {
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly time: {
readonly created: number
readonly started?: number
readonly generated?: number
readonly completed?: number
}
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
@@ -2903,7 +2916,12 @@ export type SessionImportInput = {
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly time: {
readonly created: number
readonly started?: number
readonly generated?: number
readonly completed?: number
}
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
@@ -3170,7 +3188,12 @@ export type SessionImportInput = {
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly time: {
readonly created: number
readonly started?: number
readonly generated?: number
readonly completed?: number
}
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
@@ -3355,21 +3378,8 @@ export type SessionRenameOutput = void
export type SessionMoveInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly directory: {
readonly directory: string
readonly workspaceID?: string
readonly delivery?: ("steer" | "queue") | null
}["directory"]
readonly workspaceID?: {
readonly directory: string
readonly workspaceID?: string
readonly delivery?: ("steer" | "queue") | null
}["workspaceID"]
readonly delivery?: {
readonly directory: string
readonly workspaceID?: string
readonly delivery?: ("steer" | "queue") | null
}["delivery"]
readonly directory: { readonly directory: string; readonly workspaceID?: string }["directory"]
readonly workspaceID?: { readonly directory: string; readonly workspaceID?: string }["workspaceID"]
}
export type SessionMoveOutput = void
@@ -3394,7 +3404,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["id"]
readonly text: {
@@ -3415,7 +3425,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["text"]
readonly files?: {
@@ -3436,7 +3446,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["files"]
readonly agents?: {
@@ -3457,7 +3467,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["agents"]
readonly skills?: {
@@ -3478,7 +3488,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["skills"]
readonly metadata?: {
@@ -3499,7 +3509,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["metadata"]
readonly delivery?: {
@@ -3520,7 +3530,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["delivery"]
readonly resume?: {
@@ -3541,12 +3551,12 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["resume"]
}
export type SessionPromptOutput = { data: SessionInboxUser }["data"]
export type SessionPromptOutput = { data: SessionPendingUser }["data"]
export type SessionCommandInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -3570,7 +3580,7 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["id"]
readonly command: {
@@ -3593,7 +3603,7 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["command"]
readonly arguments?: {
@@ -3616,7 +3626,7 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["arguments"]
readonly agent?: {
@@ -3639,7 +3649,7 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["agent"]
readonly model?: {
@@ -3662,7 +3672,7 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["model"]
readonly files?: {
@@ -3685,7 +3695,7 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["files"]
readonly agents?: {
@@ -3708,7 +3718,7 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["agents"]
readonly skills?: {
@@ -3731,7 +3741,7 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["skills"]
readonly delivery?: {
@@ -3754,7 +3764,7 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["delivery"]
readonly resume?: {
@@ -3777,12 +3787,12 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["resume"]
}
export type SessionCommandOutput = { data: SessionInboxUser }["data"]
export type SessionCommandOutput = { data: SessionPendingUser }["data"]
export type SessionSkillInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -3812,7 +3822,7 @@ export type SessionSyntheticInput = {
readonly text: string
readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["id"]
readonly text: {
@@ -3820,7 +3830,7 @@ export type SessionSyntheticInput = {
readonly text: string
readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["text"]
readonly description?: {
@@ -3828,7 +3838,7 @@ export type SessionSyntheticInput = {
readonly text: string
readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["description"]
readonly metadata?: {
@@ -3836,7 +3846,7 @@ export type SessionSyntheticInput = {
readonly text: string
readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["metadata"]
readonly delivery?: {
@@ -3844,7 +3854,7 @@ export type SessionSyntheticInput = {
readonly text: string
readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["delivery"]
readonly resume?: {
@@ -3852,12 +3862,12 @@ export type SessionSyntheticInput = {
readonly text: string
readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["resume"]
}
export type SessionSyntheticOutput = { data: SessionInboxSynthetic }["data"]
export type SessionSyntheticOutput = { data: SessionPendingSynthetic }["data"]
export type SessionShellInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -3869,14 +3879,10 @@ export type SessionShellOutput = void
export type SessionCompactInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: { readonly id?: string | undefined; readonly delivery?: ("steer" | "queue") | undefined }["id"]
readonly delivery?: {
readonly id?: string | undefined
readonly delivery?: ("steer" | "queue") | undefined
}["delivery"]
readonly id?: { readonly id?: string | undefined }["id"]
}
export type SessionCompactOutput = { data: SessionInboxCompaction }["data"]
export type SessionCompactOutput = { data: SessionPendingCompaction }["data"]
export type SessionWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
@@ -3902,30 +3908,30 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionPendingListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"]
export type SessionInboxCancelInput = {
readonly sessionID: { readonly sessionID: string; readonly inboxID: string }["sessionID"]
readonly inboxID: { readonly sessionID: string; readonly inboxID: string }["inboxID"]
export type SessionPendingCancelInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionInboxCancelOutput = void
export type SessionPendingCancelOutput = void
export type SessionInboxSteerInput = {
readonly sessionID: { readonly sessionID: string; readonly inboxID: string }["sessionID"]
readonly inboxID: { readonly sessionID: string; readonly inboxID: string }["inboxID"]
export type SessionPendingSteerInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionInboxSteerOutput = void
export type SessionPendingSteerOutput = void
export type SessionInboxQueueInput = {
readonly sessionID: { readonly sessionID: string; readonly inboxID: string }["sessionID"]
readonly inboxID: { readonly sessionID: string; readonly inboxID: string }["inboxID"]
export type SessionPendingQueueInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionInboxQueueOutput = void
export type SessionPendingQueueOutput = void
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
@@ -5335,7 +5341,7 @@ export type FileListOutput = {
data: Array<FileSystemEntry>
}
export type FileGetInput = {
export type FileFindInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
@@ -5362,7 +5368,7 @@ export type FileGetInput = {
}["limit"]
}
export type FileGetOutput = {
export type FileFindOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<FileSystemEntry>
}
+2 -4
View File
@@ -213,7 +213,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
expect(result.created.id).toBe("ses_test")
expect(Object.getPrototypeOf(result.admitted)).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.admitted.payload)).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.admitted.data)).toBe(Object.prototype)
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
expect(result.context).toEqual([])
expect(logQueries[0]).toEqual({ after: "0" })
@@ -271,7 +271,7 @@ const admission = {
id: "msg_test",
sessionID: "ses_test",
type: "user",
payload: { text: "Hello" },
data: { text: "Hello" },
delivery: "steer",
timeCreated: 1_717_171_717_000,
},
@@ -280,8 +280,6 @@ const admission = {
const compactionAdmission = {
data: {
type: "compaction",
payload: {},
delivery: "queue",
id: "msg_compaction",
sessionID: "ses_test",
timeCreated: 1_717_171_717_000,
+11 -11
View File
@@ -373,7 +373,7 @@ test("session instructions methods use the public HTTP contract", async () => {
])
})
test("session.inbox.list uses the public HTTP contract", async () => {
test("session.pending.list uses the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const pending = [
{
@@ -381,7 +381,7 @@ test("session.inbox.list uses the public HTTP contract", async () => {
sessionID: "ses_test",
timeCreated: 1_717_171_717_000,
type: "user",
payload: { text: "Fix the failing tests" },
data: { text: "Fix the failing tests" },
delivery: "steer",
},
]
@@ -394,13 +394,13 @@ test("session.inbox.list uses the public HTTP contract", async () => {
},
})
const result = await client.session.inbox.list({ sessionID: "ses_test" })
const result = await client.session.pending.list({ sessionID: "ses_test" })
expect(result).toEqual(pending)
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/inbox" }])
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
})
test("session.inbox mutations use the public HTTP contract", async () => {
test("session.pending mutations use the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
@@ -411,14 +411,14 @@ 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" })
await client.session.pending.cancel({ sessionID: "ses_test", inputID: "msg_cancel" })
await client.session.pending.steer({ sessionID: "ses_test", inputID: "msg_steer" })
await client.session.pending.queue({ sessionID: "ses_test", inputID: "msg_queue" })
expect(requests).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: "DELETE", url: "http://localhost:3000/api/session/ses_test/pending/msg_cancel" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_steer/steer" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_queue/queue" },
])
})
+1 -1
View File
@@ -6866,7 +6866,7 @@
"/api/fs/find": {
"get": {
"tags": ["filesystem"],
"operationId": "v2.fs.get",
"operationId": "v2.fs.find",
"parameters": [
{
"name": "location",
+2 -134
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "5c1aa56b-c3ee-4283-9a84-c0bf626dc604",
"prevIds": ["00924d88-1842-4d71-ac74-5682ddc47e1c"],
"id": "00924d88-1842-4d71-ac74-5682ddc47e1c",
"prevIds": ["15060ec5-05f7-4b86-b2a5-9108609432b3"],
"ddl": [
{
"name": "account_state",
@@ -56,10 +56,6 @@
"name": "instruction_state",
"entityType": "tables"
},
{
"name": "session_inbox",
"entityType": "tables"
},
{
"name": "session_message",
"entityType": "tables"
@@ -846,76 +842,6 @@
"entityType": "columns",
"table": "instruction_state"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "type",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "payload",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "delivery",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "enqueued_seq",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "text",
"notNull": false,
@@ -1502,17 +1428,6 @@
"entityType": "fks",
"table": "instruction_state"
},
{
"columns": ["session_id"],
"tableTo": "session_v2",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_inbox_session_id_session_v2_id_fk",
"entityType": "fks",
"table": "session_inbox"
},
{
"columns": ["session_id"],
"tableTo": "session_v2",
@@ -1637,13 +1552,6 @@
"table": "instruction_state",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "session_inbox_pk",
"table": "session_inbox",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
@@ -1734,46 +1642,6 @@
"entityType": "indexes",
"table": "permission"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
},
{
"value": "delivery",
"isExpression": false
},
{
"value": "enqueued_seq",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_inbox_session_delivery_seq_idx",
"entityType": "indexes",
"table": "session_inbox"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
},
{
"value": "enqueued_seq",
"isExpression": false
}
],
"isUnique": true,
"where": null,
"origin": "manual",
"name": "session_inbox_session_enqueued_seq_idx",
"entityType": "indexes",
"table": "session_inbox"
},
{
"columns": [
{
+30 -196
View File
@@ -7,7 +7,6 @@ import { and, asc, eq, gt, lte, sql } from "drizzle-orm"
import { Database } from "./database/database.js"
import { EventSequenceTable, EventTable } from "./event/sql.js"
import { Location } from "./location.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { isDeepStrictEqual } from "node:util"
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
@@ -93,16 +92,6 @@ export interface PublishOptions {
readonly commit?: (seq: number) => Effect.Effect<void>
}
export type PublishInput<D extends Event.DurableDefinition = Event.DurableDefinition> = readonly [
definition: D,
data: Event.Data<D>,
options?: PublishOptions,
]
export type PublishResult<I extends readonly PublishInput[]> = {
readonly [K in keyof I]: I[K] extends PublishInput<infer D> ? Event.Payload<D> : never
}
/** Marker/event union emitted by `log`. */
export type LogItem = Event.Payload | EventLog.Synced
@@ -135,9 +124,6 @@ export interface Interface {
data: Event.Data<D>,
options?: PublishOptions,
) => Effect.Effect<Event.Payload<D>>
readonly publishAll: <const I extends readonly [PublishInput, ...PublishInput[]]>(
events: I,
) => Effect.Effect<PublishResult<I>>
readonly subscribe: Subscribe
/**
* Durable, ordered per-aggregate log read. Forked aggregates may reserve an
@@ -190,7 +176,6 @@ export function configured(options?: Options) {
}
const projectors = new Map<string, Subscriber[]>()
const listeners = new Array<Subscriber>()
const durableLocks = KeyedMutex.makeUnsafe<string>()
const { db } = yield* Database.Service
const logReadPageSize = options?.logReadPageSize ?? 512
const persist = options?.persist ?? false
@@ -400,23 +385,15 @@ export function configured(options?: Options) {
}),
)
if (definition?.durable) {
const aggregateID = (event.data as Record<string, unknown>)[definition.durable.aggregate]
if (typeof aggregateID !== "string")
return yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit).pipe(
Effect.as(event),
)
return yield* durableLocks.withLock(aggregateID)(
Effect.gen(function* () {
const committed = yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit)
if (!committed) return event
event = {
...event,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
}
yield* notify(event as Event.Payload, true)
return event
}),
)
const committed = yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit)
if (committed) {
event = {
...event,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
}
yield* notify(event as Event.Payload, true)
return event
}
}
yield* notify(event as Event.Payload, false)
return event
@@ -467,144 +444,6 @@ export function configured(options?: Options) {
})
}
function publishAll<const I extends readonly [PublishInput, ...PublishInput[]]>(events: I) {
return Effect.gen(function* () {
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const payloads = yield* Effect.forEach(events, ([definition, data, options]) =>
Effect.gen(function* () {
const aggregateID = (data as Record<string, unknown>)[definition.durable.aggregate]
if (typeof aggregateID !== "string") {
return yield* Effect.die(
new InvalidDurableEventError({
type: definition.type,
message: `Expected string aggregate field ${definition.durable.aggregate}`,
}),
)
}
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
return {
definition,
aggregateID,
commit: options?.commit,
event: {
id: options?.id ?? Event.ID.create(),
created: yield* DateTime.now,
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(location ? { location } : {}),
data,
} as Event.Payload,
}
}),
)
const aggregateID = payloads[0].aggregateID
if (payloads.some((item) => item.aggregateID !== aggregateID)) {
return yield* Effect.die(
new InvalidDurableEventError({
type: payloads[0].definition.type,
message: "Published events must belong to the same aggregate",
}),
)
}
return yield* durableLocks.withLock(aggregateID)(
Effect.uninterruptible(
Effect.gen(function* () {
const committed = yield* db
.transaction(
() =>
Effect.gen(function* () {
const row = yield* db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
const firstSeq = (row?.seq ?? -1) + 1
const finalSeq = firstSeq + payloads.length - 1
const result = new Array<Event.Payload>()
const rows = new Array<typeof EventTable.$inferInsert>()
const ids = new Set<Event.ID>()
for (const [index, item] of payloads.entries()) {
const seq = firstSeq + index
const encoded = Schema.encodeUnknownSync(item.definition.data)(item.event.data) as Record<
string,
unknown
>
if (persist) {
if (ids.has(item.event.id))
yield* Effect.die(
new InvalidDurableEventError({
type: item.event.type,
message: `Event ${item.event.id} appears more than once in the batch`,
}),
)
ids.add(item.event.id)
const stored = yield* db
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.id, item.event.id))
.get()
.pipe(Effect.orDie)
if (stored)
yield* Effect.die(
new InvalidDurableEventError({
type: item.event.type,
message: `Event ${item.event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}),
)
}
const event = {
...item.event,
durable: envelope(aggregateID, seq, item.definition.durable.version),
} as Event.Payload
for (const projector of projectors.get(
versionedType(item.definition.type, item.definition.durable.version),
) ?? []) {
yield* projector(event)
}
if (item.commit) yield* item.commit(seq)
if (persist)
rows.push({
id: event.id,
aggregate_id: aggregateID,
seq,
created: DateTime.toEpochMillis(event.created),
type: versionedType(item.definition.type, item.definition.durable.version),
data: encoded,
})
result.push(event)
}
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq: finalSeq }])
.onConflictDoUpdate({ target: EventSequenceTable.aggregate_id, set: { seq: finalSeq } })
.run()
.pipe(Effect.orDie)
if (persist) yield* db.insert(EventTable).values(rows).run().pipe(Effect.orDie)
return result
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
yield* Effect.forEach(
pubsub.durable.get(aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
{
discard: true,
},
)
yield* Effect.forEach(committed, (event) => notify(event, true), { discard: true })
return committed as PublishResult<I>
}),
),
)
})
}
function replay(
event: SerializedEvent,
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
@@ -616,31 +455,27 @@ export function configured(options?: Options) {
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
)
} else {
yield* durableLocks.withLock(event.aggregateID)(
Effect.gen(function* () {
const payload = {
id: event.id,
created: event.created ?? DateTime.makeUnsafe(0),
type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Event.Payload
const committed = yield* commitDurableEvent(definition, payload, {
seq: event.seq,
aggregateID: event.aggregateID,
ownerID: options?.ownerID,
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
yield* notify(
{
...payload,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
},
true,
)
}
}),
)
const payload = {
id: event.id,
created: event.created ?? DateTime.makeUnsafe(0),
type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Event.Payload
const committed = yield* commitDurableEvent(definition, payload, {
seq: event.seq,
aggregateID: event.aggregateID,
ownerID: options?.ownerID,
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
yield* notify(
{
...payload,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
},
true,
)
}
}
})
}
@@ -857,7 +692,6 @@ export function configured(options?: Options) {
return Service.of({
publish,
publishAll,
subscribe,
log,
listen,
-2
View File
@@ -41,7 +41,6 @@ import m38 from "./migration/20260804233008_loose_psylocke.js"
import m39 from "./migration/20260805200742_import_legacy_credentials.js"
import m40 from "./migration/20260808023530_workspace_domain.js"
import m41 from "./migration/20260811161259_execution_claim_attempts.js"
import m42 from "./migration/20260812181746_session_inbox.js"
export const migrations = [
m00,
@@ -86,5 +85,4 @@ export const migrations = [
m39,
m40,
m41,
m42,
] satisfies DatabaseMigration.Migration[]
@@ -1,30 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260812181746_session_inbox",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_inbox\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`payload\` text NOT NULL,
\`delivery\` text NOT NULL,
\`enqueued_seq\` integer NOT NULL,
\`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
);
`)
yield* tx.run(
`CREATE INDEX \`session_inbox_session_delivery_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`delivery\`,\`enqueued_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_inbox_session_enqueued_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`enqueued_seq\`);`,
)
})
},
}
export default migration
-18
View File
@@ -142,18 +142,6 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
CONSTRAINT \`fk_instruction_state_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_inbox\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`payload\` text NOT NULL,
\`delivery\` text NOT NULL,
\`enqueued_seq\` integer NOT NULL,
\`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
);
`)
yield* tx.run(`
CREATE TABLE \`session_message\` (
\`id\` text PRIMARY KEY,
@@ -230,12 +218,6 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
yield* tx.run(
`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_inbox_session_delivery_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`delivery\`,\`enqueued_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_inbox_session_enqueued_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`enqueued_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
)
@@ -1,17 +0,0 @@
import { Effect, Layer, PlatformError } from "effect"
import { ChildProcessSpawner, make } from "effect/unstable/process/ChildProcessSpawner"
export const spawner = make(() =>
Effect.fail(
PlatformError.systemError({
_tag: "Unknown",
module: "Environment",
method: "spawn",
description: "This location has no execution plane: no workspace is attached and the host cannot spawn processes",
}),
),
)
export const layer = Layer.succeed(ChildProcessSpawner, spawner)
export * as EnvironmentUnavailable from "./unavailable.js"
+7
View File
@@ -165,6 +165,8 @@ export const Options = Schema.Struct({
version: Schema.String,
}),
),
/** Set false on runtimes that cannot spawn child processes; local (stdio) servers report failed instead of connecting. */
stdio: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
@@ -498,6 +500,11 @@ export const layer = (options?: Options) =>
const startServer = (name: ServerName, entry: ServerEntry) =>
Effect.gen(function* () {
if (options?.stdio === false && entry.config.type === "local") {
entry.status = { status: "failed", error: "stdio MCP servers are unavailable in this runtime" }
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
return
}
// Announce the handshake so connect() and credential reconnects don't show a stale
// disabled/failed status for the duration of the connection attempt.
entry.status = { status: "pending" }
@@ -204,11 +204,10 @@ export const GithubCopilotPlugin = define({
for (const [id, model] of loaded.models) {
evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model)))
}
} else {
} else if (loaded.baseURL) {
for (const id of item.models.keys()) {
evt.model.update(item.provider.id, id, (model) => {
model.package = "@ai-sdk/github-copilot"
if (loaded.baseURL) model.settings = Provider.mergeOverlay(model.settings, { baseURL: loaded.baseURL })
model.settings = Provider.mergeOverlay(model.settings, { baseURL: loaded.baseURL })
})
}
}
+57 -60
View File
@@ -31,7 +31,7 @@ import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/err
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LocationServiceMap } from "./location-service-map.js"
import { SessionEvent } from "./session/event.js"
import { SessionInbox } from "./session/inbox.js"
import { SessionPending } from "./session/pending.js"
import { InstructionState } from "./session/instruction-state.js"
import { SessionGenerate } from "./session/generate.js"
import { Snapshot } from "./snapshot.js"
@@ -99,7 +99,6 @@ type CreateInput = CreateBaseInput &
type CompactInput = {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
delivery?: SessionInbox.Delivery
}
type ForkInput = {
@@ -134,11 +133,14 @@ export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionC
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
export class InboxConflictError extends Schema.TaggedErrorClass<InboxConflictError>()("Session.InboxConflictError", {
sessionID: SessionSchema.ID,
inboxID: SessionMessage.ID,
}) {}
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
export class PendingInputConflictError extends Schema.TaggedErrorClass<PendingInputConflictError>()(
"Session.PendingInputConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
type PendingInputRef = { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID }
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID,
}) {}
@@ -186,10 +188,10 @@ export interface Interface {
* ordered by admission. 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 pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
readonly cancelPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
readonly steerPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
readonly queuePending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
/**
* Durable, ordered session log read. Replays durable session bus after
* the exclusive `after` cursor, emits a `Synced` marker at the captured
@@ -209,7 +211,6 @@ export interface Interface {
sessionID: SessionSchema.ID
directory: AbsolutePath
workspaceID?: Location.Ref["workspaceID"]
delivery?: SessionInbox.Delivery
}) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
readonly prompt: (input: {
id?: SessionMessage.ID
@@ -219,9 +220,9 @@ export interface Interface {
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
metadata?: Record<string, unknown>
delivery?: SessionInbox.Delivery
delivery?: SessionPending.Delivery
resume?: boolean
}) => Effect.Effect<SessionInbox.User, NotFoundError | PromptConflictError | AttachmentError | SkillNotFoundError>
}) => Effect.Effect<SessionPending.User, NotFoundError | PromptConflictError | AttachmentError | SkillNotFoundError>
/** Generates text from current Session context without admitting input or mutating history. */
readonly generate: (input: {
sessionID: SessionSchema.ID
@@ -237,10 +238,10 @@ export interface Interface {
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
delivery?: SessionInbox.Delivery
delivery?: SessionPending.Delivery
resume?: boolean
}) => Effect.Effect<
SessionInbox.User,
SessionPending.User,
| NotFoundError
| PromptConflictError
| AttachmentError
@@ -261,7 +262,7 @@ export interface Interface {
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
readonly compact: (
input: CompactInput,
) => Effect.Effect<SessionInbox.Compaction, NotFoundError | CompactionConflictError>
) => Effect.Effect<SessionPending.Compaction, NotFoundError | CompactionConflictError>
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
@@ -273,9 +274,9 @@ export interface Interface {
text: string
description?: string
metadata?: Record<string, unknown>
delivery?: SessionInbox.Delivery
delivery?: SessionPending.Delivery
resume?: boolean
}) => Effect.Effect<SessionInbox.Synthetic, NotFoundError | SyntheticConflictError>
}) => Effect.Effect<SessionPending.Synthetic, NotFoundError | SyntheticConflictError>
readonly revert: {
readonly stage: (input: {
sessionID: SessionSchema.ID
@@ -320,12 +321,12 @@ const layer = Layer.effect(
),
)
const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: InboxItemRef) {
const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: PendingInputRef) {
yield* result.get(input.sessionID)
return yield* new InboxConflictError(input)
return yield* new PendingInputConflictError(input)
})
const mutatePending = (
input: InboxItemRef,
input: PendingInputRef,
mutation: (
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
@@ -334,9 +335,9 @@ const layer = Layer.effect(
) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* mutation(bus, { sessionID: input.sessionID, id: input.inboxID }).pipe(
yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict ? pendingConflict(input) : Effect.die(defect),
defect instanceof SessionPending.LifecycleConflict ? pendingConflict(input) : Effect.die(defect),
),
)
if (wake) yield* execution.wake(input.sessionID)
@@ -528,13 +529,13 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
inbox: Effect.fn("Session.inbox")(function* (sessionID) {
pending: Effect.fn("Session.pending")(function* (sessionID) {
yield* result.get(sessionID)
return yield* SessionInbox.list(db, sessionID)
return yield* SessionPending.list(db, sessionID)
}),
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)),
cancelPending: Effect.fn("Session.cancelPending")((input) => mutatePending(input, SessionPending.cancel)),
steerPending: Effect.fn("Session.steerPending")((input) => mutatePending(input, SessionPending.steer, true)),
queuePending: Effect.fn("Session.queuePending")((input) => mutatePending(input, SessionPending.queue)),
log: (input) =>
Stream.unwrap(
result
@@ -563,25 +564,25 @@ const layer = Layer.effect(
skills,
).pipe(Effect.provideService(FSUtil.Service, fs))
const messageID = input.id ?? SessionMessage.ID.create()
const admittedInput = SessionInbox.Item.make({
const admittedInput = SessionPending.Message.make({
type: "user",
payload: { ...prompt, metadata: input.metadata },
data: { ...prompt, metadata: input.metadata },
delivery: input.delivery ?? "steer",
})
const admitted = yield* SessionInbox.admit(db, bus, {
const admitted = yield* SessionPending.admit(db, bus, {
id: messageID,
sessionID: input.sessionID,
item: admittedInput,
input: admittedInput,
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict
defect instanceof SessionPending.LifecycleConflict
? new PromptConflictError({ sessionID: input.sessionID, messageID })
: Effect.die(defect),
),
)
if (
admitted.type !== "user" ||
!SessionInbox.equivalent(admitted, { sessionID: input.sessionID, item: admittedInput })
!SessionPending.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput })
)
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) {
@@ -639,9 +640,7 @@ const layer = Layer.effect(
yield* execution.awaitIdle(input.sessionID)
const started = yield* Effect.gen(function* () {
const shell = yield* Shell.Service
return yield* shell
.create({ command: input.command, cwd: session.location.directory, timeout: 0 })
.pipe(Effect.orDie)
return yield* shell.create({ command: input.command, cwd: session.location.directory, timeout: 0 })
}).pipe(Effect.provide(locations.get(session.location)))
yield* bus.publish(
SessionEvent.Shell.Started,
@@ -739,35 +738,33 @@ const layer = Layer.effect(
const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info) return yield* new DestinationNotFoundError({ directory })
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
if (current.location.directory === directory && current.location.workspaceID === input.workspaceID) return
const project = yield* projects.resolve(directory)
yield* persistProject(project)
const item = SessionInbox.Item.make({
type: "move",
payload: {
if ((yield* execution.active).has(input.sessionID)) {
yield* execution.interrupt(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
}
yield* bus.publish(
SessionEvent.Moved,
{
sessionID: input.sessionID,
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id,
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
},
delivery: input.delivery ?? "queue",
})
const inboxID = SessionMessage.ID.create()
yield* SessionInbox.admit(db, bus, {
id: inboxID,
sessionID: input.sessionID,
item,
})
yield* execution.wake(input.sessionID)
{ location: current.location },
)
}),
compact: Effect.fn("Session.compact")(function* (input) {
yield* result.get(input.sessionID)
const inputID = input.id ?? SessionMessage.ID.create()
const admitted = yield* SessionInbox.admitCompaction(db, bus, {
const admitted = yield* SessionPending.admitCompaction(db, bus, {
id: inputID,
sessionID: input.sessionID,
delivery: input.delivery ?? "queue",
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict
defect instanceof SessionPending.LifecycleConflict
? new CompactionConflictError({ sessionID: input.sessionID, inputID })
: Effect.die(defect),
),
@@ -807,29 +804,29 @@ const layer = Layer.effect(
Effect.gen(function* () {
yield* result.get(input.sessionID)
const inputID = input.id ?? SessionMessage.ID.create()
const admittedInput = SessionInbox.Item.make({
const admittedInput = SessionPending.Message.make({
type: "synthetic",
payload: {
data: {
text: input.text,
description: input.description,
metadata: input.metadata,
},
delivery: input.delivery ?? "steer",
})
const admitted = yield* SessionInbox.admit(db, bus, {
const admitted = yield* SessionPending.admit(db, bus, {
id: inputID,
sessionID: input.sessionID,
item: admittedInput,
input: admittedInput,
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict
defect instanceof SessionPending.LifecycleConflict
? new SyntheticConflictError({ sessionID: input.sessionID, inputID })
: Effect.die(defect),
),
)
if (
admitted.type !== "synthetic" ||
!SessionInbox.equivalent(admitted, { sessionID: input.sessionID, item: admittedInput })
!SessionPending.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput })
)
return yield* new SyntheticConflictError({ sessionID: input.sessionID, inputID })
if (input.resume !== false && !(yield* result.get(input.sessionID)).revert)
@@ -842,7 +839,7 @@ const layer = Layer.effect(
Effect.uninterruptible(
Effect.gen(function* () {
yield* execution.interrupt(sessionID)
if (options?.continue && (yield* SessionInbox.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
if (options?.continue && (yield* SessionPending.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
}),
),
),
+6 -10
View File
@@ -89,7 +89,6 @@ export type ManualInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
readonly inputID: SessionMessage.ID
readonly started?: boolean
}
type RequiredInput = Omit<AutoInput, "ref">
@@ -103,7 +102,6 @@ type Plan = {
readonly prompt: string
readonly recent: string
readonly inputID?: SessionMessage.ID
readonly started?: boolean
}
export type Outcome =
@@ -250,13 +248,12 @@ const make = (dependencies: Dependencies) => {
return { status: "failed" as const, error: input.error }
})
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
if (!plan.started)
yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
sessionID: plan.session.id,
reason: plan.reason,
recent: plan.recent,
inputID: plan.inputID,
})
yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
sessionID: plan.session.id,
reason: plan.reason,
recent: plan.recent,
inputID: plan.inputID,
})
const chunks: string[] = []
let failure: SessionError.Error | undefined
@@ -411,7 +408,6 @@ const make = (dependencies: Dependencies) => {
cost: resolved.cost,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
})
})
+1 -5
View File
@@ -1,6 +1,6 @@
export * as SessionExecution from "./execution.js"
import { Cause, Context, Effect, Exit, Layer, Stream } from "effect"
import { Cause, Context, Effect, Exit, Layer } from "effect"
import { Bus } from "../bus.js"
import { LocationServiceMap } from "../location-service-map.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -116,10 +116,6 @@ export const layer = Layer.effect(
}),
),
})
yield* bus.subscribe(SessionEvent.Moved).pipe(
Stream.runForEach((event) => coordinator.wake(event.data.sessionID)),
Effect.forkScoped,
)
return Service.of({
active: coordinator.active,
-505
View File
@@ -1,505 +0,0 @@
export * as SessionInbox from "./inbox.js"
import { and, asc, eq, or } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import {
Compaction,
CompactionPayload,
Delivery,
Info,
Item,
Move,
MovePayload,
Synthetic,
SyntheticPayload,
User,
UserPayload,
} from "@opencode-ai/schema/session-inbox"
import type { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { KeyedMutex } from "../effect/keyed-mutex.js"
import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionInboxTable, SessionMessageTable } from "./sql.js"
type DatabaseService = Database.Interface["db"]
export {
Compaction,
CompactionPayload,
Delivery,
Info,
Item,
Move,
MovePayload,
Synthetic,
SyntheticPayload,
User,
UserPayload,
}
/**
* Which pending input `promote` may consume: "steer" promotes steers only (a step
* boundary mid-work), while "input" also allows one queued input when no steers are
* waiting (the idle boundary, where the Session picks up fresh work).
*/
export type Promotable = "input" | "steer"
const decodeUser = Schema.decodeUnknownSync(UserPayload)
const encodeUser = Schema.encodeSync(UserPayload)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticPayload)
const encodeSynthetic = Schema.encodeSync(SyntheticPayload)
const decodeCompaction = Schema.decodeUnknownSync(CompactionPayload)
const encodeCompaction = Schema.encodeSync(CompactionPayload)
const decodeMove = Schema.decodeUnknownSync(MovePayload)
const encodeMove = Schema.encodeSync(MovePayload)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
export const serialized = <A, E, R>(sessionID: SessionSchema.ID, effect: Effect.Effect<A, E, R>) =>
inboxLocks.withLock(sessionID)(effect)
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInbox.LifecycleConflict", {
id: SessionMessage.ID,
}) {}
const fromRow = (row: typeof SessionInboxTable.$inferSelect): Info => {
const base = {
id: SessionMessage.ID.make(row.id),
sessionID: SessionSchema.ID.make(row.session_id),
timeCreated: DateTime.makeUnsafe(row.time_created),
}
if (row.type === "compaction")
return Compaction.make({
...base,
type: "compaction",
payload: decodeCompaction(row.payload),
delivery: row.delivery,
})
if (row.type === "move")
return Move.make({ ...base, type: "move", payload: decodeMove(row.payload), delivery: row.delivery })
if (row.type === "user")
return User.make({
...base,
type: "user",
payload: decodeUser(row.payload),
delivery: row.delivery,
})
if (row.type === "synthetic")
return Synthetic.make({
...base,
type: "synthetic",
payload: decodeSynthetic(row.payload),
delivery: row.delivery,
})
throw new LifecycleConflict({ id: base.id })
}
export const find = Effect.fn("SessionInbox.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
const row = yield* db.select().from(SessionInboxTable).where(eq(SessionInboxTable.id, id)).get().pipe(Effect.orDie)
return row === undefined ? undefined : fromRow(row)
})
const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
id: SessionMessage.ID,
delivery: Delivery,
) {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, id))
.get()
.pipe(Effect.orDie)
if (row === undefined) return undefined
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
return yield* Effect.die(new LifecycleConflict({ id }))
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
const base = { id, sessionID, timeCreated: message.time.created, delivery }
if (message.type === "user")
return User.make({
...base,
type: "user",
payload: decodeUser(message),
})
if (message.type === "synthetic")
return Synthetic.make({
...base,
type: "synthetic",
payload: decodeSynthetic(message),
})
return yield* Effect.die(new LifecycleConflict({ id }))
})
export const admit = Effect.fn("SessionInbox.admit")(function* (
db: DatabaseService,
bus: Bus.Interface,
request: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly item: Item
},
) {
const existing = yield* find(db, request.id)
if (existing !== undefined) {
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
return existing
}
const promoted = yield* promotedFromMessage(db, request.sessionID, request.id, request.item.delivery)
if (promoted !== undefined) return promoted
return yield* bus
.publish(SessionEvent.InboxEnqueued, {
inboxID: request.id,
sessionID: request.sessionID,
item: request.item,
})
.pipe(
Effect.flatMap((event) => {
const base = {
id: request.id,
sessionID: request.sessionID,
timeCreated: event.created,
}
return Effect.succeed(Info.make({ ...base, ...request.item }))
}),
Effect.catchDefect((defect) =>
find(db, request.id).pipe(
Effect.flatMap((stored) =>
stored?.type === request.item.type ? Effect.succeed(stored) : Effect.die(defect),
),
),
),
)
})
export const admitCompaction = Effect.fn("SessionInbox.admitCompaction")(function* (
db: DatabaseService,
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID; readonly delivery: Delivery },
) {
const admitted = yield* admit(db, bus, {
id: input.id,
sessionID: input.sessionID,
item: Item.make({ type: "compaction", payload: {}, delivery: input.delivery }),
})
if (admitted.type === "compaction") return admitted
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(function* (
db: DatabaseService,
request: {
readonly enqueuedSeq: number
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly item: Item
readonly timeCreated: DateTime.Utc
},
) {
const message = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, request.id))
.get()
.pipe(Effect.orDie)
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
const stored = yield* db
.insert(SessionInboxTable)
.values({
id: request.id,
session_id: request.sessionID,
type: request.item.type,
payload:
request.item.type === "user"
? encodeUser(request.item.payload)
: request.item.type === "synthetic"
? encodeSynthetic(request.item.payload)
: request.item.type === "compaction"
? encodeCompaction(request.item.payload)
: encodeMove(request.item.payload),
delivery: request.item.delivery,
enqueued_seq: request.enqueuedSeq,
time_created: DateTime.toEpochMillis(request.timeCreated),
})
.onConflictDoNothing()
.returning({ id: SessionInboxTable.id })
.get()
.pipe(Effect.orDie)
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
})
/**
* Consume one pending row at promotion. The row's content feeds the projected
* message insert inside the same event transaction; the deleted row is what
* makes the table pending-only.
*/
export const projectDelivered = Effect.fn("SessionInbox.projectDelivered")(function* (
db: DatabaseService,
input: PendingRef,
) {
const deleted = yield* db
.delete(SessionInboxTable)
.where(and(eq(SessionInboxTable.id, input.id), eq(SessionInboxTable.session_id, input.sessionID)))
.returning()
.get()
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return fromRow(deleted)
})
export const projectCancelled = Effect.fn("SessionInbox.projectCancelled")(function* (
db: DatabaseService,
input: PendingRef,
) {
const deleted = yield* db
.delete(SessionInboxTable)
.where(
and(
eq(SessionInboxTable.id, input.id),
eq(SessionInboxTable.session_id, input.sessionID),
or(eq(SessionInboxTable.delivery, "queue"), eq(SessionInboxTable.delivery, "steer")),
),
)
.returning({ id: SessionInboxTable.id })
.get()
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
const projectDelivery = Effect.fn("SessionInbox.projectDelivery")(function* (
db: DatabaseService,
input: PendingRef & { readonly from: Delivery; readonly to: Delivery },
) {
const updated = yield* db
.update(SessionInboxTable)
.set({ delivery: input.to })
.where(
and(
eq(SessionInboxTable.id, input.id),
eq(SessionInboxTable.session_id, input.sessionID),
eq(SessionInboxTable.delivery, input.from),
),
)
.returning({ id: SessionInboxTable.id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectDeliveryChanged = Effect.fn("SessionInbox.projectDeliveryChanged")(
(db: DatabaseService, input: PendingRef & { readonly delivery: Delivery }) =>
projectDelivery(db, {
...input,
from: input.delivery === "steer" ? "queue" : "steer",
to: input.delivery,
}),
)
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))
.all()
.pipe(Effect.orDie)
return rows.map(fromRow)
})
export const nextQueued = Effect.fn("SessionInbox.nextQueued")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "queue")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
return row ? fromRow(row) : undefined
})
export const nextSteer = Effect.fn("SessionInbox.nextSteer")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
return row ? fromRow(row) : undefined
})
/**
* Which pending rows count: "any" counts every row, while "input" means any
* item in either delivery mode.
*/
export type Scope = "any" | "input" | Delivery
export const has = Effect.fn("SessionInbox.has")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
scope: Scope,
) {
const row = yield* db
.select({ id: SessionInboxTable.id })
.from(SessionInboxTable)
.where(
and(
eq(SessionInboxTable.session_id, sessionID),
scope === "any"
? undefined
: scope === "input"
? or(eq(SessionInboxTable.delivery, "steer"), eq(SessionInboxTable.delivery, "queue"))
: eq(SessionInboxTable.delivery, scope),
),
)
.limit(1)
.get()
.pipe(Effect.orDie)
return row !== undefined
})
export const equivalent = (input: Info, expected: { readonly sessionID: SessionSchema.ID; readonly item: Item }) => {
if (
input.type !== expected.item.type ||
input.delivery !== expected.item.delivery ||
input.sessionID !== expected.sessionID
)
return false
if (input.type === "user" && expected.item.type === "user")
return JSON.stringify(encodeUser(input.payload)) === JSON.stringify(encodeUser(expected.item.payload))
if (input.type === "synthetic" && expected.item.type === "synthetic")
return JSON.stringify(encodeSynthetic(input.payload)) === JSON.stringify(encodeSynthetic(expected.item.payload))
if (input.type === "compaction" && expected.item.type === "compaction") return true
if (input.type === "move" && expected.item.type === "move")
return JSON.stringify(encodeMove(input.payload)) === JSON.stringify(encodeMove(expected.item.payload))
return false
}
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
serialized(input.sessionID, effect).pipe(Effect.asVoid)
export const cancel = Effect.fn("SessionInbox.cancel")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxCancelled, {
sessionID: input.sessionID,
inboxID: input.id,
}),
),
)
export const steer = Effect.fn("SessionInbox.steer")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxDeliveryChanged, {
sessionID: input.sessionID,
inboxID: input.id,
delivery: "steer",
}),
),
)
export const queue = Effect.fn("SessionInbox.queue")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxDeliveryChanged, {
sessionID: input.sessionID,
inboxID: input.id,
delivery: "queue",
}),
),
)
const publish = Effect.fn("SessionInbox.publish")(function* (
db: DatabaseService,
bus: Bus.Interface,
sessionID: SessionSchema.ID,
rows: ReadonlyArray<typeof SessionInboxTable.$inferSelect>,
) {
yield* Effect.forEach(
rows,
(row) => {
const entry = fromRow(row)
if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id }))
return bus
.publish(SessionEvent.InboxDelivered, {
sessionID,
inboxID: entry.id,
})
.pipe(
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? promotedFromMessage(db, sessionID, entry.id, entry.delivery).pipe(
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
)
: Effect.die(defect),
),
)
},
{ discard: true },
)
return rows.length
})
/**
* Promotes pending input into visible messages and returns the promoted count.
* Steers always go first; only the "input" scope may fall through to one queued
* input, and it then collects steers that arrived during promotion.
*/
export const promote = Effect.fn("SessionInbox.promote")(function* (
db: DatabaseService,
bus: Bus.Interface,
sessionID: SessionSchema.ID,
scope: Promotable,
) {
return yield* serialized(
sessionID,
Effect.gen(function* () {
const steers = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.all()
.pipe(Effect.orDie)
if (steers.length > 0 || scope === "steer") {
const control = steers.findIndex((row) => row.type === "compaction" || row.type === "move")
return yield* publish(db, bus, sessionID, control === -1 ? steers : steers.slice(0, control))
}
const queued = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "queue")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!queued) return 0
const promoted = yield* publish(db, bus, sessionID, [queued])
const arrivedSteers = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.all()
.pipe(Effect.orDie)
const control = arrivedSteers.findIndex((row) => row.type === "compaction" || row.type === "move")
return (
promoted +
(yield* publish(db, bus, sessionID, control === -1 ? arrivedSteers : arrivedSteers.slice(0, control)))
)
}),
)
})
+11 -4
View File
@@ -109,10 +109,11 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.renamed": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
"session.inbox.delivered": () => Effect.void,
"session.inbox.enqueued": () => Effect.void,
"session.inbox.cancelled": () => Effect.void,
"session.inbox.delivery.changed": () => Effect.void,
"session.input.promoted": () => Effect.void,
"session.input.admitted": () => Effect.void,
"session.input.cancelled": () => Effect.void,
"session.input.steered": () => Effect.void,
"session.input.queued": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
@@ -194,6 +195,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.retry = undefined
draft.error = undefined
draft.finish = undefined
draft.time.started = undefined
draft.time.generated = undefined
draft.time.completed = undefined
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot }
}),
@@ -229,6 +232,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.finish = event.data.finish
draft.cost = event.data.cost
draft.tokens = event.data.tokens
draft.time.generated = event.data.generated
if (event.data.snapshot || event.data.files)
draft.snapshot = {
...draft.snapshot,
@@ -247,6 +251,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.cost = event.data.cost
draft.tokens = castDraft(event.data.tokens)
}
draft.time.generated = event.data.generated
if (event.data.snapshot || event.data.files)
draft.snapshot = {
...draft.snapshot,
@@ -257,6 +262,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
},
"session.text.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
if (draft.time.completed === undefined) draft.time.started ??= event.created
draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
})
},
@@ -379,6 +385,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
}
})
},
"session.compaction.admitted": () => Effect.void,
"session.compaction.started": (event) =>
adapter.appendMessage(
SessionMessage.CompactionRunning.make({
+545
View File
@@ -0,0 +1,545 @@
export * as SessionPending from "./pending.js"
import { and, asc, eq, or } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import {
Compaction,
Delivery,
Info,
Message,
Synthetic,
SyntheticData,
User,
UserData,
} from "@opencode-ai/schema/session-pending"
import type { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { KeyedMutex } from "../effect/keyed-mutex.js"
import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable, SessionPendingTable } from "./sql.js"
type DatabaseService = Database.Interface["db"]
export { Compaction, Delivery, Info, Message, Synthetic, SyntheticData, User, UserData }
/**
* Which pending input `promote` may consume: "steer" promotes steers only (a step
* boundary mid-work), while "input" also allows one queued input when no steers are
* waiting (the idle boundary, where the Session picks up fresh work).
*/
export type Promotable = "input" | "steer"
const decodeUser = Schema.decodeUnknownSync(UserData)
const encodeUser = Schema.encodeSync(UserData)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
const encodeSynthetic = Schema.encodeSync(SyntheticData)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
"SessionPending.LifecycleConflict",
{
id: SessionMessage.ID,
},
) {}
const fromRow = (row: typeof SessionPendingTable.$inferSelect): Info => {
const base = {
id: SessionMessage.ID.make(row.id),
sessionID: SessionSchema.ID.make(row.session_id),
timeCreated: DateTime.makeUnsafe(row.time_created),
}
if (row.type === "compaction") return Compaction.make({ ...base, type: "compaction" })
if (!row.delivery) throw new LifecycleConflict({ id: base.id })
if (row.type === "user")
return User.make({
...base,
type: "user",
data: decodeUser(row.data),
delivery: row.delivery,
})
if (row.type === "synthetic")
return Synthetic.make({
...base,
type: "synthetic",
data: decodeSynthetic(row.data),
delivery: row.delivery,
})
throw new LifecycleConflict({ id: base.id })
}
export const find = Effect.fn("SessionPending.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
const row = yield* db
.select()
.from(SessionPendingTable)
.where(eq(SessionPendingTable.id, id))
.get()
.pipe(Effect.orDie)
return row === undefined ? undefined : fromRow(row)
})
export const compaction = Effect.fn("SessionPending.compaction")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.type, "compaction")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!row) return
const entry = fromRow(row)
return entry.type === "compaction" ? entry : undefined
})
const promotedFromMessage = Effect.fn("SessionPending.promotedFromMessage")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
id: SessionMessage.ID,
delivery: Delivery,
) {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, id))
.get()
.pipe(Effect.orDie)
if (row === undefined) return undefined
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
return yield* Effect.die(new LifecycleConflict({ id }))
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
const base = { id, sessionID, timeCreated: message.time.created, delivery }
if (message.type === "user")
return User.make({
...base,
type: "user",
data: decodeUser(message),
})
if (message.type === "synthetic")
return Synthetic.make({
...base,
type: "synthetic",
data: decodeSynthetic(message),
})
return yield* Effect.die(new LifecycleConflict({ id }))
})
export const admit = Effect.fn("SessionPending.admit")(function* (
db: DatabaseService,
bus: Bus.Interface,
request: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly input: Message
},
) {
const existing = yield* find(db, request.id)
if (existing !== undefined) {
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
return existing
}
const promoted = yield* promotedFromMessage(db, request.sessionID, request.id, request.input.delivery)
if (promoted !== undefined) return promoted
return yield* bus
.publish(SessionEvent.InputAdmitted, {
inputID: request.id,
sessionID: request.sessionID,
input: request.input,
})
.pipe(
Effect.flatMap((event) => {
const base = {
id: request.id,
sessionID: request.sessionID,
timeCreated: event.created,
}
return Effect.succeed(
request.input.type === "user"
? User.make({ ...base, ...request.input })
: Synthetic.make({ ...base, ...request.input }),
)
}),
Effect.catchDefect((defect) =>
find(db, request.id).pipe(
Effect.flatMap((stored) =>
stored?.type === request.input.type ? Effect.succeed(stored) : Effect.die(defect),
),
),
),
)
})
export const admitCompaction = Effect.fn("SessionPending.admitCompaction")(function* (
db: DatabaseService,
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) {
return yield* inboxLocks.withLock(input.sessionID)(
Effect.gen(function* () {
const exact = yield* find(db, input.id)
if (exact) {
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
}
const pending = yield* compaction(db, input.sessionID)
if (pending) return pending
return yield* bus
.publish(SessionEvent.Compaction.Admitted, {
inputID: input.id,
sessionID: input.sessionID,
})
.pipe(
Effect.flatMap((event) => {
if (event.durable === undefined)
return Effect.die(new Error("Compaction admission event is missing aggregate sequence"))
return compaction(db, input.sessionID).pipe(
Effect.flatMap((stored) =>
stored ? Effect.succeed(stored) : Effect.die(new LifecycleConflict({ id: input.id })),
),
)
}),
Effect.catchDefect((defect) =>
compaction(db, input.sessionID).pipe(
Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect))),
),
),
)
}),
)
})
export const projectAdmitted = Effect.fn("SessionPending.projectAdmitted")(function* (
db: DatabaseService,
request: {
readonly admittedSeq: number
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly input: Message
readonly timeCreated: DateTime.Utc
},
) {
const message = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, request.id))
.get()
.pipe(Effect.orDie)
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
const stored = yield* db
.insert(SessionPendingTable)
.values({
id: request.id,
session_id: request.sessionID,
type: request.input.type,
data: request.input.type === "user" ? encodeUser(request.input.data) : encodeSynthetic(request.input.data),
delivery: request.input.delivery,
admitted_seq: request.admittedSeq,
time_created: DateTime.toEpochMillis(request.timeCreated),
})
.onConflictDoNothing()
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
})
export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompactionAdmitted")(function* (
db: DatabaseService,
input: {
readonly admittedSeq: number
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly timeCreated: DateTime.Utc
},
) {
const message = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, input.id))
.get()
.pipe(Effect.orDie)
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const stored = yield* db
.insert(SessionPendingTable)
.values({
id: input.id,
session_id: input.sessionID,
type: "compaction",
data: {},
admitted_seq: input.admittedSeq,
time_created: DateTime.toEpochMillis(input.timeCreated),
})
.onConflictDoNothing()
.returning()
.get()
.pipe(Effect.orDie)
if (stored) {
const entry = fromRow(stored)
return entry.type === "compaction" ? entry : yield* Effect.die(new LifecycleConflict({ id: entry.id }))
}
const pending = yield* compaction(db, input.sessionID)
if (pending) return pending
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
/**
* Consume one pending row at promotion. The row's content feeds the projected
* message insert inside the same event transaction; the deleted row is what
* makes the table pending-only.
*/
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
db: DatabaseService,
input: PendingRef,
) {
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const deleted = yield* db
.delete(SessionPendingTable)
.where(and(eq(SessionPendingTable.id, input.id), eq(SessionPendingTable.session_id, input.sessionID)))
.returning()
.get()
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const stored = fromRow(deleted)
if (stored.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return stored
})
export const projectCancelled = Effect.fn("SessionPending.projectCancelled")(function* (
db: DatabaseService,
input: PendingRef,
) {
const deleted = yield* db
.delete(SessionPendingTable)
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
or(eq(SessionPendingTable.delivery, "queue"), eq(SessionPendingTable.delivery, "steer")),
),
)
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
const projectDelivery = Effect.fn("SessionPending.projectDelivery")(function* (
db: DatabaseService,
input: PendingRef & { readonly from: Delivery; readonly to: Delivery },
) {
const updated = yield* db
.update(SessionPendingTable)
.set({ delivery: input.to })
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
eq(SessionPendingTable.delivery, input.from),
),
)
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectSteered = Effect.fn("SessionPending.projectSteered")((db: DatabaseService, input: PendingRef) =>
projectDelivery(db, { ...input, from: "queue", to: "steer" }),
)
export const projectQueued = Effect.fn("SessionPending.projectQueued")((db: DatabaseService, input: PendingRef) =>
projectDelivery(db, { ...input, from: "steer", to: "queue" }),
)
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID },
) {
const deleted = yield* db
.delete(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, input.sessionID), eq(SessionPendingTable.type, "compaction")))
.returning()
.get()
.pipe(Effect.orDie)
if (deleted) {
const stored = fromRow(deleted)
return stored.type === "compaction" ? stored : yield* Effect.die(new LifecycleConflict({ id: stored.id }))
}
return undefined
})
export const list = Effect.fn("SessionPending.list")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const rows = yield* db
.select()
.from(SessionPendingTable)
.where(eq(SessionPendingTable.session_id, sessionID))
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie)
return rows.map(fromRow)
})
/**
* Which pending rows count: "any" counts every row including compaction, while
* delivery scopes are blocked behind a pending compaction barrier. "input" means
* any model-facing input, steered or queued.
*/
export type Scope = "any" | "input" | Delivery
export const has = Effect.fn("SessionPending.has")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
scope: Scope,
) {
if (scope !== "any" && (yield* compaction(db, sessionID))) return false
const row = yield* db
.select({ id: SessionPendingTable.id })
.from(SessionPendingTable)
.where(
and(
eq(SessionPendingTable.session_id, sessionID),
scope === "any"
? undefined
: scope === "input"
? or(eq(SessionPendingTable.delivery, "steer"), eq(SessionPendingTable.delivery, "queue"))
: eq(SessionPendingTable.delivery, scope),
),
)
.limit(1)
.get()
.pipe(Effect.orDie)
return row !== undefined
})
export const equivalent = (
input: User | Synthetic,
expected: { readonly sessionID: SessionSchema.ID; readonly input: Message },
) => {
if (
input.type !== expected.input.type ||
input.delivery !== expected.input.delivery ||
input.sessionID !== expected.sessionID
)
return false
if (input.type === "user" && expected.input.type === "user")
return JSON.stringify(encodeUser(input.data)) === JSON.stringify(encodeUser(expected.input.data))
if (input.type === "synthetic" && expected.input.type === "synthetic")
return JSON.stringify(encodeSynthetic(input.data)) === JSON.stringify(encodeSynthetic(expected.input.data))
return false
}
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
inboxLocks.withLock(input.sessionID)(effect).pipe(Effect.asVoid)
export const cancel = Effect.fn("SessionPending.cancel")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputCancelled, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
export const steer = Effect.fn("SessionPending.steer")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputSteered, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
export const queue = Effect.fn("SessionPending.queue")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputQueued, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
const publish = Effect.fn("SessionPending.publish")(function* (
db: DatabaseService,
bus: Bus.Interface,
sessionID: SessionSchema.ID,
rows: ReadonlyArray<typeof SessionPendingTable.$inferSelect>,
) {
if (yield* compaction(db, sessionID)) return 0
yield* Effect.forEach(
rows,
(row) => {
const entry = fromRow(row)
if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id }))
return bus
.publish(SessionEvent.InputPromoted, {
sessionID,
inputID: entry.id,
})
.pipe(
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? promotedFromMessage(db, sessionID, entry.id, entry.delivery).pipe(
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
)
: Effect.die(defect),
),
)
},
{ discard: true },
)
return rows.length
})
/**
* Promotes pending input into visible messages and returns the promoted count.
* Steers always go first; only the "input" scope may fall through to one queued
* input, and it then collects steers that arrived during promotion.
*/
export const promote = Effect.fn("SessionPending.promote")(function* (
db: DatabaseService,
bus: Bus.Interface,
sessionID: SessionSchema.ID,
scope: Promotable,
) {
return yield* inboxLocks.withLock(sessionID)(
Effect.gen(function* () {
if (yield* compaction(db, sessionID)) return 0
const steers = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie)
if (steers.length > 0 || scope === "steer") return yield* publish(db, bus, sessionID, steers)
const queued = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "queue")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!queued) return 0
const promoted = yield* publish(db, bus, sessionID, [queued])
const arrivedSteers = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie)
return promoted + (yield* publish(db, bus, sessionID, arrivedSteers))
}),
)
})
+49 -29
View File
@@ -10,10 +10,10 @@ import { Model } from "../model.js"
import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js"
import { SessionMessageUpdater } from "./message-updater.js"
import { SessionInbox } from "./inbox.js"
import { SessionPending } from "./pending.js"
import { Workspace } from "../workspace.js"
import { InstructionState } from "./instruction-state.js"
import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql.js"
import { Slug } from "../util/slug.js"
import { Money } from "@opencode-ai/schema/money"
import { AbsolutePath, RelativePath } from "../schema.js"
@@ -470,15 +470,14 @@ const layer = Layer.effectDiscard(
)
yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* bus.project(SessionEvent.InboxDelivered, (event) =>
yield* bus.project(SessionEvent.InputPromoted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const input = yield* SessionInbox.projectDelivered(db, {
id: event.data.inboxID,
const input = yield* SessionPending.projectPromoted(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
})
if (input.type === "compaction" || input.type === "move") return
yield* insertMessage(
db,
event,
@@ -486,33 +485,33 @@ const layer = Layer.effectDiscard(
? {
id: input.id,
type: "user",
metadata: input.payload.metadata,
text: input.payload.text,
files: input.payload.files,
agents: input.payload.agents,
skills: input.payload.skills,
metadata: input.data.metadata,
text: input.data.text,
files: input.data.files,
agents: input.data.agents,
skills: input.data.skills,
time: { created: event.created },
}
: {
id: input.id,
type: "synthetic",
text: input.payload.text,
description: input.payload.description,
metadata: input.payload.metadata,
text: input.data.text,
description: input.data.description,
metadata: input.data.metadata,
time: { created: event.created },
},
)
}),
)
yield* bus.project(SessionEvent.InboxEnqueued, (event) =>
yield* bus.project(SessionEvent.InputAdmitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
yield* SessionInbox.projectAdmitted(db, {
enqueuedSeq: event.durable.seq,
id: event.data.inboxID,
yield* SessionPending.projectAdmitted(db, {
admittedSeq: event.durable.seq,
id: event.data.inputID,
sessionID: event.data.sessionID,
item: event.data.item,
input: event.data.input,
timeCreated: event.created,
})
yield* db
@@ -523,17 +522,34 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.InboxCancelled, (event) =>
SessionInbox.projectCancelled(db, {
id: event.data.inboxID,
yield* bus.project(SessionEvent.InputCancelled, (event) =>
SessionPending.projectCancelled(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.InboxDeliveryChanged, (event) =>
SessionInbox.projectDeliveryChanged(db, {
id: event.data.inboxID,
yield* bus.project(SessionEvent.InputSteered, (event) =>
SessionPending.projectSteered(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
delivery: event.data.delivery,
}),
)
yield* bus.project(SessionEvent.InputQueued, (event) =>
SessionPending.projectQueued(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
yield* SessionPending.projectCompactionAdmitted(db, {
admittedSeq: event.durable.seq,
id: event.data.inputID,
sessionID: event.data.sessionID,
timeCreated: event.created,
})
}),
)
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
@@ -580,6 +596,8 @@ const layer = Layer.effectDiscard(
yield* InstructionState.advanceEpoch(db, event.data.sessionID, event.durable.seq)
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
if (event.data.reason === "manual")
yield* SessionPending.settleCompaction(db, { sessionID: event.data.sessionID })
}),
)
yield* bus.project(SessionEvent.Compaction.Failed, (event) =>
@@ -587,6 +605,8 @@ const layer = Layer.effectDiscard(
yield* run(db, event)
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
if (event.data.reason === "manual")
yield* SessionPending.settleCompaction(db, { sessionID: event.data.sessionID })
}),
)
yield* bus.project(SessionEvent.RevertEvent.Staged, (event) =>
@@ -630,11 +650,11 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie)
yield* db
.delete(SessionInboxTable)
.delete(SessionPendingTable)
.where(
and(
eq(SessionInboxTable.session_id, event.data.sessionID),
gte(SessionInboxTable.enqueued_seq, boundary.seq),
eq(SessionPendingTable.session_id, event.data.sessionID),
gte(SessionPendingTable.admitted_seq, boundary.seq),
),
)
.run()
+30 -71
View File
@@ -17,7 +17,7 @@ import { InstructionState } from "../instruction-state.js"
import { SessionCompaction } from "../compaction.js"
import { SessionContext } from "../context.js"
import { SessionEvent } from "../event.js"
import { SessionInbox } from "../inbox.js"
import { SessionPending } from "../pending.js"
import { SessionModelRequest } from "../model-request.js"
import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
@@ -125,19 +125,13 @@ const layer = Layer.effect(
readonly sessionID: SessionSchema.ID
readonly force: boolean
}) {
let force = input.force
if (!force && !(yield* SessionInbox.has(db, input.sessionID, "any"))) return
if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "any"))) return
yield* settleStaleToolCalls(input.sessionID)
while (true) {
if (yield* runPendingCompaction(input.sessionID)) {
force = false
continue
}
if (yield* runPendingMove(input.sessionID)) return
if (!force && !(yield* SessionInbox.has(db, input.sessionID, "input"))) return
if (yield* runSteps(input.sessionID)) return
force = false
}
yield* runPendingCompaction(input.sessionID)
if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "input"))) return
do {
yield* runSteps(input.sessionID)
} while (yield* SessionPending.has(db, input.sessionID, "input"))
})
/**
@@ -146,13 +140,12 @@ const layer = Layer.effect(
*/
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (sessionID: SessionSchema.ID) {
// Fresh work may promote queued input; later steps absorb steers only.
let promotable: SessionInbox.Promotable = "input"
let promotable: SessionPending.Promotable = "input"
let step = 1
while (true) {
if (yield* runPendingCompaction(sessionID)) continue
if (yield* runPendingMove(sessionID)) return true
const result = yield* runStep(sessionID, promotable, step)
if (!result.needsContinuation && !(yield* SessionInbox.has(db, sessionID, "steer"))) return false
yield* runPendingCompaction(sessionID)
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
promotable = "steer"
step = result.step + 1
}
@@ -161,7 +154,7 @@ const layer = Layer.effect(
/** Completes one logical model step, transparently retrying or rebuilding after compaction. */
const runStep = Effect.fnUntraced(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
promotable: SessionPending.Promotable,
step: number,
) {
// Minting message identity before any attempt lets retries resume the same durable
@@ -189,7 +182,7 @@ const layer = Layer.effect(
.pipe(Effect.andThen(Effect.fail(failure.cause))),
),
)
let currentPromotable: SessionInbox.Promotable | undefined = promotable
let currentPromotable: SessionPending.Promotable | undefined = promotable
let currentStep = step
// Overflow recovery is one-shot: a call after recovery must not recover another overflow.
let recoverOverflow = true
@@ -232,7 +225,7 @@ const layer = Layer.effect(
*/
const callModel = Effect.fn("SessionRunner.callModel")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable | undefined,
promotable: SessionPending.Promotable | undefined,
step: number,
recoverOverflow: boolean,
assistantMessageID: SessionMessage.ID,
@@ -241,7 +234,7 @@ const layer = Layer.effect(
// Establish what the model knows before admitting what the user said, so
// a blocked first step leaves pending inputs untouched.
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
const promoted = promotable ? yield* SessionInbox.promote(db, bus, selected.session.id, promotable) : 0
const promoted = promotable ? yield* SessionPending.promote(db, bus, selected.session.id, promotable) : 0
if (promoted > 0) yield* startTitle(sessionID)
// Promoted input opens a fresh step allowance.
const currentStep = promoted > 0 ? 1 : step
@@ -252,7 +245,7 @@ const layer = Layer.effect(
// Make room: history must fit the context window before the call. A pending manual
// compaction owns this instead; the runner executes it between steps.
const compactionInput = { session, messages: loaded.messages, model, ref: resolved.ref, cost: resolved.cost }
if (compaction.required(compactionInput)) {
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed")
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
@@ -282,6 +275,7 @@ const layer = Layer.effect(
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) => ({
cost: SessionUsage.calculateCost(resolved.cost, finish.tokens),
tokens: finish.tokens,
generated: finish.generated,
})
const captureStepEnd = Effect.fnUntraced(function* () {
@@ -489,71 +483,36 @@ const layer = Layer.effect(
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
sessionID: SessionSchema.ID,
) {
const pending = yield* SessionPending.compaction(db, sessionID)
if (!pending) return
const session = yield* getSession(sessionID)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const pending = yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const selected =
(yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
if (selected?.type !== "compaction") return
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
[SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "", inputID: selected.id }],
])
return selected
}),
)
if (pending?.type !== "compaction") return false
const session = yield* getSession(sessionID)
const compacted = yield* restore(
Effect.gen(function* () {
return yield* compaction.compactManual({
session,
messages: yield* store.context(sessionID),
inputID: pending.id,
started: true,
})
}),
).pipe(Effect.exit)
if (Exit.isSuccess(compacted)) return true
yield* bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
error: Cause.hasInterruptsOnly(compacted.cause)
? { type: "aborted", message: "Compaction cancelled" }
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
inputID: pending.id,
})
if (Exit.isSuccess(compacted)) return
const unsettled = yield* SessionPending.compaction(db, sessionID)
if (unsettled)
yield* bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
error: Cause.hasInterruptsOnly(compacted.cause)
? { type: "aborted", message: "Compaction cancelled" }
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
inputID: unsettled.id,
})
return yield* Effect.failCause(compacted.cause)
}),
)
})
const runPendingMove = Effect.fn("SessionRunner.runPendingMove")(function* (sessionID: SessionSchema.ID) {
return yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const pending =
(yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
if (pending?.type !== "move") return false
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: pending.id }],
[
SessionEvent.Moved,
{
sessionID,
location: pending.payload.location,
projectID: pending.payload.projectID,
subpath: pending.payload.subpath,
},
],
])
return true
}),
)
})
/** Closes stale tool calls left active by an earlier interrupted drain. */
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
sessionID: SessionSchema.ID,
@@ -1,5 +1,5 @@
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Effect } from "effect"
import { DateTime, Effect } from "effect"
import { Bus } from "../../bus.js"
import { Model } from "../../model.js"
import { SessionEvent } from "../event.js"
@@ -36,6 +36,7 @@ export interface StepRecord {
readonly finish?: {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
readonly tokens: ReturnType<typeof SessionUsage.tokens>
readonly generated: DateTime.Utc
}
readonly calls: ReadonlyArray<{
readonly id: string
@@ -310,6 +311,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
const publishStepFailure = Effect.fnUntraced(function* (details?: {
readonly cost?: Money.USD
readonly tokens?: ReturnType<typeof SessionUsage.tokens>
readonly generated?: DateTime.Utc
readonly snapshot?: Snapshot.ID
readonly files?: readonly RelativePath[]
}) {
@@ -493,9 +495,14 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
return
}
case "step-finish":
const generated = yield* DateTime.now
yield* flush()
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
stepSettlement = { finish: event.reason.normalized, tokens: SessionUsage.tokens(event.usage) }
stepSettlement = {
finish: event.reason.normalized,
tokens: SessionUsage.tokens(event.usage),
generated,
}
if (event.reason.normalized === "content-filter") {
providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
+5 -27
View File
@@ -3,7 +3,7 @@ import { sql } from "drizzle-orm"
import { directoryColumn, pathColumn } from "../database/path.js"
import { ProjectTable } from "../project/sql.js"
import type { SessionMessage } from "./message.js"
import type { SessionInbox } from "./inbox.js"
import type { SessionPending } from "./pending.js"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import { PermissionV1 } from "../v1/permission.js"
import { Project } from "../project.js"
@@ -12,7 +12,7 @@ import { Workspace } from "../workspace.js"
import { Timestamps } from "../database/schema.sql.js"
import type { Instruction } from "@opencode-ai/schema/instruction"
import type { Session } from "@opencode-ai/schema/session"
import type { CompactionPayload, MovePayload, SyntheticPayload, UserPayload } from "@opencode-ai/schema/session-inbox"
import type { SyntheticData, UserData } from "@opencode-ai/schema/session-pending"
import type { RevertV1 } from "@opencode-ai/schema/session-revert"
import type { Schema } from "effect"
@@ -101,9 +101,9 @@ export const SessionPendingTable = sqliteTable(
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
type: text().$type<SessionInbox.Info["type"]>().notNull(),
data: text({ mode: "json" }).$type<UserPayload | SyntheticPayload | Record<string, never>>().notNull(),
delivery: text().$type<SessionInbox.Delivery>(),
type: text().$type<SessionPending.Info["type"]>().notNull(),
data: text({ mode: "json" }).$type<UserData | SyntheticData | Record<string, never>>().notNull(),
delivery: text().$type<SessionPending.Delivery>(),
admitted_seq: integer().notNull(),
time_created: integer()
.notNull()
@@ -118,28 +118,6 @@ export const SessionPendingTable = sqliteTable(
],
)
export const SessionInboxTable = sqliteTable(
"session_inbox",
{
id: text().$type<SessionMessage.ID>().primaryKey(),
session_id: text()
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
type: text().$type<SessionInbox.Info["type"]>().notNull(),
payload: text({ mode: "json" }).$type<UserPayload | SyntheticPayload | CompactionPayload | MovePayload>().notNull(),
delivery: text().$type<SessionInbox.Delivery>().notNull(),
enqueued_seq: integer().notNull(),
time_created: integer()
.notNull()
.$default(() => Date.now()),
},
(table) => [
index("session_inbox_session_delivery_seq_idx").on(table.session_id, table.delivery, table.enqueued_seq),
uniqueIndex("session_inbox_session_enqueued_seq_idx").on(table.session_id, table.enqueued_seq),
],
)
export const InstructionEntryTable = sqliteTable(
"instruction_entry",
{
+12 -17
View File
@@ -5,7 +5,6 @@ import { Context, Deferred, Duration, Effect, Fiber, Layer, Schema, Stream } fro
import { ChildProcess } from "effect/unstable/process"
import { produce } from "immer"
import { Shell } from "@opencode-ai/schema/shell"
import { AppProcess } from "@opencode-ai/util/process"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Config } from "./config.js"
import { Bus } from "./bus.js"
@@ -51,7 +50,7 @@ export interface Interface {
readonly create: <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) => Effect.Effect<Shell.Info, E | AppProcess.AppProcessError, R>
) => Effect.Effect<Shell.Info, E, R>
// Currently running commands only; exited shells are retained for get/output but excluded here.
readonly list: () => Effect.Effect<Shell.Info[]>
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
@@ -216,23 +215,19 @@ export const layer = (options?: ShellSelect.Options) =>
// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
const ready = Deferred.makeUnsafe<Active, AppProcess.AppProcessError>()
const ready = Deferred.makeUnsafe<Active>()
runFork(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* environment.spawner
.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
}),
)
.pipe(
Effect.mapError((cause) => new AppProcess.AppProcessError({ command: invocation.command, cause })),
)
const handle = yield* environment.spawner.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
}),
)
const session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
@@ -334,7 +329,7 @@ export const layer = (options?: ShellSelect.Options) =>
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catchTag("AppProcessError", (error) => Deferred.fail(ready, error))),
).pipe(Effect.catch(() => Effect.void)),
)
const session = yield* Deferred.await(ready)
+1 -14
View File
@@ -2,7 +2,7 @@ export * as Tool from "./tool.js"
export { CallID, Content, Error, FileContent, TextContent } from "@opencode-ai/schema/tool"
export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/tool"
import { ToolDefinition, type ToolCall } from "@opencode-ai/ai"
import type { ToolCall, ToolDefinition } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
import { Context, Effect, Layer, Schema, Scope, Semaphore } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -165,19 +165,6 @@ const layer = Layer.effect(
}),
)
if (entries.length === 0) return
yield* Effect.forEach(
entries,
(entry) =>
Effect.try({
try: () => ToolDefinition.make(definition(entry.tool)),
catch: (error) =>
new RegistrationError({
name: entry.key,
message: `Invalid tool definition ${entry.key}: ${error instanceof Error ? error.message : String(error)}`,
}),
}),
{ discard: true },
)
yield* Effect.uninterruptible(
lock.withPermit(
Effect.gen(function* () {
-114
View File
@@ -455,120 +455,6 @@ describe("Bus", () => {
}),
)
it.effect("publishes a durable batch atomically in provided order", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const aggregateID = Event.ID.create()
const observed = new Array<string>()
yield* bus.project(SyncMessage, (event) =>
Effect.sync(() => {
observed.push(`project:${event.data.text}`)
}),
)
yield* bus.listen((event) =>
event.type === SyncMessage.type
? Effect.gen(function* () {
const text = (event.data as { readonly text: string }).text
const row = yield* db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
observed.push(`notify:${text}:${row?.seq}`)
})
: Effect.void,
)
const events = yield* bus.publishAll([
[SyncMessage, { id: aggregateID, text: "first" }],
[SyncMessage, { id: aggregateID, text: "second" }],
])
expect(events.map((event) => event.durable.seq)).toEqual([Event.Seq.make(0), Event.Seq.make(1)])
expect(observed).toEqual(["project:first", "project:second", "notify:first:1", "notify:second:1"])
expect(
(yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).map(
(row) => row.seq,
),
).toEqual([0, 1])
}),
)
it.effect("rolls back every batch event when a projector fails", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const aggregateID = Event.ID.create()
const notifications = new Array<string>()
yield* db.run("CREATE TABLE IF NOT EXISTS event_batch_probe (value text NOT NULL)")
yield* db.run("DELETE FROM event_batch_probe")
yield* bus.project(SyncMessage, (event) =>
db
.run(`INSERT INTO event_batch_probe (value) VALUES ('${event.data.text}')`)
.pipe(
Effect.orDie,
Effect.andThen(event.data.text === "second" ? Effect.die("projector failed") : Effect.void),
),
)
yield* bus.listen((event) =>
Effect.sync(() => {
notifications.push(event.type)
}),
)
const exit = yield* bus
.publishAll([
[SyncMessage, { id: aggregateID, text: "first" }],
[SyncMessage, { id: aggregateID, text: "second" }],
])
.pipe(Effect.exit)
expect(String(exit)).toContain("projector failed")
expect(yield* db.all("SELECT value FROM event_batch_probe")).toEqual([])
expect(yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).toEqual([])
expect(
yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all(),
).toEqual([])
expect(notifications).toEqual([])
}),
)
it.effect("does not interleave a concurrent publish with batch notifications", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const aggregateID = Event.ID.create()
const firstObserved = yield* Deferred.make<void>()
const continueNotifications = yield* Deferred.make<void>()
const observed = new Array<string>()
yield* bus.listen((event) => {
if (event.type !== SyncMessage.type) return Effect.void
const text = (event.data as { readonly text: string }).text
return Effect.sync(() => observed.push(text)).pipe(
Effect.andThen(text === "first" ? Deferred.succeed(firstObserved, undefined) : Effect.void),
Effect.andThen(text === "first" ? Deferred.await(continueNotifications) : Effect.void),
)
})
const batch = yield* bus
.publishAll([
[SyncMessage, { id: aggregateID, text: "first" }],
[SyncMessage, { id: aggregateID, text: "second" }],
])
.pipe(Effect.forkScoped)
yield* Deferred.await(firstObserved)
const single = yield* bus.publish(SyncMessage, { id: aggregateID, text: "third" }).pipe(Effect.forkScoped)
yield* Effect.yieldNow
expect(observed).toEqual(["first"])
yield* Deferred.succeed(continueNotifications, undefined)
yield* Fiber.join(batch)
yield* Fiber.join(single)
expect(observed).toEqual(["first", "second", "third"])
}),
)
it.effect("replays durable aggregate events after a sequence and tails new events", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
+1 -13
View File
@@ -1,10 +1,9 @@
import fs from "node:fs/promises"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EnvironmentUnavailable } from "../src/environment/unavailable"
import {
execDefaults,
Failed,
@@ -36,17 +35,6 @@ describe("typeFollowing", () => {
)
})
describe("no execution plane", () => {
it.effect("fails spawn with a typed location error", () =>
Effect.gen(function* () {
const error = yield* EnvironmentUnavailable.spawner.spawn(ChildProcess.make("echo", ["hello"])).pipe(Effect.flip)
expect(error._tag).toBe("PlatformError")
expect(error.message).toContain("location has no execution plane")
}),
)
})
environmentConformance("memory environment", () =>
Effect.sync(() => {
const driver = makeMemoryDriver()
-22
View File
@@ -23,7 +23,6 @@ import { ID, type Payload } from "@opencode-ai/schema/event"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { Environment } from "@opencode-ai/core/environment/index"
import { EnvironmentUnavailable } from "@opencode-ai/core/environment/unavailable"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { MCPClient } from "@opencode-ai/core/mcp/client"
@@ -479,27 +478,6 @@ test("spawns local MCP servers through the location environment", async () => {
expect(command.options.env).toEqual({ MCP_LOCATION_TEST: "configured" })
})
test("reports a local MCP server as failed when the location has no execution plane", async () => {
const config = new ConfigMCP.Local({ type: "local", command: ["example-mcp"] })
const driver = Environment.makeMemoryDriver()
const environment = Layer.succeed(
Environment.Service,
Environment.Service.of({ files: Environment.makeFiles(driver), spawner: EnvironmentUnavailable.spawner }),
)
await Effect.runPromise(
Effect.gen(function* () {
const service = yield* MCP.Service
yield* service.tools()
const status = (yield* service.servers()).find((server) => server.name === "resources")?.status
expect(status).toEqual({
status: "failed",
error: expect.stringContaining("location has no execution plane"),
})
}).pipe(Effect.provide(resourceMcpLayer(config, undefined, undefined, { environment }))),
)
})
test("rejects sends before the stdio transport is started", async () => {
await Effect.runPromise(
Effect.scoped(
+3 -3
View File
@@ -11,7 +11,7 @@ import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { Tool } from "@opencode-ai/core/tool"
import { Provider } from "@opencode-ai/core/provider"
import { define } from "@opencode-ai/plugin/promise/plugin"
@@ -61,12 +61,12 @@ describe("fromPromise", () => {
synthetic: (value) => {
seen = value
return Effect.succeed(
SessionInbox.Synthetic.make({
SessionPending.Synthetic.make({
id: SessionMessage.ID.make(input.id),
sessionID: Session.ID.make(input.sessionID),
timeCreated: DateTime.makeUnsafe(0),
type: "synthetic",
payload: {
data: {
text: input.text,
metadata: input.metadata,
},
@@ -195,22 +195,6 @@ describe("GithubCopilotPlugin", () => {
}),
)
it.effect("rewrites models.dev fallback models to the GitHub Copilot package", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(Provider.ID.githubCopilot, () => {})
catalog.model.update(Provider.ID.githubCopilot, Model.ID.make("gpt-5.6-sol"), (model) => {
model.package = "@ai-sdk/openai-compatible"
})
})
yield* addPlugin()
expect(required(yield* catalog.model.get(Provider.ID.githubCopilot, Model.ID.make("gpt-5.6-sol"))).package).toBe(
"@ai-sdk/github-copilot",
)
}),
)
it.effect("selects languageModel when responses and chat are absent", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
+12 -16
View File
@@ -15,6 +15,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@@ -80,7 +81,7 @@ const it = testEffect(
)
describe("Session.compact", () => {
it.effect("durably stacks manual compaction", () =>
it.effect("durably admits and coalesces manual compaction", () =>
Effect.gen(function* () {
requests = []
const session = yield* Session.Service
@@ -88,18 +89,18 @@ describe("Session.compact", () => {
const created = yield* session.create({ location })
const messageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.InboxEnqueued, {
yield* bus.publish(SessionEvent.InputAdmitted, {
sessionID: created.id,
inboxID: messageID,
item: {
inputID: messageID,
input: {
type: "user",
payload: { text: "Please compact this session history." },
data: { text: "Please compact this session history." },
delivery: "steer",
},
})
yield* bus.publish(SessionEvent.InboxDelivered, {
yield* bus.publish(SessionEvent.InputPromoted, {
sessionID: created.id,
inboxID: messageID,
inputID: messageID,
})
expect(yield* session.compact({ id: messageID, sessionID: created.id }).pipe(Effect.flip)).toMatchObject({
@@ -109,17 +110,12 @@ describe("Session.compact", () => {
const first = yield* session.compact({ sessionID: created.id })
const second = yield* session.compact({ sessionID: created.id })
expect(second.id).not.toBe(first.id)
expect(second.id).toBe(first.id)
expect(requests).toHaveLength(0)
expect(yield* session.inbox(created.id)).toEqual([
expect.objectContaining({ id: first.id, type: "compaction", delivery: "queue" }),
expect.objectContaining({ id: second.id, type: "compaction", delivery: "queue" }),
])
expect(yield* SessionPending.compaction((yield* Database.Service).db, created.id)).toMatchObject({
id: first.id,
})
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toBeUndefined()
const steered = yield* session.create({ location })
const steer = yield* session.compact({ sessionID: steered.id, delivery: "steer" })
expect(steer).toMatchObject({ type: "compaction", delivery: "steer" })
}),
)
})
+23 -26
View File
@@ -19,7 +19,7 @@ import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
@@ -267,9 +267,9 @@ describe("Session.create", () => {
text: "First",
resume: false,
})
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* SessionPending.promote(db, bus, parent.id, "steer")
yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* SessionPending.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const parentContext = yield* session.context(parent.id)
@@ -289,24 +289,24 @@ describe("Session.create", () => {
durable: { seq: 0 },
data: { sessionID: forked.id, parentID: parent.id },
})
expect(yield* SessionInbox.find(db, forkContext[0].id)).toBeUndefined()
expect(yield* SessionInbox.find(db, forkContext[1].id)).toBeUndefined()
expect(yield* SessionPending.find(db, forkContext[0].id)).toBeUndefined()
expect(yield* SessionPending.find(db, forkContext[1].id)).toBeUndefined()
expect(
yield* session.prompt({ id: forkContext[0].id, sessionID: forked.id, text: "First", resume: false }),
).toMatchObject({ id: forkContext[0].id, type: "user", payload: { text: "First" } })
).toMatchObject({ id: forkContext[0].id, type: "user", data: { text: "First" } })
yield* session.prompt({
sessionID: parent.id,
text: "Parent changed",
resume: false,
})
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* SessionPending.promote(db, bus, parent.id, "steer")
yield* session.prompt({
sessionID: forked.id,
text: "Child continues",
resume: false,
})
yield* SessionInbox.promote(db, bus, forked.id, "steer")
yield* SessionPending.promote(db, bus, forked.id, "steer")
expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
@@ -316,7 +316,7 @@ describe("Session.create", () => {
(event): number | undefined => event.durable?.seq,
),
).toEqual([0, 5, 6])
expect(yield* SessionInbox.find(db, admitted.id)).toBeUndefined()
expect(yield* SessionPending.find(db, admitted.id)).toBeUndefined()
}),
)
@@ -327,7 +327,7 @@ describe("Session.create", () => {
const { db } = yield* Database.Service
const parent = yield* session.create({ location })
yield* session.prompt({ sessionID: parent.id, text: "First", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* SessionPending.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, forked.id)).get().pipe(Effect.orDie)
@@ -359,13 +359,13 @@ describe("Session.create", () => {
text: "First",
resume: false,
})
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* SessionPending.promote(db, bus, parent.id, "steer")
const second = yield* session.prompt({
sessionID: parent.id,
text: "Second",
resume: false,
})
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* SessionPending.promote(db, bus, parent.id, "steer")
const assistantMessageID = SessionMessage.ID.create()
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
yield* bus.publish(SessionEvent.Step.Started, {
@@ -496,7 +496,7 @@ describe("Session.create", () => {
text: "Hello",
resume: false,
})
yield* SessionInbox.promote(db, bus, created.id, "steer")
yield* SessionPending.promote(db, bus, created.id, "steer")
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(3), Stream.runCollect)),
@@ -504,13 +504,10 @@ describe("Session.create", () => {
{ durable: { seq: 0 }, type: "session.created" },
{
durable: { seq: 1 },
type: "session.inbox.enqueued",
data: {
inboxID: expect.any(String),
item: { type: "user", payload: { text: "Hello" }, delivery: "steer" },
},
type: "session.input.admitted",
data: { input: { type: "user", data: { text: "Hello" }, delivery: "steer" } },
},
{ durable: { seq: 2 }, type: "session.inbox.delivered" },
{ durable: { seq: 2 }, type: "session.input.promoted" },
])
}),
)
@@ -526,7 +523,7 @@ describe("Session.create", () => {
text: "Replay lifecycle",
resume: false,
})
yield* SessionInbox.promote(sourceDb, sourceEvents, created.id, "steer")
yield* SessionPending.promote(sourceDb, sourceEvents, created.id, "steer")
const serialized = (yield* sourceDb
.select()
.from(EventTable)
@@ -566,17 +563,17 @@ describe("Session.create", () => {
expect(yield* store.get(created.id)).toBeUndefined()
expect(yield* bus.replayAll(serialized.slice(0, 2))).toBe(created.id)
expect(yield* SessionInbox.find(db, admitted.id)).toMatchObject({
expect(yield* SessionPending.find(db, admitted.id)).toMatchObject({
id: admitted.id,
sessionID: created.id,
type: "user",
payload: { text: "Replay lifecycle" },
data: { text: "Replay lifecycle" },
delivery: "steer",
})
expect(yield* store.context(created.id)).toEqual([])
expect(yield* bus.replayAll(serialized.slice(2))).toBe(created.id)
expect(yield* SessionInbox.find(db, admitted.id)).toBeUndefined()
expect(yield* SessionPending.find(db, admitted.id)).toBeUndefined()
expect(yield* store.context(created.id)).toMatchObject([
{ id: admitted.id, type: "user", text: "Replay lifecycle" },
])
@@ -590,8 +587,8 @@ describe("Session.create", () => {
.pipe(Effect.orDie)).map((event) => [event.seq, event.type]),
).toEqual([
[0, Bus.versionedType(SessionEvent.Created.type, 1)],
[1, Bus.versionedType(SessionEvent.InboxEnqueued.type, 1)],
[2, Bus.versionedType(SessionEvent.InboxDelivered.type, 1)],
[1, Bus.versionedType(SessionEvent.InputAdmitted.type, 1)],
[2, Bus.versionedType(SessionEvent.InputPromoted.type, 1)],
])
}).pipe(Effect.provide(Layer.fresh(targetLayer)))
}),
@@ -811,7 +808,7 @@ describe("SessionTransfer", () => {
])
yield* session.prompt({ sessionID, text: "Continue", resume: false })
yield* SessionInbox.promote(db, bus, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
expect((yield* session.messages({ sessionID, order: "asc" })).map((message) => message.type)).toEqual([
"user",
+11 -14
View File
@@ -38,7 +38,7 @@ import {
InstructionBlobTable,
InstructionStateTable,
SessionMessageTable,
SessionInboxTable,
SessionPendingTable,
SessionTable,
} from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
@@ -170,9 +170,9 @@ const durableState = (db: Database.Interface["db"], sessionID: SessionSchema.ID)
.pipe(Effect.orDie),
pending: db
.select()
.from(SessionInboxTable)
.where(eq(SessionInboxTable.session_id, sessionID))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.from(SessionPendingTable)
.where(eq(SessionPendingTable.session_id, sessionID))
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie),
instructions: db
@@ -225,15 +225,12 @@ it.effect("generates from fresh settled Session context without durable mutation
const { db, bus, instructions } = yield* setup
yield* InstructionState.prepare(db, bus, instructions, sessionID)
const existing = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.InboxEnqueued, {
yield* bus.publish(SessionEvent.InputAdmitted, {
sessionID,
inboxID: existing,
item: { type: "user", payload: { text: "Existing durable context" }, delivery: "steer" },
})
yield* bus.publish(SessionEvent.InboxDelivered, {
sessionID,
inboxID: existing,
inputID: existing,
input: { type: "user", data: { text: "Existing durable context" }, delivery: "steer" },
})
yield* bus.publish(SessionEvent.InputPromoted, { sessionID, inputID: existing })
const settledAssistant = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
@@ -278,10 +275,10 @@ it.effect("generates from fresh settled Session context without durable mutation
input: {},
executed: false,
})
yield* bus.publish(SessionEvent.InboxEnqueued, {
yield* bus.publish(SessionEvent.InputAdmitted, {
sessionID,
inboxID: SessionMessage.ID.create(),
item: { type: "user", payload: { text: "Queued input must remain invisible" }, delivery: "queue" },
inputID: SessionMessage.ID.create(),
input: { type: "user", data: { text: "Queued input must remain invisible" }, delivery: "queue" },
})
instruction = "Changed context"
const before = yield* durableState(db, sessionID)
+14 -18
View File
@@ -34,7 +34,7 @@ const it = testEffect(
)
describe("Session.move", () => {
it.effect("enqueues one move when the source directory no longer exists", () =>
it.effect("moves a session whose source directory no longer exists", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -49,28 +49,24 @@ describe("Session.move", () => {
yield* session.move({ sessionID: created.id, directory: destination })
expect((yield* session.get(created.id)).location.directory).toBe(
AbsolutePath.make(path.join(tmp.path, "deleted")),
)
expect(yield* session.inbox(created.id)).toMatchObject([
{
type: "move",
delivery: "queue",
payload: {
location: { directory: destination },
expect((yield* session.get(created.id)).location.directory).toBe(destination)
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
expect(messages).toEqual([
expect.objectContaining({
type: "location-switched",
location: { directory: destination },
projectID: Project.ID.global,
previous: {
location: { directory: path.join(tmp.path, "deleted") },
projectID: Project.ID.global,
subpath: "",
},
},
subpath: "",
}),
])
yield* session.move({ sessionID: created.id, directory: destination })
expect(yield* session.inbox(created.id)).toHaveLength(2)
const steered = yield* session.create({
location: Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "other")) }),
})
yield* session.move({ sessionID: steered.id, directory: destination, delivery: "steer" })
expect(yield* session.inbox(steered.id)).toMatchObject([{ type: "move", delivery: "steer" }])
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toEqual(messages)
}),
),
),
+33 -21
View File
@@ -20,11 +20,11 @@ import { Money } from "@opencode-ai/schema/money"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { fromRow } from "@opencode-ai/core/session/info"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { Shell } from "@opencode-ai/schema/shell"
import {
InstructionStateTable,
SessionInboxTable,
SessionPendingTable,
SessionMessageTable,
SessionTable,
} from "@opencode-ai/core/session/sql"
@@ -47,7 +47,9 @@ const build = Agent.defaultID
const assistantRow = (
id: SessionMessage.ID,
seq: number,
time: { created: DateTime.Utc; completed?: DateTime.Utc } = { created },
time: { created: DateTime.Utc; started?: DateTime.Utc; generated?: DateTime.Utc; completed?: DateTime.Utc } = {
created,
},
usage?: Pick<SessionMessage.Assistant, "cost" | "tokens">,
) => {
const {
@@ -81,7 +83,7 @@ describe("SessionProjector", () => {
.run()
const bus = yield* Bus.Service
const inputID = SessionMessage.ID.make("msg_manual_compaction")
yield* SessionInbox.admitCompaction(db, bus, { id: inputID, sessionID, delivery: "queue" })
yield* SessionPending.admitCompaction(db, bus, { id: inputID, sessionID })
yield* bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
@@ -89,7 +91,7 @@ describe("SessionProjector", () => {
error: { type: "compaction.failed", message: "Auto compaction failed" },
})
expect(yield* SessionInbox.find(db, inputID)).toMatchObject({ id: inputID })
expect(yield* SessionPending.compaction(db, sessionID)).toMatchObject({ id: inputID })
}),
)
@@ -247,29 +249,29 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie)
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.InboxEnqueued, {
yield* bus.publish(SessionEvent.InputAdmitted, {
sessionID,
inboxID: SessionMessage.ID.make("msg_first"),
item: { type: "user", payload: { text: "first" }, delivery: "steer" },
inputID: SessionMessage.ID.make("msg_first"),
input: { type: "user", data: { text: "first" }, delivery: "steer" },
})
yield* bus.publish(
SessionEvent.InboxDelivered,
SessionEvent.InputPromoted,
{
sessionID,
inboxID: SessionMessage.ID.make("msg_first"),
inputID: SessionMessage.ID.make("msg_first"),
},
{ id: Event.ID.make("evt_z") },
)
yield* bus.publish(SessionEvent.InboxEnqueued, {
yield* bus.publish(SessionEvent.InputAdmitted, {
sessionID,
inboxID: SessionMessage.ID.make("msg_second"),
item: { type: "user", payload: { text: "second" }, delivery: "steer" },
inputID: SessionMessage.ID.make("msg_second"),
input: { type: "user", data: { text: "second" }, delivery: "steer" },
})
yield* bus.publish(
SessionEvent.InboxDelivered,
SessionEvent.InputPromoted,
{
sessionID,
inboxID: SessionMessage.ID.make("msg_second"),
inputID: SessionMessage.ID.make("msg_second"),
},
{ id: Event.ID.make("evt_a") },
)
@@ -320,20 +322,20 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie)
const bus = yield* Bus.Service
const id = SessionMessage.ID.make("msg_admitted")
const admitted = yield* SessionInbox.admit(db, bus, {
const admitted = yield* SessionPending.admit(db, bus, {
id,
sessionID,
item: { type: "user", payload: { text: "promote me" }, delivery: "steer" },
input: { type: "user", data: { text: "promote me" }, delivery: "steer" },
})
if (!admitted) return yield* Effect.die("Prompt admission failed")
const event = yield* bus.publish(SessionEvent.InboxDelivered, {
const event = yield* bus.publish(SessionEvent.InputPromoted, {
sessionID,
inboxID: id,
inputID: id,
})
expect(
yield* db.select().from(SessionInboxTable).where(eq(SessionInboxTable.id, id)).get().pipe(Effect.orDie),
yield* db.select().from(SessionPendingTable).where(eq(SessionPendingTable.id, id)).get().pipe(Effect.orDie),
).toBeUndefined()
expect(
yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),
@@ -667,12 +669,18 @@ describe("SessionProjector", () => {
const usageUpdated = yield* service
.subscribe(SessionEvent.UsageUpdated)
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* service.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
ordinal: 0,
})
yield* service.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
finish: "stop",
cost: Money.USD.make(1.25),
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
generated: DateTime.makeUnsafe(0),
})
const rows = yield* db
@@ -691,7 +699,11 @@ describe("SessionProjector", () => {
finish: "stop",
cost: Money.USD.make(1.25),
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
time: { completed: DateTime.makeUnsafe(0) },
time: {
started: DateTime.makeUnsafe(0),
generated: DateTime.makeUnsafe(0),
completed: DateTime.makeUnsafe(0),
},
})
expect(
yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
+87 -83
View File
@@ -21,8 +21,8 @@ import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
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 { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionPendingTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
@@ -100,11 +100,11 @@ const setup = Effect.gen(function* () {
.pipe(Effect.orDie)
})
const admitted = (id: SessionMessage.ID) => Database.Service.use(({ db }) => SessionInbox.find(db, id))
const admitted = (id: SessionMessage.ID) => Database.Service.use(({ db }) => SessionPending.find(db, id))
const admittedCount = Database.Service.use(({ db }) =>
db
.select()
.from(SessionInboxTable)
.from(SessionPendingTable)
.all()
.pipe(
Effect.orDie,
@@ -227,13 +227,13 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.payload.text).toBe("Fix the failing tests")
expect(message.data.text).toBe("Fix the failing tests")
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admitted(message.id)).toMatchObject({
id: message.id,
sessionID,
type: "user",
payload: { text: "Fix the failing tests" },
data: { text: "Fix the failing tests" },
delivery: "steer",
})
}),
@@ -251,7 +251,7 @@ describe("Session.prompt", () => {
text: "boundary",
resume: false,
})
yield* SessionInbox.promote(db, bus, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
const stale = SessionMessage.ID.make("msg_stale_assistant")
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
@@ -268,7 +268,7 @@ describe("Session.prompt", () => {
(row) => row.id,
),
).not.toContainAnyValues([boundary.id, stale])
expect(yield* SessionInbox.find(db, boundary.id)).toBeUndefined()
expect(yield* SessionPending.find(db, boundary.id)).toBeUndefined()
}),
)
@@ -283,7 +283,7 @@ describe("Session.prompt", () => {
text: "boundary",
resume: false,
})
yield* SessionInbox.promote(db, bus, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
revert: { messageID: boundary.id, files: [] },
@@ -293,11 +293,11 @@ describe("Session.prompt", () => {
const completion = yield* session.synthetic({ sessionID, text: "stale completion" })
expect(wakeCalls).toEqual([])
expect(yield* SessionInbox.find(db, completion.id)).toMatchObject({ type: "synthetic" })
expect(yield* SessionPending.find(db, completion.id)).toMatchObject({ type: "synthetic" })
yield* session.revert.commit(sessionID)
expect(yield* SessionInbox.find(db, completion.id)).toBeUndefined()
expect(yield* SessionPending.find(db, completion.id)).toBeUndefined()
}),
)
@@ -315,7 +315,7 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.payload.files).toEqual([
expect(message.data.files).toEqual([
{
data: uri.slice(uri.indexOf(",") + 1),
mime: "image/png",
@@ -326,7 +326,7 @@ describe("Session.prompt", () => {
])
const stored = yield* admitted(message.id)
expect(stored?.type).toBe("user")
if (stored?.type === "user") expect(stored.payload.files).toEqual(message.payload.files)
if (stored?.type === "user") expect(stored.data.files).toEqual(message.data.files)
}),
)
@@ -347,14 +347,14 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.payload.files).toHaveLength(1)
expect(message.payload.files?.[0]).toMatchObject({
expect(message.data.files).toHaveLength(1)
expect(message.data.files?.[0]).toMatchObject({
mime: "text/plain",
source: { type: "uri", uri: sourceUri.href },
name: "main.ts",
})
expect(
Buffer.from(message.payload.files?.[0]?.data ?? "", "base64")
Buffer.from(message.data.files?.[0]?.data ?? "", "base64")
.toString("utf8")
.replace(/\r$/, ""),
).toBe('import { describe, expect } from "bun:test"')
@@ -374,13 +374,13 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.payload.files).toHaveLength(1)
expect(message.payload.files?.[0]).toMatchObject({
expect(message.data.files).toHaveLength(1)
expect(message.data.files?.[0]).toMatchObject({
mime: "application/x-directory",
source: { type: "uri", uri },
name: "source",
})
expect(Buffer.from(message.payload.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain(
expect(Buffer.from(message.data.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain(
"session-prompt.test.ts",
)
}),
@@ -408,7 +408,7 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.payload.files).toEqual([
expect(message.data.files).toEqual([
{
data: bytes.toString("base64"),
mime: "image/png",
@@ -417,7 +417,7 @@ describe("Session.prompt", () => {
},
])
const stored = yield* admitted(message.id)
expect(stored?.type === "user" ? stored.payload.files : undefined).toEqual(message.payload.files)
expect(stored?.type === "user" ? stored.data.files : undefined).toEqual(message.data.files)
}),
)
@@ -440,7 +440,7 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.payload.files).toEqual([
expect(message.data.files).toEqual([
{
data: "AA==",
mime: "image/png",
@@ -463,7 +463,7 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.payload.files).toEqual([
expect(message.data.files).toEqual([
{
data: Buffer.from("export const value = 1\n").toString("base64"),
mime: "text/plain",
@@ -512,20 +512,20 @@ describe("Session.prompt", () => {
yield* session.prompt({ sessionID, text: "First", resume: false })
yield* session.prompt({ sessionID, text: "Second", resume: false })
yield* SessionInbox.promote(db, bus, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
const streamed = Array.from(yield* Fiber.join(fiber))
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
[0, "session.inbox.enqueued"],
[1, "session.inbox.enqueued"],
[2, "session.inbox.delivered"],
[3, "session.inbox.delivered"],
[0, "session.input.admitted"],
[1, "session.input.admitted"],
[2, "session.input.promoted"],
[3, "session.input.promoted"],
])
expect(
Array.from(
yield* publicEvents({ sessionID, after: streamed[0].durable?.seq }).pipe(Stream.take(1), Stream.runCollect),
).map((event): [number | undefined, string] => [event.durable?.seq, event.type]),
).toEqual([[1, "session.inbox.enqueued"]])
).toEqual([[1, "session.input.admitted"]])
}),
)
@@ -593,12 +593,12 @@ describe("Session.prompt", () => {
const { db } = yield* Database.Service
const input = { sessionID, id: messageID, text: "Fix the failing tests", resume: false }
const first = yield* session.prompt(input)
yield* SessionInbox.promote(db, bus, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, sessionID)).run().pipe(Effect.orDie)
const retried = yield* session.prompt(input)
expect(retried).toMatchObject({ id: first.id, type: "user", payload: { text: first.payload.text } })
expect(retried).toMatchObject({ id: first.id, type: "user", data: { text: first.data.text } })
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: messageID, type: "user", text: "Fix the failing tests" },
])
@@ -613,11 +613,11 @@ describe("Session.prompt", () => {
const { db } = yield* Database.Service
const input = { sessionID, id: messageID, text: "Fix the failing tests", resume: false }
yield* session.prompt(input)
yield* SessionInbox.promote(db, bus, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
const retried = yield* session.prompt({ ...input, delivery: "queue" })
expect(retried).toMatchObject({ id: messageID, type: "user", payload: { text: input.text } })
expect(retried).toMatchObject({ id: messageID, type: "user", data: { text: input.text } })
expect(yield* admitted(messageID)).toBeUndefined()
}),
)
@@ -708,7 +708,7 @@ describe("Session.prompt", () => {
expect(messages[1]).toEqual(messages[0])
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admittedCount).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxEnqueued.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
}),
)
@@ -726,11 +726,11 @@ describe("Session.prompt", () => {
})
yield* Effect.all(
[SessionInbox.promote(db, bus, sessionID, "steer"), SessionInbox.promote(db, bus, sessionID, "steer")],
[SessionPending.promote(db, bus, sessionID, "steer"), SessionPending.promote(db, bus, sessionID, "steer")],
{ concurrency: "unbounded" },
)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxDelivered.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputPromoted.type, 1))).toBe(1)
expect(yield* admitted(messageID)).toBeUndefined()
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: messageID, type: "user", text: "Promote once" },
@@ -761,7 +761,11 @@ describe("Session.prompt", () => {
.pipe(Effect.orDie)
yield* bus.remove(sessionID)
yield* db.delete(SessionInboxTable).where(eq(SessionInboxTable.session_id, sessionID)).run().pipe(Effect.orDie)
yield* db
.delete(SessionPendingTable)
.where(eq(SessionPendingTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
yield* db
.delete(SessionMessageTable)
.where(eq(SessionMessageTable.session_id, sessionID))
@@ -781,12 +785,12 @@ describe("Session.prompt", () => {
expect(yield* admitted(messageID)).toMatchObject({
id: messageID,
type: "user",
payload: { text: "Replay pending" },
data: { text: "Replay pending" },
})
expect(yield* admitted(syntheticID)).toMatchObject({
id: syntheticID,
type: "synthetic",
payload: { text: "Replay synthetic" },
data: { text: "Replay synthetic" },
})
expect(yield* session.messages({ sessionID })).toEqual([])
expect(wakeCalls).toEqual([])
@@ -919,7 +923,7 @@ describe("Session.prompt", () => {
const failure = yield* session.prompt({ ...input, metadata: { source: "plugin" } }).pipe(Effect.flip)
expect(retried).toEqual(first)
expect(first.payload.metadata).toEqual({ source: "api" })
expect(first.data.metadata).toEqual({ source: "api" })
expect(failure._tag).toBe("Session.PromptConflictError")
}),
)
@@ -945,14 +949,14 @@ describe("Session.prompt", () => {
type: "synthetic",
sessionID,
delivery: "steer",
payload: {
data: {
text: "Background work completed",
description: "shell completion",
metadata: { job: "shell" },
},
})
yield* SessionInbox.promote(db, bus, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
expect(yield* session.messages({ sessionID })).toMatchObject([
{
@@ -977,15 +981,15 @@ describe("Session.prompt", () => {
const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], {
concurrency: "unbounded",
})
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
yield* SessionPending.promote(database.db, bus, sessionID, "steer")
const promotedRetry = yield* session.synthetic(input)
const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip)
expect(entries[1]).toEqual(entries[0])
expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", payload: { text: "Completed" } })
expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", data: { text: "Completed" } })
expect(failure).toMatchObject({ _tag: "Session.SyntheticConflictError", sessionID, inputID: messageID })
expect(yield* admittedCount).toBe(0)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxEnqueued.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
}),
)
@@ -1004,11 +1008,11 @@ describe("Session.prompt", () => {
})
expect(input.delivery).toBe("queue")
expect(yield* SessionInbox.has(db, sessionID, "input")).toBe(true)
expect(yield* SessionInbox.promote(db, bus, sessionID, "steer")).toBe(0)
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(true)
expect(yield* SessionPending.promote(db, bus, sessionID, "steer")).toBe(0)
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* SessionInbox.promote(db, bus, sessionID, "input")).toBe(1)
expect(yield* SessionInbox.has(db, sessionID, "input")).toBe(false)
expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(1)
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: input.id, type: "synthetic", text: "Queued completion" },
])
@@ -1034,7 +1038,7 @@ describe("Session.prompt", () => {
resume: false,
})
yield* SessionInbox.promote(db, bus, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
expect(
(yield* session.messages({ sessionID, order: "asc" })).map((message) =>
@@ -1045,11 +1049,11 @@ describe("Session.prompt", () => {
)
})
describe("Session.inbox", () => {
describe("Session.pending", () => {
it.effect("fails for an unknown session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
expect(yield* session.inbox(Session.ID.make("ses_missing")).pipe(Effect.flip)).toMatchObject({
expect(yield* session.pending(Session.ID.make("ses_missing")).pipe(Effect.flip)).toMatchObject({
_tag: "Session.NotFoundError",
})
}),
@@ -1071,34 +1075,34 @@ describe("Session.inbox", () => {
})
const second = yield* session.prompt({ sessionID, text: "Second steer", resume: false })
expect(yield* session.inbox(sessionID)).toMatchObject([
expect(yield* session.pending(sessionID)).toMatchObject([
{ id: first.id, type: "user", delivery: "steer" },
{ id: queued.id, type: "synthetic", delivery: "queue" },
{ id: second.id, type: "user", delivery: "steer" },
])
expect(yield* SessionInbox.promote(db, bus, sessionID, "input")).toBe(2)
expect(yield* session.inbox(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }])
expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(2)
expect(yield* session.pending(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }])
expect(yield* SessionInbox.promote(db, bus, sessionID, "input")).toBe(1)
expect(yield* session.inbox(sessionID)).toEqual([])
expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(1)
expect(yield* session.pending(sessionID)).toEqual([])
}),
)
it.effect("lists an unhandled compaction until it is cancelled", () =>
it.effect("lists an unhandled compaction barrier until it settles", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const { db } = yield* Database.Service
const barrier = yield* session.compact({ sessionID })
expect(yield* SessionInbox.has(db, sessionID, "any")).toBe(true)
expect(yield* SessionInbox.has(db, sessionID, "input")).toBe(true)
expect(yield* session.inbox(sessionID)).toMatchObject([{ id: barrier.id, type: "compaction" }])
expect(yield* SessionPending.has(db, sessionID, "any")).toBe(true)
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
expect(yield* session.pending(sessionID)).toMatchObject([{ id: barrier.id, type: "compaction" }])
yield* session.cancelInbox({ sessionID, inboxID: barrier.id })
expect(yield* SessionInbox.has(db, sessionID, "any")).toBe(false)
expect(yield* session.inbox(sessionID)).toEqual([])
yield* SessionPending.settleCompaction(db, { sessionID })
expect(yield* SessionPending.has(db, sessionID, "any")).toBe(false)
expect(yield* session.pending(sessionID)).toEqual([])
}),
)
@@ -1115,16 +1119,16 @@ describe("Session.inbox", () => {
resume: false,
})
yield* session.cancelInbox({ sessionID, inboxID: inputID })
yield* session.cancelPending({ sessionID, inputID })
expect(yield* session.inbox(sessionID)).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxCancelled.type, 1))).toBe(1)
expect(yield* session.cancelInbox({ sessionID, inboxID: inputID }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.InboxConflictError",
expect(yield* session.pending(sessionID)).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
expect(yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.PendingInputConflictError",
sessionID,
inboxID: inputID,
inputID,
})
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxCancelled.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
const retried = yield* session.prompt({
id: inputID,
@@ -1150,33 +1154,33 @@ describe("Session.inbox", () => {
const alreadySteered = yield* session.prompt({ sessionID, text: "Already steer", resume: false })
wakeCalls.length = 0
yield* session.steerInbox({ sessionID, inboxID: queued.id })
yield* session.steerPending({ sessionID, inputID: queued.id })
expect(yield* session.inbox(sessionID)).toMatchObject([
expect(yield* session.pending(sessionID)).toMatchObject([
{ id: queued.id, delivery: "steer" },
{ id: alreadySteered.id, delivery: "steer" },
])
expect(wakeCalls).toEqual([sessionID])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxDeliveryChanged.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
wakeCalls.length = 0
yield* session.queueInbox({ sessionID, inboxID: queued.id })
expect(yield* session.inbox(sessionID)).toMatchObject([
yield* session.queuePending({ sessionID, inputID: queued.id })
expect(yield* session.pending(sessionID)).toMatchObject([
{ id: queued.id, delivery: "queue" },
{ id: alreadySteered.id, delivery: "steer" },
])
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxDeliveryChanged.type, 1))).toBe(2)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputQueued.type, 1))).toBe(1)
expect(yield* session.steerInbox({ sessionID, inboxID: alreadySteered.id }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.InboxConflictError",
expect(yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.PendingInputConflictError",
sessionID,
inboxID: alreadySteered.id,
inputID: alreadySteered.id,
})
yield* session.cancelInbox({ sessionID, inboxID: alreadySteered.id })
yield* session.cancelPending({ sessionID, inputID: alreadySteered.id })
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxDeliveryChanged.type, 1))).toBe(2)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxCancelled.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
}),
)
})
@@ -243,9 +243,9 @@ describe("SessionRunnerLLM recorded", () => {
.orderBy(EventTable.seq)
.all()).map((event) => event.type),
).toEqual([
"session.inbox.enqueued.1",
"session.input.admitted.1",
"session.instructions.updated.2",
"session.inbox.delivered.1",
"session.input.promoted.1",
"session.step.started.1",
"session.text.started.1",
"session.text.ended.1",
@@ -266,6 +266,7 @@ test("step finish records settlement without publishing step ended", async () =>
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
expect(publisher.record().finish).toMatchObject({ finish: "stop" })
expect(publisher.record().finish).toHaveProperty("generated")
})
test("content-filter finish retains failure evidence until step closeout", async () => {
@@ -110,28 +110,6 @@ describe("Tool", () => {
}),
)
it.effect("rejects invalid tool definitions before installing any tools", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const error = yield* service
.transform((draft) => {
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
draft.add({
name: "phone_type",
input: Schema.Struct({}),
execute: () => Effect.succeed({ content: "ok" }),
options: { codemode: false },
} as unknown as Info)
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.name).toBe("phone_type")
expect(error.message).toContain('Expected string, got undefined\n at ["description"]')
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
it.effect("canonicalizes effective definitions and keeps Code Mode last", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
+29 -64
View File
@@ -32,7 +32,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Money } from "@opencode-ai/schema/money"
import { SessionProjector } from "@opencode-ai/core/session/projector"
@@ -55,7 +55,7 @@ import { Tool } from "@opencode-ai/core/tool"
import type { Info as ToolInfo } from "@opencode-ai/schema/tool"
import {
InstructionStateTable,
SessionInboxTable,
SessionPendingTable,
SessionMessageTable,
SessionTable,
} from "@opencode-ai/core/session/sql"
@@ -72,7 +72,7 @@ import { Location } from "@opencode-ai/core/location"
import { Provider } from "@opencode-ai/core/provider"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { asc, desc, eq } from "drizzle-orm"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
import { agentHost, catalogHost, host } from "./plugin/host"
import PROMPT_DEFAULT from "../src/session/runner/prompt/base.txt"
@@ -629,7 +629,7 @@ const replaySessionProjection = (id: Session.ID) =>
yield* bus.remove(id)
yield* db.delete(InstructionStateTable).where(eq(InstructionStateTable.session_id, id)).run().pipe(Effect.orDie)
yield* db.delete(SessionInboxTable).where(eq(SessionInboxTable.session_id, id)).run().pipe(Effect.orDie)
yield* db.delete(SessionPendingTable).where(eq(SessionPendingTable.session_id, id)).run().pipe(Effect.orDie)
yield* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, id)).run().pipe(Effect.orDie)
yield* bus.replayAll(
recorded.map((event) => ({
@@ -1186,7 +1186,7 @@ describe("SessionRunnerLLM", () => {
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Instructions.InitializationBlocked)
expect(requests).toHaveLength(0)
expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(true)
expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true)
expect(
yield* db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(),
).toBeUndefined()
@@ -1210,7 +1210,6 @@ describe("SessionRunnerLLM", () => {
yield* bus.publish(SessionEvent.Moved, {
sessionID,
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
projectID: Project.ID.global,
})
expect(
yield* db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(),
@@ -1221,43 +1220,7 @@ describe("SessionRunnerLLM", () => {
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(requests).toHaveLength(1)
expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(true)
}),
)
it.effect("delivers a queued move atomically at the idle boundary", () =>
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const inboxID = SessionMessage.ID.create()
yield* SessionInbox.admit(db, bus, {
id: inboxID,
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
projectID: Project.ID.global,
},
delivery: "queue",
},
})
yield* session.resume(sessionID)
expect((yield* session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved"))
expect(yield* session.inbox(sessionID)).toEqual([])
expect(requests).toEqual([])
expect(
(yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(desc(EventTable.seq))
.limit(2)
.all()).map((event) => event.type),
).toEqual([Bus.versionedType(SessionEvent.Moved.type, 1), Bus.versionedType(SessionEvent.InboxDelivered.type, 1)])
expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true)
}),
)
@@ -1862,15 +1825,15 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("runs steers before queued compaction and later queued input", () =>
it.effect("runs one durable compaction barrier after tool settlement and before later inputs", () =>
Effect.gen(function* () {
const session = yield* setup
currentModel = recoveryModel
const stream = yield* TestLLM.gate
yield* TestLLM.push(
TestLLM.tool("call-active", "echo", { text: "active" }),
TestLLM.text("Steer complete", "text-steer"),
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
TestLLM.text("Steer complete", "text-steer"),
TestLLM.text("Queue complete", "text-queue"),
)
yield* admit(session, "Active work")
@@ -1878,7 +1841,9 @@ describe("SessionRunnerLLM", () => {
yield* stream.started
const first = yield* session.compact({ sessionID })
expect(yield* SessionInbox.find((yield* Database.Service).db, first.id)).toMatchObject({
const second = yield* session.compact({ sessionID })
expect(second.id).toBe(first.id)
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toMatchObject({
id: first.id,
})
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toBeUndefined()
@@ -1891,17 +1856,17 @@ describe("SessionRunnerLLM", () => {
delivery: "queue",
resume: false,
})
expect(yield* SessionInbox.has((yield* Database.Service).db, sessionID, "steer")).toBe(true)
expect(yield* SessionPending.has((yield* Database.Service).db, sessionID, "steer")).toBe(false)
yield* stream.release
yield* Fiber.join(active)
expect(requests).toHaveLength(4)
expect(userTexts(requests[1])).toContain("Steer after compaction")
expect(userTexts(requests[1])).toContain("Completion after compaction")
expect(userTexts(requests[2])[0]).toContain("Create a new anchored summary")
expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary")
expect(userTexts(requests[2])).toContain("Steer after compaction")
expect(userTexts(requests[2])).toContain("Completion after compaction")
expect(userTexts(requests[3])).toContain("Queue after compaction")
expect(yield* SessionInbox.find((yield* Database.Service).db, first.id)).toBeUndefined()
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({
type: "compaction",
status: "completed",
@@ -1936,7 +1901,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(3)
expect(userTexts(requests[2])).toContain("Continue after failure")
expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
@@ -1958,7 +1923,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
@@ -1973,7 +1938,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("delivers steered manual compaction when the model has no context limit", () =>
it.effect("manually compacts when the model has no context limit", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-unknown-history"))
@@ -1981,7 +1946,7 @@ describe("SessionRunnerLLM", () => {
requests.length = 0
yield* TestLLM.push(TestLLM.text("Manual summary", "text-manual-unknown-summary"))
const compaction = yield* session.compact({ sessionID, delivery: "steer" })
const compaction = yield* session.compact({ sessionID })
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
@@ -2050,7 +2015,7 @@ describe("SessionRunnerLLM", () => {
yield* session.interrupt(sessionID)
yield* Fiber.await(run)
expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
@@ -2071,7 +2036,7 @@ describe("SessionRunnerLLM", () => {
expect(yield* Effect.exit(session.resume(sessionID))).toMatchObject({ _tag: "Failure" })
expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
@@ -2899,7 +2864,7 @@ describe("SessionRunnerLLM", () => {
yield* session.interrupt(sessionID)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
expect(requests).toHaveLength(1)
expect(yield* SessionInbox.has(db, sessionID, "queue")).toBe(true)
expect(yield* SessionPending.has(db, sessionID, "queue")).toBe(true)
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* stream.release
@@ -2929,7 +2894,7 @@ describe("SessionRunnerLLM", () => {
yield* session.interrupt(sessionID)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
expect(requests).toHaveLength(1)
expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(true)
expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true)
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
@@ -3082,7 +3047,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const bus = yield* Bus.Service
yield* admit(session, "Recover interrupted tool")
yield* SessionInbox.promote((yield* Database.Service).db, bus, sessionID, "steer")
yield* SessionPending.promote((yield* Database.Service).db, bus, sessionID, "steer")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
@@ -3139,7 +3104,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const bus = yield* Bus.Service
yield* admit(session, "Recover interrupted hosted tool")
yield* SessionInbox.promote((yield* Database.Service).db, bus, sessionID, "steer")
yield* SessionPending.promote((yield* Database.Service).db, bus, sessionID, "steer")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
@@ -3190,7 +3155,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const bus = yield* Bus.Service
yield* admit(session, "Recover interrupted tool input")
yield* SessionInbox.promote((yield* Database.Service).db, bus, sessionID, "steer")
yield* SessionPending.promote((yield* Database.Service).db, bus, sessionID, "steer")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
@@ -3243,7 +3208,7 @@ describe("SessionRunnerLLM", () => {
const bus = yield* Bus.Service
const defect = new Error("fail after prompt promotion")
let fail = true
yield* bus.project(SessionEvent.InboxDelivered, () => (fail ? Effect.die(defect) : Effect.void))
yield* bus.project(SessionEvent.InputPromoted, () => (fail ? Effect.die(defect) : Effect.void))
yield* admit(session, "Recover promoted input")
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
@@ -3265,7 +3230,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const bus = yield* Bus.Service
yield* bus.listen((event) =>
event.type === SessionEvent.InboxDelivered.type
event.type === SessionEvent.InputPromoted.type
? Effect.die("fail after prompt promotion commits")
: Effect.void,
)
+2 -2
View File
@@ -15,7 +15,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { Skill } from "@opencode-ai/core/skill"
import { testEffect } from "./lib/effect"
@@ -71,7 +71,7 @@ describe("Session.skill", () => {
skills: [{ id: Skill.ID.make("effect"), mention: { start: 20, end: 27, text: "/effect" } }],
resume: false,
})
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
yield* SessionPending.promote(database.db, bus, session.id, "steer")
expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
expect.objectContaining({
+5 -5
View File
@@ -114,14 +114,14 @@ const prompt = (sessionID: Session.ID, text: string) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const messageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.InboxEnqueued, {
yield* bus.publish(SessionEvent.InputAdmitted, {
sessionID,
inboxID: messageID,
item: { type: "user", payload: { text }, delivery: "steer" },
inputID: messageID,
input: { type: "user", data: { text }, delivery: "steer" },
})
yield* bus.publish(SessionEvent.InboxDelivered, {
yield* bus.publish(SessionEvent.InputPromoted, {
sessionID,
inboxID: messageID,
inputID: messageID,
})
})
+7 -7
View File
@@ -9,7 +9,7 @@ import { Project } from "@opencode-ai/schema/project"
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
import { PermissionV1 } from "@opencode-ai/schema/permission-v1"
import { Prompt } from "@opencode-ai/schema/prompt"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionPending } from "@opencode-ai/schema/session-pending"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Command } from "@opencode-ai/schema/command"
@@ -44,7 +44,7 @@ test("Core reuses the canonical shared schemas", async () => {
coreProject,
coreProvider,
coreReference,
coreSessionInbox,
coreSessionPending,
coreSessionMessage,
coreSkill,
coreSchema,
@@ -65,7 +65,7 @@ test("Core reuses the canonical shared schemas", async () => {
import("@opencode-ai/core/project/schema"),
import("@opencode-ai/core/provider"),
import("@opencode-ai/core/reference"),
import("@opencode-ai/core/session/inbox"),
import("@opencode-ai/core/session/pending"),
import("@opencode-ai/core/session/message"),
import("@opencode-ai/core/skill"),
import("@opencode-ai/core/schema"),
@@ -126,10 +126,10 @@ test("Core reuses the canonical shared schemas", async () => {
[Session.ID, schemaSession.Session.ID],
[Session.Info, schemaSession.Session.Info],
[Session.ListAnchor, schemaSession.Session.ListAnchor],
[coreSessionInbox.Delivery, SessionInbox.Delivery],
[coreSessionInbox.Item, SessionInbox.Item],
[coreSessionInbox.User, SessionInbox.User],
[coreSessionInbox.Synthetic, SessionInbox.Synthetic],
[coreSessionPending.Delivery, SessionPending.Delivery],
[coreSessionPending.Message, SessionPending.Message],
[coreSessionPending.User, SessionPending.User],
[coreSessionPending.Synthetic, SessionPending.Synthetic],
[coreSessionMessage.ID, SessionMessage.ID],
[coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry],
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
+3 -3
View File
@@ -669,8 +669,8 @@ describe("ShellTool", () => {
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const admitted = yield* bus.subscribe(SessionEvent.InboxEnqueued).pipe(
Stream.filter((event) => event.data.sessionID === sessionID && event.data.item.type === "synthetic"),
const admitted = yield* bus.subscribe(SessionEvent.InputAdmitted).pipe(
Stream.filter((event) => event.data.sessionID === sessionID && event.data.input.type === "synthetic"),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
@@ -684,7 +684,7 @@ describe("ShellTool", () => {
const id = ShellSchema.ID.make(shellID)
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
expect((yield* shell.wait(id)).status).toBe("timeout")
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.item.payload).toMatchObject({
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.input.data).toMatchObject({
description: idleCommand,
metadata: {
source: "shell",
+7 -9
View File
@@ -19,7 +19,7 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStore } from "@opencode-ai/core/session/store"
@@ -304,7 +304,7 @@ describe("SubagentTool", () => {
agent: "reviewer",
model: childModel,
})
expect((yield* sessions.inbox(child.id)).find((message) => message.type === "user")?.payload.text).toBe(
expect((yield* sessions.pending(child.id)).find((message) => message.type === "user")?.data.text).toBe(
"You are a subagent spawned by another session.\nreview this",
)
@@ -376,8 +376,8 @@ describe("SubagentTool", () => {
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
const bus = yield* Bus.Service
const admitted = yield* bus.subscribe(SessionEvent.InboxEnqueued).pipe(
Stream.filter((event) => event.data.sessionID === parent.id && event.data.item.type === "synthetic"),
const admitted = yield* bus.subscribe(SessionEvent.InputAdmitted).pipe(
Stream.filter((event) => event.data.sessionID === parent.id && event.data.input.type === "synthetic"),
Stream.take(1),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
@@ -401,10 +401,8 @@ describe("SubagentTool", () => {
expect(settled.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
const admission = Array.from(yield* Fiber.join(admitted))[0]
expect(admission?.data.item.type).toBe("synthetic")
if (admission?.data.item.type !== "synthetic") return yield* Effect.die("Expected synthetic inbox item")
expect(admission?.data.item.payload.text).toContain(`<subagent id="${childID}" state="completed"`)
expect(admission?.data.item.payload).toMatchObject({
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
expect(admission?.data.input.data).toMatchObject({
description: "background review",
metadata: {
source: "subagent",
@@ -414,7 +412,7 @@ describe("SubagentTool", () => {
},
})
const database = yield* Database.Service
yield* SessionInbox.promote(database.db, bus, parent.id, "steer")
yield* SessionPending.promote(database.db, bus, parent.id, "steer")
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
expect(synthetic).toHaveLength(1)
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
+1 -7
View File
@@ -4,7 +4,7 @@ import { DiagramCanvas, DiagramCanvasSizeError, type DiagramCanvasCell } from ".
describe("DiagramCanvas", () => {
test("rejects canvases that exceed the rendering budget", () => {
expect(() => new DiagramCanvas(1_000, 251)).toThrow(DiagramCanvasSizeError)
expect(() => new DiagramCanvas(2_000, 1_000)).toThrow(DiagramCanvasSizeError)
})
test("rejects invalid canvas dimensions", () => {
@@ -38,12 +38,6 @@ describe("DiagramCanvas", () => {
expect(stringWidth(canvas.toString())).toBe(4)
})
test("does not emit partial wide graphemes", () => {
const clipped = new DiagramCanvas<"label">(1, 1)
clipped.setText(0, 0, "界", "label")
expect(clipped.toString()).toBe("")
})
test("keeps custom measurement for ASCII text", () => {
let measurements = 0
const canvas = new DiagramCanvas<"label">(5, 1, {
+2 -5
View File
@@ -40,7 +40,7 @@ export interface DiagramCanvasRunOptions<Style extends string, Metadata extends
trimBottom?: boolean
}
const MAX_DIAGRAM_CELLS = 250_000
const MAX_DIAGRAM_CELLS = 1_000_000
export class DiagramCanvasSizeError extends Error {
constructor(
@@ -122,6 +122,7 @@ export class DiagramCanvas<Style extends string, Metadata extends object = objec
merge: boolean,
): void {
if (y < 0 || y >= this.cells.length || x < 0 || x >= this.cells[y]!.length) return
const incoming = { char, style, ...metadata } as DiagramCanvasCell<Style, Metadata>
const cell = merge ? (this.mergeCell?.(this.cells[y]![x]!, incoming) ?? incoming) : incoming
this.cells[y]![x] = cell
@@ -150,10 +151,6 @@ export class DiagramCanvas<Style extends string, Metadata extends object = objec
let offset = 0
for (const grapheme of diagramTextGraphemes(text)) {
const width = Math.max(1, this.measure(grapheme))
if (x + offset < 0 || x + offset + width > this.width) {
offset += width
continue
}
this.setCell(x + offset, y, grapheme, style, metadataAt(x + offset))
for (let continuation = 1; continuation < width; continuation++) {
this.setCell(x + offset + continuation, y, "", style, metadataAt(x + offset + continuation))
-4
View File
@@ -30,7 +30,6 @@ const BOX_NODE_RE = new RegExp(`^(${ID_RE})\\[(.+)\\]$`)
const ID_ONLY_RE = new RegExp(`^${ID_RE}$`)
const EXPLICIT_NODE_SHAPE_RE = new RegExp(`^${ID_RE}(?:\\[|\\(|\\{)`)
const CIRCLE_NODE_RE = new RegExp(`^${ID_RE}\\(\\(.+\\)\\)$`)
const MAX_FLOWCHART_LINE_LENGTH = 10_000
const EDGE_OPERATOR_RE =
/(-\.(?!->)(.+?)\.(?:->|-))|(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->|-\.->|\.-)|(<-->|-->|==>|-\.->|---|~~~)\s*(?:\|([^|]*)\|\s*)?/dg
@@ -253,9 +252,6 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
for (const source of meaningfulNumberedMermaidLines(content)) {
const line = source.text
if (line.length > MAX_FLOWCHART_LINE_LENGTH) {
throw new MermaidSyntaxError("flowchart", source.lineNumber, line, "Flowchart statement is too long")
}
if (hasInternalStatementSeparator(line)) throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
const header = line.match(FLOWCHART_HEADER_RE)
if (header) {
+3 -16
View File
@@ -37,8 +37,6 @@ interface PreparedDiagram {
export interface MermaidMarkdownRendererOptions {
compact?: boolean
/** Fold horizontal flowcharts that exceed this width. Defaults to 120 columns. */
layoutMaxWidth?: number
colors?: {
text?: ColorInput
primary?: ColorInput
@@ -103,19 +101,11 @@ class StaticDiagramRenderable extends TextRenderable {
}
}
function prepareDiagram(
kind: DiagramKind,
source: string,
options: MermaidMarkdownRendererOptions,
layoutMaxWidth: number,
): PreparedDiagram {
function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkdownRendererOptions): PreparedDiagram {
const colors = options.colors ?? {}
switch (kind) {
case "flowchart": {
const grid = drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(source), {
compact: options.compact,
layoutMaxWidth,
})
const grid = drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(source), { compact: options.compact })
const size = grid.getTextSize({ trimTop: true, trimBottom: true })
return {
kind,
@@ -202,12 +192,9 @@ export function createMermaidCodeBlockRenderer(
// OpenTUI's default block ID is the stable identity available for this fence across streaming updates.
const key = context.defaultRender()?.id
const options = typeof input === "function" ? input() : input
const configuredMaxWidth =
options.layoutMaxWidth === undefined ? 120 : Math.max(1, Math.trunc(options.layoutMaxWidth))
const layoutMaxWidth = Math.min(configuredMaxWidth, Math.max(1, Math.trunc(ctx.width)))
try {
const prepared = prepareDiagram(kind, token.text, options, layoutMaxWidth)
const prepared = prepareDiagram(kind, token.text, options)
const diagram = new StaticDiagramRenderable(ctx, prepared)
if (key) claimLastGood(key, prepared, diagram, lastGood)
return diagram
-4
View File
@@ -23,7 +23,6 @@ const ELSE_RE = /^else(?:\s+(.+))?$/i
const LOOP_RE = /^loop\s+(.+)$/i
const AUTONUMBER_RE = /^autonumber(?:\s+(\d+)(?:\s+(\d+))?)?$/i
const UNSUPPORTED_BIDIRECTIONAL_MESSAGE_RE = /<<-{1,2}>>/
const MESSAGE_OPERATOR_RE = /-->>|->>|--x|-x|--\)|-\)|-->|->/
const CSS_COLOR_NAMES = new Set([
"black",
"white",
@@ -238,9 +237,6 @@ export function parseMermaidSequenceDiagram(content: string): SequenceDiagram {
const arrow = messageMatch[2]!
const activationMarker = messageMatch[3]!
const to = stripQuotes(messageMatch[4]!)
if (MESSAGE_OPERATOR_RE.test(from) || MESSAGE_OPERATOR_RE.test(to)) {
throw new MermaidSyntaxError("sequence", source.lineNumber, line)
}
const message: SequenceMessage = {
from,
to,
-3
View File
@@ -176,9 +176,6 @@ export function parseMermaidStateDiagram(content: string): StateDiagram {
if (transitionMatch) {
const rawFrom = transitionMatch[1]!
const rawTo = transitionMatch[2]!
if (rawFrom.includes("-->") || rawTo.includes("-->")) {
throw new MermaidSyntaxError("state", source.lineNumber, line)
}
const from = normalizeStateDiagramEndpoint(rawFrom, "from", parentId)
const to = normalizeStateDiagramEndpoint(rawTo, "to", parentId)
ensureState(states, from, rawFrom === "[*]" ? "●" : from, rawFrom === "[*]" ? "start" : "state", parentId)
@@ -41,11 +41,6 @@ describe("parser diagnostics", () => {
expect(diagram.edges).toHaveLength(1)
})
test("rejects pathological flowchart statements before parsing edge operators", () => {
const statement = "-.a".repeat(4_000)
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n${statement}`)).toThrow("Flowchart statement is too long")
})
test("exposes structured syntax errors through top-level rendering", () => {
try {
renderSequenceDiagram(`sequenceDiagram
@@ -67,11 +62,6 @@ describe("parser diagnostics", () => {
}
})
test("rejects chained sequence and state transitions instead of creating phantom endpoints", () => {
expect(() => parseMermaidSequenceDiagram("sequenceDiagram\n A->>B->>C: hello")).toThrow(MermaidSyntaxError)
expect(() => parseMermaidStateDiagram("stateDiagram-v2\n A-->B-->C")).toThrow(MermaidSyntaxError)
})
test("reports unclosed state constructs at their opening line", () => {
expect(() =>
parseMermaidStateDiagram(`stateDiagram-v2
-22
View File
@@ -289,28 +289,6 @@ flowchart TB
expect(frame).not.toMatch(/<\/?i>|<br|events persist|mcp stdio/)
})
test("folds a horizontal flowchart to fit the Markdown viewport", async () => {
const testRenderer = await createTestRenderer({ width: 160, height: 30 })
renderer = testRenderer.renderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-horizontal-flowchart",
content: `\`\`\`mermaid
flowchart LR
A["per TURN<br/>fresh sandbox each turn"] --- B["per SESSION/thread<br/>one sandbox per Slack thread"] --- C["per REPO<br/>threads share a sandbox"] --- D["GLOBAL registry<br/>(upstream today: hardwired at boot)"]
\`\`\``,
syntaxStyle,
renderNode: createMermaidMarkdownRenderer(renderer),
})
renderer.root.add(markdown)
await renderMarkdown(markdown, testRenderer.renderOnce)
const diagram = markdown.getChildren()[0] as CodeRenderable
expect(diagram.scrollWidth).toBeLessThanOrEqual(diagram.width)
expect(diagram.scrollWidth).toBeLessThanOrEqual(120)
expect(testRenderer.captureCharFrame()).toContain("GLOBAL registry")
})
test("renders a Mermaid state fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
renderer = testRenderer.renderer
+2 -2
View File
@@ -16,7 +16,7 @@ import type {
ReferenceInfo,
SessionInfo,
SessionMessageInfo,
SessionInboxInfo,
SessionPendingInfo,
ShellInfo,
SkillInfo,
VcsInfo,
@@ -70,7 +70,7 @@ export interface Data {
cost(sessionID: string): number
status(sessionID: string): "idle" | "running"
readonly pending: {
list(sessionID: string): SessionInboxInfo[]
list(sessionID: string): SessionPendingInfo[]
sync(sessionID: string): Promise<void>
invalidate(sessionID: string): void
}
+279 -255
View File
@@ -1670,32 +1670,7 @@
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"workspaceID": {
"type": "string",
"allOf": [
{
"pattern": "^wrk"
}
]
},
"delivery": {
"anyOf": [
{
"$ref": "#/components/schemas/Session.Inbox.Delivery"
},
{
"type": "null"
}
]
}
},
"required": ["directory"],
"additionalProperties": false
"$ref": "#/components/schemas/Location.Ref"
}
}
},
@@ -1732,7 +1707,7 @@
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Session.Inbox.User"
"$ref": "#/components/schemas/SessionPending.User"
}
},
"required": ["data"],
@@ -1846,7 +1821,8 @@
"delivery": {
"anyOf": [
{
"$ref": "#/components/schemas/Session.Inbox.Delivery"
"type": "string",
"enum": ["steer", "queue"]
},
{
"type": "null"
@@ -1902,7 +1878,7 @@
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Session.Inbox.User"
"$ref": "#/components/schemas/SessionPending.User"
}
},
"required": ["data"],
@@ -2056,7 +2032,8 @@
"delivery": {
"anyOf": [
{
"$ref": "#/components/schemas/Session.Inbox.Delivery"
"type": "string",
"enum": ["steer", "queue"]
},
{
"type": "null"
@@ -2223,7 +2200,7 @@
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Session.Inbox.Synthetic"
"$ref": "#/components/schemas/SessionPending.Synthetic"
}
},
"required": ["data"],
@@ -2322,7 +2299,8 @@
"delivery": {
"anyOf": [
{
"$ref": "#/components/schemas/Session.Inbox.Delivery"
"type": "string",
"enum": ["steer", "queue"]
},
{
"type": "null"
@@ -2476,7 +2454,7 @@
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Session.Inbox.Compaction"
"$ref": "#/components/schemas/SessionPending.Compaction"
}
},
"required": ["data"],
@@ -2555,16 +2533,6 @@
"type": "null"
}
]
},
"delivery": {
"anyOf": [
{
"$ref": "#/components/schemas/Session.Inbox.Delivery"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
@@ -3032,10 +3000,10 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/inbox": {
"/api/session/{sessionID}/pending": {
"get": {
"tags": ["session"],
"operationId": "v2.session.inbox.list",
"operationId": "v2.session.pending.list",
"parameters": [
{
"name": "sessionID",
@@ -3063,7 +3031,7 @@
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Session.Inbox.Info"
"$ref": "#/components/schemas/SessionPending.Info"
}
}
},
@@ -3104,14 +3072,14 @@
}
}
},
"description": "List durable enqueued session work not yet delivered, ordered by enqueue sequence. Includes user, synthetic, compaction, and move items.",
"summary": "List session inbox"
"description": "List durable admitted session work not yet visible in projected history, ordered by admission. Includes unpromoted user and synthetic inputs and unhandled compaction barriers. The runner owns consumption; items disappear once promoted or handled.",
"summary": "List pending session work"
}
},
"/api/session/{sessionID}/inbox/{inboxID}": {
"/api/session/{sessionID}/pending/{inputID}": {
"delete": {
"tags": ["session"],
"operationId": "v2.session.inbox.cancel",
"operationId": "v2.session.pending.cancel",
"parameters": [
{
"name": "sessionID",
@@ -3127,7 +3095,7 @@
"required": true
},
{
"name": "inboxID",
"name": "inputID",
"in": "path",
"schema": {
"type": "string",
@@ -3186,14 +3154,14 @@
}
}
},
"description": "Cancel an inbox item that has not yet been delivered.",
"summary": "Cancel inbox input"
"description": "Cancel an input that has not yet been promoted into session history.",
"summary": "Cancel pending input"
}
},
"/api/session/{sessionID}/inbox/{inboxID}/steer": {
"/api/session/{sessionID}/pending/{inputID}/steer": {
"post": {
"tags": ["session"],
"operationId": "v2.session.inbox.steer",
"operationId": "v2.session.pending.steer",
"parameters": [
{
"name": "sessionID",
@@ -3209,7 +3177,7 @@
"required": true
},
{
"name": "inboxID",
"name": "inputID",
"in": "path",
"schema": {
"type": "string",
@@ -3268,14 +3236,14 @@
}
}
},
"description": "Change a queued inbox item to steer delivery and wake session execution.",
"summary": "Steer queued item"
"description": "Change a queued input to steer delivery and wake session execution.",
"summary": "Steer queued input"
}
},
"/api/session/{sessionID}/inbox/{inboxID}/queue": {
"/api/session/{sessionID}/pending/{inputID}/queue": {
"post": {
"tags": ["session"],
"operationId": "v2.session.inbox.queue",
"operationId": "v2.session.pending.queue",
"parameters": [
{
"name": "sessionID",
@@ -3291,7 +3259,7 @@
"required": true
},
{
"name": "inboxID",
"name": "inputID",
"in": "path",
"schema": {
"type": "string",
@@ -3350,8 +3318,8 @@
}
}
},
"description": "Change a steered inbox item to queued delivery.",
"summary": "Queue steered item"
"description": "Change a pending steer to queued delivery.",
"summary": "Queue pending steer"
}
},
"/api/session/{sessionID}/instructions/entries": {
@@ -3964,7 +3932,7 @@
}
}
},
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.",
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable pending work remains after interruption.",
"summary": "Interrupt session execution"
}
},
@@ -8534,7 +8502,7 @@
"/api/fs/find": {
"get": {
"tags": ["filesystem"],
"operationId": "v2.fs.get",
"operationId": "v2.fs.find",
"parameters": [
{
"name": "location",
@@ -13983,10 +13951,6 @@
"required": ["_tag", "sessionID", "messageID", "message"],
"additionalProperties": false
},
"Session.Inbox.Delivery": {
"type": "string",
"enum": ["steer", "queue"]
},
"PromptInput.FileAttachment": {
"type": "object",
"properties": {
@@ -14019,7 +13983,7 @@
"required": ["id"],
"additionalProperties": false
},
"Session.Inbox.UserPayload": {
"SessionPending.UserData": {
"type": "object",
"properties": {
"text": {
@@ -14050,7 +14014,7 @@
"required": ["text"],
"additionalProperties": false
},
"Session.Inbox.User": {
"SessionPending.User": {
"type": "object",
"properties": {
"id": {
@@ -14076,14 +14040,15 @@
"type": "string",
"enum": ["user"]
},
"payload": {
"$ref": "#/components/schemas/Session.Inbox.UserPayload"
"data": {
"$ref": "#/components/schemas/SessionPending.UserData"
},
"delivery": {
"$ref": "#/components/schemas/Session.Inbox.Delivery"
"type": "string",
"enum": ["steer", "queue"]
}
},
"required": ["id", "sessionID", "timeCreated", "type", "payload", "delivery"],
"required": ["id", "sessionID", "timeCreated", "type", "data", "delivery"],
"additionalProperties": false
},
"CommandNotFoundError": {
@@ -14137,7 +14102,7 @@
"required": ["_tag", "skill", "message"],
"additionalProperties": false
},
"Session.Inbox.SyntheticPayload": {
"SessionPending.SyntheticData": {
"type": "object",
"properties": {
"text": {
@@ -14153,7 +14118,7 @@
"required": ["text"],
"additionalProperties": false
},
"Session.Inbox.Synthetic": {
"SessionPending.Synthetic": {
"type": "object",
"properties": {
"id": {
@@ -14179,27 +14144,18 @@
"type": "string",
"enum": ["synthetic"]
},
"payload": {
"$ref": "#/components/schemas/Session.Inbox.SyntheticPayload"
"data": {
"$ref": "#/components/schemas/SessionPending.SyntheticData"
},
"delivery": {
"$ref": "#/components/schemas/Session.Inbox.Delivery"
"type": "string",
"enum": ["steer", "queue"]
}
},
"required": ["id", "sessionID", "timeCreated", "type", "payload", "delivery"],
"required": ["id", "sessionID", "timeCreated", "type", "data", "delivery"],
"additionalProperties": false
},
"Session.Inbox.CompactionPayload": {
"anyOf": [
{
"type": "object"
},
{
"type": "array"
}
]
},
"Session.Inbox.Compaction": {
"SessionPending.Compaction": {
"type": "object",
"properties": {
"id": {
@@ -14224,15 +14180,9 @@
"type": {
"type": "string",
"enum": ["compaction"]
},
"payload": {
"$ref": "#/components/schemas/Session.Inbox.CompactionPayload"
},
"delivery": {
"$ref": "#/components/schemas/Session.Inbox.Delivery"
}
},
"required": ["id", "sessionID", "timeCreated", "type", "payload", "delivery"],
"required": ["id", "sessionID", "timeCreated", "type"],
"additionalProperties": false
},
"ServiceUnavailableError": {
@@ -14276,71 +14226,16 @@
"required": ["_tag", "sessionID", "message"],
"additionalProperties": false
},
"Session.Inbox.MovePayload": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"projectID": {
"type": "string"
},
"subpath": {
"type": "string"
}
},
"required": ["location", "projectID"],
"additionalProperties": false
},
"Session.Inbox.Move": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^msg_"
}
]
},
"sessionID": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"timeCreated": {
"type": "number"
},
"type": {
"type": "string",
"enum": ["move"]
},
"payload": {
"$ref": "#/components/schemas/Session.Inbox.MovePayload"
},
"delivery": {
"$ref": "#/components/schemas/Session.Inbox.Delivery"
}
},
"required": ["id", "sessionID", "timeCreated", "type", "payload", "delivery"],
"additionalProperties": false
},
"Session.Inbox.Info": {
"SessionPending.Info": {
"anyOf": [
{
"$ref": "#/components/schemas/Session.Inbox.User"
"$ref": "#/components/schemas/SessionPending.User"
},
{
"$ref": "#/components/schemas/Session.Inbox.Synthetic"
"$ref": "#/components/schemas/SessionPending.Synthetic"
},
{
"$ref": "#/components/schemas/Session.Inbox.Compaction"
},
{
"$ref": "#/components/schemas/Session.Inbox.Move"
"$ref": "#/components/schemas/SessionPending.Compaction"
}
]
},
@@ -14707,7 +14602,7 @@
"type": "string"
}
},
"required": ["sessionID", "location", "projectID"],
"required": ["sessionID", "location"],
"additionalProperties": false
}
},
@@ -14936,7 +14831,7 @@
"required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false
},
"session.inbox.delivered": {
"session.input.promoted": {
"type": "object",
"properties": {
"id": {
@@ -14955,7 +14850,7 @@
},
"type": {
"type": "string",
"enum": ["session.inbox.delivered"]
"enum": ["session.input.promoted"]
},
"durable": {
"type": "object",
@@ -14993,7 +14888,7 @@
}
]
},
"inboxID": {
"inputID": {
"type": "string",
"allOf": [
{
@@ -15002,14 +14897,14 @@
]
}
},
"required": ["sessionID", "inboxID"],
"required": ["sessionID", "inputID"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false
},
"Session.Inbox.UserPayload1": {
"SessionPending.UserData1": {
"type": "object",
"properties": {
"text": {
@@ -15040,7 +14935,25 @@
"required": ["text"],
"additionalProperties": false
},
"Session.Inbox.SyntheticPayload1": {
"SessionPending.UserMessage": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["user"]
},
"data": {
"$ref": "#/components/schemas/SessionPending.UserData1"
},
"delivery": {
"type": "string",
"enum": ["steer", "queue"]
}
},
"required": ["type", "data", "delivery"],
"additionalProperties": false
},
"SessionPending.SyntheticData1": {
"type": "object",
"properties": {
"text": {
@@ -15056,79 +14969,35 @@
"required": ["text"],
"additionalProperties": false
},
"Session.Inbox.Item": {
"SessionPending.SyntheticMessage": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["synthetic"]
},
"data": {
"$ref": "#/components/schemas/SessionPending.SyntheticData1"
},
"delivery": {
"type": "string",
"enum": ["steer", "queue"]
}
},
"required": ["type", "data", "delivery"],
"additionalProperties": false
},
"SessionPending.Message": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["user"]
},
"payload": {
"$ref": "#/components/schemas/Session.Inbox.UserPayload1"
},
"delivery": {
"$ref": "#/components/schemas/Session.Inbox.Delivery"
}
},
"required": ["type", "payload", "delivery"],
"additionalProperties": false
"$ref": "#/components/schemas/SessionPending.UserMessage"
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["synthetic"]
},
"payload": {
"$ref": "#/components/schemas/Session.Inbox.SyntheticPayload1"
},
"delivery": {
"$ref": "#/components/schemas/Session.Inbox.Delivery"
}
},
"required": ["type", "payload", "delivery"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["compaction"]
},
"payload": {
"$ref": "#/components/schemas/Session.Inbox.CompactionPayload"
},
"delivery": {
"$ref": "#/components/schemas/Session.Inbox.Delivery"
}
},
"required": ["type", "payload", "delivery"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["move"]
},
"payload": {
"$ref": "#/components/schemas/Session.Inbox.MovePayload"
},
"delivery": {
"$ref": "#/components/schemas/Session.Inbox.Delivery"
}
},
"required": ["type", "payload", "delivery"],
"additionalProperties": false
"$ref": "#/components/schemas/SessionPending.SyntheticMessage"
}
]
},
"session.inbox.enqueued": {
"session.input.admitted": {
"type": "object",
"properties": {
"id": {
@@ -15147,7 +15016,7 @@
},
"type": {
"type": "string",
"enum": ["session.inbox.enqueued"]
"enum": ["session.input.admitted"]
},
"durable": {
"type": "object",
@@ -15185,7 +15054,7 @@
}
]
},
"inboxID": {
"inputID": {
"type": "string",
"allOf": [
{
@@ -15193,18 +15062,18 @@
}
]
},
"item": {
"$ref": "#/components/schemas/Session.Inbox.Item"
"input": {
"$ref": "#/components/schemas/SessionPending.Message"
}
},
"required": ["sessionID", "inboxID", "item"],
"required": ["sessionID", "inputID", "input"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false
},
"session.inbox.cancelled": {
"session.input.cancelled": {
"type": "object",
"properties": {
"id": {
@@ -15223,7 +15092,7 @@
},
"type": {
"type": "string",
"enum": ["session.inbox.cancelled"]
"enum": ["session.input.cancelled"]
},
"durable": {
"type": "object",
@@ -15261,7 +15130,7 @@
}
]
},
"inboxID": {
"inputID": {
"type": "string",
"allOf": [
{
@@ -15270,14 +15139,14 @@
]
}
},
"required": ["sessionID", "inboxID"],
"required": ["sessionID", "inputID"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false
},
"session.inbox.delivery.changed": {
"session.input.steered": {
"type": "object",
"properties": {
"id": {
@@ -15296,7 +15165,7 @@
},
"type": {
"type": "string",
"enum": ["session.inbox.delivery.changed"]
"enum": ["session.input.steered"]
},
"durable": {
"type": "object",
@@ -15334,19 +15203,89 @@
}
]
},
"inboxID": {
"inputID": {
"type": "string",
"allOf": [
{
"pattern": "^msg_"
}
]
},
"delivery": {
"$ref": "#/components/schemas/Session.Inbox.Delivery"
}
},
"required": ["sessionID", "inboxID", "delivery"],
"required": ["sessionID", "inputID"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false
},
"session.input.queued": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^evt_"
}
]
},
"created": {
"type": "number"
},
"metadata": {
"type": "object"
},
"type": {
"type": "string",
"enum": ["session.input.queued"]
},
"durable": {
"type": "object",
"properties": {
"aggregateID": {
"type": "string"
},
"seq": {
"type": "integer",
"allOf": [
{
"minimum": 0
}
]
},
"version": {
"type": "number",
"enum": [1]
}
},
"required": ["aggregateID", "seq", "version"],
"additionalProperties": false
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"data": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"inputID": {
"type": "string",
"allOf": [
{
"pattern": "^msg_"
}
]
}
},
"required": ["sessionID", "inputID"],
"additionalProperties": false
}
},
@@ -17269,6 +17208,79 @@
"required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false
},
"session.compaction.admitted": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^evt_"
}
]
},
"created": {
"type": "number"
},
"metadata": {
"type": "object"
},
"type": {
"type": "string",
"enum": ["session.compaction.admitted"]
},
"durable": {
"type": "object",
"properties": {
"aggregateID": {
"type": "string"
},
"seq": {
"type": "integer",
"allOf": [
{
"minimum": 0
}
]
},
"version": {
"type": "number",
"enum": [1]
}
},
"required": ["aggregateID", "seq", "version"],
"additionalProperties": false
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"data": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"inputID": {
"type": "string",
"allOf": [
{
"pattern": "^msg_"
}
]
}
},
"required": ["sessionID", "inputID"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false
},
"session.compaction.started": {
"type": "object",
"properties": {
@@ -17809,16 +17821,19 @@
"$ref": "#/components/schemas/session.forked"
},
{
"$ref": "#/components/schemas/session.inbox.delivered"
"$ref": "#/components/schemas/session.input.promoted"
},
{
"$ref": "#/components/schemas/session.inbox.enqueued"
"$ref": "#/components/schemas/session.input.admitted"
},
{
"$ref": "#/components/schemas/session.inbox.cancelled"
"$ref": "#/components/schemas/session.input.cancelled"
},
{
"$ref": "#/components/schemas/session.inbox.delivery.changed"
"$ref": "#/components/schemas/session.input.steered"
},
{
"$ref": "#/components/schemas/session.input.queued"
},
{
"$ref": "#/components/schemas/session.execution.started"
@@ -17886,6 +17901,9 @@
{
"$ref": "#/components/schemas/session.retry.scheduled"
},
{
"$ref": "#/components/schemas/session.compaction.admitted"
},
{
"$ref": "#/components/schemas/session.compaction.started"
},
@@ -23363,16 +23381,19 @@
"$ref": "#/components/schemas/session.forked"
},
{
"$ref": "#/components/schemas/session.inbox.delivered"
"$ref": "#/components/schemas/session.input.promoted"
},
{
"$ref": "#/components/schemas/session.inbox.enqueued"
"$ref": "#/components/schemas/session.input.admitted"
},
{
"$ref": "#/components/schemas/session.inbox.cancelled"
"$ref": "#/components/schemas/session.input.cancelled"
},
{
"$ref": "#/components/schemas/session.inbox.delivery.changed"
"$ref": "#/components/schemas/session.input.steered"
},
{
"$ref": "#/components/schemas/session.input.queued"
},
{
"$ref": "#/components/schemas/session.execution.started"
@@ -23452,6 +23473,9 @@
{
"$ref": "#/components/schemas/session.retry.scheduled"
},
{
"$ref": "#/components/schemas/session.compaction.admitted"
},
{
"$ref": "#/components/schemas/session.compaction.started"
},
+2 -2
View File
@@ -47,14 +47,14 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
),
)
.add(
HttpApiEndpoint.get("fs.get", "/api/fs/find", {
HttpApiEndpoint.get("fs.find", "/api/fs/find", {
query: FindQuery,
success: Location.response(Schema.Array(FileSystem.Entry)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.fs.get",
identifier: "v2.fs.find",
summary: "Find files",
description: "Find recursively ranked filesystem entries relative to the requested location.",
}),
+32 -35
View File
@@ -1,6 +1,6 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionPending } from "@opencode-ai/schema/session-pending"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Session } from "@opencode-ai/schema/session"
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
@@ -301,7 +301,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.add(
HttpApiEndpoint.post("session.move", "/api/session/:sessionID/move", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ ...Location.Ref.fields, delivery: SessionInbox.Delivery.pipe(Schema.optional) }),
payload: Location.Ref,
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, InvalidRequestError],
}).annotateMerge(
@@ -318,11 +318,11 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
payload: Schema.Struct({
id: SessionMessage.ID.pipe(Schema.optional),
...PromptInput.Prompt.fields,
metadata: SessionInbox.UserPayload.fields.metadata,
delivery: SessionInbox.Delivery.pipe(Schema.optional),
metadata: SessionPending.UserData.fields.metadata,
delivery: SessionPending.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionInbox.User }),
success: Schema.Struct({ data: SessionPending.User }),
error: [ConflictError, InvalidRequestError, SessionNotFoundError],
})
.middleware(sessionLocationMiddleware)
@@ -346,10 +346,10 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
files: PromptInput.Prompt.fields.files,
agents: PromptInput.Prompt.fields.agents,
skills: PromptInput.Prompt.fields.skills,
delivery: SessionInbox.Delivery.pipe(Schema.optional),
delivery: SessionPending.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionInbox.User }),
success: Schema.Struct({ data: SessionPending.User }),
error: [ConflictError, InvalidRequestError, SessionNotFoundError, CommandNotFoundError, CommandEvaluationError],
})
.middleware(sessionLocationMiddleware)
@@ -390,10 +390,10 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
text: Schema.String,
description: Schema.String.pipe(Schema.optional),
metadata: SessionMessage.Synthetic.fields.metadata,
delivery: SessionInbox.Delivery.pipe(Schema.optional),
delivery: SessionPending.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionInbox.Synthetic }),
success: Schema.Struct({ data: SessionPending.Synthetic }),
error: [ConflictError, SessionNotFoundError],
})
.middleware(sessionLocationMiddleware)
@@ -428,11 +428,8 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.add(
HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", {
params: { sessionID: Session.ID },
payload: Schema.Struct({
id: SessionMessage.ID.pipe(Schema.optional),
delivery: SessionInbox.Delivery.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionInbox.Compaction }),
payload: Schema.Struct({ id: SessionMessage.ID.pipe(Schema.optional) }),
success: Schema.Struct({ data: SessionPending.Compaction }),
error: [ConflictError, SessionNotFoundError],
})
.middleware(sessionLocationMiddleware)
@@ -509,55 +506,55 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
),
)
.add(
HttpApiEndpoint.get("session.inbox.list", "/api/session/:sessionID/inbox", {
HttpApiEndpoint.get("session.pending.list", "/api/session/:sessionID/pending", {
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(SessionInbox.Info) }),
success: Schema.Struct({ data: Schema.Array(SessionPending.Info) }),
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.inbox.list",
summary: "List session inbox",
identifier: "v2.session.pending.list",
summary: "List pending session work",
description:
"List durable enqueued session work not yet delivered, ordered by enqueue sequence. Includes user, synthetic, compaction, and move items.",
"List durable admitted session work not yet visible in projected history, ordered by admission. Includes unpromoted user and synthetic inputs and unhandled compaction barriers. The runner owns consumption; items disappear once promoted or handled.",
}),
),
)
.add(
HttpApiEndpoint.delete("session.inbox.cancel", "/api/session/:sessionID/inbox/:inboxID", {
params: { sessionID: Session.ID, inboxID: SessionMessage.ID },
HttpApiEndpoint.delete("session.pending.cancel", "/api/session/:sessionID/pending/:inputID", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.inbox.cancel",
summary: "Cancel inbox input",
description: "Cancel an inbox item that has not yet been delivered.",
identifier: "v2.session.pending.cancel",
summary: "Cancel pending input",
description: "Cancel an input that has not yet been promoted into session history.",
}),
),
)
.add(
HttpApiEndpoint.post("session.inbox.steer", "/api/session/:sessionID/inbox/:inboxID/steer", {
params: { sessionID: Session.ID, inboxID: SessionMessage.ID },
HttpApiEndpoint.post("session.pending.steer", "/api/session/:sessionID/pending/:inputID/steer", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.inbox.steer",
summary: "Steer queued item",
description: "Change a queued inbox item to steer delivery and wake session execution.",
identifier: "v2.session.pending.steer",
summary: "Steer queued input",
description: "Change a queued input to steer delivery and wake session execution.",
}),
),
)
.add(
HttpApiEndpoint.post("session.inbox.queue", "/api/session/:sessionID/inbox/:inboxID/queue", {
params: { sessionID: Session.ID, inboxID: SessionMessage.ID },
HttpApiEndpoint.post("session.pending.queue", "/api/session/:sessionID/pending/:inputID/queue", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.inbox.queue",
summary: "Queue steered item",
description: "Change a steered inbox item to queued delivery.",
identifier: "v2.session.pending.queue",
summary: "Queue pending steer",
description: "Change a pending steer to queued delivery.",
}),
),
)
@@ -660,7 +657,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
identifier: "v2.session.interrupt",
summary: "Interrupt session execution",
description:
"Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.",
"Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable pending work remains after interruption.",
}),
),
)
+1 -1
View File
@@ -22,7 +22,7 @@ export { Reference } from "./reference.js"
export { WebSearch } from "./websearch.js"
export { Session } from "./session.js"
export { Vcs } from "./vcs.js"
export { SessionInbox } from "./session-inbox.js"
export { SessionPending } from "./session-pending.js"
export { SessionError } from "./session-error.js"
export { SessionMessage } from "./session-message.js"
export { SessionTransfer } from "./session-transfer.js"
+6
View File
@@ -0,0 +1,6 @@
export * as SessionDelivery from "./session-delivery.js"
import { Schema } from "effect"
export const Delivery = Schema.Literals(["steer", "queue"])
export type Delivery = typeof Delivery.Type
+49 -26
View File
@@ -6,7 +6,7 @@ import { Event } from "./event.js"
import { FinishReason } from "./llm.js"
import { Content } from "./tool.js"
import { Model } from "./model.js"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
import { DateTimeUtcFromMillis, NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
import { FileAttachment } from "./prompt.js"
import { SessionID } from "./session-id.js"
import { Location } from "./location.js"
@@ -20,7 +20,7 @@ import { Skill as SkillSchema } from "./skill.js"
import { Money } from "./money.js"
import { Snapshot } from "./snapshot.js"
import { TokenUsage } from "./token-usage.js"
import { SessionInbox } from "./session-inbox.js"
import { SessionPending } from "./session-pending.js"
import { Project } from "./project.js"
import { SessionFork } from "./session-fork.js"
@@ -90,7 +90,9 @@ export const Moved = Event.durable({
...options,
schema: {
...Base,
...SessionInbox.MovePayload.fields,
location: Location.Ref,
projectID: Project.ID.pipe(optional),
subpath: RelativePath.pipe(optional),
},
})
export type Moved = typeof Moved.Type
@@ -152,41 +154,48 @@ export const Forked = Event.durable({
})
export type Forked = typeof Forked.Type
const InboxRef = {
const InputRef = {
...Base,
inboxID: SessionMessage.ID,
inputID: SessionMessage.ID,
}
export const InboxDelivered = Event.durable({
type: "session.inbox.delivered",
export const InputPromoted = Event.durable({
type: "session.input.promoted",
...options,
schema: InboxRef,
schema: InputRef,
})
export type InboxDelivered = typeof InboxDelivered.Type
export type InputPromoted = typeof InputPromoted.Type
export const InboxEnqueued = Event.durable({
type: "session.inbox.enqueued",
export const InputAdmitted = Event.durable({
type: "session.input.admitted",
...options,
schema: {
...InboxRef,
item: SessionInbox.Item,
...InputRef,
input: SessionPending.Message,
},
})
export type InboxEnqueued = typeof InboxEnqueued.Type
export type InputAdmitted = typeof InputAdmitted.Type
export const InboxCancelled = Event.durable({
type: "session.inbox.cancelled",
export const InputCancelled = Event.durable({
type: "session.input.cancelled",
...options,
schema: InboxRef,
schema: InputRef,
})
export type InboxCancelled = typeof InboxCancelled.Type
export type InputCancelled = typeof InputCancelled.Type
export const InboxDeliveryChanged = Event.durable({
type: "session.inbox.delivery.changed",
export const InputSteered = Event.durable({
type: "session.input.steered",
...options,
schema: { ...InboxRef, delivery: SessionInbox.Delivery },
schema: InputRef,
})
export type InboxDeliveryChanged = typeof InboxDeliveryChanged.Type
export type InputSteered = typeof InputSteered.Type
export const InputQueued = Event.durable({
type: "session.input.queued",
...options,
schema: InputRef,
})
export type InputQueued = typeof InputQueued.Type
export namespace Execution {
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
@@ -300,6 +309,7 @@ export namespace Step {
finish: FinishReason,
cost: Money.USD,
tokens: TokenUsage.Info,
generated: DateTimeUtcFromMillis.pipe(optional),
snapshot: Snapshot.ID.pipe(optional),
files: Schema.Array(RelativePath).pipe(optional),
},
@@ -315,6 +325,7 @@ export namespace Step {
error: SessionError.Error,
cost: Money.USD.pipe(optional),
tokens: TokenUsage.Info.pipe(optional),
generated: DateTimeUtcFromMillis.pipe(optional),
snapshot: Snapshot.ID.pipe(optional),
files: Schema.Array(RelativePath).pipe(optional),
},
@@ -514,6 +525,16 @@ export const RetryScheduled = Event.durable({
export type RetryScheduled = typeof RetryScheduled.Type
export namespace Compaction {
export const Admitted = Event.durable({
type: "session.compaction.admitted",
...options,
schema: {
...Base,
inputID: SessionMessage.ID,
},
})
export type Admitted = typeof Admitted.Type
export const Started = Event.durable({
type: "session.compaction.started",
...options,
@@ -583,10 +604,11 @@ export const Definitions = Event.inventory(
UsageUpdated,
Deleted,
Forked,
InboxDelivered,
InboxEnqueued,
InboxCancelled,
InboxDeliveryChanged,
InputPromoted,
InputAdmitted,
InputCancelled,
InputSteered,
InputQueued,
Execution.Started,
Execution.Succeeded,
Execution.Failed,
@@ -613,6 +635,7 @@ export const Definitions = Event.inventory(
Tool.Success,
Tool.Failed,
RetryScheduled,
Compaction.Admitted,
Compaction.Started,
Compaction.Delta,
Compaction.Ended,
-78
View File
@@ -1,78 +0,0 @@
export * as SessionInbox from "./session-inbox.js"
import { Schema } from "effect"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { Prompt } from "./prompt.js"
import { DateTimeUtcFromMillis, optional, RelativePath } from "./schema.js"
import { SessionID } from "./session-id.js"
import { SessionMessage } from "./session-message.js"
export const Delivery = Schema.Literals(["steer", "queue"]).annotate({ identifier: "Session.Inbox.Delivery" })
export type Delivery = typeof Delivery.Type
export interface UserPayload extends Schema.Schema.Type<typeof UserPayload> {}
export const UserPayload = Schema.Struct({
...Prompt.fields,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
}).annotate({ identifier: "Session.Inbox.UserPayload" })
export interface SyntheticPayload extends Schema.Schema.Type<typeof SyntheticPayload> {}
export const SyntheticPayload = Schema.Struct({
text: Schema.String,
description: Schema.String.pipe(optional),
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
}).annotate({ identifier: "Session.Inbox.SyntheticPayload" })
export interface CompactionPayload extends Schema.Schema.Type<typeof CompactionPayload> {}
export const CompactionPayload = Schema.Struct({}).annotate({ identifier: "Session.Inbox.CompactionPayload" })
export interface MovePayload extends Schema.Schema.Type<typeof MovePayload> {}
export const MovePayload = Schema.Struct({
location: Location.Ref,
projectID: Project.ID,
subpath: RelativePath.pipe(optional),
}).annotate({ identifier: "Session.Inbox.MovePayload" })
const UserItem = Schema.Struct({ type: Schema.tag("user"), payload: UserPayload, delivery: Delivery })
const SyntheticItem = Schema.Struct({ type: Schema.tag("synthetic"), payload: SyntheticPayload, delivery: Delivery })
const CompactionItem = Schema.Struct({
type: Schema.tag("compaction"),
payload: CompactionPayload,
delivery: Delivery,
})
const MoveItem = Schema.Struct({ type: Schema.tag("move"), payload: MovePayload, delivery: Delivery })
export const Item = Schema.Union([UserItem, SyntheticItem, CompactionItem, MoveItem]).pipe(
Schema.toTaggedUnion("type"),
Schema.annotate({ identifier: "Session.Inbox.Item" }),
)
export type Item = typeof Item.Type
const Enqueued = {
id: SessionMessage.ID,
sessionID: SessionID,
timeCreated: DateTimeUtcFromMillis,
}
export interface User extends Schema.Schema.Type<typeof User> {}
export const User = Schema.Struct({ ...Enqueued, ...UserItem.fields }).annotate({ identifier: "Session.Inbox.User" })
export interface Synthetic extends Schema.Schema.Type<typeof Synthetic> {}
export const Synthetic = Schema.Struct({ ...Enqueued, ...SyntheticItem.fields }).annotate({
identifier: "Session.Inbox.Synthetic",
})
export interface Compaction extends Schema.Schema.Type<typeof Compaction> {}
export const Compaction = Schema.Struct({ ...Enqueued, ...CompactionItem.fields }).annotate({
identifier: "Session.Inbox.Compaction",
})
export interface Move extends Schema.Schema.Type<typeof Move> {}
export const Move = Schema.Struct({ ...Enqueued, ...MoveItem.fields }).annotate({ identifier: "Session.Inbox.Move" })
export const Info = Schema.Union([User, Synthetic, Compaction, Move]).pipe(
Schema.toTaggedUnion("type"),
Schema.annotate({ identifier: "Session.Inbox.Info" }),
)
export type Info = typeof Info.Type
+2
View File
@@ -221,6 +221,8 @@ export const Assistant = Schema.Struct({
retry: AssistantRetry.pipe(optional),
time: Schema.Struct({
created: DateTimeUtcFromMillis,
started: DateTimeUtcFromMillis.pipe(optional),
generated: DateTimeUtcFromMillis.pipe(optional),
completed: DateTimeUtcFromMillis.pipe(optional),
}),
}).annotate({ identifier: "Session.Message.Assistant" })
+75
View File
@@ -0,0 +1,75 @@
export * as SessionPending from "./session-pending.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { Prompt } from "./prompt.js"
import { DateTimeUtcFromMillis } from "./schema.js"
import { SessionDelivery } from "./session-delivery.js"
import { SessionID } from "./session-id.js"
import { SessionMessage } from "./session-message.js"
export const Delivery = SessionDelivery.Delivery
export type Delivery = SessionDelivery.Delivery
export interface UserData extends Schema.Schema.Type<typeof UserData> {}
export const UserData = Schema.Struct({
...Prompt.fields,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
}).annotate({ identifier: "SessionPending.UserData" })
export interface SyntheticData extends Schema.Schema.Type<typeof SyntheticData> {}
export const SyntheticData = Schema.Struct({
text: Schema.String,
description: Schema.String.pipe(optional),
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
}).annotate({ identifier: "SessionPending.SyntheticData" })
export interface UserMessage extends Schema.Schema.Type<typeof UserMessage> {}
export const UserMessage = Schema.Struct({
type: Schema.tag("user"),
data: UserData,
delivery: Delivery,
}).annotate({ identifier: "SessionPending.UserMessage" })
export interface SyntheticMessage extends Schema.Schema.Type<typeof SyntheticMessage> {}
export const SyntheticMessage = Schema.Struct({
type: Schema.tag("synthetic"),
data: SyntheticData,
delivery: Delivery,
}).annotate({ identifier: "SessionPending.SyntheticMessage" })
export const Message = Schema.Union([UserMessage, SyntheticMessage]).pipe(
Schema.toTaggedUnion("type"),
Schema.annotate({ identifier: "SessionPending.Message" }),
)
export type Message = typeof Message.Type
const Admitted = {
id: SessionMessage.ID,
sessionID: SessionID,
timeCreated: DateTimeUtcFromMillis,
}
export interface User extends Schema.Schema.Type<typeof User> {}
export const User = Schema.Struct({
...Admitted,
...UserMessage.fields,
}).annotate({ identifier: "SessionPending.User" })
export interface Synthetic extends Schema.Schema.Type<typeof Synthetic> {}
export const Synthetic = Schema.Struct({
...Admitted,
...SyntheticMessage.fields,
}).annotate({ identifier: "SessionPending.Synthetic" })
export interface Compaction extends Schema.Schema.Type<typeof Compaction> {}
export const Compaction = Schema.Struct({
...Admitted,
type: Schema.tag("compaction"),
}).annotate({ identifier: "SessionPending.Compaction" })
export const Info = Schema.Union([User, Synthetic, Compaction]).pipe(
Schema.toTaggedUnion("type"),
Schema.annotate({ identifier: "SessionPending.Info" }),
)
export type Info = typeof Info.Type
+12 -34
View File
@@ -11,7 +11,7 @@ import { Pty } from "../src/pty.js"
import { Question } from "../src/question.js"
import { Session } from "../src/session.js"
import { SessionMessage } from "../src/session-message.js"
import { SessionInbox } from "../src/session-inbox.js"
import { SessionPending } from "../src/session-pending.js"
import { FileDiff } from "../src/file-diff.js"
import { Money } from "../src/money.js"
import { Skill } from "../src/skill.js"
@@ -47,7 +47,7 @@ describe("contract hygiene", () => {
expect(Schema.encodeSync(Value)({ value: 1 })).toEqual({ value: "1" })
expect(Schema.encodeSync(Value)({ value: undefined })).toEqual({})
expect(
Schema.encodeSync(SessionInbox.SyntheticPayload)({
Schema.encodeSync(SessionPending.SyntheticData)({
text: "completed",
description: undefined,
metadata: undefined,
@@ -67,16 +67,16 @@ describe("contract hygiene", () => {
).not.toHaveProperty("title")
})
test("session inbox items omit the internal enqueue sequence", () => {
test("pending session items omit the internal admission sequence", () => {
expect(
Schema.encodeSync(SessionInbox.Info)(
Schema.decodeUnknownSync(SessionInbox.Info)({
Schema.encodeSync(SessionPending.Info)(
Schema.decodeUnknownSync(SessionPending.Info)({
admittedSeq: 3,
id: "msg_pending",
sessionID: "ses_pending",
timeCreated: 1,
type: "user",
payload: { text: "hello" },
data: { text: "hello" },
delivery: "steer",
}),
),
@@ -85,7 +85,7 @@ describe("contract hygiene", () => {
sessionID: "ses_pending",
timeCreated: 1,
type: "user",
payload: { text: "hello" },
data: { text: "hello" },
delivery: "steer",
})
})
@@ -167,17 +167,10 @@ describe("contract hygiene", () => {
Pty.Info,
Session.ListAnchor,
Session.Revert,
SessionInbox.Delivery,
SessionInbox.UserPayload,
SessionInbox.SyntheticPayload,
SessionInbox.CompactionPayload,
SessionInbox.MovePayload,
SessionInbox.Item,
SessionInbox.User,
SessionInbox.Synthetic,
SessionInbox.Compaction,
SessionInbox.Move,
SessionInbox.Info,
SessionPending.UserData,
SessionPending.SyntheticData,
SessionPending.User,
SessionPending.Synthetic,
Vcs.Branch,
Vcs.Info,
].map((schema) => schema.ast.annotations?.identifier)
@@ -186,21 +179,6 @@ describe("contract hygiene", () => {
expect(new Set(identifiers).size).toBe(identifiers.length)
})
test("all session inbox item types accept both delivery modes", () => {
const decode = Schema.decodeUnknownSync(SessionInbox.Info)
const base = { id: "msg_inbox", sessionID: "ses_inbox", timeCreated: 1 }
const move = {
location: { directory: "/project" },
projectID: "global",
}
for (const delivery of ["steer", "queue"] as const) {
expect(decode({ ...base, type: "user", payload: { text: "hello" }, delivery }).delivery).toBe(delivery)
expect(decode({ ...base, type: "synthetic", payload: { text: "context" }, delivery }).delivery).toBe(delivery)
expect(decode({ ...base, type: "compaction", payload: {}, delivery }).delivery).toBe(delivery)
expect(decode({ ...base, type: "move", payload: move, delivery }).delivery).toBe(delivery)
}
})
test("current source limits Any to provider options and avoids mutable contract wrappers", async () => {
const files = [...new Bun.Glob("*.ts").scanSync(new URL("../src", import.meta.url).pathname)].filter(
(file) => !file.endsWith("-v1.ts"),
@@ -243,7 +221,7 @@ describe("contract hygiene", () => {
test("reviewed session contracts use their canonical current shapes", () => {
expect(SessionMessage.Info.ast.annotations?.identifier).toBe("Session.Message.Info")
expect(SessionInbox.Info.ast.annotations?.identifier).toBe("Session.Inbox.Info")
expect(SessionPending.Info.ast.annotations?.identifier).toBe("SessionPending.Info")
expect(Money.USD).not.toBe(Money.USDPerMillionTokens)
expect(
FileDiff.Info.make({ file: "src/index.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }),
+6 -4
View File
@@ -82,10 +82,11 @@ describe("public event manifest", () => {
"session.renamed.1",
"session.usage.recorded.1",
"session.forked.2",
"session.inbox.delivered.1",
"session.inbox.enqueued.1",
"session.inbox.cancelled.1",
"session.inbox.delivery.changed.1",
"session.input.promoted.1",
"session.input.admitted.1",
"session.input.cancelled.1",
"session.input.steered.1",
"session.input.queued.1",
"session.execution.started.1",
"session.execution.succeeded.1",
"session.execution.failed.1",
@@ -108,6 +109,7 @@ describe("public event manifest", () => {
"session.reasoning.started.1",
"session.reasoning.ended.1",
"session.retry.scheduled.1",
"session.compaction.admitted.1",
"session.compaction.started.1",
"session.compaction.ended.1",
"session.compaction.failed.1",
+1 -1
View File
@@ -24,6 +24,6 @@ export { Reference } from "@opencode-ai/schema/reference"
export { WebSearch } from "@opencode-ai/schema/websearch"
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
export { Session } from "@opencode-ai/schema/session"
export { SessionInbox } from "@opencode-ai/schema/session-inbox"
export { SessionPending } from "@opencode-ai/schema/session-pending"
export { SessionMessage } from "@opencode-ai/schema/session-message"
export { Skill } from "@opencode-ai/schema/skill"
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { Location as CoreLocation } from "@opencode-ai/core/location"
import { SessionInbox as CoreSessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionPending as CoreSessionPending } from "@opencode-ai/core/session/pending"
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
import { Agent } from "@opencode-ai/schema/agent"
import { Config } from "@opencode-ai/schema/config"
@@ -10,7 +10,7 @@ import { Project } from "@opencode-ai/schema/project"
import { Provider } from "@opencode-ai/schema/provider"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Session } from "@opencode-ai/schema/session"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionPending } from "@opencode-ai/schema/session-pending"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Api } from "@opencode-ai/server/api"
@@ -53,8 +53,8 @@ test("re-exports canonical contracts directly from Schema", () => {
"Reference",
"RelativePath",
"Session",
"SessionInbox",
"SessionMessage",
"SessionPending",
"Skill",
"Tool",
"WebSearch",
@@ -69,9 +69,9 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
expect(CoreProject.Current).toBe(Project.Current)
expect(CoreProject.Directory).toBe(Project.Directory)
expect(CoreProject.Directories).toBe(Project.Directories)
expect(CoreSessionInbox.Item).toBe(SessionInbox.Item)
expect(CoreSessionInbox.User).toBe(SessionInbox.User)
expect(CoreSessionInbox.Synthetic).toBe(SessionInbox.Synthetic)
expect(CoreSessionPending.Message).toBe(SessionPending.Message)
expect(CoreSessionPending.User).toBe(SessionPending.User)
expect(CoreSessionPending.Synthetic).toBe(SessionPending.Synthetic)
expect(CoreSessionMessage.Info).toBe(SessionMessage.Info)
expect(CoreSessionMessage.AssistantText).toBe(SessionMessage.AssistantText)
expect(Api.groups["server.session"].identifier).toBe("server.session")
+7 -7
View File
@@ -241,7 +241,7 @@ it.live(
resume: false,
})
const context = yield* opencode.sessions.context({ sessionID: id })
const pendingAfterAdmit = yield* opencode.sessions.inbox.list({ sessionID: id })
const pendingAfterAdmit = yield* opencode.sessions.pending.list({ sessionID: id })
yield* opencode.sessions.instructions.entry.put({ sessionID: id, key: "deploy-target", value: "production" })
yield* opencode.sessions.instructions.entry.put({ sessionID: id, key: "flags", value: { beta: true } })
const contextEntries = yield* opencode.sessions.instructions.entry.list({ sessionID: id })
@@ -252,13 +252,13 @@ it.live(
text: "Promote this input",
})
const prompted = yield* opencode.sessions.log({ sessionID: id, follow: true }).pipe(
Stream.filter((event) => event.type === "session.inbox.delivered" && event.data.inboxID === wake.id),
Stream.filter((event) => event.type === "session.input.promoted" && event.data.inputID === wake.id),
Stream.runHead,
Effect.timeout("10 seconds"),
Effect.map(Option.getOrThrow),
)
const wakeContext = yield* opencode.sessions.context({ sessionID: id })
const pendingAfterPromote = yield* opencode.sessions.inbox.list({ sessionID: id })
const pendingAfterPromote = yield* opencode.sessions.pending.list({ sessionID: id })
const event = yield* opencode.sessions.log({ sessionID: id }).pipe(
Stream.filter((item) => item.type === "session.model.selected"),
Stream.take(1),
@@ -278,7 +278,7 @@ it.live(
opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip),
opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip),
opencode.sessions.instructions.entry.list({ sessionID: missingSessionID }).pipe(Effect.flip),
opencode.sessions.inbox.list({ sessionID: missingSessionID }).pipe(Effect.flip),
opencode.sessions.pending.list({ sessionID: missingSessionID }).pipe(Effect.flip),
],
{ concurrency: "unbounded" },
)
@@ -298,7 +298,7 @@ it.live(
expect(pendingAfterAdmit).toContainEqual(
expect.objectContaining({ id: admitted.id, type: "user", delivery: "steer" }),
)
expect(prompted.type).toBe("session.inbox.delivered")
expect(prompted.type).toBe("session.input.promoted")
expect(pendingAfterPromote.map((item) => item.id)).not.toContainAnyValues([admitted.id, wake.id])
expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))
expect(contextEntries).toEqual([
@@ -362,13 +362,13 @@ it.live(
const opencode = yield* fixture.sdk.OpenCode.create()
const id = sessionID(fixture)
const connected = yield* Latch.make(false)
const prompted = yield* Deferred.make<Extract<OpenCodeEvent, { type: "session.inbox.delivered" }>>()
const prompted = yield* Deferred.make<Extract<OpenCodeEvent, { type: "session.input.promoted" }>>()
yield* opencode.events.subscribe().pipe(
Stream.runForEach((event) =>
event.type === "server.connected"
? connected.open
: event.type === "session.inbox.delivered" && event.data.sessionID === id
: event.type === "session.input.promoted" && event.data.sessionID === id
? Deferred.succeed(prompted, event).pipe(Effect.asVoid)
: Effect.void,
),
+1 -1
View File
@@ -28,7 +28,7 @@ export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handler
}),
),
)
.handle("fs.get", (ctx) =>
.handle("fs.find", (ctx) =>
response(
Effect.gen(function* () {
const fs = yield* FileSystem.Service
+27 -30
View File
@@ -26,7 +26,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
const pendingMutation = (effect: ReturnType<typeof session.cancelInbox>, conflict: string) =>
const pendingMutation = (effect: ReturnType<typeof session.cancelPending>, conflict: string) =>
effect.pipe(
Effect.catchTag(
"Session.NotFoundError",
@@ -37,8 +37,8 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}),
),
Effect.catchTag(
"Session.InboxConflictError",
(error) => new ConflictError({ resource: error.inboxID, message: `${conflict}: ${error.inboxID}` }),
"Session.PendingInputConflictError",
(error) => new ConflictError({ resource: error.inputID, message: `${conflict}: ${error.inputID}` }),
),
Effect.as(HttpApiSchema.NoContent.make()),
)
@@ -282,7 +282,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
sessionID: ctx.params.sessionID,
directory: ctx.payload.directory,
workspaceID: ctx.payload.workspaceID,
delivery: ctx.payload.delivery,
})
.pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
@@ -489,26 +488,24 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
"session.compact",
Effect.fn(function* (ctx) {
return {
data: yield* session
.compact({ sessionID: ctx.params.sessionID, id: ctx.payload.id, delivery: ctx.payload.delivery })
.pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.CompactionConflictError", (error) =>
Effect.fail(
new ConflictError({
message: `Compaction input ID conflicts with an existing durable record: ${error.inputID}`,
resource: error.inputID,
}),
),
data: yield* session.compact({ sessionID: ctx.params.sessionID, id: ctx.payload.id }).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.CompactionConflictError", (error) =>
Effect.fail(
new ConflictError({
message: `Compaction input ID conflicts with an existing durable record: ${error.inputID}`,
resource: error.inputID,
}),
),
),
),
}
}),
)
@@ -672,10 +669,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}),
)
.handle(
"session.inbox.list",
"session.pending.list",
Effect.fn(function* (ctx) {
return {
data: yield* session.inbox(ctx.params.sessionID).pipe(
data: yield* session.pending(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
@@ -689,28 +686,28 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}),
)
.handle(
"session.inbox.cancel",
"session.pending.cancel",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.cancelInbox({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID }),
session.cancelPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input can no longer be cancelled",
)
}),
)
.handle(
"session.inbox.steer",
"session.pending.steer",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.steerInbox({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID }),
session.steerPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input is no longer queued",
)
}),
)
.handle(
"session.inbox.queue",
"session.pending.queue",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.queueInbox({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID }),
session.queuePending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input is no longer a steer",
)
}),
+1 -3
View File
@@ -21,9 +21,7 @@ export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers)
Effect.fn(function* (ctx) {
const shell = yield* Shell.Service
const location = yield* Location.Service
return yield* response(
shell.create({ ...ctx.payload, cwd: ctx.payload.cwd || location.directory }).pipe(Effect.orDie),
)
return yield* response(shell.create({ ...ctx.payload, cwd: ctx.payload.cwd || location.directory }))
}),
)
.handle(
+6
View File
@@ -40,5 +40,11 @@ export const ServerOptions = Schema.Struct({
fff: Schema.optional(Schema.Boolean),
}),
),
mcp: Schema.optional(
Schema.Struct({
/** Set false on runtimes that cannot spawn child processes; local (stdio) MCP servers report failed instead of connecting. */
stdio: Schema.optional(Schema.Boolean),
}),
),
})
export type ServerOptions = typeof ServerOptions.Type
+1
View File
@@ -120,6 +120,7 @@ function makeRoutes<AuthError, AuthServices>(
name: options.app?.name ?? "opencode",
version: options.app?.version ?? "unknown",
},
stdio: options.mcp?.stdio,
}),
],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
+24 -7
View File
@@ -5,13 +5,12 @@ import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
import { Database } from "@opencode-ai/core/database/database"
import { sqliteLayer } from "@opencode-ai/core/database/sqlite.workerd"
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
import { EnvironmentUnavailable } from "@opencode-ai/core/environment/unavailable"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Pty } from "@opencode-ai/core/pty"
import { Shell } from "@opencode-ai/core/shell"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Vcs } from "@opencode-ai/core/vcs"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import type { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { ServerFetch } from "./fetch"
import type { ServerOptions } from "./options"
@@ -25,11 +24,10 @@ import type { ServerOptions } from "./options"
* - Watcher and fff are disabled through their existing option flags; pty, fff,
* shell-parser, photon, and process-lock native modules resolve to inert
* stubs under the `workerd` bundle condition.
* - Bare locations use a typed no-execution-plane process spawner; FileSystem,
* FileSystemSearch, and Pty fail with a clear defect until a remote sandbox
* backs them; Snapshot and Vcs degrade to no-op results.
* - Shell, FileSystem, FileSystemSearch, and Pty fail with a clear defect until
* a remote sandbox backs them; Snapshot and Vcs degrade to no-op results.
* - Config is injected as a string (no filesystem); plugin discovery is
* precompiled-only, and stdio MCP reports the same no-plane failure as Shell.
* precompiled-only and MCP is restricted to remote transports.
*
* Bundle with the `workerd` condition, e.g.
* `bun build src/workerd.ts --conditions=workerd --target=node`
@@ -69,6 +67,9 @@ export function serverOptions(options: Options): ServerOptions {
events: { persist: true },
config: { content: options.config?.content },
models: options.models,
// No child processes on workerd: local (stdio) MCP servers report failed
// instead of connecting; remote transports work unchanged.
mcp: { stdio: false },
}
}
@@ -76,9 +77,9 @@ export function serverOptions(options: Options): ServerOptions {
export function replacements(options: Options): LayerNode.Replacements {
return [
[Database.node, Database.configuredClient(sqliteLayer({ storage: options.storage }))],
[CrossSpawnSpawner.node, EnvironmentUnavailable.layer],
[Snapshot.node, Snapshot.noopLayer],
[Vcs.node, vcsLayer],
[Shell.node, shellLayer],
[FileSystem.node, fileSystemLayer],
[FileSystemSearch.node, fileSystemSearchLayer],
[Pty.node, ptyLayer],
@@ -101,6 +102,22 @@ const vcsLayer = Layer.succeed(
}),
)
// Shell commands need a real process; queries for unknown IDs stay typed while
// creation is a defect until a remote sandbox backs them.
const shellLayer = Layer.succeed(
Shell.Service,
Shell.Service.of({
name: () => Effect.succeed("unsupported"),
create: () => unavailable("Shell.create"),
list: () => Effect.succeed([]),
get: (id) => Effect.fail(new Shell.NotFoundError({ id })),
wait: (id) => Effect.fail(new Shell.NotFoundError({ id })),
timeout: (id) => Effect.fail(new Shell.NotFoundError({ id })),
output: (id) => Effect.fail(new Shell.NotFoundError({ id })),
remove: (id) => Effect.fail(new Shell.NotFoundError({ id })),
}),
)
// The Location-scoped filesystem has no local worktree to serve until a remote
// sandbox backs it.
const fileSystemLayer = Layer.succeed(
+1 -1
View File
@@ -182,7 +182,7 @@ export function DevToolsBar() {
location: sessionLocation,
status: data.session.status(sessionID),
pending: data.session.pending.list(sessionID),
inboxIDs: data.session.input.list(sessionID),
inputIDs: data.session.input.list(sessionID),
permissions: data.session.permission.list(sessionID) ?? [],
forms: data.session.form.list(sessionID) ?? [],
}
@@ -141,11 +141,11 @@ export function DialogSessionList() {
const option = (session: SessionInfo, category: string) => {
const directory = session.location.directory
const project = data.project.get(session.projectID)
const root = session.subpath ? path.resolve(directory, ...session.subpath.split("/").map(() => "..")) : directory
const relative = path.relative(project?.canonical ?? root, root)
const footer =
relative.startsWith("..") || path.isAbsolute(relative)
? Locale.truncate(path.basename(relative), 25)
const relative = path.relative(project?.canonical ?? directory, directory)
const footer = allProjects()
? Locale.truncate(projectName(project, directory) ?? "", 20)
: relative.startsWith("..") || path.isAbsolute(relative)
? Locale.truncate(path.basename(directory), 20)
: undefined
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const deleting = toDelete() === session.id
@@ -227,9 +227,8 @@ export function DialogSessionList() {
</box>
}
onMove={() => setToDelete(undefined)}
onSelect={(option, activation) => {
if (activation.shift) route.navigate({ type: "session", sessionID: option.value })
else sessionTabs.replace(option.value)
onSelect={(option) => {
route.navigate({ type: "session", sessionID: option.value })
dialog.clear()
}}
actions={[
@@ -353,7 +353,7 @@ export function Autocomplete(props: {
const result = await (
input.visible === "directory"
? client.api.file.list({ location: requestLocation })
: client.api.file.get({ query: base, limit: 20, location: requestLocation })
: client.api.file.find({ query: base, limit: 20, location: requestLocation })
).then(
(result) => result,
() => undefined,
+3 -3
View File
@@ -61,7 +61,7 @@ import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
import { Slot } from "../../plugin/render"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import {
deduplicatePromptImages,
preserveMentionlessPromptAttachments,
@@ -1047,7 +1047,7 @@ export function Prompt(props: PromptProps) {
})
let submitting = false
async function submit(delivery: SessionInbox.Delivery = "steer") {
async function submit(delivery: SessionPending.Delivery = "steer") {
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
// input's native onSubmit racing another dispatch). Without this guard,
// a second call slips past the empty-input check before the first call
@@ -1063,7 +1063,7 @@ export function Prompt(props: PromptProps) {
}
}
async function submitInner(delivery: SessionInbox.Delivery) {
async function submitInner(delivery: SessionPending.Delivery) {
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads.
+45 -43
View File
@@ -72,13 +72,14 @@ function fadeTitleColor(color: RGBA, background: RGBA, index: number, length: nu
return opacity === 0 ? color : tint(color, background, opacity)
}
export function createMarquee(animations: () => boolean) {
function createMarquee(animations: () => boolean) {
const [offset, setOffset] = createSignal(0)
const [active, setActive] = createSignal<string>()
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
let delay: ReturnType<typeof setTimeout> | undefined
let interval: ReturnType<typeof setInterval> | undefined
let cycleWidth = 0
let returning = false
const clear = () => {
if (delay) clearTimeout(delay)
@@ -90,15 +91,17 @@ export function createMarquee(animations: () => boolean) {
interval = setInterval(() => setOffset((value) => (value + 1) % cycleWidth), MARQUEE_INTERVAL)
}
const enter = (sessionID: string, title: string, width: number) => {
if (!marqueeOverflows(title, width)) {
reset()
return
}
if (active() === sessionID) return
if (active() === sessionID && !returning) return
clear()
if (active() === sessionID) {
returning = false
return scroll()
}
if (!marqueeOverflows(title, width)) return
cycleWidth = marqueeCycleWidth(title)
setActive(sessionID)
setOffset(0)
returning = false
leading.jump({ opacity: 0 })
delay = setTimeout(() => {
setOffset(1)
@@ -108,10 +111,27 @@ export function createMarquee(animations: () => boolean) {
}
const leave = (sessionID: string) => {
if (active() !== sessionID) return
reset()
clear()
if (offset() === 0) {
setActive(undefined)
return
}
returning = true
interval = setInterval(() => {
setOffset((value) => {
const next = (value + 1) % cycleWidth
if (next !== 0) return next
clear()
returning = false
setActive(undefined)
leading.animate({ opacity: 0 })
return 0
})
}, MARQUEE_INTERVAL)
}
const reset = () => {
clear()
returning = false
setActive(undefined)
setOffset(0)
leading.jump({ opacity: 0 })
@@ -121,7 +141,7 @@ export function createMarquee(animations: () => boolean) {
return { offset, active, enter, leave, reset, leading: () => leading.value().opacity }
}
export function createTabMarquee(animations: () => boolean) {
function createTabMarquee(animations: () => boolean) {
const [hovered, setHovered] = createSignal<string>()
const marquee = createMarquee(animations)
let hoverClear: ReturnType<typeof setTimeout> | undefined
@@ -139,21 +159,11 @@ export function createTabMarquee(animations: () => boolean) {
marquee.leave(sessionID)
})
}
const leaveHovered = () => {
const sessionID = hovered()
if (sessionID) leave(sessionID)
}
const reset = () => {
if (hoverClear) clearTimeout(hoverClear)
hoverClear = undefined
setHovered(undefined)
marquee.reset()
}
onCleanup(() => {
if (hoverClear) clearTimeout(hoverClear)
})
return { ...marquee, hovered, enter, leave, leaveHovered, reset }
return { ...marquee, hovered, enter, leave }
}
function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsController; onClose: () => void }) {
@@ -338,7 +348,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
position="relative"
paddingTop={1}
backgroundColor={theme.background.default}
onMouseOut={marquee.leaveHovered}
>
<scrollbox ref={(element) => (scroll = element)} flexGrow={1} scrollbarOptions={{ visible: false }}>
<box flexShrink={0} flexDirection="column" gap={1}>
@@ -347,7 +356,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const selected = () => activeID() === tab.sessionID
const status = createMemo(() => itemStatus(tab))
const [sweepLevel, setSweepLevel] = createSignal(0)
const [closeHovered, setCloseHovered] = createSignal(false)
const session = createMemo(() => data.session.get(tab.sessionID))
const project = createMemo(() => {
const value = session()
@@ -355,17 +363,18 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
})
const numberWidth = () => 2
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1)
const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
const titleWidth = () => Math.max(1, restingTitleWidth() - (hovered() === tab.sessionID ? 1 : 0))
const title = () => tab.title ?? "Untitled session"
const scrolling = () => marquee.active() === tab.sessionID
const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0
const visibleTitle = createMemo(() =>
scrolling()
? marqueeText(title(), titleWidth(), marquee.offset())
: Locale.takeWidth(title(), titleWidth()),
)
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(() => marqueeOverflows(title(), titleWidth()) && titleWidth() > FADE_WIDTH)
const titleFades = createMemo(
() => marqueeOverflows(title(), restingTitleWidth()) && titleWidth() > FADE_WIDTH,
)
const detail = createMemo(() => {
const value = session()
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
@@ -447,7 +456,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
position="relative"
flexDirection="column"
backgroundColor={background()}
onMouseOver={() => marquee.enter(tab.sessionID, title(), hoveredTitleWidth())}
onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())}
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) {
@@ -463,7 +472,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
event.stopPropagation()
return
}
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
marquee.enter(tab.sessionID, title(), restingTitleWidth())
setDragging(tab.sessionID)
}}
onMouseUp={(event) => {
@@ -575,10 +584,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
right={1}
zIndex={2}
width={1}
fg={closeHovered() ? theme.text.default : theme.text.subdued}
fg={theme.text.subdued}
selectable={false}
onMouseOver={() => setCloseHovered(true)}
onMouseOut={() => setCloseHovered(false)}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (hovered() !== tab.sessionID) return
@@ -586,7 +593,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
tabs.close(tab.sessionID)
}}
>
{hovered() === tab.sessionID ? "" : ""}
{hovered() === tab.sessionID ? "×" : ""}
</text>
</box>
</box>
@@ -835,7 +842,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
position="relative"
flexDirection="row"
zIndex={1}
onMouseOut={marquee.leaveHovered}
renderAfter={function (buffer) {
const x = Math.max(0, this.screenX)
const y = this.screenY + this.height
@@ -891,9 +897,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const numberWidth = () => 2
// Hovering reveals the close mark, so the title's right bound shifts left of it.
const restingTitleWidth = () => Math.max(1, width() - 1 - numberWidth())
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 2)
const availableTitleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
const scrolling = () => marquee.active() === tab.sessionID
const availableTitleWidth = () => Math.max(1, restingTitleWidth() - (hovered() === tab.sessionID ? 2 : 0))
const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0
const visibleTitle = createMemo(() =>
scrolling()
? marqueeText(title(), availableTitleWidth(), marquee.offset())
@@ -901,7 +906,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
)
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(
() => marqueeOverflows(title(), availableTitleWidth()) && availableTitleWidth() > FADE_WIDTH,
() => marqueeOverflows(title(), restingTitleWidth()) && availableTitleWidth() > FADE_WIDTH,
)
const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default
@@ -924,7 +929,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
}
// The running sweep's level under the number cell, reported by the pulse renderable.
const [sweepLevel, setSweepLevel] = createSignal(0)
const [closeHovered, setCloseHovered] = createSignal(false)
const numberColor = () => {
const feedback = feedbackColor()
if (feedback) return feedback
@@ -953,7 +957,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
position="relative"
flexDirection="row"
backgroundColor={background()}
onMouseOver={() => marquee.enter(tab.sessionID, title(), hoveredTitleWidth())}
onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())}
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) {
@@ -968,7 +972,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
event.stopPropagation()
return
}
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
marquee.enter(tab.sessionID, title(), restingTitleWidth())
setDragging(tab.sessionID)
}}
onMouseUp={(event) => {
@@ -1022,10 +1026,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
right={1}
zIndex={2}
width={1}
fg={closeHovered() ? theme.text.default : closeColor()}
fg={closeColor()}
selectable={false}
onMouseOver={() => setCloseHovered(true)}
onMouseOut={() => setCloseHovered(false)}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
// The close mark only renders while hovered; without motion events a click can
@@ -1035,7 +1037,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
}}
>
{hovered() === tab.sessionID ? "" : ""}
{hovered() === tab.sessionID ? "×" : ""}
</text>
</box>
</box>
+1 -1
View File
@@ -241,7 +241,7 @@ export const Definitions = {
"dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"),
"dialog.select.home": keybind("home", "Move to first dialog item"),
"dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return,shift+return,linefeed", "Submit selected dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.project_copy.generate": keybind("tab", "Generate project copy name"),
"dialog.move_session.new": keybind("ctrl+m", "New project copy"),
+1 -1
View File
@@ -218,7 +218,7 @@ export const Definitions = {
"dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"),
"dialog.select.home": keybind("home", "Move to first dialog item"),
"dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return,shift+return,linefeed", "Submit selected dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.project_copy.generate": keybind("tab", "Generate project copy name"),
"dialog.move_session.new": keybind("ctrl+m", "New project copy"),
+70 -45
View File
@@ -25,7 +25,7 @@ import type {
SessionMessageAssistantText,
SessionMessageAssistantTool,
SessionInfo,
SessionInboxInfo,
SessionPendingInfo,
ShellInfo,
SkillInfo,
VcsInfo,
@@ -38,7 +38,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { nonEmptyToolContent } from "../util/tool-display"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import { createEffect, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running"
@@ -80,7 +80,7 @@ type Store = {
family: Record<string, string[]>
active: Record<string, DataSessionStatus>
message: Record<string, SessionMessageInfo[]>
pending: Record<string, SessionInboxInfo[]>
pending: Record<string, SessionPendingInfo[]>
input: Record<string, string[]>
permission: Record<string, PermissionRequest[]>
// Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
@@ -164,26 +164,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("session", "active", sessionID, status)
}
function addPending(item: SessionInboxInfo) {
function addPending(item: SessionPendingInfo) {
if (store.session.pending[item.sessionID]?.some((pending) => pending.id === item.id)) return
setStore("session", "pending", item.sessionID, [...(store.session.pending[item.sessionID] ?? []), item])
}
function removePending(sessionID: string, inboxID?: string) {
if (!inboxID) return
if (store.session.pending[sessionID]?.some((item) => item.id === inboxID))
function removePending(sessionID: string, inputID?: string) {
if (!inputID) return
if (store.session.pending[sessionID]?.some((item) => item.id === inputID))
setStore(
"session",
"pending",
sessionID,
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inboxID),
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
)
if (store.session.input[sessionID]?.includes(inboxID))
if (store.session.input[sessionID]?.includes(inputID))
setStore(
"session",
"input",
sessionID,
(store.session.input[sessionID] ?? []).filter((id) => id !== inboxID),
(store.session.input[sessionID] ?? []).filter((id) => id !== inputID),
)
}
@@ -198,10 +198,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
}
function updatePending(sessionID: string, inboxID: string, delivery: SessionInbox.Delivery) {
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inboxID) ?? -1
function updatePending(sessionID: string, inputID: string, delivery: SessionPending.Delivery) {
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inputID) ?? -1
const item = store.session.pending[sessionID]?.[index]
if (index < 0 || !item || item.delivery === delivery) return
if (index < 0 || !item || item.type === "compaction" || item.delivery === delivery) return
setStore("session", "pending", sessionID, index, { ...item, delivery })
}
@@ -457,11 +457,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}
break
}
case "session.inbox.delivered": {
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false
removePending(event.data.sessionID, event.data.inboxID)
case "session.input.promoted": {
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inputID) ?? false
removePending(event.data.sessionID, event.data.inputID)
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inboxID)
const position = index.get(event.data.inputID)
if (position === undefined) return
const existing = draft[position]
if (!existing || !admitted) return
@@ -472,56 +472,56 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
}
case "session.inbox.delivery.changed":
updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery)
case "session.input.steered":
updatePending(event.data.sessionID, event.data.inputID, "steer")
break
case "session.inbox.cancelled": {
removePending(event.data.sessionID, event.data.inboxID)
if (messageIndex.get(event.data.sessionID)?.has(event.data.inboxID))
case "session.input.queued":
updatePending(event.data.sessionID, event.data.inputID, "queue")
break
case "session.input.cancelled": {
removePending(event.data.sessionID, event.data.inputID)
if (messageIndex.get(event.data.sessionID)?.has(event.data.inputID))
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inboxID)
const position = index.get(event.data.inputID)
if (position === undefined) return
draft.splice(position, 1)
index.delete(event.data.inboxID)
index.delete(event.data.inputID)
message.reindex(draft, index, position)
})
break
}
case "session.inbox.enqueued": {
const item = event.data.item
case "session.input.admitted":
addPending({
id: event.data.inboxID,
id: event.data.inputID,
sessionID: event.data.sessionID,
timeCreated: event.created,
...item,
...event.data.input,
})
if (!store.session.input[event.data.sessionID]?.includes(event.data.inboxID))
if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID))
setStore("session", "input", event.data.sessionID, [
...(store.session.input[event.data.sessionID] ?? []),
event.data.inboxID,
event.data.inputID,
])
if (item.type !== "user" && item.type !== "synthetic") break
message.update(event.data.sessionID, (draft, index) => {
message.append(
draft,
index,
item.type === "user"
event.data.input.type === "user"
? {
id: event.data.inboxID,
id: event.data.inputID,
type: "user",
...item.payload,
...event.data.input.data,
time: { created: event.created },
}
: {
id: event.data.inboxID,
id: event.data.inputID,
type: "synthetic",
...item.payload,
...event.data.input.data,
time: { created: event.created },
},
)
})
break
}
case "session.instructions.updated":
// Mirror the projector: the initial baseline and empty-rendering deltas carry no text
// and produce no transcript message.
@@ -584,6 +584,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
existing.retry = undefined
existing.error = undefined
existing.finish = undefined
existing.time.started = undefined
existing.time.generated = undefined
existing.time.completed = undefined
if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot }
return
@@ -613,6 +615,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
currentAssistant.finish = event.data.finish
currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens
currentAssistant.time.generated = event.data.generated
if (event.data.snapshot)
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
})
@@ -630,11 +633,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens
}
currentAssistant.time.generated = event.data.generated
})
break
case "session.text.started":
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
const assistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!assistant) return
if (assistant.time.completed === undefined) assistant.time.started ??= event.created
assistant.content.push({
type: "text",
text: "",
})
@@ -654,7 +661,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
case "session.tool.input.started":
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
const assistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!assistant) return
assistant.content.push({
type: "tool",
id: event.data.id,
name: event.data.name,
@@ -743,7 +752,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
case "session.reasoning.started":
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
const assistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!assistant) return
assistant.content.push({
type: "reasoning",
text: "",
state: event.data.state,
@@ -781,8 +792,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.execution.started":
setSessionActive(event.data.sessionID, "running")
break
case "session.compaction.admitted":
addPending({
id: event.data.inputID,
sessionID: event.data.sessionID,
timeCreated: event.created,
type: "compaction",
})
break
case "session.compaction.started":
if (event.data.inputID) removePending(event.data.sessionID, event.data.inputID)
removePending(event.data.sessionID, event.data.inputID)
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.inputID ?? messageIDFromEvent(event.id),
@@ -835,6 +854,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.compaction.ended":
setStore(
"session",
"pending",
event.data.sessionID,
(store.session.pending[event.data.sessionID] ?? []).filter((item) => item.type !== "compaction"),
)
message.update(event.data.sessionID, (draft, index) => {
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
const current = draft[position]
@@ -859,7 +884,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.compaction.failed":
if (event.data.inputID) removePending(event.data.sessionID, event.data.inputID)
removePending(event.data.sessionID, event.data.inputID)
message.update(event.data.sessionID, (draft, index) => {
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
const current = draft[position]
@@ -996,8 +1021,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
list(sessionID: string) {
return store.session.input[sessionID] ?? []
},
has(sessionID: string, inboxID: string) {
return store.session.input[sessionID]?.includes(inboxID) ?? false
has(sessionID: string, inputID: string) {
return store.session.input[sessionID]?.includes(inputID) ?? false
},
},
pending: {
@@ -1006,7 +1031,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
sync(sessionID: string) {
return sync.run(`session.pending:${sessionID}`, async () => {
const pending = await client.api.session.inbox.list({ sessionID })
const pending = await client.api.session.pending.list({ sessionID })
setStore("session", "pending", sessionID, reconcile(pending))
setStore(
"session",
@@ -35,13 +35,6 @@ export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[
return tabs.map((item, position) => (position === index ? { ...item, title: tab.title } : item))
}
export function replaceSessionTab(tabs: SessionTab[], current: string | undefined, tab: SessionTab): SessionTab[] {
if (tabs.some((item) => item.sessionID === tab.sessionID)) return tabs
const index = current ? tabs.findIndex((item) => item.sessionID === current) : -1
if (index === -1) return [...tabs, tab]
return tabs.map((item, position) => (position === index ? tab : item))
}
export function closeSessionTab(tabs: SessionTab[], sessionID: string) {
const index = tabs.findIndex((tab) => tab.sessionID === sessionID)
// Like openSessionTab and moveSessionTab, a no-op returns the same reference so callers can

Some files were not shown because too many files have changed in this diff Show More