Compare commits

..

15 Commits

Author SHA1 Message Date
James Long b2c0cbf323 Revert "refactor(core): move workspace sql under worktree"
This reverts commit 3c2005a244.
2026-08-12 20:39:55 +00:00
James Long 3c2005a244 refactor(core): move workspace sql under worktree 2026-08-12 20:39:11 +00:00
James Long 2f92b3762a refactor(api): rename filesystem operation to get 2026-08-12 20:20:20 +00:00
James Long 30b977880c feat(tui): replace tab from session picker 2026-08-12 20:10:28 +00:00
Dax Raad c9539979bd fix(tui): highlight moved session location 2026-08-12 15:37:41 -04:00
opencode-agent[bot] f5e6b8c769 chore: generate 2026-08-12 19:29:55 +00:00
Kit Langton 2025ed939a feat(server): typed no-execution-plane environment for workerd profile (#42113) 2026-08-12 19:28:07 +00:00
Dax a7642737c7 feat(core): add generic session inbox (#42109) 2026-08-12 15:28:04 -04:00
opencode-agent[bot] c081e12c60 chore: generate 2026-08-12 19:10:52 +00:00
opencode-agent[bot] 8d5dd206a6 fix(core): validate tool definitions on registration (#42117)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 14:08:13 -05:00
Kit Langton c6a86acb0b fix(tui): harden Mermaid rendering limits (#42123) 2026-08-12 15:06:28 -04:00
opencode-agent[bot] c7bee09632 fix(core): preserve Copilot fallback provider (#42099)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 14:03:27 -05:00
opencode-agent[bot] a3625cd2cb chore: generate 2026-08-12 18:50:05 +00:00
Kit Langton 0dec446ee6 fix(tui): restore tab marquee hover (#42093) 2026-08-12 14:47:34 -04:00
James Long dff6eb631b fix(tui): show worktree names in session list (#42108) 2026-08-12 14:44:48 -04:00
125 changed files with 3573 additions and 2931 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
.find({
.get({
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: {
find: (input: { location?: { directory?: string } }) => {
get: (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: {
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
get: () => 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: {
find: () => Promise.resolve({ data: [] }),
get: () => 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: {
find: () => Promise.resolve({ data: [] }),
get: () => 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
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.get({ 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.find(
.api.file.get(
{
location: { directory: sdk().directory },
query,
@@ -18,18 +18,18 @@ describe("v2 session reducer", () => {
apply({
...base,
id: "evt_admitted",
type: "session.input.admitted",
type: "session.inbox.enqueued",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "steer", data: { text: "hello" } },
inboxID: "msg_user",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
})
apply({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
type: "session.inbox.delivered",
data: { sessionID: "ses_1", inboxID: "msg_user" },
})
apply({
...base,
@@ -203,8 +203,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
type: "session.inbox.delivered",
data: { sessionID: "ses_1", inboxID: "msg_user" },
}),
)
@@ -218,11 +218,11 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
type: "session.inbox.enqueued",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
inboxID: "msg_user",
item: { type: "user", delivery: "queue", payload: { text: "cancel me" } },
},
}),
)
@@ -231,8 +231,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_cancelled",
type: "session.input.cancelled",
data: { sessionID: "ses_1", inputID: "msg_user" },
type: "session.inbox.cancelled",
data: { sessionID: "ses_1", inboxID: "msg_user" },
}),
)
const result = reducer.reduce(
@@ -240,8 +240,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
type: "session.inbox.delivered",
data: { sessionID: "ses_1", inboxID: "msg_user" },
}),
)
@@ -255,11 +255,11 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
type: "session.inbox.enqueued",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
inboxID: "msg_user",
item: { type: "user", delivery: "queue", payload: { text: "steer me" } },
},
}),
)
@@ -268,8 +268,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_steered",
type: "session.input.steered",
data: { sessionID: "ses_1", inputID: "msg_user" },
type: "session.inbox.delivery.changed",
data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "steer" },
}),
)
reducer.reduce(
@@ -277,8 +277,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_queued",
type: "session.input.queued",
data: { sessionID: "ses_1", inputID: "msg_user" },
type: "session.inbox.delivery.changed",
data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "queue" },
}),
)
@@ -287,8 +287,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
type: "session.inbox.delivered",
data: { sessionID: "ses_1", inboxID: "msg_user" },
}),
)
@@ -1,4 +1,4 @@
import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise"
import type { OpenCodeEvent, SessionInboxItem, SessionMessageInfo } 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, SessionPendingMessage>()
const pending = new Map<string, SessionInboxItem>()
const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => {
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
@@ -26,32 +26,33 @@ export function createV2SessionReducer() {
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
switch (event.type) {
case "session.input.admitted":
pending.set(key(sessionID, event.data.inputID), event.data.input)
case "session.inbox.enqueued":
pending.set(key(sessionID, event.data.inboxID), event.data.item)
return result([...source])
case "session.input.cancelled":
pending.delete(key(sessionID, event.data.inputID))
case "session.inbox.cancelled":
pending.delete(key(sessionID, event.data.inboxID))
return
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 }
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 }
if (input.type === "user")
return append({
id: event.data.inputID,
id: event.data.inboxID,
type: "user",
metadata: input.data.metadata,
text: input.data.text,
files: input.data.files,
agents: input.data.agents,
metadata: input.payload.metadata,
text: input.payload.text,
files: input.payload.files,
agents: input.payload.agents,
time: { created: event.created },
})
if (input.type !== "synthetic") return result([...source])
return append({
id: event.data.inputID,
id: event.data.inboxID,
type: "synthetic",
metadata: input.data.metadata,
text: input.data.text,
description: input.data.description,
metadata: input.payload.metadata,
text: input.payload.text,
description: input.payload.description,
time: { created: event.created },
})
}
@@ -351,7 +352,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":
+2 -3
View File
@@ -538,9 +538,8 @@ async function replayMessage(
}
function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
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
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
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.input.promoted") {
if (event.data.inputID === messageID) {
if (event.type === "session.inbox.delivered") {
if (event.data.inboxID === messageID) {
promoted = true
prePromotionError = undefined
continue
+18 -18
View File
@@ -22,8 +22,8 @@ describe("acp event behavior", () => {
delta: "before admission",
}),
)
send(durableEvent("session.input.promoted", { sessionID: "ses_b", inputID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: "input_other" }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_b", inboxID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: "input_other" }))
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_a",
@@ -32,7 +32,7 @@ describe("acp event behavior", () => {
delta: "wrong input",
}),
)
send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: id }))
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_b",
@@ -68,7 +68,7 @@ describe("acp event behavior", () => {
fixture,
connection: recordingConnection(updates),
sessionID: "ses_a",
inputID: "input_a",
inboxID: "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.input.promoted", { sessionID: "ses_order", inputID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_order", inboxID: 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", inputID: "input_order" })
const result = turn({ fixture, connection, sessionID: "ses_order", inboxID: "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.input.promoted", { sessionID: "ses_parent", inputID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_child",
@@ -240,7 +240,7 @@ describe("acp event behavior", () => {
fixture,
connection: recordingConnection(updates),
sessionID: "ses_parent",
inputID: "input_parent",
inboxID: "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.input.promoted", { sessionID: "ses_parent", inputID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_background",
@@ -292,7 +292,7 @@ describe("acp event behavior", () => {
fixture,
connection: recordingConnection(updates),
sessionID: "ses_parent",
inputID: "input_parent",
inboxID: "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.input.promoted", { sessionID: "ses_tools", inputID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_tools", inboxID: 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",
inputID: "input_tools",
inboxID: "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.input.promoted", { sessionID: "ses_cancel", inputID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_cancel", inboxID: 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.input.promoted", { sessionID: "ses_form", inputID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_form", inboxID: id }))
send(
ephemeralEvent("form.created", {
form: {
@@ -704,7 +704,7 @@ describe("acp event behavior", () => {
fixture,
connection: recordingConnection([]),
sessionID: "ses_form",
inputID: "input_form",
inboxID: "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 inputID: string
readonly inboxID: 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.inputID },
start: { type: "input", id: input.inboxID },
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() },
childSessionUpdate: input.childSessionUpdate,
submit: (signal) =>
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inboxID, 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.input.promoted",
data: { sessionID: "ses_test", inputID: id },
type: "session.inbox.delivered",
data: { sessionID: "ses_test", inboxID: 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.input.promoted", { sessionID: "ses_allow", inputID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_allow", inboxID: 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.input.promoted", { sessionID: "ses_external", inputID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_external", inboxID: 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.input.promoted", { sessionID: "ses_parent", inputID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: 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.input.promoted", { sessionID: "ses_edit", inputID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_edit", inboxID: 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.input.promoted", { sessionID: "ses_patch", inputID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_patch", inboxID: 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.input.promoted", { sessionID: "ses_reject", inputID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_reject", inboxID: 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.input.promoted", { sessionID: "ses_serial", inputID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_serial", inboxID: 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.input.promoted", { sessionID: "ses_blocked", inputID: blockedID }))
send(durableEvent("session.input.promoted", { sessionID: "ses_free", inputID: freeID }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_blocked", inboxID: blockedID }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_free", inboxID: 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, inputID: string, cwd = "/workspace") {
function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inboxID: string, cwd = "/workspace") {
return streamTurn({
client: fixture.client,
connection,
sessionID,
cwd,
start: { type: "input", id: inputID },
start: { type: "input", id: inboxID },
writeTextFile: true,
control: { cancelled: false, admission: new AbortController() },
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inboxID, 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.input.promoted",
data: { sessionID: "ses_routes", inputID: id },
type: "session.inbox.delivered",
data: { sessionID: "ses_routes", inboxID: 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.compaction.admitted",
data: { sessionID: "ses_routes", inputID: id },
type: "session.inbox.delivered",
data: { sessionID: "ses_routes", inboxID: 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.input.promoted",
data: { sessionID: "ses_usage", inputID: id },
type: "session.inbox.delivered",
data: { sessionID: "ses_usage", inboxID: 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.input.promoted",
data: { sessionID: "ses_usage_failure", inputID: id },
type: "session.inbox.delivered",
data: { sessionID: "ses_usage_failure", inboxID: 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(inputID: string): V2Event {
function prompted(inboxID: string): V2Event {
return {
id: "evt_prompted",
created: 0,
type: "session.input.promoted",
type: "session.inbox.delivered",
durable: { aggregateID: "ses_1", seq: 0, version: 1 },
data: { sessionID: "ses_1", inputID },
data: { sessionID: "ses_1", inboxID },
}
}
@@ -98,9 +98,9 @@ function executionFailed(message: string): V2Event {
}
}
function failedTool(inputID: string): V2Event[] {
function failedTool(inboxID: string): V2Event[] {
return [
prompted(inputID),
prompted(inboxID),
{
id: "evt_failed_tool_input",
created: 1,
@@ -156,10 +156,10 @@ function failedTool(inputID: string): V2Event[] {
]
}
function successfulGrep(inputID: string): V2Event[] {
function successfulGrep(inboxID: 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(inputID),
prompted(inboxID),
{
id: "evt_grep_input",
created: 1,
@@ -206,7 +206,7 @@ function successfulGrep(inputID: 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: (inputID: string) => V2Event[]
turn: (inboxID: 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?: (inputID: string) => SessionMessageInfo[]
messages?: (inboxID: string) => SessionMessageInfo[]
wait?: () => Promise<void>
terminalDelay?: number
}) {
@@ -515,7 +515,7 @@ describe("runNonInteractivePrompt", () => {
const rendered: SessionMessageAssistantTool[] = []
const failed: SessionMessageAssistantTool[] = []
await capture({
turn: (inputID) => failedTool(inputID).filter((event) => event.type !== "session.tool.progress"),
turn: (inboxID) => failedTool(inboxID).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 { SessionPending } from "@opencode-ai/schema/session-pending"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
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("SessionPending", "@opencode-ai/schema/session-pending", SessionPending),
...namespaceTypes("SessionInbox", "@opencode-ai/schema/session-inbox", SessionInbox),
...namespaceTypes("Shell", "@opencode-ai/schema/shell", Shell),
...namespaceTypes("Skill", "@opencode-ai/schema/skill", Skill),
...namespaceTypes("Vcs", "@opencode-ai/schema/vcs", Vcs),
+43 -56
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,6 +170,7 @@ 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>
@@ -182,10 +183,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?: "steer" | "queue" | undefined
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_12Output = SessionPending.User
export type Endpoint5_12Output = SessionInbox.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_13Input = {
@@ -198,10 +199,10 @@ export type Endpoint5_13Input = {
readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
readonly agents?: ReadonlyArray<AgentAttachment> | undefined
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
readonly delivery?: "steer" | "queue" | undefined
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_13Output = SessionPending.User
export type Endpoint5_13Output = SessionInbox.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_14Input = {
@@ -219,10 +220,10 @@ export type Endpoint5_15Input = {
readonly text: string
readonly description?: string | undefined
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly delivery?: "steer" | "queue" | undefined
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_15Output = SessionPending.Synthetic
export type Endpoint5_15Output = SessionInbox.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_16Input = {
@@ -233,8 +234,12 @@ 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 }
export type Endpoint5_17Output = SessionPending.Compaction
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 SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
@@ -262,22 +267,20 @@ 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<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_23Output = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_24Output = void
export type SessionPendingCancelOperation<E = never> = (
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E>
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_25Output = void
export type SessionPendingSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_26Output = void
export type SessionPendingQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type SessionInboxQueueOperation<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>
@@ -368,7 +371,7 @@ export type Endpoint5_31Output =
readonly data: {
readonly sessionID: Session.ID
readonly location: Location.Ref
readonly projectID?: Project.ID | undefined
readonly projectID: Project.ID
readonly subpath?: RelativePath | undefined
}
}
@@ -410,50 +413,45 @@ export type Endpoint5_31Output =
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.promoted"
readonly type: "session.inbox.delivered"
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 data: { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.admitted"
readonly type: "session.inbox.enqueued"
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 input: SessionPending.Message
readonly inboxID: SessionMessage.ID
readonly item: SessionInbox.Item
}
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.cancelled"
readonly type: "session.inbox.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 inputID: SessionMessage.ID }
readonly data: { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.steered"
readonly type: "session.inbox.delivery.changed"
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
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 data: {
readonly sessionID: Session.ID
readonly inboxID: SessionMessage.ID
readonly delivery: SessionInbox.Delivery
}
}
| {
readonly id: Event.ID
@@ -595,7 +593,6 @@ 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
}
@@ -620,7 +617,6 @@ 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
}
@@ -816,15 +812,6 @@ 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
@@ -955,11 +942,11 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly pending: {
readonly list: SessionPendingListOperation<E>
readonly cancel: SessionPendingCancelOperation<E>
readonly steer: SessionPendingSteerOperation<E>
readonly queue: SessionPendingQueueOperation<E>
readonly inbox: {
readonly list: SessionInboxListOperation<E>
readonly cancel: SessionInboxCancelOperation<E>
readonly steer: SessionInboxSteerOperation<E>
readonly queue: SessionInboxQueueOperation<E>
}
readonly instructions: {
readonly entry: {
@@ -1375,11 +1362,11 @@ export type Endpoint16_1Input = {
readonly limit?: number | undefined
}
export type Endpoint16_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
export type FileFindOperation<E = never> = (input: Endpoint16_1Input) => Effect.Effect<Endpoint16_1Output, E>
export type FileGetOperation<E = never> = (input: Endpoint16_1Input) => Effect.Effect<Endpoint16_1Output, E>
export interface FileApi<E = never> {
readonly list: FileListOperation<E>
readonly find: FileFindOperation<E>
readonly get: FileGetOperation<E>
}
export type Endpoint17_0Input = {
+12 -9
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"] },
payload: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -481,7 +481,10 @@ 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"] } }).pipe(
raw["session.compact"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], delivery: input["delivery"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -523,7 +526,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.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -531,21 +534,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.pending.cancel"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.pending.steer"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()(
raw["session.pending.queue"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
@@ -637,7 +640,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),
pending: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
inbox: { 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),
@@ -1031,12 +1034,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.find"]({
raw["fs.get"]({
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), find: Endpoint16_1(raw) })
const adaptGroup16 = (raw: RawClient["server.fs"]) => ({ list: Endpoint16_0(raw), get: 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 { SessionPending } from "@opencode-ai/schema/session-pending"
export { SessionInbox } from "@opencode-ai/schema/session-inbox"
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,
SessionPendingListInput,
SessionPendingListOutput,
SessionPendingCancelInput,
SessionPendingCancelOutput,
SessionPendingSteerInput,
SessionPendingSteerOutput,
SessionPendingQueueInput,
SessionPendingQueueOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
SessionInboxCancelOutput,
SessionInboxSteerInput,
SessionInboxSteerOutput,
SessionInboxQueueInput,
SessionInboxQueueOutput,
SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput,
@@ -167,8 +167,8 @@ import type {
FileReadOutput,
FileListInput,
FileListOutput,
FileFindInput,
FileFindOutput,
FileGetInput,
FileGetOutput,
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"] },
body: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] },
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"] },
body: { id: input["id"], delivery: input["delivery"] },
successStatus: 200,
declaredStatuses: [409, 404, 400, 401],
empty: false,
@@ -762,45 +762,45 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
pending: {
list: (input: SessionPendingListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionPendingListOutput }>(
inbox: {
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionInboxListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending`,
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox`,
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
cancel: (input: SessionPendingCancelInput, requestOptions?: RequestOptions) =>
request<SessionPendingCancelOutput>(
cancel: (input: SessionInboxCancelInput, requestOptions?: RequestOptions) =>
request<SessionInboxCancelOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}`,
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
steer: (input: SessionPendingSteerInput, requestOptions?: RequestOptions) =>
request<SessionPendingSteerOutput>(
steer: (input: SessionInboxSteerInput, requestOptions?: RequestOptions) =>
request<SessionInboxSteerOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/steer`,
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}/steer`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
queue: (input: SessionPendingQueueInput, requestOptions?: RequestOptions) =>
request<SessionPendingQueueOutput>(
queue: (input: SessionInboxQueueInput, requestOptions?: RequestOptions) =>
request<SessionInboxQueueOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/queue`,
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}/queue`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
@@ -1473,8 +1473,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
find: (input: FileFindInput, requestOptions?: RequestOptions) =>
request<FileFindOutput>(
get: (input: FileGetInput, requestOptions?: RequestOptions) =>
request<FileGetOutput>(
{
method: "GET",
path: `/api/fs/find`,
+179 -185
View File
@@ -130,15 +130,17 @@ export type SessionMessageCompactionCompleted = {
export type SessionActive = { type: "running" }
export type SessionPendingSyntheticData = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
export type SessionInboxDelivery = "steer" | "queue"
export type SessionPendingCompaction = { id: string; sessionID: string; timeCreated: number; type: "compaction" }
export type SessionInboxSyntheticPayload = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
export type SessionInboxCompactionPayload = {}
export type InstructionEntryKey = string
export type SessionGenerateResponse = { data: { text: string } }
export type SessionPendingSyntheticData1 = { text: string; description?: string; metadata?: { [x: string]: any } }
export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } }
export type ShellInfo = {
id: string
@@ -420,6 +422,8 @@ 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
@@ -468,7 +472,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 = {
@@ -501,44 +505,24 @@ export type SessionForked = {
data: { sessionID: string; parentID: string; boundary: SessionForkBoundary; instructions?: { [x: string]: string } }
}
export type SessionInputPromoted = {
export type SessionInboxDelivered = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.promoted"
type: "session.inbox.delivered"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
data: { sessionID: string; inboxID: string }
}
export type SessionInputCancelled = {
export type SessionInboxCancelled = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.cancelled"
type: "session.inbox.cancelled"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
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 }
data: { sessionID: string; inboxID: string }
}
export type SessionExecutionStarted = {
@@ -624,7 +608,6 @@ export type SessionStepEnded = {
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
cost: MoneyUSD
tokens: TokenUsageInfo
generated?: number
snapshot?: string
files?: Array<string>
}
@@ -660,16 +643,6 @@ 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
@@ -1138,7 +1111,6 @@ export type SessionStepFailed = {
error: SessionStructuredError
cost?: MoneyUSD
tokens?: TokenUsageInfo
generated?: number
snapshot?: string
files?: Array<string>
}
@@ -1164,23 +1136,36 @@ export type SessionCompactionFailed = {
data: { sessionID: string; reason: "auto" | "manual"; error: SessionStructuredError; inputID?: string }
}
export type SessionPendingSynthetic = {
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 = {
id: string
sessionID: string
timeCreated: number
type: "synthetic"
data: SessionPendingSyntheticData
delivery: "steer" | "queue"
payload: SessionInboxSyntheticPayload
delivery: SessionInboxDelivery
}
export type SessionInboxCompaction = {
id: string
sessionID: string
timeCreated: number
type: "compaction"
payload: SessionInboxCompactionPayload
delivery: SessionInboxDelivery
}
export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue }
export type SessionPendingSyntheticMessage = {
type: "synthetic"
data: SessionPendingSyntheticData1
delivery: "steer" | "queue"
}
export type SessionShellStarted = {
id: string
created: number
@@ -1529,6 +1514,15 @@ 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
@@ -1566,7 +1560,7 @@ export type SessionMessageUser = {
type: "user"
}
export type SessionPendingUserData = {
export type SessionInboxUserPayload = {
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
@@ -1574,7 +1568,7 @@ export type SessionPendingUserData = {
metadata?: { [x: string]: JsonValue }
}
export type SessionPendingUserData1 = {
export type SessionInboxUserPayload1 = {
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
@@ -1886,16 +1880,20 @@ export type ConfigEntry =
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
export type SessionPendingUser = {
export type SessionInboxUser = {
id: string
sessionID: string
timeCreated: number
type: "user"
data: SessionPendingUserData
delivery: "steer" | "queue"
payload: SessionInboxUserPayload
delivery: SessionInboxDelivery
}
export type SessionPendingUserMessage = { type: "user"; data: SessionPendingUserData1; 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 SessionMessageAssistantTool = {
type: "tool"
@@ -1916,14 +1914,22 @@ export type FormFields = [FormField, ...Array<FormField>]
export type FormFields3 = [FormField1, ...Array<FormField1>]
export type SessionPendingInfo = SessionPendingUser | SessionPendingSynthetic | SessionPendingCompaction
export type SessionInboxInfo = SessionInboxUser | SessionInboxSynthetic | SessionInboxCompaction | SessionInboxMove
export type SessionPendingMessage = SessionPendingUserMessage | SessionPendingSyntheticMessage
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 SessionMessageAssistant = {
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number; started?: number; generated?: number; completed?: number }
time: { created: number; completed?: number }
type: "assistant"
agent: string
model: ModelRef
@@ -1944,15 +1950,47 @@ 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 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 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 SessionMessageInfo =
| SessionMessageAgentSelected
@@ -1981,49 +2019,7 @@ export type FormCreated = {
data: { form: FormInfo1 }
}
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 SessionLogItem = SessionEventDurable | EventLogSynced
export type SessionTransferData = { info: SessionInfo; messages: Array<SessionMessageInfo> }
@@ -2053,11 +2049,10 @@ export type V2Event =
| SessionUsageUpdated
| SessionDeleted
| SessionForked
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionInboxDelivered
| SessionInboxEnqueued
| SessionInboxCancelled
| SessionInboxDeliveryChanged
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -2084,7 +2079,6 @@ export type V2Event =
| SessionToolSuccess
| SessionToolFailed
| SessionRetryScheduled
| SessionCompactionAdmitted
| SessionCompactionStarted
| SessionCompactionDelta
| SessionCompactionEnded
@@ -2129,8 +2123,6 @@ 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"
@@ -2644,12 +2636,7 @@ export type SessionImportInput = {
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: {
readonly created: number
readonly started?: number
readonly generated?: number
readonly completed?: number
}
readonly time: { readonly created: number; readonly completed?: number }
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
@@ -2916,12 +2903,7 @@ export type SessionImportInput = {
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: {
readonly created: number
readonly started?: number
readonly generated?: number
readonly completed?: number
}
readonly time: { readonly created: number; readonly completed?: number }
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
@@ -3188,12 +3170,7 @@ export type SessionImportInput = {
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: {
readonly created: number
readonly started?: number
readonly generated?: number
readonly completed?: number
}
readonly time: { readonly created: number; readonly completed?: number }
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
@@ -3378,8 +3355,21 @@ export type SessionRenameOutput = void
export type SessionMoveInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly directory: { readonly directory: string; readonly workspaceID?: string }["directory"]
readonly workspaceID?: { readonly directory: string; readonly workspaceID?: string }["workspaceID"]
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"]
}
export type SessionMoveOutput = void
@@ -3404,7 +3394,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: {
@@ -3425,7 +3415,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?: {
@@ -3446,7 +3436,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?: {
@@ -3467,7 +3457,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?: {
@@ -3488,7 +3478,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?: {
@@ -3509,7 +3499,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?: {
@@ -3530,7 +3520,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?: {
@@ -3551,12 +3541,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: SessionPendingUser }["data"]
export type SessionPromptOutput = { data: SessionInboxUser }["data"]
export type SessionCommandInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -3580,7 +3570,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: {
@@ -3603,7 +3593,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?: {
@@ -3626,7 +3616,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?: {
@@ -3649,7 +3639,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?: {
@@ -3672,7 +3662,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?: {
@@ -3695,7 +3685,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?: {
@@ -3718,7 +3708,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?: {
@@ -3741,7 +3731,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?: {
@@ -3764,7 +3754,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?: {
@@ -3787,12 +3777,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: SessionPendingUser }["data"]
export type SessionCommandOutput = { data: SessionInboxUser }["data"]
export type SessionSkillInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -3822,7 +3812,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: {
@@ -3830,7 +3820,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?: {
@@ -3838,7 +3828,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?: {
@@ -3846,7 +3836,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?: {
@@ -3854,7 +3844,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?: {
@@ -3862,12 +3852,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: SessionPendingSynthetic }["data"]
export type SessionSyntheticOutput = { data: SessionInboxSynthetic }["data"]
export type SessionShellInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -3879,10 +3869,14 @@ export type SessionShellOutput = void
export type SessionCompactInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: { readonly id?: string | undefined }["id"]
readonly id?: { readonly id?: string | undefined; readonly delivery?: ("steer" | "queue") | undefined }["id"]
readonly delivery?: {
readonly id?: string | undefined
readonly delivery?: ("steer" | "queue") | undefined
}["delivery"]
}
export type SessionCompactOutput = { data: SessionPendingCompaction }["data"]
export type SessionCompactOutput = { data: SessionInboxCompaction }["data"]
export type SessionWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
@@ -3908,30 +3902,30 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
export type SessionPendingListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"]
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
export type SessionPendingCancelInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
export type SessionInboxCancelInput = {
readonly sessionID: { readonly sessionID: string; readonly inboxID: string }["sessionID"]
readonly inboxID: { readonly sessionID: string; readonly inboxID: string }["inboxID"]
}
export type SessionPendingCancelOutput = void
export type SessionInboxCancelOutput = void
export type SessionPendingSteerInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
export type SessionInboxSteerInput = {
readonly sessionID: { readonly sessionID: string; readonly inboxID: string }["sessionID"]
readonly inboxID: { readonly sessionID: string; readonly inboxID: string }["inboxID"]
}
export type SessionPendingSteerOutput = void
export type SessionInboxSteerOutput = void
export type SessionPendingQueueInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
export type SessionInboxQueueInput = {
readonly sessionID: { readonly sessionID: string; readonly inboxID: string }["sessionID"]
readonly inboxID: { readonly sessionID: string; readonly inboxID: string }["inboxID"]
}
export type SessionPendingQueueOutput = void
export type SessionInboxQueueOutput = void
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
@@ -5341,7 +5335,7 @@ export type FileListOutput = {
data: Array<FileSystemEntry>
}
export type FileFindInput = {
export type FileGetInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
@@ -5368,7 +5362,7 @@ export type FileFindInput = {
}["limit"]
}
export type FileFindOutput = {
export type FileGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<FileSystemEntry>
}
+4 -2
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.data)).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.admitted.payload)).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",
data: { text: "Hello" },
payload: { text: "Hello" },
delivery: "steer",
timeCreated: 1_717_171_717_000,
},
@@ -280,6 +280,8 @@ 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.pending.list uses the public HTTP contract", async () => {
test("session.inbox.list uses the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const pending = [
{
@@ -381,7 +381,7 @@ test("session.pending.list uses the public HTTP contract", async () => {
sessionID: "ses_test",
timeCreated: 1_717_171_717_000,
type: "user",
data: { text: "Fix the failing tests" },
payload: { text: "Fix the failing tests" },
delivery: "steer",
},
]
@@ -394,13 +394,13 @@ test("session.pending.list uses the public HTTP contract", async () => {
},
})
const result = await client.session.pending.list({ sessionID: "ses_test" })
const result = await client.session.inbox.list({ sessionID: "ses_test" })
expect(result).toEqual(pending)
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/inbox" }])
})
test("session.pending mutations use the public HTTP contract", async () => {
test("session.inbox 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.pending mutations use the public HTTP contract", async () => {
},
})
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" })
await client.session.inbox.cancel({ sessionID: "ses_test", inboxID: "msg_cancel" })
await client.session.inbox.steer({ sessionID: "ses_test", inboxID: "msg_steer" })
await client.session.inbox.queue({ sessionID: "ses_test", inboxID: "msg_queue" })
expect(requests).toEqual([
{ 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" },
{ 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" },
])
})
+1 -1
View File
@@ -6866,7 +6866,7 @@
"/api/fs/find": {
"get": {
"tags": ["filesystem"],
"operationId": "v2.fs.find",
"operationId": "v2.fs.get",
"parameters": [
{
"name": "location",
+134 -2
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "00924d88-1842-4d71-ac74-5682ddc47e1c",
"prevIds": ["15060ec5-05f7-4b86-b2a5-9108609432b3"],
"id": "5c1aa56b-c3ee-4283-9a84-c0bf626dc604",
"prevIds": ["00924d88-1842-4d71-ac74-5682ddc47e1c"],
"ddl": [
{
"name": "account_state",
@@ -56,6 +56,10 @@
"name": "instruction_state",
"entityType": "tables"
},
{
"name": "session_inbox",
"entityType": "tables"
},
{
"name": "session_message",
"entityType": "tables"
@@ -842,6 +846,76 @@
"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,
@@ -1428,6 +1502,17 @@
"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",
@@ -1552,6 +1637,13 @@
"table": "instruction_state",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "session_inbox_pk",
"table": "session_inbox",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
@@ -1642,6 +1734,46 @@
"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": [
{
+196 -30
View File
@@ -7,6 +7,7 @@ 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"
@@ -92,6 +93,16 @@ 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
@@ -124,6 +135,9 @@ 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
@@ -176,6 +190,7 @@ 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
@@ -385,15 +400,23 @@ export function configured(options?: Options) {
}),
)
if (definition?.durable) {
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
}
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
}),
)
}
yield* notify(event as Event.Payload, false)
return event
@@ -444,6 +467,144 @@ 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 },
@@ -455,27 +616,31 @@ export function configured(options?: Options) {
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
)
} else {
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,
)
}
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,
)
}
}),
)
}
})
}
@@ -692,6 +857,7 @@ export function configured(options?: Options) {
return Service.of({
publish,
publishAll,
subscribe,
log,
listen,
+2
View File
@@ -41,6 +41,7 @@ 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,
@@ -85,4 +86,5 @@ export const migrations = [
m39,
m40,
m41,
m42,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,30 @@
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,6 +142,18 @@ 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,
@@ -218,6 +230,12 @@ 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\`);`,
)
@@ -0,0 +1,17 @@
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,8 +165,6 @@ 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
@@ -500,11 +498,6 @@ 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,10 +204,11 @@ 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 if (loaded.baseURL) {
} else {
for (const id of item.models.keys()) {
evt.model.update(item.provider.id, id, (model) => {
model.settings = Provider.mergeOverlay(model.settings, { baseURL: loaded.baseURL })
model.package = "@ai-sdk/github-copilot"
if (loaded.baseURL) model.settings = Provider.mergeOverlay(model.settings, { baseURL: loaded.baseURL })
})
}
}
+60 -57
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 { SessionPending } from "./session/pending.js"
import { SessionInbox } from "./session/inbox.js"
import { InstructionState } from "./session/instruction-state.js"
import { SessionGenerate } from "./session/generate.js"
import { Snapshot } from "./snapshot.js"
@@ -99,6 +99,7 @@ type CreateInput = CreateBaseInput &
type CompactInput = {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
delivery?: SessionInbox.Delivery
}
type ForkInput = {
@@ -133,14 +134,11 @@ export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionC
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.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 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 SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID,
}) {}
@@ -188,10 +186,10 @@ export interface Interface {
* ordered by admission. Includes unpromoted user and synthetic inputs and
* unhandled compaction barriers.
*/
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>
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>
/**
* Durable, ordered session log read. Replays durable session bus after
* the exclusive `after` cursor, emits a `Synced` marker at the captured
@@ -211,6 +209,7 @@ 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
@@ -220,9 +219,9 @@ export interface Interface {
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
metadata?: Record<string, unknown>
delivery?: SessionPending.Delivery
delivery?: SessionInbox.Delivery
resume?: boolean
}) => Effect.Effect<SessionPending.User, NotFoundError | PromptConflictError | AttachmentError | SkillNotFoundError>
}) => Effect.Effect<SessionInbox.User, NotFoundError | PromptConflictError | AttachmentError | SkillNotFoundError>
/** Generates text from current Session context without admitting input or mutating history. */
readonly generate: (input: {
sessionID: SessionSchema.ID
@@ -238,10 +237,10 @@ export interface Interface {
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
delivery?: SessionPending.Delivery
delivery?: SessionInbox.Delivery
resume?: boolean
}) => Effect.Effect<
SessionPending.User,
SessionInbox.User,
| NotFoundError
| PromptConflictError
| AttachmentError
@@ -262,7 +261,7 @@ export interface Interface {
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
readonly compact: (
input: CompactInput,
) => Effect.Effect<SessionPending.Compaction, NotFoundError | CompactionConflictError>
) => Effect.Effect<SessionInbox.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>
@@ -274,9 +273,9 @@ export interface Interface {
text: string
description?: string
metadata?: Record<string, unknown>
delivery?: SessionPending.Delivery
delivery?: SessionInbox.Delivery
resume?: boolean
}) => Effect.Effect<SessionPending.Synthetic, NotFoundError | SyntheticConflictError>
}) => Effect.Effect<SessionInbox.Synthetic, NotFoundError | SyntheticConflictError>
readonly revert: {
readonly stage: (input: {
sessionID: SessionSchema.ID
@@ -321,12 +320,12 @@ const layer = Layer.effect(
),
)
const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: PendingInputRef) {
const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: InboxItemRef) {
yield* result.get(input.sessionID)
return yield* new PendingInputConflictError(input)
return yield* new InboxConflictError(input)
})
const mutatePending = (
input: PendingInputRef,
input: InboxItemRef,
mutation: (
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
@@ -335,9 +334,9 @@ const layer = Layer.effect(
) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
yield* mutation(bus, { sessionID: input.sessionID, id: input.inboxID }).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionPending.LifecycleConflict ? pendingConflict(input) : Effect.die(defect),
defect instanceof SessionInbox.LifecycleConflict ? pendingConflict(input) : Effect.die(defect),
),
)
if (wake) yield* execution.wake(input.sessionID)
@@ -529,13 +528,13 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
pending: Effect.fn("Session.pending")(function* (sessionID) {
inbox: Effect.fn("Session.inbox")(function* (sessionID) {
yield* result.get(sessionID)
return yield* SessionPending.list(db, sessionID)
return yield* SessionInbox.list(db, sessionID)
}),
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)),
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)),
log: (input) =>
Stream.unwrap(
result
@@ -564,25 +563,25 @@ const layer = Layer.effect(
skills,
).pipe(Effect.provideService(FSUtil.Service, fs))
const messageID = input.id ?? SessionMessage.ID.create()
const admittedInput = SessionPending.Message.make({
const admittedInput = SessionInbox.Item.make({
type: "user",
data: { ...prompt, metadata: input.metadata },
payload: { ...prompt, metadata: input.metadata },
delivery: input.delivery ?? "steer",
})
const admitted = yield* SessionPending.admit(db, bus, {
const admitted = yield* SessionInbox.admit(db, bus, {
id: messageID,
sessionID: input.sessionID,
input: admittedInput,
item: admittedInput,
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionPending.LifecycleConflict
defect instanceof SessionInbox.LifecycleConflict
? new PromptConflictError({ sessionID: input.sessionID, messageID })
: Effect.die(defect),
),
)
if (
admitted.type !== "user" ||
!SessionPending.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput })
!SessionInbox.equivalent(admitted, { sessionID: input.sessionID, item: admittedInput })
)
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) {
@@ -640,7 +639,9 @@ 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 })
return yield* shell
.create({ command: input.command, cwd: session.location.directory, timeout: 0 })
.pipe(Effect.orDie)
}).pipe(Effect.provide(locations.get(session.location)))
yield* bus.publish(
SessionEvent.Shell.Started,
@@ -738,33 +739,35 @@ 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)
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,
const item = SessionInbox.Item.make({
type: "move",
payload: {
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id,
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
},
{ location: current.location },
)
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)
}),
compact: Effect.fn("Session.compact")(function* (input) {
yield* result.get(input.sessionID)
const inputID = input.id ?? SessionMessage.ID.create()
const admitted = yield* SessionPending.admitCompaction(db, bus, {
const admitted = yield* SessionInbox.admitCompaction(db, bus, {
id: inputID,
sessionID: input.sessionID,
delivery: input.delivery ?? "queue",
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionPending.LifecycleConflict
defect instanceof SessionInbox.LifecycleConflict
? new CompactionConflictError({ sessionID: input.sessionID, inputID })
: Effect.die(defect),
),
@@ -804,29 +807,29 @@ const layer = Layer.effect(
Effect.gen(function* () {
yield* result.get(input.sessionID)
const inputID = input.id ?? SessionMessage.ID.create()
const admittedInput = SessionPending.Message.make({
const admittedInput = SessionInbox.Item.make({
type: "synthetic",
data: {
payload: {
text: input.text,
description: input.description,
metadata: input.metadata,
},
delivery: input.delivery ?? "steer",
})
const admitted = yield* SessionPending.admit(db, bus, {
const admitted = yield* SessionInbox.admit(db, bus, {
id: inputID,
sessionID: input.sessionID,
input: admittedInput,
item: admittedInput,
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionPending.LifecycleConflict
defect instanceof SessionInbox.LifecycleConflict
? new SyntheticConflictError({ sessionID: input.sessionID, inputID })
: Effect.die(defect),
),
)
if (
admitted.type !== "synthetic" ||
!SessionPending.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput })
!SessionInbox.equivalent(admitted, { sessionID: input.sessionID, item: admittedInput })
)
return yield* new SyntheticConflictError({ sessionID: input.sessionID, inputID })
if (input.resume !== false && !(yield* result.get(input.sessionID)).revert)
@@ -839,7 +842,7 @@ const layer = Layer.effect(
Effect.uninterruptible(
Effect.gen(function* () {
yield* execution.interrupt(sessionID)
if (options?.continue && (yield* SessionPending.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
if (options?.continue && (yield* SessionInbox.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
}),
),
),
+10 -6
View File
@@ -89,6 +89,7 @@ export type ManualInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
readonly inputID: SessionMessage.ID
readonly started?: boolean
}
type RequiredInput = Omit<AutoInput, "ref">
@@ -102,6 +103,7 @@ type Plan = {
readonly prompt: string
readonly recent: string
readonly inputID?: SessionMessage.ID
readonly started?: boolean
}
export type Outcome =
@@ -248,12 +250,13 @@ const make = (dependencies: Dependencies) => {
return { status: "failed" as const, error: input.error }
})
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
sessionID: plan.session.id,
reason: plan.reason,
recent: plan.recent,
inputID: plan.inputID,
})
if (!plan.started)
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
@@ -408,6 +411,7 @@ const make = (dependencies: Dependencies) => {
cost: resolved.cost,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
})
})
+5 -1
View File
@@ -1,6 +1,6 @@
export * as SessionExecution from "./execution.js"
import { Cause, Context, Effect, Exit, Layer } from "effect"
import { Cause, Context, Effect, Exit, Layer, Stream } from "effect"
import { Bus } from "../bus.js"
import { LocationServiceMap } from "../location-service-map.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -116,6 +116,10 @@ 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
@@ -0,0 +1,505 @@
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)))
)
}),
)
})
+4 -11
View File
@@ -109,11 +109,10 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.renamed": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => 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.inbox.delivered": () => Effect.void,
"session.inbox.enqueued": () => Effect.void,
"session.inbox.cancelled": () => Effect.void,
"session.inbox.delivery.changed": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
@@ -195,8 +194,6 @@ 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 }
}),
@@ -232,7 +229,6 @@ 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,
@@ -251,7 +247,6 @@ 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,
@@ -262,7 +257,6 @@ 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: "" })))
})
},
@@ -385,7 +379,6 @@ 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
@@ -1,545 +0,0 @@
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))
}),
)
})
+29 -49
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 { SessionPending } from "./pending.js"
import { SessionInbox } from "./inbox.js"
import { Workspace } from "../workspace.js"
import { InstructionState } from "./instruction-state.js"
import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql.js"
import { SessionInboxTable, 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,14 +470,15 @@ 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.InputPromoted, (event) =>
yield* bus.project(SessionEvent.InboxDelivered, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const input = yield* SessionPending.projectPromoted(db, {
id: event.data.inputID,
const input = yield* SessionInbox.projectDelivered(db, {
id: event.data.inboxID,
sessionID: event.data.sessionID,
})
if (input.type === "compaction" || input.type === "move") return
yield* insertMessage(
db,
event,
@@ -485,33 +486,33 @@ const layer = Layer.effectDiscard(
? {
id: input.id,
type: "user",
metadata: input.data.metadata,
text: input.data.text,
files: input.data.files,
agents: input.data.agents,
skills: input.data.skills,
metadata: input.payload.metadata,
text: input.payload.text,
files: input.payload.files,
agents: input.payload.agents,
skills: input.payload.skills,
time: { created: event.created },
}
: {
id: input.id,
type: "synthetic",
text: input.data.text,
description: input.data.description,
metadata: input.data.metadata,
text: input.payload.text,
description: input.payload.description,
metadata: input.payload.metadata,
time: { created: event.created },
},
)
}),
)
yield* bus.project(SessionEvent.InputAdmitted, (event) =>
yield* bus.project(SessionEvent.InboxEnqueued, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
yield* SessionPending.projectAdmitted(db, {
admittedSeq: event.durable.seq,
id: event.data.inputID,
yield* SessionInbox.projectAdmitted(db, {
enqueuedSeq: event.durable.seq,
id: event.data.inboxID,
sessionID: event.data.sessionID,
input: event.data.input,
item: event.data.item,
timeCreated: event.created,
})
yield* db
@@ -522,34 +523,17 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.InputCancelled, (event) =>
SessionPending.projectCancelled(db, {
id: event.data.inputID,
yield* bus.project(SessionEvent.InboxCancelled, (event) =>
SessionInbox.projectCancelled(db, {
id: event.data.inboxID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.InputSteered, (event) =>
SessionPending.projectSteered(db, {
id: event.data.inputID,
yield* bus.project(SessionEvent.InboxDeliveryChanged, (event) =>
SessionInbox.projectDeliveryChanged(db, {
id: event.data.inboxID,
sessionID: event.data.sessionID,
}),
)
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,
})
delivery: event.data.delivery,
}),
)
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
@@ -596,8 +580,6 @@ 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) =>
@@ -605,8 +587,6 @@ 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) =>
@@ -650,11 +630,11 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie)
yield* db
.delete(SessionPendingTable)
.delete(SessionInboxTable)
.where(
and(
eq(SessionPendingTable.session_id, event.data.sessionID),
gte(SessionPendingTable.admitted_seq, boundary.seq),
eq(SessionInboxTable.session_id, event.data.sessionID),
gte(SessionInboxTable.enqueued_seq, boundary.seq),
),
)
.run()
+71 -30
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 { SessionPending } from "../pending.js"
import { SessionInbox } from "../inbox.js"
import { SessionModelRequest } from "../model-request.js"
import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
@@ -125,13 +125,19 @@ const layer = Layer.effect(
readonly sessionID: SessionSchema.ID
readonly force: boolean
}) {
if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "any"))) return
let force = input.force
if (!force && !(yield* SessionInbox.has(db, input.sessionID, "any"))) return
yield* settleStaleToolCalls(input.sessionID)
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"))
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
}
})
/**
@@ -140,12 +146,13 @@ 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: SessionPending.Promotable = "input"
let promotable: SessionInbox.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)
yield* runPendingCompaction(sessionID)
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
if (!result.needsContinuation && !(yield* SessionInbox.has(db, sessionID, "steer"))) return false
promotable = "steer"
step = result.step + 1
}
@@ -154,7 +161,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: SessionPending.Promotable,
promotable: SessionInbox.Promotable,
step: number,
) {
// Minting message identity before any attempt lets retries resume the same durable
@@ -182,7 +189,7 @@ const layer = Layer.effect(
.pipe(Effect.andThen(Effect.fail(failure.cause))),
),
)
let currentPromotable: SessionPending.Promotable | undefined = promotable
let currentPromotable: SessionInbox.Promotable | undefined = promotable
let currentStep = step
// Overflow recovery is one-shot: a call after recovery must not recover another overflow.
let recoverOverflow = true
@@ -225,7 +232,7 @@ const layer = Layer.effect(
*/
const callModel = Effect.fn("SessionRunner.callModel")(function* (
sessionID: SessionSchema.ID,
promotable: SessionPending.Promotable | undefined,
promotable: SessionInbox.Promotable | undefined,
step: number,
recoverOverflow: boolean,
assistantMessageID: SessionMessage.ID,
@@ -234,7 +241,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* SessionPending.promote(db, bus, selected.session.id, promotable) : 0
const promoted = promotable ? yield* SessionInbox.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
@@ -245,7 +252,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) && !(yield* SessionPending.compaction(db, session.id))) {
if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed")
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
@@ -275,7 +282,6 @@ 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* () {
@@ -483,36 +489,71 @@ 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
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,
})
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,
})
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 { DateTime, Effect } from "effect"
import { Effect } from "effect"
import { Bus } from "../../bus.js"
import { Model } from "../../model.js"
import { SessionEvent } from "../event.js"
@@ -36,7 +36,6 @@ 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
@@ -311,7 +310,6 @@ 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[]
}) {
@@ -495,14 +493,9 @@ 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),
generated,
}
stepSettlement = { finish: event.reason.normalized, tokens: SessionUsage.tokens(event.usage) }
if (event.reason.normalized === "content-filter") {
providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
+27 -5
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 { SessionPending } from "./pending.js"
import type { SessionInbox } from "./inbox.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 { SyntheticData, UserData } from "@opencode-ai/schema/session-pending"
import type { CompactionPayload, MovePayload, SyntheticPayload, UserPayload } from "@opencode-ai/schema/session-inbox"
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<SessionPending.Info["type"]>().notNull(),
data: text({ mode: "json" }).$type<UserData | SyntheticData | Record<string, never>>().notNull(),
delivery: text().$type<SessionPending.Delivery>(),
type: text().$type<SessionInbox.Info["type"]>().notNull(),
data: text({ mode: "json" }).$type<UserPayload | SyntheticPayload | Record<string, never>>().notNull(),
delivery: text().$type<SessionInbox.Delivery>(),
admitted_seq: integer().notNull(),
time_created: integer()
.notNull()
@@ -118,6 +118,28 @@ 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",
{
+17 -12
View File
@@ -5,6 +5,7 @@ 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"
@@ -50,7 +51,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, R>
) => Effect.Effect<Shell.Info, E | AppProcess.AppProcessError, 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>
@@ -215,19 +216,23 @@ 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>()
const ready = Deferred.makeUnsafe<Active, AppProcess.AppProcessError>()
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),
}),
)
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 session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
@@ -329,7 +334,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.catch(() => Effect.void)),
).pipe(Effect.catchTag("AppProcessError", (error) => Deferred.fail(ready, error))),
)
const session = yield* Deferred.await(ready)
+14 -1
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 type { ToolCall, ToolDefinition } from "@opencode-ai/ai"
import { ToolDefinition, type ToolCall } 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,6 +165,19 @@ 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,6 +455,120 @@ 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
+13 -1
View File
@@ -1,9 +1,10 @@
import fs from "node:fs/promises"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process"
import { ChildProcess, 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,
@@ -35,6 +36,17 @@ 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,6 +23,7 @@ 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"
@@ -478,6 +479,27 @@ 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 { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
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(
SessionPending.Synthetic.make({
SessionInbox.Synthetic.make({
id: SessionMessage.ID.make(input.id),
sessionID: Session.ID.make(input.sessionID),
timeCreated: DateTime.makeUnsafe(0),
type: "synthetic",
data: {
payload: {
text: input.text,
metadata: input.metadata,
},
@@ -195,6 +195,22 @@ 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
+16 -12
View File
@@ -15,7 +15,6 @@ 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"
@@ -81,7 +80,7 @@ const it = testEffect(
)
describe("Session.compact", () => {
it.effect("durably admits and coalesces manual compaction", () =>
it.effect("durably stacks manual compaction", () =>
Effect.gen(function* () {
requests = []
const session = yield* Session.Service
@@ -89,18 +88,18 @@ describe("Session.compact", () => {
const created = yield* session.create({ location })
const messageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.InputAdmitted, {
yield* bus.publish(SessionEvent.InboxEnqueued, {
sessionID: created.id,
inputID: messageID,
input: {
inboxID: messageID,
item: {
type: "user",
data: { text: "Please compact this session history." },
payload: { text: "Please compact this session history." },
delivery: "steer",
},
})
yield* bus.publish(SessionEvent.InputPromoted, {
yield* bus.publish(SessionEvent.InboxDelivered, {
sessionID: created.id,
inputID: messageID,
inboxID: messageID,
})
expect(yield* session.compact({ id: messageID, sessionID: created.id }).pipe(Effect.flip)).toMatchObject({
@@ -110,12 +109,17 @@ describe("Session.compact", () => {
const first = yield* session.compact({ sessionID: created.id })
const second = yield* session.compact({ sessionID: created.id })
expect(second.id).toBe(first.id)
expect(second.id).not.toBe(first.id)
expect(requests).toHaveLength(0)
expect(yield* SessionPending.compaction((yield* Database.Service).db, created.id)).toMatchObject({
id: first.id,
})
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* 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" })
}),
)
})
+26 -23
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 { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
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* SessionPending.promote(db, bus, parent.id, "steer")
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false })
yield* SessionPending.promote(db, bus, parent.id, "steer")
yield* SessionInbox.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* SessionPending.find(db, forkContext[0].id)).toBeUndefined()
expect(yield* SessionPending.find(db, forkContext[1].id)).toBeUndefined()
expect(yield* SessionInbox.find(db, forkContext[0].id)).toBeUndefined()
expect(yield* SessionInbox.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", data: { text: "First" } })
).toMatchObject({ id: forkContext[0].id, type: "user", payload: { text: "First" } })
yield* session.prompt({
sessionID: parent.id,
text: "Parent changed",
resume: false,
})
yield* SessionPending.promote(db, bus, parent.id, "steer")
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* session.prompt({
sessionID: forked.id,
text: "Child continues",
resume: false,
})
yield* SessionPending.promote(db, bus, forked.id, "steer")
yield* SessionInbox.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* SessionPending.find(db, admitted.id)).toBeUndefined()
expect(yield* SessionInbox.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* SessionPending.promote(db, bus, parent.id, "steer")
yield* SessionInbox.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* SessionPending.promote(db, bus, parent.id, "steer")
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const second = yield* session.prompt({
sessionID: parent.id,
text: "Second",
resume: false,
})
yield* SessionPending.promote(db, bus, parent.id, "steer")
yield* SessionInbox.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* SessionPending.promote(db, bus, created.id, "steer")
yield* SessionInbox.promote(db, bus, created.id, "steer")
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(3), Stream.runCollect)),
@@ -504,10 +504,13 @@ describe("Session.create", () => {
{ durable: { seq: 0 }, type: "session.created" },
{
durable: { seq: 1 },
type: "session.input.admitted",
data: { input: { type: "user", data: { text: "Hello" }, delivery: "steer" } },
type: "session.inbox.enqueued",
data: {
inboxID: expect.any(String),
item: { type: "user", payload: { text: "Hello" }, delivery: "steer" },
},
},
{ durable: { seq: 2 }, type: "session.input.promoted" },
{ durable: { seq: 2 }, type: "session.inbox.delivered" },
])
}),
)
@@ -523,7 +526,7 @@ describe("Session.create", () => {
text: "Replay lifecycle",
resume: false,
})
yield* SessionPending.promote(sourceDb, sourceEvents, created.id, "steer")
yield* SessionInbox.promote(sourceDb, sourceEvents, created.id, "steer")
const serialized = (yield* sourceDb
.select()
.from(EventTable)
@@ -563,17 +566,17 @@ describe("Session.create", () => {
expect(yield* store.get(created.id)).toBeUndefined()
expect(yield* bus.replayAll(serialized.slice(0, 2))).toBe(created.id)
expect(yield* SessionPending.find(db, admitted.id)).toMatchObject({
expect(yield* SessionInbox.find(db, admitted.id)).toMatchObject({
id: admitted.id,
sessionID: created.id,
type: "user",
data: { text: "Replay lifecycle" },
payload: { text: "Replay lifecycle" },
delivery: "steer",
})
expect(yield* store.context(created.id)).toEqual([])
expect(yield* bus.replayAll(serialized.slice(2))).toBe(created.id)
expect(yield* SessionPending.find(db, admitted.id)).toBeUndefined()
expect(yield* SessionInbox.find(db, admitted.id)).toBeUndefined()
expect(yield* store.context(created.id)).toMatchObject([
{ id: admitted.id, type: "user", text: "Replay lifecycle" },
])
@@ -587,8 +590,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.InputAdmitted.type, 1)],
[2, Bus.versionedType(SessionEvent.InputPromoted.type, 1)],
[1, Bus.versionedType(SessionEvent.InboxEnqueued.type, 1)],
[2, Bus.versionedType(SessionEvent.InboxDelivered.type, 1)],
])
}).pipe(Effect.provide(Layer.fresh(targetLayer)))
}),
@@ -808,7 +811,7 @@ describe("SessionTransfer", () => {
])
yield* session.prompt({ sessionID, text: "Continue", resume: false })
yield* SessionPending.promote(db, bus, sessionID, "steer")
yield* SessionInbox.promote(db, bus, sessionID, "steer")
expect((yield* session.messages({ sessionID, order: "asc" })).map((message) => message.type)).toEqual([
"user",
+14 -11
View File
@@ -38,7 +38,7 @@ import {
InstructionBlobTable,
InstructionStateTable,
SessionMessageTable,
SessionPendingTable,
SessionInboxTable,
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(SessionPendingTable)
.where(eq(SessionPendingTable.session_id, sessionID))
.orderBy(asc(SessionPendingTable.admitted_seq))
.from(SessionInboxTable)
.where(eq(SessionInboxTable.session_id, sessionID))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.all()
.pipe(Effect.orDie),
instructions: db
@@ -225,12 +225,15 @@ 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.InputAdmitted, {
yield* bus.publish(SessionEvent.InboxEnqueued, {
sessionID,
inputID: existing,
input: { type: "user", data: { text: "Existing durable context" }, delivery: "steer" },
inboxID: existing,
item: { type: "user", payload: { text: "Existing durable context" }, delivery: "steer" },
})
yield* bus.publish(SessionEvent.InboxDelivered, {
sessionID,
inboxID: existing,
})
yield* bus.publish(SessionEvent.InputPromoted, { sessionID, inputID: existing })
const settledAssistant = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
@@ -275,10 +278,10 @@ it.effect("generates from fresh settled Session context without durable mutation
input: {},
executed: false,
})
yield* bus.publish(SessionEvent.InputAdmitted, {
yield* bus.publish(SessionEvent.InboxEnqueued, {
sessionID,
inputID: SessionMessage.ID.create(),
input: { type: "user", data: { text: "Queued input must remain invisible" }, delivery: "queue" },
inboxID: SessionMessage.ID.create(),
item: { type: "user", payload: { text: "Queued input must remain invisible" }, delivery: "queue" },
})
instruction = "Changed context"
const before = yield* durableState(db, sessionID)
+18 -14
View File
@@ -34,7 +34,7 @@ const it = testEffect(
)
describe("Session.move", () => {
it.effect("moves a session whose source directory no longer exists", () =>
it.effect("enqueues one move when the source directory no longer exists", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -49,24 +49,28 @@ describe("Session.move", () => {
yield* session.move({ sessionID: created.id, 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") },
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 },
projectID: Project.ID.global,
subpath: "",
},
subpath: "",
}),
},
])
yield* session.move({ sessionID: created.id, directory: destination })
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toEqual(messages)
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" }])
}),
),
),
+21 -33
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 { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { Shell } from "@opencode-ai/schema/shell"
import {
InstructionStateTable,
SessionPendingTable,
SessionInboxTable,
SessionMessageTable,
SessionTable,
} from "@opencode-ai/core/session/sql"
@@ -47,9 +47,7 @@ const build = Agent.defaultID
const assistantRow = (
id: SessionMessage.ID,
seq: number,
time: { created: DateTime.Utc; started?: DateTime.Utc; generated?: DateTime.Utc; completed?: DateTime.Utc } = {
created,
},
time: { created: DateTime.Utc; completed?: DateTime.Utc } = { created },
usage?: Pick<SessionMessage.Assistant, "cost" | "tokens">,
) => {
const {
@@ -83,7 +81,7 @@ describe("SessionProjector", () => {
.run()
const bus = yield* Bus.Service
const inputID = SessionMessage.ID.make("msg_manual_compaction")
yield* SessionPending.admitCompaction(db, bus, { id: inputID, sessionID })
yield* SessionInbox.admitCompaction(db, bus, { id: inputID, sessionID, delivery: "queue" })
yield* bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
@@ -91,7 +89,7 @@ describe("SessionProjector", () => {
error: { type: "compaction.failed", message: "Auto compaction failed" },
})
expect(yield* SessionPending.compaction(db, sessionID)).toMatchObject({ id: inputID })
expect(yield* SessionInbox.find(db, inputID)).toMatchObject({ id: inputID })
}),
)
@@ -249,29 +247,29 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie)
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.InputAdmitted, {
yield* bus.publish(SessionEvent.InboxEnqueued, {
sessionID,
inputID: SessionMessage.ID.make("msg_first"),
input: { type: "user", data: { text: "first" }, delivery: "steer" },
inboxID: SessionMessage.ID.make("msg_first"),
item: { type: "user", payload: { text: "first" }, delivery: "steer" },
})
yield* bus.publish(
SessionEvent.InputPromoted,
SessionEvent.InboxDelivered,
{
sessionID,
inputID: SessionMessage.ID.make("msg_first"),
inboxID: SessionMessage.ID.make("msg_first"),
},
{ id: Event.ID.make("evt_z") },
)
yield* bus.publish(SessionEvent.InputAdmitted, {
yield* bus.publish(SessionEvent.InboxEnqueued, {
sessionID,
inputID: SessionMessage.ID.make("msg_second"),
input: { type: "user", data: { text: "second" }, delivery: "steer" },
inboxID: SessionMessage.ID.make("msg_second"),
item: { type: "user", payload: { text: "second" }, delivery: "steer" },
})
yield* bus.publish(
SessionEvent.InputPromoted,
SessionEvent.InboxDelivered,
{
sessionID,
inputID: SessionMessage.ID.make("msg_second"),
inboxID: SessionMessage.ID.make("msg_second"),
},
{ id: Event.ID.make("evt_a") },
)
@@ -322,20 +320,20 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie)
const bus = yield* Bus.Service
const id = SessionMessage.ID.make("msg_admitted")
const admitted = yield* SessionPending.admit(db, bus, {
const admitted = yield* SessionInbox.admit(db, bus, {
id,
sessionID,
input: { type: "user", data: { text: "promote me" }, delivery: "steer" },
item: { type: "user", payload: { text: "promote me" }, delivery: "steer" },
})
if (!admitted) return yield* Effect.die("Prompt admission failed")
const event = yield* bus.publish(SessionEvent.InputPromoted, {
const event = yield* bus.publish(SessionEvent.InboxDelivered, {
sessionID,
inputID: id,
inboxID: id,
})
expect(
yield* db.select().from(SessionPendingTable).where(eq(SessionPendingTable.id, id)).get().pipe(Effect.orDie),
yield* db.select().from(SessionInboxTable).where(eq(SessionInboxTable.id, id)).get().pipe(Effect.orDie),
).toBeUndefined()
expect(
yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),
@@ -669,18 +667,12 @@ 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
@@ -699,11 +691,7 @@ describe("SessionProjector", () => {
finish: "stop",
cost: Money.USD.make(1.25),
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
time: {
started: DateTime.makeUnsafe(0),
generated: DateTime.makeUnsafe(0),
completed: DateTime.makeUnsafe(0),
},
time: { completed: DateTime.makeUnsafe(0) },
})
expect(
yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
+83 -87
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 { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionPendingTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionInboxTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { 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 }) => SessionPending.find(db, id))
const admitted = (id: SessionMessage.ID) => Database.Service.use(({ db }) => SessionInbox.find(db, id))
const admittedCount = Database.Service.use(({ db }) =>
db
.select()
.from(SessionPendingTable)
.from(SessionInboxTable)
.all()
.pipe(
Effect.orDie,
@@ -227,13 +227,13 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.data.text).toBe("Fix the failing tests")
expect(message.payload.text).toBe("Fix the failing tests")
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admitted(message.id)).toMatchObject({
id: message.id,
sessionID,
type: "user",
data: { text: "Fix the failing tests" },
payload: { text: "Fix the failing tests" },
delivery: "steer",
})
}),
@@ -251,7 +251,7 @@ describe("Session.prompt", () => {
text: "boundary",
resume: false,
})
yield* SessionPending.promote(db, bus, sessionID, "steer")
yield* SessionInbox.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* SessionPending.find(db, boundary.id)).toBeUndefined()
expect(yield* SessionInbox.find(db, boundary.id)).toBeUndefined()
}),
)
@@ -283,7 +283,7 @@ describe("Session.prompt", () => {
text: "boundary",
resume: false,
})
yield* SessionPending.promote(db, bus, sessionID, "steer")
yield* SessionInbox.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* SessionPending.find(db, completion.id)).toMatchObject({ type: "synthetic" })
expect(yield* SessionInbox.find(db, completion.id)).toMatchObject({ type: "synthetic" })
yield* session.revert.commit(sessionID)
expect(yield* SessionPending.find(db, completion.id)).toBeUndefined()
expect(yield* SessionInbox.find(db, completion.id)).toBeUndefined()
}),
)
@@ -315,7 +315,7 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.data.files).toEqual([
expect(message.payload.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.data.files).toEqual(message.data.files)
if (stored?.type === "user") expect(stored.payload.files).toEqual(message.payload.files)
}),
)
@@ -347,14 +347,14 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.data.files).toHaveLength(1)
expect(message.data.files?.[0]).toMatchObject({
expect(message.payload.files).toHaveLength(1)
expect(message.payload.files?.[0]).toMatchObject({
mime: "text/plain",
source: { type: "uri", uri: sourceUri.href },
name: "main.ts",
})
expect(
Buffer.from(message.data.files?.[0]?.data ?? "", "base64")
Buffer.from(message.payload.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.data.files).toHaveLength(1)
expect(message.data.files?.[0]).toMatchObject({
expect(message.payload.files).toHaveLength(1)
expect(message.payload.files?.[0]).toMatchObject({
mime: "application/x-directory",
source: { type: "uri", uri },
name: "source",
})
expect(Buffer.from(message.data.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain(
expect(Buffer.from(message.payload.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain(
"session-prompt.test.ts",
)
}),
@@ -408,7 +408,7 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.data.files).toEqual([
expect(message.payload.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.data.files : undefined).toEqual(message.data.files)
expect(stored?.type === "user" ? stored.payload.files : undefined).toEqual(message.payload.files)
}),
)
@@ -440,7 +440,7 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.data.files).toEqual([
expect(message.payload.files).toEqual([
{
data: "AA==",
mime: "image/png",
@@ -463,7 +463,7 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.data.files).toEqual([
expect(message.payload.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* SessionPending.promote(db, bus, sessionID, "steer")
yield* SessionInbox.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.input.admitted"],
[1, "session.input.admitted"],
[2, "session.input.promoted"],
[3, "session.input.promoted"],
[0, "session.inbox.enqueued"],
[1, "session.inbox.enqueued"],
[2, "session.inbox.delivered"],
[3, "session.inbox.delivered"],
])
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.input.admitted"]])
).toEqual([[1, "session.inbox.enqueued"]])
}),
)
@@ -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* SessionPending.promote(db, bus, sessionID, "steer")
yield* SessionInbox.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", data: { text: first.data.text } })
expect(retried).toMatchObject({ id: first.id, type: "user", payload: { text: first.payload.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* SessionPending.promote(db, bus, sessionID, "steer")
yield* SessionInbox.promote(db, bus, sessionID, "steer")
const retried = yield* session.prompt({ ...input, delivery: "queue" })
expect(retried).toMatchObject({ id: messageID, type: "user", data: { text: input.text } })
expect(retried).toMatchObject({ id: messageID, type: "user", payload: { 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.InputAdmitted.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxEnqueued.type, 1))).toBe(1)
}),
)
@@ -726,11 +726,11 @@ describe("Session.prompt", () => {
})
yield* Effect.all(
[SessionPending.promote(db, bus, sessionID, "steer"), SessionPending.promote(db, bus, sessionID, "steer")],
[SessionInbox.promote(db, bus, sessionID, "steer"), SessionInbox.promote(db, bus, sessionID, "steer")],
{ concurrency: "unbounded" },
)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputPromoted.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxDelivered.type, 1))).toBe(1)
expect(yield* admitted(messageID)).toBeUndefined()
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: messageID, type: "user", text: "Promote once" },
@@ -761,11 +761,7 @@ describe("Session.prompt", () => {
.pipe(Effect.orDie)
yield* bus.remove(sessionID)
yield* db
.delete(SessionPendingTable)
.where(eq(SessionPendingTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
yield* db.delete(SessionInboxTable).where(eq(SessionInboxTable.session_id, sessionID)).run().pipe(Effect.orDie)
yield* db
.delete(SessionMessageTable)
.where(eq(SessionMessageTable.session_id, sessionID))
@@ -785,12 +781,12 @@ describe("Session.prompt", () => {
expect(yield* admitted(messageID)).toMatchObject({
id: messageID,
type: "user",
data: { text: "Replay pending" },
payload: { text: "Replay pending" },
})
expect(yield* admitted(syntheticID)).toMatchObject({
id: syntheticID,
type: "synthetic",
data: { text: "Replay synthetic" },
payload: { text: "Replay synthetic" },
})
expect(yield* session.messages({ sessionID })).toEqual([])
expect(wakeCalls).toEqual([])
@@ -923,7 +919,7 @@ describe("Session.prompt", () => {
const failure = yield* session.prompt({ ...input, metadata: { source: "plugin" } }).pipe(Effect.flip)
expect(retried).toEqual(first)
expect(first.data.metadata).toEqual({ source: "api" })
expect(first.payload.metadata).toEqual({ source: "api" })
expect(failure._tag).toBe("Session.PromptConflictError")
}),
)
@@ -949,14 +945,14 @@ describe("Session.prompt", () => {
type: "synthetic",
sessionID,
delivery: "steer",
data: {
payload: {
text: "Background work completed",
description: "shell completion",
metadata: { job: "shell" },
},
})
yield* SessionPending.promote(db, bus, sessionID, "steer")
yield* SessionInbox.promote(db, bus, sessionID, "steer")
expect(yield* session.messages({ sessionID })).toMatchObject([
{
@@ -981,15 +977,15 @@ describe("Session.prompt", () => {
const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], {
concurrency: "unbounded",
})
yield* SessionPending.promote(database.db, bus, sessionID, "steer")
yield* SessionInbox.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", data: { text: "Completed" } })
expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", payload: { text: "Completed" } })
expect(failure).toMatchObject({ _tag: "Session.SyntheticConflictError", sessionID, inputID: messageID })
expect(yield* admittedCount).toBe(0)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxEnqueued.type, 1))).toBe(1)
}),
)
@@ -1008,11 +1004,11 @@ describe("Session.prompt", () => {
})
expect(input.delivery).toBe("queue")
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(true)
expect(yield* SessionPending.promote(db, bus, sessionID, "steer")).toBe(0)
expect(yield* SessionInbox.has(db, sessionID, "input")).toBe(true)
expect(yield* SessionInbox.promote(db, bus, sessionID, "steer")).toBe(0)
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(1)
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
expect(yield* SessionInbox.promote(db, bus, sessionID, "input")).toBe(1)
expect(yield* SessionInbox.has(db, sessionID, "input")).toBe(false)
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: input.id, type: "synthetic", text: "Queued completion" },
])
@@ -1038,7 +1034,7 @@ describe("Session.prompt", () => {
resume: false,
})
yield* SessionPending.promote(db, bus, sessionID, "steer")
yield* SessionInbox.promote(db, bus, sessionID, "steer")
expect(
(yield* session.messages({ sessionID, order: "asc" })).map((message) =>
@@ -1049,11 +1045,11 @@ describe("Session.prompt", () => {
)
})
describe("Session.pending", () => {
describe("Session.inbox", () => {
it.effect("fails for an unknown session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
expect(yield* session.pending(Session.ID.make("ses_missing")).pipe(Effect.flip)).toMatchObject({
expect(yield* session.inbox(Session.ID.make("ses_missing")).pipe(Effect.flip)).toMatchObject({
_tag: "Session.NotFoundError",
})
}),
@@ -1075,34 +1071,34 @@ describe("Session.pending", () => {
})
const second = yield* session.prompt({ sessionID, text: "Second steer", resume: false })
expect(yield* session.pending(sessionID)).toMatchObject([
expect(yield* session.inbox(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* 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(2)
expect(yield* session.inbox(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }])
expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(1)
expect(yield* session.pending(sessionID)).toEqual([])
expect(yield* SessionInbox.promote(db, bus, sessionID, "input")).toBe(1)
expect(yield* session.inbox(sessionID)).toEqual([])
}),
)
it.effect("lists an unhandled compaction barrier until it settles", () =>
it.effect("lists an unhandled compaction until it is cancelled", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const { db } = yield* Database.Service
const barrier = yield* session.compact({ sessionID })
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" }])
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" }])
yield* SessionPending.settleCompaction(db, { sessionID })
expect(yield* SessionPending.has(db, sessionID, "any")).toBe(false)
expect(yield* session.pending(sessionID)).toEqual([])
yield* session.cancelInbox({ sessionID, inboxID: barrier.id })
expect(yield* SessionInbox.has(db, sessionID, "any")).toBe(false)
expect(yield* session.inbox(sessionID)).toEqual([])
}),
)
@@ -1119,16 +1115,16 @@ describe("Session.pending", () => {
resume: false,
})
yield* session.cancelPending({ sessionID, inputID })
yield* session.cancelInbox({ sessionID, inboxID: inputID })
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",
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",
sessionID,
inputID,
inboxID: inputID,
})
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxCancelled.type, 1))).toBe(1)
const retried = yield* session.prompt({
id: inputID,
@@ -1154,33 +1150,33 @@ describe("Session.pending", () => {
const alreadySteered = yield* session.prompt({ sessionID, text: "Already steer", resume: false })
wakeCalls.length = 0
yield* session.steerPending({ sessionID, inputID: queued.id })
yield* session.steerInbox({ sessionID, inboxID: queued.id })
expect(yield* session.pending(sessionID)).toMatchObject([
expect(yield* session.inbox(sessionID)).toMatchObject([
{ id: queued.id, delivery: "steer" },
{ id: alreadySteered.id, delivery: "steer" },
])
expect(wakeCalls).toEqual([sessionID])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxDeliveryChanged.type, 1))).toBe(1)
wakeCalls.length = 0
yield* session.queuePending({ sessionID, inputID: queued.id })
expect(yield* session.pending(sessionID)).toMatchObject([
yield* session.queueInbox({ sessionID, inboxID: queued.id })
expect(yield* session.inbox(sessionID)).toMatchObject([
{ id: queued.id, delivery: "queue" },
{ id: alreadySteered.id, delivery: "steer" },
])
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputQueued.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxDeliveryChanged.type, 1))).toBe(2)
expect(yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.PendingInputConflictError",
expect(yield* session.steerInbox({ sessionID, inboxID: alreadySteered.id }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.InboxConflictError",
sessionID,
inputID: alreadySteered.id,
inboxID: alreadySteered.id,
})
yield* session.cancelPending({ sessionID, inputID: alreadySteered.id })
yield* session.cancelInbox({ sessionID, inboxID: alreadySteered.id })
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxDeliveryChanged.type, 1))).toBe(2)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxCancelled.type, 1))).toBe(1)
}),
)
})
@@ -243,9 +243,9 @@ describe("SessionRunnerLLM recorded", () => {
.orderBy(EventTable.seq)
.all()).map((event) => event.type),
).toEqual([
"session.input.admitted.1",
"session.inbox.enqueued.1",
"session.instructions.updated.2",
"session.input.promoted.1",
"session.inbox.delivered.1",
"session.step.started.1",
"session.text.started.1",
"session.text.ended.1",
@@ -266,7 +266,6 @@ 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,6 +110,28 @@ 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
+64 -29
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 { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
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,
SessionPendingTable,
SessionInboxTable,
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, eq } from "drizzle-orm"
import { asc, desc, 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(SessionPendingTable).where(eq(SessionPendingTable.session_id, id)).run().pipe(Effect.orDie)
yield* db.delete(SessionInboxTable).where(eq(SessionInboxTable.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* SessionPending.has(db, sessionID, "steer")).toBe(true)
expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(true)
expect(
yield* db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(),
).toBeUndefined()
@@ -1210,6 +1210,7 @@ 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(),
@@ -1220,7 +1221,43 @@ describe("SessionRunnerLLM", () => {
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(requests).toHaveLength(1)
expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true)
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)])
}),
)
@@ -1825,15 +1862,15 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("runs one durable compaction barrier after tool settlement and before later inputs", () =>
it.effect("runs steers before queued compaction and later queued input", () =>
Effect.gen(function* () {
const session = yield* setup
currentModel = recoveryModel
const stream = yield* TestLLM.gate
yield* TestLLM.push(
TestLLM.tool("call-active", "echo", { text: "active" }),
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
TestLLM.text("Steer complete", "text-steer"),
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
TestLLM.text("Queue complete", "text-queue"),
)
yield* admit(session, "Active work")
@@ -1841,9 +1878,7 @@ describe("SessionRunnerLLM", () => {
yield* stream.started
const first = yield* session.compact({ sessionID })
const second = yield* session.compact({ sessionID })
expect(second.id).toBe(first.id)
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toMatchObject({
expect(yield* SessionInbox.find((yield* Database.Service).db, first.id)).toMatchObject({
id: first.id,
})
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toBeUndefined()
@@ -1856,17 +1891,17 @@ describe("SessionRunnerLLM", () => {
delivery: "queue",
resume: false,
})
expect(yield* SessionPending.has((yield* Database.Service).db, sessionID, "steer")).toBe(false)
expect(yield* SessionInbox.has((yield* Database.Service).db, sessionID, "steer")).toBe(true)
yield* stream.release
yield* Fiber.join(active)
expect(requests).toHaveLength(4)
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[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[3])).toContain("Queue after compaction")
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect(yield* SessionInbox.find((yield* Database.Service).db, first.id)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({
type: "compaction",
status: "completed",
@@ -1901,7 +1936,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(3)
expect(userTexts(requests[2])).toContain("Continue after failure")
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
@@ -1923,7 +1958,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
@@ -1938,7 +1973,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("manually compacts when the model has no context limit", () =>
it.effect("delivers steered manual compaction 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"))
@@ -1946,7 +1981,7 @@ describe("SessionRunnerLLM", () => {
requests.length = 0
yield* TestLLM.push(TestLLM.text("Manual summary", "text-manual-unknown-summary"))
const compaction = yield* session.compact({ sessionID })
const compaction = yield* session.compact({ sessionID, delivery: "steer" })
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
@@ -2015,7 +2050,7 @@ describe("SessionRunnerLLM", () => {
yield* session.interrupt(sessionID)
yield* Fiber.await(run)
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
@@ -2036,7 +2071,7 @@ describe("SessionRunnerLLM", () => {
expect(yield* Effect.exit(session.resume(sessionID))).toMatchObject({ _tag: "Failure" })
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
@@ -2864,7 +2899,7 @@ describe("SessionRunnerLLM", () => {
yield* session.interrupt(sessionID)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
expect(requests).toHaveLength(1)
expect(yield* SessionPending.has(db, sessionID, "queue")).toBe(true)
expect(yield* SessionInbox.has(db, sessionID, "queue")).toBe(true)
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* stream.release
@@ -2894,7 +2929,7 @@ describe("SessionRunnerLLM", () => {
yield* session.interrupt(sessionID)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
expect(requests).toHaveLength(1)
expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true)
expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(true)
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
@@ -3047,7 +3082,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const bus = yield* Bus.Service
yield* admit(session, "Recover interrupted tool")
yield* SessionPending.promote((yield* Database.Service).db, bus, sessionID, "steer")
yield* SessionInbox.promote((yield* Database.Service).db, bus, sessionID, "steer")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
@@ -3104,7 +3139,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const bus = yield* Bus.Service
yield* admit(session, "Recover interrupted hosted tool")
yield* SessionPending.promote((yield* Database.Service).db, bus, sessionID, "steer")
yield* SessionInbox.promote((yield* Database.Service).db, bus, sessionID, "steer")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
@@ -3155,7 +3190,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const bus = yield* Bus.Service
yield* admit(session, "Recover interrupted tool input")
yield* SessionPending.promote((yield* Database.Service).db, bus, sessionID, "steer")
yield* SessionInbox.promote((yield* Database.Service).db, bus, sessionID, "steer")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
@@ -3208,7 +3243,7 @@ describe("SessionRunnerLLM", () => {
const bus = yield* Bus.Service
const defect = new Error("fail after prompt promotion")
let fail = true
yield* bus.project(SessionEvent.InputPromoted, () => (fail ? Effect.die(defect) : Effect.void))
yield* bus.project(SessionEvent.InboxDelivered, () => (fail ? Effect.die(defect) : Effect.void))
yield* admit(session, "Recover promoted input")
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
@@ -3230,7 +3265,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const bus = yield* Bus.Service
yield* bus.listen((event) =>
event.type === SessionEvent.InputPromoted.type
event.type === SessionEvent.InboxDelivered.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 { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
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* SessionPending.promote(database.db, bus, session.id, "steer")
yield* SessionInbox.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.InputAdmitted, {
yield* bus.publish(SessionEvent.InboxEnqueued, {
sessionID,
inputID: messageID,
input: { type: "user", data: { text }, delivery: "steer" },
inboxID: messageID,
item: { type: "user", payload: { text }, delivery: "steer" },
})
yield* bus.publish(SessionEvent.InputPromoted, {
yield* bus.publish(SessionEvent.InboxDelivered, {
sessionID,
inputID: messageID,
inboxID: 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 { SessionPending } from "@opencode-ai/schema/session-pending"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
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,
coreSessionPending,
coreSessionInbox,
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/pending"),
import("@opencode-ai/core/session/inbox"),
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],
[coreSessionPending.Delivery, SessionPending.Delivery],
[coreSessionPending.Message, SessionPending.Message],
[coreSessionPending.User, SessionPending.User],
[coreSessionPending.Synthetic, SessionPending.Synthetic],
[coreSessionInbox.Delivery, SessionInbox.Delivery],
[coreSessionInbox.Item, SessionInbox.Item],
[coreSessionInbox.User, SessionInbox.User],
[coreSessionInbox.Synthetic, SessionInbox.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.InputAdmitted).pipe(
Stream.filter((event) => event.data.sessionID === sessionID && event.data.input.type === "synthetic"),
const admitted = yield* bus.subscribe(SessionEvent.InboxEnqueued).pipe(
Stream.filter((event) => event.data.sessionID === sessionID && event.data.item.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.input.data).toMatchObject({
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.item.payload).toMatchObject({
description: idleCommand,
metadata: {
source: "shell",
+9 -7
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 { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
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.pending(child.id)).find((message) => message.type === "user")?.data.text).toBe(
expect((yield* sessions.inbox(child.id)).find((message) => message.type === "user")?.payload.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.InputAdmitted).pipe(
Stream.filter((event) => event.data.sessionID === parent.id && event.data.input.type === "synthetic"),
const admitted = yield* bus.subscribe(SessionEvent.InboxEnqueued).pipe(
Stream.filter((event) => event.data.sessionID === parent.id && event.data.item.type === "synthetic"),
Stream.take(1),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
@@ -401,8 +401,10 @@ 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.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
expect(admission?.data.input.data).toMatchObject({
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({
description: "background review",
metadata: {
source: "subagent",
@@ -412,7 +414,7 @@ describe("SubagentTool", () => {
},
})
const database = yield* Database.Service
yield* SessionPending.promote(database.db, bus, parent.id, "steer")
yield* SessionInbox.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"`)
+7 -1
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(2_000, 1_000)).toThrow(DiagramCanvasSizeError)
expect(() => new DiagramCanvas(1_000, 251)).toThrow(DiagramCanvasSizeError)
})
test("rejects invalid canvas dimensions", () => {
@@ -38,6 +38,12 @@ 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, {
+5 -2
View File
@@ -40,7 +40,7 @@ export interface DiagramCanvasRunOptions<Style extends string, Metadata extends
trimBottom?: boolean
}
const MAX_DIAGRAM_CELLS = 1_000_000
const MAX_DIAGRAM_CELLS = 250_000
export class DiagramCanvasSizeError extends Error {
constructor(
@@ -122,7 +122,6 @@ 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
@@ -151,6 +150,10 @@ 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,6 +30,7 @@ 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
@@ -252,6 +253,9 @@ 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) {
+16 -3
View File
@@ -37,6 +37,8 @@ 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
@@ -101,11 +103,19 @@ class StaticDiagramRenderable extends TextRenderable {
}
}
function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkdownRendererOptions): PreparedDiagram {
function prepareDiagram(
kind: DiagramKind,
source: string,
options: MermaidMarkdownRendererOptions,
layoutMaxWidth: number,
): PreparedDiagram {
const colors = options.colors ?? {}
switch (kind) {
case "flowchart": {
const grid = drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(source), { compact: options.compact })
const grid = drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(source), {
compact: options.compact,
layoutMaxWidth,
})
const size = grid.getTextSize({ trimTop: true, trimBottom: true })
return {
kind,
@@ -192,9 +202,12 @@ 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)
const prepared = prepareDiagram(kind, token.text, options, layoutMaxWidth)
const diagram = new StaticDiagramRenderable(ctx, prepared)
if (key) claimLastGood(key, prepared, diagram, lastGood)
return diagram
+4
View File
@@ -23,6 +23,7 @@ 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",
@@ -237,6 +238,9 @@ 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,6 +176,9 @@ 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,6 +41,11 @@ 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
@@ -62,6 +67,11 @@ 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,6 +289,28 @@ 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,
SessionPendingInfo,
SessionInboxInfo,
ShellInfo,
SkillInfo,
VcsInfo,
@@ -70,7 +70,7 @@ export interface Data {
cost(sessionID: string): number
status(sessionID: string): "idle" | "running"
readonly pending: {
list(sessionID: string): SessionPendingInfo[]
list(sessionID: string): SessionInboxInfo[]
sync(sessionID: string): Promise<void>
invalidate(sessionID: string): void
}
+255 -279
View File
@@ -1670,7 +1670,32 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Location.Ref"
"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
}
}
},
@@ -1707,7 +1732,7 @@
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/SessionPending.User"
"$ref": "#/components/schemas/Session.Inbox.User"
}
},
"required": ["data"],
@@ -1821,8 +1846,7 @@
"delivery": {
"anyOf": [
{
"type": "string",
"enum": ["steer", "queue"]
"$ref": "#/components/schemas/Session.Inbox.Delivery"
},
{
"type": "null"
@@ -1878,7 +1902,7 @@
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/SessionPending.User"
"$ref": "#/components/schemas/Session.Inbox.User"
}
},
"required": ["data"],
@@ -2032,8 +2056,7 @@
"delivery": {
"anyOf": [
{
"type": "string",
"enum": ["steer", "queue"]
"$ref": "#/components/schemas/Session.Inbox.Delivery"
},
{
"type": "null"
@@ -2200,7 +2223,7 @@
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/SessionPending.Synthetic"
"$ref": "#/components/schemas/Session.Inbox.Synthetic"
}
},
"required": ["data"],
@@ -2299,8 +2322,7 @@
"delivery": {
"anyOf": [
{
"type": "string",
"enum": ["steer", "queue"]
"$ref": "#/components/schemas/Session.Inbox.Delivery"
},
{
"type": "null"
@@ -2454,7 +2476,7 @@
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/SessionPending.Compaction"
"$ref": "#/components/schemas/Session.Inbox.Compaction"
}
},
"required": ["data"],
@@ -2533,6 +2555,16 @@
"type": "null"
}
]
},
"delivery": {
"anyOf": [
{
"$ref": "#/components/schemas/Session.Inbox.Delivery"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
@@ -3000,10 +3032,10 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/pending": {
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
"operationId": "v2.session.pending.list",
"operationId": "v2.session.inbox.list",
"parameters": [
{
"name": "sessionID",
@@ -3031,7 +3063,7 @@
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SessionPending.Info"
"$ref": "#/components/schemas/Session.Inbox.Info"
}
}
},
@@ -3072,14 +3104,14 @@
}
}
},
"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"
"description": "List durable enqueued session work not yet delivered, ordered by enqueue sequence. Includes user, synthetic, compaction, and move items.",
"summary": "List session inbox"
}
},
"/api/session/{sessionID}/pending/{inputID}": {
"/api/session/{sessionID}/inbox/{inboxID}": {
"delete": {
"tags": ["session"],
"operationId": "v2.session.pending.cancel",
"operationId": "v2.session.inbox.cancel",
"parameters": [
{
"name": "sessionID",
@@ -3095,7 +3127,7 @@
"required": true
},
{
"name": "inputID",
"name": "inboxID",
"in": "path",
"schema": {
"type": "string",
@@ -3154,14 +3186,14 @@
}
}
},
"description": "Cancel an input that has not yet been promoted into session history.",
"summary": "Cancel pending input"
"description": "Cancel an inbox item that has not yet been delivered.",
"summary": "Cancel inbox input"
}
},
"/api/session/{sessionID}/pending/{inputID}/steer": {
"/api/session/{sessionID}/inbox/{inboxID}/steer": {
"post": {
"tags": ["session"],
"operationId": "v2.session.pending.steer",
"operationId": "v2.session.inbox.steer",
"parameters": [
{
"name": "sessionID",
@@ -3177,7 +3209,7 @@
"required": true
},
{
"name": "inputID",
"name": "inboxID",
"in": "path",
"schema": {
"type": "string",
@@ -3236,14 +3268,14 @@
}
}
},
"description": "Change a queued input to steer delivery and wake session execution.",
"summary": "Steer queued input"
"description": "Change a queued inbox item to steer delivery and wake session execution.",
"summary": "Steer queued item"
}
},
"/api/session/{sessionID}/pending/{inputID}/queue": {
"/api/session/{sessionID}/inbox/{inboxID}/queue": {
"post": {
"tags": ["session"],
"operationId": "v2.session.pending.queue",
"operationId": "v2.session.inbox.queue",
"parameters": [
{
"name": "sessionID",
@@ -3259,7 +3291,7 @@
"required": true
},
{
"name": "inputID",
"name": "inboxID",
"in": "path",
"schema": {
"type": "string",
@@ -3318,8 +3350,8 @@
}
}
},
"description": "Change a pending steer to queued delivery.",
"summary": "Queue pending steer"
"description": "Change a steered inbox item to queued delivery.",
"summary": "Queue steered item"
}
},
"/api/session/{sessionID}/instructions/entries": {
@@ -3932,7 +3964,7 @@
}
}
},
"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.",
"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.",
"summary": "Interrupt session execution"
}
},
@@ -8502,7 +8534,7 @@
"/api/fs/find": {
"get": {
"tags": ["filesystem"],
"operationId": "v2.fs.find",
"operationId": "v2.fs.get",
"parameters": [
{
"name": "location",
@@ -13951,6 +13983,10 @@
"required": ["_tag", "sessionID", "messageID", "message"],
"additionalProperties": false
},
"Session.Inbox.Delivery": {
"type": "string",
"enum": ["steer", "queue"]
},
"PromptInput.FileAttachment": {
"type": "object",
"properties": {
@@ -13983,7 +14019,7 @@
"required": ["id"],
"additionalProperties": false
},
"SessionPending.UserData": {
"Session.Inbox.UserPayload": {
"type": "object",
"properties": {
"text": {
@@ -14014,7 +14050,7 @@
"required": ["text"],
"additionalProperties": false
},
"SessionPending.User": {
"Session.Inbox.User": {
"type": "object",
"properties": {
"id": {
@@ -14040,15 +14076,14 @@
"type": "string",
"enum": ["user"]
},
"data": {
"$ref": "#/components/schemas/SessionPending.UserData"
"payload": {
"$ref": "#/components/schemas/Session.Inbox.UserPayload"
},
"delivery": {
"type": "string",
"enum": ["steer", "queue"]
"$ref": "#/components/schemas/Session.Inbox.Delivery"
}
},
"required": ["id", "sessionID", "timeCreated", "type", "data", "delivery"],
"required": ["id", "sessionID", "timeCreated", "type", "payload", "delivery"],
"additionalProperties": false
},
"CommandNotFoundError": {
@@ -14102,7 +14137,7 @@
"required": ["_tag", "skill", "message"],
"additionalProperties": false
},
"SessionPending.SyntheticData": {
"Session.Inbox.SyntheticPayload": {
"type": "object",
"properties": {
"text": {
@@ -14118,7 +14153,7 @@
"required": ["text"],
"additionalProperties": false
},
"SessionPending.Synthetic": {
"Session.Inbox.Synthetic": {
"type": "object",
"properties": {
"id": {
@@ -14144,18 +14179,27 @@
"type": "string",
"enum": ["synthetic"]
},
"data": {
"$ref": "#/components/schemas/SessionPending.SyntheticData"
"payload": {
"$ref": "#/components/schemas/Session.Inbox.SyntheticPayload"
},
"delivery": {
"type": "string",
"enum": ["steer", "queue"]
"$ref": "#/components/schemas/Session.Inbox.Delivery"
}
},
"required": ["id", "sessionID", "timeCreated", "type", "data", "delivery"],
"required": ["id", "sessionID", "timeCreated", "type", "payload", "delivery"],
"additionalProperties": false
},
"SessionPending.Compaction": {
"Session.Inbox.CompactionPayload": {
"anyOf": [
{
"type": "object"
},
{
"type": "array"
}
]
},
"Session.Inbox.Compaction": {
"type": "object",
"properties": {
"id": {
@@ -14180,9 +14224,15 @@
"type": {
"type": "string",
"enum": ["compaction"]
},
"payload": {
"$ref": "#/components/schemas/Session.Inbox.CompactionPayload"
},
"delivery": {
"$ref": "#/components/schemas/Session.Inbox.Delivery"
}
},
"required": ["id", "sessionID", "timeCreated", "type"],
"required": ["id", "sessionID", "timeCreated", "type", "payload", "delivery"],
"additionalProperties": false
},
"ServiceUnavailableError": {
@@ -14226,16 +14276,71 @@
"required": ["_tag", "sessionID", "message"],
"additionalProperties": false
},
"SessionPending.Info": {
"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": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionPending.User"
"$ref": "#/components/schemas/Session.Inbox.User"
},
{
"$ref": "#/components/schemas/SessionPending.Synthetic"
"$ref": "#/components/schemas/Session.Inbox.Synthetic"
},
{
"$ref": "#/components/schemas/SessionPending.Compaction"
"$ref": "#/components/schemas/Session.Inbox.Compaction"
},
{
"$ref": "#/components/schemas/Session.Inbox.Move"
}
]
},
@@ -14602,7 +14707,7 @@
"type": "string"
}
},
"required": ["sessionID", "location"],
"required": ["sessionID", "location", "projectID"],
"additionalProperties": false
}
},
@@ -14831,7 +14936,7 @@
"required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false
},
"session.input.promoted": {
"session.inbox.delivered": {
"type": "object",
"properties": {
"id": {
@@ -14850,7 +14955,7 @@
},
"type": {
"type": "string",
"enum": ["session.input.promoted"]
"enum": ["session.inbox.delivered"]
},
"durable": {
"type": "object",
@@ -14888,7 +14993,7 @@
}
]
},
"inputID": {
"inboxID": {
"type": "string",
"allOf": [
{
@@ -14897,14 +15002,14 @@
]
}
},
"required": ["sessionID", "inputID"],
"required": ["sessionID", "inboxID"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false
},
"SessionPending.UserData1": {
"Session.Inbox.UserPayload1": {
"type": "object",
"properties": {
"text": {
@@ -14935,25 +15040,7 @@
"required": ["text"],
"additionalProperties": false
},
"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": {
"Session.Inbox.SyntheticPayload1": {
"type": "object",
"properties": {
"text": {
@@ -14969,35 +15056,79 @@
"required": ["text"],
"additionalProperties": false
},
"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": {
"Session.Inbox.Item": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionPending.UserMessage"
"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.SyntheticMessage"
"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
}
]
},
"session.input.admitted": {
"session.inbox.enqueued": {
"type": "object",
"properties": {
"id": {
@@ -15016,7 +15147,7 @@
},
"type": {
"type": "string",
"enum": ["session.input.admitted"]
"enum": ["session.inbox.enqueued"]
},
"durable": {
"type": "object",
@@ -15054,7 +15185,7 @@
}
]
},
"inputID": {
"inboxID": {
"type": "string",
"allOf": [
{
@@ -15062,18 +15193,18 @@
}
]
},
"input": {
"$ref": "#/components/schemas/SessionPending.Message"
"item": {
"$ref": "#/components/schemas/Session.Inbox.Item"
}
},
"required": ["sessionID", "inputID", "input"],
"required": ["sessionID", "inboxID", "item"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false
},
"session.input.cancelled": {
"session.inbox.cancelled": {
"type": "object",
"properties": {
"id": {
@@ -15092,7 +15223,7 @@
},
"type": {
"type": "string",
"enum": ["session.input.cancelled"]
"enum": ["session.inbox.cancelled"]
},
"durable": {
"type": "object",
@@ -15130,7 +15261,7 @@
}
]
},
"inputID": {
"inboxID": {
"type": "string",
"allOf": [
{
@@ -15139,14 +15270,14 @@
]
}
},
"required": ["sessionID", "inputID"],
"required": ["sessionID", "inboxID"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false
},
"session.input.steered": {
"session.inbox.delivery.changed": {
"type": "object",
"properties": {
"id": {
@@ -15165,7 +15296,7 @@
},
"type": {
"type": "string",
"enum": ["session.input.steered"]
"enum": ["session.inbox.delivery.changed"]
},
"durable": {
"type": "object",
@@ -15203,89 +15334,19 @@
}
]
},
"inputID": {
"inboxID": {
"type": "string",
"allOf": [
{
"pattern": "^msg_"
}
]
},
"delivery": {
"$ref": "#/components/schemas/Session.Inbox.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"],
"required": ["sessionID", "inboxID", "delivery"],
"additionalProperties": false
}
},
@@ -17208,79 +17269,6 @@
"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": {
@@ -17821,19 +17809,16 @@
"$ref": "#/components/schemas/session.forked"
},
{
"$ref": "#/components/schemas/session.input.promoted"
"$ref": "#/components/schemas/session.inbox.delivered"
},
{
"$ref": "#/components/schemas/session.input.admitted"
"$ref": "#/components/schemas/session.inbox.enqueued"
},
{
"$ref": "#/components/schemas/session.input.cancelled"
"$ref": "#/components/schemas/session.inbox.cancelled"
},
{
"$ref": "#/components/schemas/session.input.steered"
},
{
"$ref": "#/components/schemas/session.input.queued"
"$ref": "#/components/schemas/session.inbox.delivery.changed"
},
{
"$ref": "#/components/schemas/session.execution.started"
@@ -17901,9 +17886,6 @@
{
"$ref": "#/components/schemas/session.retry.scheduled"
},
{
"$ref": "#/components/schemas/session.compaction.admitted"
},
{
"$ref": "#/components/schemas/session.compaction.started"
},
@@ -23381,19 +23363,16 @@
"$ref": "#/components/schemas/session.forked"
},
{
"$ref": "#/components/schemas/session.input.promoted"
"$ref": "#/components/schemas/session.inbox.delivered"
},
{
"$ref": "#/components/schemas/session.input.admitted"
"$ref": "#/components/schemas/session.inbox.enqueued"
},
{
"$ref": "#/components/schemas/session.input.cancelled"
"$ref": "#/components/schemas/session.inbox.cancelled"
},
{
"$ref": "#/components/schemas/session.input.steered"
},
{
"$ref": "#/components/schemas/session.input.queued"
"$ref": "#/components/schemas/session.inbox.delivery.changed"
},
{
"$ref": "#/components/schemas/session.execution.started"
@@ -23473,9 +23452,6 @@
{
"$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.find", "/api/fs/find", {
HttpApiEndpoint.get("fs.get", "/api/fs/find", {
query: FindQuery,
success: Location.response(Schema.Array(FileSystem.Entry)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.fs.find",
identifier: "v2.fs.get",
summary: "Find files",
description: "Find recursively ranked filesystem entries relative to the requested location.",
}),
+35 -32
View File
@@ -1,6 +1,6 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
import { SessionPending } from "@opencode-ai/schema/session-pending"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
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: Location.Ref,
payload: Schema.Struct({ ...Location.Ref.fields, delivery: SessionInbox.Delivery.pipe(Schema.optional) }),
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: SessionPending.UserData.fields.metadata,
delivery: SessionPending.Delivery.pipe(Schema.optional),
metadata: SessionInbox.UserPayload.fields.metadata,
delivery: SessionInbox.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionPending.User }),
success: Schema.Struct({ data: SessionInbox.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: SessionPending.Delivery.pipe(Schema.optional),
delivery: SessionInbox.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionPending.User }),
success: Schema.Struct({ data: SessionInbox.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: SessionPending.Delivery.pipe(Schema.optional),
delivery: SessionInbox.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionPending.Synthetic }),
success: Schema.Struct({ data: SessionInbox.Synthetic }),
error: [ConflictError, SessionNotFoundError],
})
.middleware(sessionLocationMiddleware)
@@ -428,8 +428,11 @@ 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) }),
success: Schema.Struct({ data: SessionPending.Compaction }),
payload: Schema.Struct({
id: SessionMessage.ID.pipe(Schema.optional),
delivery: SessionInbox.Delivery.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionInbox.Compaction }),
error: [ConflictError, SessionNotFoundError],
})
.middleware(sessionLocationMiddleware)
@@ -506,55 +509,55 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
),
)
.add(
HttpApiEndpoint.get("session.pending.list", "/api/session/:sessionID/pending", {
HttpApiEndpoint.get("session.inbox.list", "/api/session/:sessionID/inbox", {
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(SessionPending.Info) }),
success: Schema.Struct({ data: Schema.Array(SessionInbox.Info) }),
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.list",
summary: "List pending session work",
identifier: "v2.session.inbox.list",
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.",
"List durable enqueued session work not yet delivered, ordered by enqueue sequence. Includes user, synthetic, compaction, and move items.",
}),
),
)
.add(
HttpApiEndpoint.delete("session.pending.cancel", "/api/session/:sessionID/pending/:inputID", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
HttpApiEndpoint.delete("session.inbox.cancel", "/api/session/:sessionID/inbox/:inboxID", {
params: { sessionID: Session.ID, inboxID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.cancel",
summary: "Cancel pending input",
description: "Cancel an input that has not yet been promoted into session history.",
identifier: "v2.session.inbox.cancel",
summary: "Cancel inbox input",
description: "Cancel an inbox item that has not yet been delivered.",
}),
),
)
.add(
HttpApiEndpoint.post("session.pending.steer", "/api/session/:sessionID/pending/:inputID/steer", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
HttpApiEndpoint.post("session.inbox.steer", "/api/session/:sessionID/inbox/:inboxID/steer", {
params: { sessionID: Session.ID, inboxID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.steer",
summary: "Steer queued input",
description: "Change a queued input to steer delivery and wake session execution.",
identifier: "v2.session.inbox.steer",
summary: "Steer queued item",
description: "Change a queued inbox item to steer delivery and wake session execution.",
}),
),
)
.add(
HttpApiEndpoint.post("session.pending.queue", "/api/session/:sessionID/pending/:inputID/queue", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
HttpApiEndpoint.post("session.inbox.queue", "/api/session/:sessionID/inbox/:inboxID/queue", {
params: { sessionID: Session.ID, inboxID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.queue",
summary: "Queue pending steer",
description: "Change a pending steer to queued delivery.",
identifier: "v2.session.inbox.queue",
summary: "Queue steered item",
description: "Change a steered inbox item to queued delivery.",
}),
),
)
@@ -657,7 +660,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 pending 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 inbox 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 { SessionPending } from "./session-pending.js"
export { SessionInbox } from "./session-inbox.js"
export { SessionError } from "./session-error.js"
export { SessionMessage } from "./session-message.js"
export { SessionTransfer } from "./session-transfer.js"
-6
View File
@@ -1,6 +0,0 @@
export * as SessionDelivery from "./session-delivery.js"
import { Schema } from "effect"
export const Delivery = Schema.Literals(["steer", "queue"])
export type Delivery = typeof Delivery.Type
+26 -49
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 { DateTimeUtcFromMillis, NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
import { 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 { SessionPending } from "./session-pending.js"
import { SessionInbox } from "./session-inbox.js"
import { Project } from "./project.js"
import { SessionFork } from "./session-fork.js"
@@ -90,9 +90,7 @@ export const Moved = Event.durable({
...options,
schema: {
...Base,
location: Location.Ref,
projectID: Project.ID.pipe(optional),
subpath: RelativePath.pipe(optional),
...SessionInbox.MovePayload.fields,
},
})
export type Moved = typeof Moved.Type
@@ -154,48 +152,41 @@ export const Forked = Event.durable({
})
export type Forked = typeof Forked.Type
const InputRef = {
const InboxRef = {
...Base,
inputID: SessionMessage.ID,
inboxID: SessionMessage.ID,
}
export const InputPromoted = Event.durable({
type: "session.input.promoted",
export const InboxDelivered = Event.durable({
type: "session.inbox.delivered",
...options,
schema: InputRef,
schema: InboxRef,
})
export type InputPromoted = typeof InputPromoted.Type
export type InboxDelivered = typeof InboxDelivered.Type
export const InputAdmitted = Event.durable({
type: "session.input.admitted",
export const InboxEnqueued = Event.durable({
type: "session.inbox.enqueued",
...options,
schema: {
...InputRef,
input: SessionPending.Message,
...InboxRef,
item: SessionInbox.Item,
},
})
export type InputAdmitted = typeof InputAdmitted.Type
export type InboxEnqueued = typeof InboxEnqueued.Type
export const InputCancelled = Event.durable({
type: "session.input.cancelled",
export const InboxCancelled = Event.durable({
type: "session.inbox.cancelled",
...options,
schema: InputRef,
schema: InboxRef,
})
export type InputCancelled = typeof InputCancelled.Type
export type InboxCancelled = typeof InboxCancelled.Type
export const InputSteered = Event.durable({
type: "session.input.steered",
export const InboxDeliveryChanged = Event.durable({
type: "session.inbox.delivery.changed",
...options,
schema: InputRef,
schema: { ...InboxRef, delivery: SessionInbox.Delivery },
})
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 type InboxDeliveryChanged = typeof InboxDeliveryChanged.Type
export namespace Execution {
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
@@ -309,7 +300,6 @@ 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),
},
@@ -325,7 +315,6 @@ 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),
},
@@ -525,16 +514,6 @@ 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,
@@ -604,11 +583,10 @@ export const Definitions = Event.inventory(
UsageUpdated,
Deleted,
Forked,
InputPromoted,
InputAdmitted,
InputCancelled,
InputSteered,
InputQueued,
InboxDelivered,
InboxEnqueued,
InboxCancelled,
InboxDeliveryChanged,
Execution.Started,
Execution.Succeeded,
Execution.Failed,
@@ -635,7 +613,6 @@ export const Definitions = Event.inventory(
Tool.Success,
Tool.Failed,
RetryScheduled,
Compaction.Admitted,
Compaction.Started,
Compaction.Delta,
Compaction.Ended,
+78
View File
@@ -0,0 +1,78 @@
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,8 +221,6 @@ 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
@@ -1,75 +0,0 @@
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
+34 -12
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 { SessionPending } from "../src/session-pending.js"
import { SessionInbox } from "../src/session-inbox.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(SessionPending.SyntheticData)({
Schema.encodeSync(SessionInbox.SyntheticPayload)({
text: "completed",
description: undefined,
metadata: undefined,
@@ -67,16 +67,16 @@ describe("contract hygiene", () => {
).not.toHaveProperty("title")
})
test("pending session items omit the internal admission sequence", () => {
test("session inbox items omit the internal enqueue sequence", () => {
expect(
Schema.encodeSync(SessionPending.Info)(
Schema.decodeUnknownSync(SessionPending.Info)({
Schema.encodeSync(SessionInbox.Info)(
Schema.decodeUnknownSync(SessionInbox.Info)({
admittedSeq: 3,
id: "msg_pending",
sessionID: "ses_pending",
timeCreated: 1,
type: "user",
data: { text: "hello" },
payload: { text: "hello" },
delivery: "steer",
}),
),
@@ -85,7 +85,7 @@ describe("contract hygiene", () => {
sessionID: "ses_pending",
timeCreated: 1,
type: "user",
data: { text: "hello" },
payload: { text: "hello" },
delivery: "steer",
})
})
@@ -167,10 +167,17 @@ describe("contract hygiene", () => {
Pty.Info,
Session.ListAnchor,
Session.Revert,
SessionPending.UserData,
SessionPending.SyntheticData,
SessionPending.User,
SessionPending.Synthetic,
SessionInbox.Delivery,
SessionInbox.UserPayload,
SessionInbox.SyntheticPayload,
SessionInbox.CompactionPayload,
SessionInbox.MovePayload,
SessionInbox.Item,
SessionInbox.User,
SessionInbox.Synthetic,
SessionInbox.Compaction,
SessionInbox.Move,
SessionInbox.Info,
Vcs.Branch,
Vcs.Info,
].map((schema) => schema.ast.annotations?.identifier)
@@ -179,6 +186,21 @@ 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"),
@@ -221,7 +243,7 @@ describe("contract hygiene", () => {
test("reviewed session contracts use their canonical current shapes", () => {
expect(SessionMessage.Info.ast.annotations?.identifier).toBe("Session.Message.Info")
expect(SessionPending.Info.ast.annotations?.identifier).toBe("SessionPending.Info")
expect(SessionInbox.Info.ast.annotations?.identifier).toBe("Session.Inbox.Info")
expect(Money.USD).not.toBe(Money.USDPerMillionTokens)
expect(
FileDiff.Info.make({ file: "src/index.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }),
+4 -6
View File
@@ -82,11 +82,10 @@ describe("public event manifest", () => {
"session.renamed.1",
"session.usage.recorded.1",
"session.forked.2",
"session.input.promoted.1",
"session.input.admitted.1",
"session.input.cancelled.1",
"session.input.steered.1",
"session.input.queued.1",
"session.inbox.delivered.1",
"session.inbox.enqueued.1",
"session.inbox.cancelled.1",
"session.inbox.delivery.changed.1",
"session.execution.started.1",
"session.execution.succeeded.1",
"session.execution.failed.1",
@@ -109,7 +108,6 @@ 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 { SessionPending } from "@opencode-ai/schema/session-pending"
export { SessionInbox } from "@opencode-ai/schema/session-inbox"
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 { SessionPending as CoreSessionPending } from "@opencode-ai/core/session/pending"
import { SessionInbox as CoreSessionInbox } from "@opencode-ai/core/session/inbox"
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 { SessionPending } from "@opencode-ai/schema/session-pending"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
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(CoreSessionPending.Message).toBe(SessionPending.Message)
expect(CoreSessionPending.User).toBe(SessionPending.User)
expect(CoreSessionPending.Synthetic).toBe(SessionPending.Synthetic)
expect(CoreSessionInbox.Item).toBe(SessionInbox.Item)
expect(CoreSessionInbox.User).toBe(SessionInbox.User)
expect(CoreSessionInbox.Synthetic).toBe(SessionInbox.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.pending.list({ sessionID: id })
const pendingAfterAdmit = yield* opencode.sessions.inbox.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.input.promoted" && event.data.inputID === wake.id),
Stream.filter((event) => event.type === "session.inbox.delivered" && event.data.inboxID === 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.pending.list({ sessionID: id })
const pendingAfterPromote = yield* opencode.sessions.inbox.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.pending.list({ sessionID: missingSessionID }).pipe(Effect.flip),
opencode.sessions.inbox.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.input.promoted")
expect(prompted.type).toBe("session.inbox.delivered")
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.input.promoted" }>>()
const prompted = yield* Deferred.make<Extract<OpenCodeEvent, { type: "session.inbox.delivered" }>>()
yield* opencode.events.subscribe().pipe(
Stream.runForEach((event) =>
event.type === "server.connected"
? connected.open
: event.type === "session.input.promoted" && event.data.sessionID === id
: event.type === "session.inbox.delivered" && 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.find", (ctx) =>
.handle("fs.get", (ctx) =>
response(
Effect.gen(function* () {
const fs = yield* FileSystem.Service
+30 -27
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.cancelPending>, conflict: string) =>
const pendingMutation = (effect: ReturnType<typeof session.cancelInbox>, conflict: string) =>
effect.pipe(
Effect.catchTag(
"Session.NotFoundError",
@@ -37,8 +37,8 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}),
),
Effect.catchTag(
"Session.PendingInputConflictError",
(error) => new ConflictError({ resource: error.inputID, message: `${conflict}: ${error.inputID}` }),
"Session.InboxConflictError",
(error) => new ConflictError({ resource: error.inboxID, message: `${conflict}: ${error.inboxID}` }),
),
Effect.as(HttpApiSchema.NoContent.make()),
)
@@ -282,6 +282,7 @@ 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) =>
@@ -488,24 +489,26 @@ 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 }).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
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,
}),
),
),
),
Effect.catchTag("Session.CompactionConflictError", (error) =>
Effect.fail(
new ConflictError({
message: `Compaction input ID conflicts with an existing durable record: ${error.inputID}`,
resource: error.inputID,
}),
),
),
),
}
}),
)
@@ -669,10 +672,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}),
)
.handle(
"session.pending.list",
"session.inbox.list",
Effect.fn(function* (ctx) {
return {
data: yield* session.pending(ctx.params.sessionID).pipe(
data: yield* session.inbox(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
@@ -686,28 +689,28 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}),
)
.handle(
"session.pending.cancel",
"session.inbox.cancel",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.cancelPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
session.cancelInbox({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID }),
"Pending input can no longer be cancelled",
)
}),
)
.handle(
"session.pending.steer",
"session.inbox.steer",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.steerPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
session.steerInbox({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID }),
"Pending input is no longer queued",
)
}),
)
.handle(
"session.pending.queue",
"session.inbox.queue",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.queuePending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
session.queueInbox({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID }),
"Pending input is no longer a steer",
)
}),
+3 -1
View File
@@ -21,7 +21,9 @@ 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 }))
return yield* response(
shell.create({ ...ctx.payload, cwd: ctx.payload.cwd || location.directory }).pipe(Effect.orDie),
)
}),
)
.handle(
-6
View File
@@ -40,11 +40,5 @@ 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,7 +120,6 @@ function makeRoutes<AuthError, AuthServices>(
name: options.app?.name ?? "opencode",
version: options.app?.version ?? "unknown",
},
stdio: options.mcp?.stdio,
}),
],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
+7 -24
View File
@@ -5,12 +5,13 @@ 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"
@@ -24,10 +25,11 @@ 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.
* - Shell, FileSystem, FileSystemSearch, and Pty fail with a clear defect until
* a remote sandbox backs them; Snapshot and Vcs degrade to no-op results.
* - 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.
* - Config is injected as a string (no filesystem); plugin discovery is
* precompiled-only and MCP is restricted to remote transports.
* precompiled-only, and stdio MCP reports the same no-plane failure as Shell.
*
* Bundle with the `workerd` condition, e.g.
* `bun build src/workerd.ts --conditions=workerd --target=node`
@@ -67,9 +69,6 @@ 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 },
}
}
@@ -77,9 +76,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],
@@ -102,22 +101,6 @@ 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),
inputIDs: data.session.input.list(sessionID),
inboxIDs: 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 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)
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)
: undefined
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const deleting = toDelete() === session.id
@@ -227,8 +227,9 @@ export function DialogSessionList() {
</box>
}
onMove={() => setToDelete(undefined)}
onSelect={(option) => {
route.navigate({ type: "session", sessionID: option.value })
onSelect={(option, activation) => {
if (activation.shift) route.navigate({ type: "session", sessionID: option.value })
else sessionTabs.replace(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.find({ query: base, limit: 20, location: requestLocation })
: client.api.file.get({ 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 { SessionPending } from "@opencode-ai/schema/session-pending"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import {
deduplicatePromptImages,
preserveMentionlessPromptAttachments,
@@ -1047,7 +1047,7 @@ export function Prompt(props: PromptProps) {
})
let submitting = false
async function submit(delivery: SessionPending.Delivery = "steer") {
async function submit(delivery: SessionInbox.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: SessionPending.Delivery) {
async function submitInner(delivery: SessionInbox.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.
+43 -45
View File
@@ -72,14 +72,13 @@ function fadeTitleColor(color: RGBA, background: RGBA, index: number, length: nu
return opacity === 0 ? color : tint(color, background, opacity)
}
function createMarquee(animations: () => boolean) {
export 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)
@@ -91,17 +90,15 @@ function createMarquee(animations: () => boolean) {
interval = setInterval(() => setOffset((value) => (value + 1) % cycleWidth), MARQUEE_INTERVAL)
}
const enter = (sessionID: string, title: string, width: number) => {
if (active() === sessionID && !returning) return
clear()
if (active() === sessionID) {
returning = false
return scroll()
if (!marqueeOverflows(title, width)) {
reset()
return
}
if (!marqueeOverflows(title, width)) return
if (active() === sessionID) return
clear()
cycleWidth = marqueeCycleWidth(title)
setActive(sessionID)
setOffset(0)
returning = false
leading.jump({ opacity: 0 })
delay = setTimeout(() => {
setOffset(1)
@@ -111,27 +108,10 @@ function createMarquee(animations: () => boolean) {
}
const leave = (sessionID: string) => {
if (active() !== sessionID) return
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)
reset()
}
const reset = () => {
clear()
returning = false
setActive(undefined)
setOffset(0)
leading.jump({ opacity: 0 })
@@ -141,7 +121,7 @@ function createMarquee(animations: () => boolean) {
return { offset, active, enter, leave, reset, leading: () => leading.value().opacity }
}
function createTabMarquee(animations: () => boolean) {
export function createTabMarquee(animations: () => boolean) {
const [hovered, setHovered] = createSignal<string>()
const marquee = createMarquee(animations)
let hoverClear: ReturnType<typeof setTimeout> | undefined
@@ -159,11 +139,21 @@ 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 }
return { ...marquee, hovered, enter, leave, leaveHovered, reset }
}
function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsController; onClose: () => void }) {
@@ -348,6 +338,7 @@ 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}>
@@ -356,6 +347,7 @@ 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()
@@ -363,18 +355,17 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
})
const numberWidth = () => 2
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
const titleWidth = () => Math.max(1, restingTitleWidth() - (hovered() === tab.sessionID ? 1 : 0))
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1)
const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
const title = () => tab.title ?? "Untitled session"
const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0
const scrolling = () => marquee.active() === tab.sessionID
const visibleTitle = createMemo(() =>
scrolling()
? marqueeText(title(), titleWidth(), marquee.offset())
: Locale.takeWidth(title(), titleWidth()),
)
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(
() => marqueeOverflows(title(), restingTitleWidth()) && titleWidth() > FADE_WIDTH,
)
const titleFades = createMemo(() => marqueeOverflows(title(), titleWidth()) && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
const value = session()
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
@@ -456,7 +447,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
position="relative"
flexDirection="column"
backgroundColor={background()}
onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())}
onMouseOver={() => marquee.enter(tab.sessionID, title(), hoveredTitleWidth())}
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) {
@@ -472,7 +463,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
event.stopPropagation()
return
}
marquee.enter(tab.sessionID, title(), restingTitleWidth())
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
setDragging(tab.sessionID)
}}
onMouseUp={(event) => {
@@ -584,8 +575,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
right={1}
zIndex={2}
width={1}
fg={theme.text.subdued}
fg={closeHovered() ? theme.text.default : 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
@@ -593,7 +586,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
tabs.close(tab.sessionID)
}}
>
{hovered() === tab.sessionID ? "×" : ""}
{hovered() === tab.sessionID ? "" : ""}
</text>
</box>
</box>
@@ -842,6 +835,7 @@ 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
@@ -897,8 +891,9 @@ 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 availableTitleWidth = () => Math.max(1, restingTitleWidth() - (hovered() === tab.sessionID ? 2 : 0))
const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 2)
const availableTitleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
const scrolling = () => marquee.active() === tab.sessionID
const visibleTitle = createMemo(() =>
scrolling()
? marqueeText(title(), availableTitleWidth(), marquee.offset())
@@ -906,7 +901,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
)
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(
() => marqueeOverflows(title(), restingTitleWidth()) && availableTitleWidth() > FADE_WIDTH,
() => marqueeOverflows(title(), availableTitleWidth()) && availableTitleWidth() > FADE_WIDTH,
)
const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default
@@ -929,6 +924,7 @@ 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
@@ -957,7 +953,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
position="relative"
flexDirection="row"
backgroundColor={background()}
onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())}
onMouseOver={() => marquee.enter(tab.sessionID, title(), hoveredTitleWidth())}
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) {
@@ -972,7 +968,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
event.stopPropagation()
return
}
marquee.enter(tab.sessionID, title(), restingTitleWidth())
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
setDragging(tab.sessionID)
}}
onMouseUp={(event) => {
@@ -1026,8 +1022,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
right={1}
zIndex={2}
width={1}
fg={closeColor()}
fg={closeHovered() ? theme.text.default : 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
@@ -1037,7 +1035,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", "Submit selected dialog item"),
"dialog.select.submit": keybind("return,shift+return,linefeed", "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", "Submit selected dialog item"),
"dialog.select.submit": keybind("return,shift+return,linefeed", "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"),
+45 -70
View File
@@ -25,7 +25,7 @@ import type {
SessionMessageAssistantText,
SessionMessageAssistantTool,
SessionInfo,
SessionPendingInfo,
SessionInboxInfo,
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 { SessionPending } from "@opencode-ai/schema/session-pending"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
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, SessionPendingInfo[]>
pending: Record<string, SessionInboxInfo[]>
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: SessionPendingInfo) {
function addPending(item: SessionInboxInfo) {
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, inputID?: string) {
if (!inputID) return
if (store.session.pending[sessionID]?.some((item) => item.id === inputID))
function removePending(sessionID: string, inboxID?: string) {
if (!inboxID) return
if (store.session.pending[sessionID]?.some((item) => item.id === inboxID))
setStore(
"session",
"pending",
sessionID,
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inboxID),
)
if (store.session.input[sessionID]?.includes(inputID))
if (store.session.input[sessionID]?.includes(inboxID))
setStore(
"session",
"input",
sessionID,
(store.session.input[sessionID] ?? []).filter((id) => id !== inputID),
(store.session.input[sessionID] ?? []).filter((id) => id !== inboxID),
)
}
@@ -198,10 +198,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
}
function updatePending(sessionID: string, inputID: string, delivery: SessionPending.Delivery) {
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inputID) ?? -1
function updatePending(sessionID: string, inboxID: string, delivery: SessionInbox.Delivery) {
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inboxID) ?? -1
const item = store.session.pending[sessionID]?.[index]
if (index < 0 || !item || item.type === "compaction" || item.delivery === delivery) return
if (index < 0 || !item || 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.input.promoted": {
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inputID) ?? false
removePending(event.data.sessionID, event.data.inputID)
case "session.inbox.delivered": {
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false
removePending(event.data.sessionID, event.data.inboxID)
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID)
const position = index.get(event.data.inboxID)
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.input.steered":
updatePending(event.data.sessionID, event.data.inputID, "steer")
case "session.inbox.delivery.changed":
updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery)
break
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))
case "session.inbox.cancelled": {
removePending(event.data.sessionID, event.data.inboxID)
if (messageIndex.get(event.data.sessionID)?.has(event.data.inboxID))
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID)
const position = index.get(event.data.inboxID)
if (position === undefined) return
draft.splice(position, 1)
index.delete(event.data.inputID)
index.delete(event.data.inboxID)
message.reindex(draft, index, position)
})
break
}
case "session.input.admitted":
case "session.inbox.enqueued": {
const item = event.data.item
addPending({
id: event.data.inputID,
id: event.data.inboxID,
sessionID: event.data.sessionID,
timeCreated: event.created,
...event.data.input,
...item,
})
if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID))
if (!store.session.input[event.data.sessionID]?.includes(event.data.inboxID))
setStore("session", "input", event.data.sessionID, [
...(store.session.input[event.data.sessionID] ?? []),
event.data.inputID,
event.data.inboxID,
])
if (item.type !== "user" && item.type !== "synthetic") break
message.update(event.data.sessionID, (draft, index) => {
message.append(
draft,
index,
event.data.input.type === "user"
item.type === "user"
? {
id: event.data.inputID,
id: event.data.inboxID,
type: "user",
...event.data.input.data,
...item.payload,
time: { created: event.created },
}
: {
id: event.data.inputID,
id: event.data.inboxID,
type: "synthetic",
...event.data.input.data,
...item.payload,
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,8 +584,6 @@ 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
@@ -615,7 +613,6 @@ 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 }
})
@@ -633,15 +630,11 @@ 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) => {
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({
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "text",
text: "",
})
@@ -661,9 +654,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
case "session.tool.input.started":
message.update(event.data.sessionID, (draft, index) => {
const assistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!assistant) return
assistant.content.push({
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "tool",
id: event.data.id,
name: event.data.name,
@@ -752,9 +743,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
case "session.reasoning.started":
message.update(event.data.sessionID, (draft, index) => {
const assistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!assistant) return
assistant.content.push({
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "reasoning",
text: "",
state: event.data.state,
@@ -792,16 +781,8 @@ 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":
removePending(event.data.sessionID, event.data.inputID)
if (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),
@@ -854,12 +835,6 @@ 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]
@@ -884,7 +859,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.compaction.failed":
removePending(event.data.sessionID, event.data.inputID)
if (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]
@@ -1021,8 +996,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
list(sessionID: string) {
return store.session.input[sessionID] ?? []
},
has(sessionID: string, inputID: string) {
return store.session.input[sessionID]?.includes(inputID) ?? false
has(sessionID: string, inboxID: string) {
return store.session.input[sessionID]?.includes(inboxID) ?? false
},
},
pending: {
@@ -1031,7 +1006,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
sync(sessionID: string) {
return sync.run(`session.pending:${sessionID}`, async () => {
const pending = await client.api.session.pending.list({ sessionID })
const pending = await client.api.session.inbox.list({ sessionID })
setStore("session", "pending", sessionID, reconcile(pending))
setStore(
"session",
@@ -35,6 +35,13 @@ 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