diff --git a/packages/app/e2e/regression/session-message-revert.spec.ts b/packages/app/e2e/regression/session-message-revert.spec.ts index a6f2910d0fde..243155c62d40 100644 --- a/packages/app/e2e/regression/session-message-revert.spec.ts +++ b/packages/app/e2e/regression/session-message-revert.spec.ts @@ -95,3 +95,44 @@ test("hides revert actions in a child session", async ({ page }) => { await message.hover() await expect(message.getByRole("button", { name: "Revert message" })).toHaveCount(0) }) + +test("keeps pending input when staging and replacing a revert", async ({ page }) => { + const changes: { inboxID: string; action: "cancel" | "steer" }[] = [] + const prompts: Record[] = [] + await mockOpenCodeServer(page, { + ...fixture, + sessions: [session], + inbox: [ + { + id: "msg_pending_before_boundary", + sessionID, + time: { created: 2 }, + type: "user", + delivery: "queue", + payload: { text: "Keep this queued input" }, + }, + ], + onInboxChange: (input) => changes.push({ inboxID: input.inboxID, action: input.action }), + onPrompt: (input) => prompts.push(input.body), + }) + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await expectSessionTitle(page, "Session message revert") + const pending = page.locator('[data-component="session-queue-row"]').filter({ hasText: "Keep this queued input" }) + await expect(pending).toBeVisible() + const message = page.locator('[data-message-id="msg_second"]') + await message.hover() + await message.getByRole("button", { name: "Revert message" }).click() + const prompt = page.getByRole("textbox", { name: "Prompt" }) + await expect(prompt).toHaveText("Second prompt") + await prompt.fill("Replacement prompt") + const admitted = page.waitForResponse( + (response) => + response.request().method() === "POST" && new URL(response.url()).pathname === `/api/session/${sessionID}/prompt`, + ) + await prompt.press("Enter") + expect((await admitted).ok()).toBe(true) + await expect(prompt).toHaveText("") + await expect(pending).toBeVisible() + expect(prompts).toMatchObject([{ text: "Replacement prompt" }]) + expect(changes).toEqual([]) +}) diff --git a/packages/app/src/composer/adapter.ts b/packages/app/src/composer/adapter.ts index 430580ebe2a0..2294198635fd 100644 --- a/packages/app/src/composer/adapter.ts +++ b/packages/app/src/composer/adapter.ts @@ -80,6 +80,12 @@ export type ComposerSession = { data: { location: { command: Pick } session: { + mutate: ( + sessionID: string, + operation: (mutation: { + prompt: (input: Parameters[0]) => Promise + }) => Promise, + ) => Promise prompt: (input: Parameters[0]) => Promise setStatus: Data["session"]["setStatus"] } diff --git a/packages/app/src/composer/submit.test.ts b/packages/app/src/composer/submit.test.ts index 355cbcd2a537..06d603e601d7 100644 --- a/packages/app/src/composer/submit.test.ts +++ b/packages/app/src/composer/submit.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { OpenCode } from "@opencode/client/promise" +import { createData } from "@opencode/client/solid" import type { ModelSelection } from "@/providers/models/selection" import type { SessionMessageUser } from "@opencode/client/promise" import { Skill } from "@opencode/schema/skill" @@ -6,6 +9,9 @@ import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessi import { createMemoryComposerState } from "./state" import { createComposerSubmit } from "./submit" +const disposers: Array<() => void> = [] +afterEach(() => disposers.splice(0).forEach((dispose) => dispose())) + const selectedModel = { id: "model-1", name: "Model 1", @@ -87,6 +93,33 @@ function session(input: { switchAgent?: ComposerSession["api"]["switchAgent"] switchModel?: ComposerSession["api"]["switchModel"] }): ComposerSession { + const api = OpenCode.make({ baseUrl: "http://opencode.test" }) + const data = createRoot((dispose) => { + disposers.push(dispose) + return createData({ + directory: "C:/repo", + event: { on: () => () => {}, listen: () => () => {} }, + api: () => ({ + ...api, + session: { + ...api.session, + prompt: async (value) => { + if (!value.id) throw new Error("Client admission must supply a message ID") + input.calls.push("prompt") + await input.prompt(value) + return { + id: value.id, + sessionID: value.sessionID, + time: { created: Date.now() }, + type: "user", + delivery: value.delivery ?? "steer", + payload: { text: value.text }, + } + }, + }, + }), + }) + }) return { id: "session-1", directory: "C:/repo", @@ -111,10 +144,8 @@ function session(input: { location: { command: { list: () => [] } }, session: { setStatus: (_sessionID, status) => input.statuses?.push(status), - prompt: async (value) => { - input.calls.push("prompt") - await input.prompt(value) - }, + mutate: data.session.mutate, + prompt: data.session.prompt, }, }, } @@ -352,6 +383,48 @@ describe("Composer submission", () => { expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }]) }) + test.each([false, true])("reserves prompt admission before later session mutations (image: %s)", async (image) => { + const state = createMemoryComposerState({ prompt: "replace history" }).capture() + if (image) + state.set([ + ...state.current(), + { + type: "image", + id: "attachment", + filename: "image.png", + mime: "image/png", + blob: { id: "attachment", url: "data:image/png;base64,YQ==" }, + }, + ]) + const calls: string[] = [] + const target = session({ + calls, + current: () => ({ agent: "build", model: { id: "model-1", providerID: "provider-1", variant: "balanced" } }), + prompt: async (value) => { + expect(value.files?.length ?? 0).toBe(image ? 1 : 0) + }, + }) + const adapter: ActiveComposerAdapter = { + kind: "active-session", + state, + ready: () => true, + controls, + working: () => false, + session: () => target, + interrupt: async () => undefined, + submitted() {}, + setEditor() {}, + } + + const submitted = submitInput(adapter).submit(new Event("submit")) + const redo = target.data.session.mutate(target.id, async () => { + calls.push("redo") + }) + await Promise.all([submitted, redo]) + + expect(calls).toEqual(["switch-model", "prompt", "redo"]) + }) + test("starts and promotes a New Session once before admitting its first prompt", async () => { const draft = createMemoryComposerState({ prompt: "first prompt" }).capture() const promoted = createMemoryComposerState({ prompt: "restored draft" }).capture() @@ -610,6 +683,57 @@ describe("Composer submission", () => { expect(history).toEqual([`add:${text}`, `remove:${text}`, `add:${text}`, `remove:${text}`]) }) + test("preserves browser comments through failed admission and same-ID retry", async () => { + const state = createMemoryComposerState({ prompt: "Update this button" }).capture() + const comment = { + type: "browser" as const, + tabID: "tab_00000000-0000-4000-8000-000000000000", + url: "http://localhost:5173/", + element: { selector: "#save", label: "button#save" }, + comment: "Rename this", + } + state.context.add({ ...comment, commentID: "browser-comment" }) + const requests: Parameters[0][] = [] + const failed = Promise.withResolvers() + const accepted = Promise.withResolvers() + const target = session({ + calls: [], + prompt: async (value) => { + requests.push(value) + if (requests.length <= 2) throw new Error("network unavailable") + accepted.resolve() + }, + }) + const adapter: ActiveComposerAdapter = { + kind: "active-session", + state, + ready: () => true, + controls, + working: () => false, + session: () => target, + interrupt: async () => undefined, + submitted() {}, + setEditor() {}, + } + const submission = submitInput(adapter, { missingSelection() {}, failed: () => failed.resolve() }) + + await submission.submit(new Event("submit")) + await failed.promise + expect(state.current()).toMatchObject([{ type: "text", content: "Update this button" }]) + expect(state.context.items()).toMatchObject([{ ...comment, commentID: "browser-comment" }]) + + await submission.submit(new Event("submit")) + await accepted.promise + expect(requests).toHaveLength(3) + expect(new Set(requests.map((request) => request.id)).size).toBe(1) + requests.forEach((request) => { + expect(request.metadata?.comments).toEqual([comment]) + expect(request.text).toContain('selector "#save"') + expect(request.text).toContain("Rename this") + }) + expect(state.context.items()).toEqual([]) + }) + test("forwards structured mentions to custom commands", async () => { const state = createMemoryComposerState().capture() state.set([ diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index 0add43eae676..4f42ca4a6cf8 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -88,22 +88,26 @@ export function createComposerSubmit(input: ComposerSubmitInput) { return } if (submitting.has(input.adapter.state)) return - // Images restored from a draft or history carry ids only; the optimistic message shows their URLs. - const value = { - ...read, - images: await Promise.all( - read.images.map(async (image) => ({ - ...image, - blob: { ...image.blob, url: (await resolveBlobUrl(image.blob)) ?? image.blob.url }, - })), - ), - } submitting.add(input.adapter.state) const comments = input.comments.capture() // Capture command intent before starting a session in a worktree whose catalog has not loaded. - const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined + const command = read.mode === "normal" ? findCommand(input.commands(), read.text) : undefined try { + // Only new sessions need image URLs for the handoff preview. Existing sessions + // load image bytes inside sendPrompt's reservation so a revert cannot overtake them. + const value = + input.adapter.kind === "new-session" && read.images.length + ? { + ...read, + images: await Promise.all( + read.images.map(async (image) => ({ + ...image, + blob: { ...image.blob, url: (await resolveBlobUrl(image.blob)) ?? image.blob.url }, + })), + ), + } + : read const started = input.adapter.kind === "active-session" ? { session: input.adapter.session(), cleanupReady: Promise.resolve() } @@ -120,6 +124,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) { const optimisticBusy = !input.adapter.working() if (optimisticBusy && input.adapter.kind === "new-session") session.data.session.setStatus(session.id, "running") + // Reserve before any await so a later revert or prompt cannot overtake this admission. const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => { if (optimisticBusy && input.adapter.kind === "active-session") session.data.session.setStatus(session.id, "running") @@ -401,44 +406,46 @@ async function applySelection( } } -async function sendPrompt( +function sendPrompt( session: ComposerSession, value: ComposerSubmission, track: ModelSelection["trackSessionCommit"] | undefined, onAdmit: () => void, ) { - const request = await buildSubmissionRequest(session, value) - // Switching agent or model reconfigures the session immediately, and with it - // the remainder of a running turn. A steer targets that turn, so its - // selection applies now; a queued follow-up must not reconfigure the turn it - // waits behind, so it runs with the session selection at delivery time (the - // intended selection stays recorded in its metadata). - if (value.delivery === "steer") { - await applySelection(session, value.selection, track) - } + return session.data.session.mutate(session.id, async (mutation) => { + const request = await buildSubmissionRequest(session, value) + // Switching agent or model reconfigures the session immediately, and with it + // the remainder of a running turn. A steer targets that turn, so its + // selection applies now; a queued follow-up must not reconfigure the turn it + // waits behind, so it runs with the session selection at delivery time (the + // intended selection stays recorded in its metadata). + if (value.delivery === "steer") { + await applySelection(session, value.selection, track) + } - const admission = { - id: value.id, - sessionID: session.id, - delivery: value.delivery, - text: request.text, - files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })), - agents: request.agents, - skills: request.skills, - metadata: { - displayText: request.displayText, - comments: request.comments, - attachments: request.attachments, - agent: value.selection.agent, - model: { - ...value.selection.model, - ...(value.selection.variant ? { variant: value.selection.variant } : {}), + const admission = { + id: value.id, + sessionID: session.id, + delivery: value.delivery, + text: request.text, + files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })), + agents: request.agents, + skills: request.skills, + metadata: { + displayText: request.displayText, + comments: request.comments, + attachments: request.attachments, + agent: value.selection.agent, + model: { + ...value.selection.model, + ...(value.selection.variant ? { variant: value.selection.variant } : {}), + }, }, - }, - } - const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission)) - onAdmit() - await sending + } + const sending = mutation.prompt(admission) + onAdmit() + await sending + }) } async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) { diff --git a/packages/app/src/new-session/composer-adapter.ts b/packages/app/src/new-session/composer-adapter.ts index 0961cbcae77d..4e0febb5ecb1 100644 --- a/packages/app/src/new-session/composer-adapter.ts +++ b/packages/app/src/new-session/composer-adapter.ts @@ -152,6 +152,16 @@ export function createNewSessionComposerAdapter(props: { location: data.location, session: { setStatus: data.session.setStatus, + mutate: (sessionID, operation) => + data.session.mutate(sessionID, (mutation) => + operation({ + prompt: (input) => + mutation.prompt({ + ...input, + gate: Promise.all([input.gate, afterCreation(async () => undefined)]), + }), + }), + ), prompt: (input) => data.session.prompt({ ...input, diff --git a/packages/app/src/session/composer/queue.ts b/packages/app/src/session/composer/queue.ts index f0255a7fa709..e5140c24604c 100644 --- a/packages/app/src/session/composer/queue.ts +++ b/packages/app/src/session/composer/queue.ts @@ -55,7 +55,6 @@ export function createSessionQueue(input: { delivery: ComposerDelivery }, ) => { - if (change.type === "reorder") return rewrite(change.inboxIDs) if (change.type === "undo") { await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.item.id }) const draft = input.draft.current() @@ -66,24 +65,31 @@ export function createSessionQueue(input: { input.restoreFocus(promptLength(prompt)) return } - const replacement = await editedPromptInput( - input.sessionID, - location().directory, - change.item, - change.prompt, - change.text, - ) - // Admit before cancelling so a failed replacement never discards the original. - const admitted = await data.session.prompt({ - ...replacement, - id: change.replacement, - delivery: change.delivery, - ...(change.delivery === "queue" ? { resume: false } : {}), + if (change.type === "reorder") + return data.session.mutate(input.sessionID, (reservation) => rewrite(change.inboxIDs, reservation.prompt)) + return data.session.mutate(input.sessionID, async (reservation) => { + const replacement = await editedPromptInput( + input.sessionID, + location().directory, + change.item, + change.prompt, + change.text, + ) + // Admit before cancelling so a failed replacement never discards the original. + const admitted = await reservation.prompt({ + ...replacement, + id: change.replacement, + delivery: change.delivery, + ...(change.delivery === "queue" ? { resume: false } : {}), + }) + await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.original }) + cancelEdit() + if (change.delivery === "queue") + await rewrite( + change.inboxIDs.map((id) => (id === change.original ? admitted.id : id)), + reservation.prompt, + ) }) - await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.original }) - cancelEdit() - if (change.delivery === "queue") - await rewrite(change.inboxIDs.map((id) => (id === change.original ? admitted.id : id))) }, onError: notify, onSettled: () => data.session.pending.sync(input.sessionID).catch(() => undefined), @@ -109,7 +115,7 @@ export function createSessionQueue(input: { }) onCleanup(() => cancelEdit()) - const rewrite = async (inboxIDs: string[]) => { + const rewrite = async (inboxIDs: string[], admit: typeof data.session.prompt) => { const pending = await server.api.session.inbox.list({ sessionID: input.sessionID }) if (pending.some((item) => item.delivery === "queue" && item.type !== "user")) throw new Error("Queued control items block reordering") @@ -121,7 +127,7 @@ export function createSessionQueue(input: { // Existing inbox APIs cannot reorder rows, so replace only the changed suffix. for (const item of ordered.slice(changed)) { - await data.session.prompt({ + await admit({ sessionID: input.sessionID, text: item.payload.text, files: item.payload.files?.map((file) => ({ diff --git a/packages/app/src/session/model.test.ts b/packages/app/src/session/model.test.ts index 0d97e0902d03..c99f5fc33ff5 100644 --- a/packages/app/src/session/model.test.ts +++ b/packages/app/src/session/model.test.ts @@ -39,12 +39,12 @@ describe("session controller invariants", () => { }) test("selects user history strictly before the revert boundary", () => { - const messages: SessionMessageInfo[] = [user("msg_a"), assistant, user("msg_b"), user("msg_c")] + const messages: SessionMessageInfo[] = [user("msg_fff"), assistant, user("msg_000"), user("msg_zzz")] const users = selectSessionUserMessages(messages) - expect(users.map((message) => message.id)).toEqual(["msg_a", "msg_b", "msg_c"]) - expect(selectVisibleSessionUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_a"]) - expect(selectVisibleSessionUserMessages(users.slice(2), "msg_b")).toEqual([]) + expect(users.map((message) => message.id)).toEqual(["msg_fff", "msg_000", "msg_zzz"]) + expect(selectVisibleSessionUserMessages(users, "msg_000").map((message) => message.id)).toEqual(["msg_fff"]) + expect(selectVisibleSessionUserMessages(users.slice(2), "msg_000")).toEqual([]) expect(selectVisibleSessionUserMessages(users)).toBe(users) }) diff --git a/packages/app/src/session/revert.test.ts b/packages/app/src/session/revert.test.ts new file mode 100644 index 000000000000..6be86255e0f3 --- /dev/null +++ b/packages/app/src/session/revert.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from "bun:test" +import type { SessionMessageUser } from "@opencode/client/promise" +import { loadRevertBoundary, loadUndoTarget } from "./session-domain" + +const user = (id: string): SessionMessageUser => ({ id, type: "user", text: id, time: { created: 1 } }) + +test("loads older pages until the revert boundary is available", async () => { + const messages = [user("msg_newest")] + const pages = [[user("msg_middle")], [user("msg_before"), user("msg_boundary")]] + let loads = 0 + + const result = await loadRevertBoundary({ + messageID: "msg_boundary", + messages: () => messages, + more: () => pages.length > 0, + loadMore: async () => { + messages.unshift(...(pages.shift() ?? [])) + loads += 1 + }, + }) + + expect(loads).toBe(2) + expect(result?.map((message) => message.id)).toEqual(["msg_before", "msg_boundary", "msg_middle", "msg_newest"]) +}) + +test("stops when the revert boundary is not available", async () => { + const messages = [user("msg_newest")] + + expect( + await loadRevertBoundary({ + messageID: "msg_boundary", + messages: () => messages, + more: () => false, + loadMore: async () => undefined, + }), + ).toBeUndefined() +}) + +test("loads older pages before selecting the next undo target", async () => { + const messages = [user("msg_newest")] + const pages = [[user("msg_previous"), user("msg_boundary")]] + + const result = await loadUndoTarget({ + messageID: "msg_boundary", + messages: () => messages, + more: () => pages.length > 0, + loadMore: async () => { + messages.unshift(...(pages.shift() ?? [])) + }, + }) + + expect(result).toEqual({ message: user("msg_previous"), previous: undefined }) +}) + +test("loads past a page-leading boundary to resolve the undo target and its predecessor", async () => { + const messages = [user("msg_boundary"), user("msg_newest")] + const pages = [[user("msg_previous"), user("msg_target")]] + + const result = await loadUndoTarget({ + messageID: "msg_boundary", + messages: () => messages, + more: () => pages.length > 0, + loadMore: async () => { + messages.unshift(...(pages.shift() ?? [])) + }, + }) + + expect(result).toEqual({ message: user("msg_target"), previous: user("msg_previous") }) +}) diff --git a/packages/app/src/session/revert.ts b/packages/app/src/session/revert.ts index 4bb7bc1add44..86b5376ef39a 100644 --- a/packages/app/src/session/revert.ts +++ b/packages/app/src/session/revert.ts @@ -8,6 +8,7 @@ import { commentContextItem } from "@/composer/comment-note" import { extractPromptComments, extractPromptFromMessage } from "@/composer/prompt" import { showToast } from "@/shell/notifications/toast" import type { SessionModel } from "./model" +import { loadRevertBoundary, loadUndoTarget } from "./session-domain" export function createSessionRevert(input: { session: SessionModel @@ -38,76 +39,85 @@ export function createSessionRevert(input: { target.context.replaceComments(extractPromptComments(message).map(commentContextItem)) } - const stage = async (message: SessionMessageUser, previous: SessionMessageUser | undefined) => { - const sessionID = input.session.identity.params.id - if (!sessionID) return + const stage = async (sessionID: string, message: SessionMessageUser, previous: SessionMessageUser | undefined) => { const owner = input.session.ownership.capture() const target = prompt.capture() if (data.session.status(sessionID) === "running") { await server.api.session.interrupt({ sessionID }).catch(() => undefined) } if (!(await request(() => server.api.session.revert.stage({ sessionID, messageID: message.id })))) return - // Reverting to a previous prompt discards the pending queue (and pending - // steers): they were written against the history being rewound. Cancel - // the authoritative inbox merged with the local snapshot, fire-and-forget - // so a slow request cannot delay restoring the composer. The cutoff keeps - // the asynchronous sweep away from prompts admitted after the revert; an - // old admission still in flight when the list is fetched can survive it, - // and fully closing that race needs a server-side revert-discards-inbox - // rule. - const cutoff = Date.now() - const local = data.session.pending - .list(sessionID) - .filter((item) => item.type === "user") - .map((item) => item.id) - void server.api.session.inbox - .list({ sessionID }) - .then((rows) => rows.filter((row) => row.type === "user" && row.time.created <= cutoff).map((row) => row.id)) - .catch(() => []) - .then((authoritative) => { - new Set([...local, ...authoritative]).forEach( - (inboxID) => void server.api.session.inbox.cancel({ sessionID, inboxID }).catch(() => undefined), - ) - }) restore(target, message) owner.run(() => input.setActiveMessage(previous)) } const to = async (messageID: string) => { + const sessionID = input.session.identity.params.id + if (!sessionID) return const messages = input.session.history.userMessages() const index = messages.findIndex((message) => message.id === messageID) const message = messages[index] if (!message) return - await stage(message, messages[index - 1]) + await data.session.mutate(sessionID, () => stage(sessionID, message, messages[index - 1])) } const undo = async () => { - const messages = input.session.history.userMessages() - const reverted = input.session.data.revertMessageID() - const boundary = reverted ? messages.findIndex((message) => message.id === reverted) : messages.length - if (boundary <= 0) return - const message = messages[boundary - 1] - if (message) await stage(message, messages[boundary - 2]) + const sessionID = input.session.identity.params.id + if (!sessionID) return + await data.session.mutate(sessionID, async () => { + const reverted = input.session.data.revertMessageID() + const messages = input.session.history.userMessages() + if (!reverted) { + const message = messages.at(-1) + if (message) await stage(sessionID, message, messages.at(-2)) + return + } + const target = await loadUndoTarget({ + messageID: reverted, + messages: input.session.history.userMessages, + more: () => data.session.message.more(sessionID), + loadMore: () => data.session.message.loadMore(sessionID), + }).catch((error) => { + showToast({ + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }) + return undefined + }) + if (target) await stage(sessionID, target.message, target.previous) + }) } const redo = async () => { const sessionID = input.session.identity.params.id const reverted = input.session.data.revertMessageID() if (!sessionID || !reverted) return - const messages = input.session.history.userMessages() - const boundary = messages.findIndex((message) => message.id === reverted) - if (boundary < 0) return - const next = messages[boundary + 1] - if (next) { - await stage(next, messages[boundary]) - return - } - const owner = input.session.ownership.capture() - const target = prompt.capture() - if (!(await request(() => server.api.session.revert.clear({ sessionID })))) return - target.reset() - target.context.replaceComments([]) - owner.run(() => input.setActiveMessage(messages.at(-1))) + await data.session.mutate(sessionID, async () => { + const messages = await loadRevertBoundary({ + messageID: reverted, + messages: input.session.history.userMessages, + more: () => data.session.message.more(sessionID), + loadMore: () => data.session.message.loadMore(sessionID), + }).catch((error) => { + showToast({ + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }) + return undefined + }) + if (!messages) return + const boundary = messages.findIndex((message) => message.id === reverted) + const next = messages[boundary + 1] + if (next) { + await stage(sessionID, next, messages[boundary]) + return + } + const owner = input.session.ownership.capture() + const target = prompt.capture() + if (!(await request(() => server.api.session.revert.clear({ sessionID })))) return + target.reset() + target.context.replaceComments([]) + owner.run(() => input.setActiveMessage(messages.at(-1))) + }) } return { to, undo, redo } diff --git a/packages/app/src/session/session-domain.ts b/packages/app/src/session/session-domain.ts index 0d36a8a5b2a3..2e73c58886fe 100644 --- a/packages/app/src/session/session-domain.ts +++ b/packages/app/src/session/session-domain.ts @@ -15,7 +15,37 @@ export function selectSessionUserMessages(messages: SessionMessageInfo[]) { export function selectVisibleSessionUserMessages(messages: SessionMessageUser[], revertMessageID?: string) { if (!revertMessageID) return messages - return messages.filter((message) => message.id < revertMessageID) + const boundary = messages.findIndex((message) => message.id === revertMessageID) + return boundary < 0 ? [] : messages.slice(0, boundary) +} + +export async function loadRevertBoundary(input: { + messageID: string + messages: () => SessionMessageUser[] + more: () => boolean + loadMore: () => Promise +}): Promise { + const messages = input.messages() + if (messages.some((message) => message.id === input.messageID)) return messages + if (!input.more()) return undefined + await input.loadMore() + return loadRevertBoundary(input) +} + +export async function loadUndoTarget( + input: Parameters[0], +): Promise<{ message: SessionMessageUser; previous?: SessionMessageUser } | undefined> { + const messages = input.messages() + const boundary = messages.findIndex((message) => message.id === input.messageID) + const more = input.more() + if (boundary >= 2 || (boundary === 1 && !more)) { + const message = messages[boundary - 1] + if (!message) return undefined + return { message, previous: boundary > 1 ? messages[boundary - 2] : undefined } + } + if (!more) return undefined + await input.loadMore() + return loadUndoTarget(input) } export function removedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) { diff --git a/packages/app/src/session/timeline/controller-projection.test.ts b/packages/app/src/session/timeline/controller-projection.test.ts index 8c2921a9ba96..85c70c82df4a 100644 --- a/packages/app/src/session/timeline/controller-projection.test.ts +++ b/packages/app/src/session/timeline/controller-projection.test.ts @@ -19,6 +19,11 @@ const messages = [ { id: "msg_4", type: "user", text: "reverted", time: { created: 4 } }, ] satisfies SessionMessageInfo[] +const nonChronological = messages.map((message, index) => ({ + ...message, + id: ["msg_fff", "msg_aaa", "msg_zzz", "msg_000"][index], +})) satisfies SessionMessageInfo[] + describe("visibleTimelineMessages", () => { const steer = { id: "msg_3", @@ -133,7 +138,7 @@ describe("visibleTimelineMessages", () => { test("hides queued inputs until delivery", () => { const pending = [ { - id: "msg_3", + id: "msg_zzz", sessionID: "ses_1", time: { created: 3 }, type: "user", @@ -142,16 +147,20 @@ describe("visibleTimelineMessages", () => { }, ] satisfies SessionInboxInfo[] - expect(visibleTimelineMessages(messages, pending).map((message) => message.id)).toEqual(["msg_1", "msg_2", "msg_4"]) + expect(visibleTimelineMessages(nonChronological, pending).map((message) => message.id)).toEqual([ + "msg_fff", + "msg_aaa", + "msg_000", + ]) }) test("hides the staged revert boundary and later messages", () => { - expect(visibleTimelineMessages(messages, [], "msg_4").map((message) => message.id)).toEqual([ - "msg_1", - "msg_2", - "msg_3", + expect(visibleTimelineMessages(nonChronological, [], "msg_000").map((message) => message.id)).toEqual([ + "msg_fff", + "msg_aaa", + "msg_zzz", ]) - expect(visibleTimelineMessages(messages, [], "msg_0")).toEqual([]) + expect(visibleTimelineMessages(nonChronological, [], "msg_missing")).toEqual([]) }) }) diff --git a/packages/app/src/session/timeline/controller-projection.ts b/packages/app/src/session/timeline/controller-projection.ts index 4a7527fa3dd3..6930a608a958 100644 --- a/packages/app/src/session/timeline/controller-projection.ts +++ b/packages/app/src/session/timeline/controller-projection.ts @@ -21,9 +21,8 @@ export function visibleTimelineMessages( pending.flatMap((item) => (item.type === "user" && item.delivery === "steer" ? [item.id] : [])), ) if (queued.size === 0 && steers.size === 0 && !revertMessageID) return messages - const visible = messages.filter( - (message) => !queued.has(message.id) && (!revertMessageID || message.id < revertMessageID), - ) + const boundary = revertMessageID ? messages.findIndex((message) => message.id === revertMessageID) : messages.length + const visible = (boundary < 0 ? [] : messages.slice(0, boundary)).filter((message) => !queued.has(message.id)) if (steers.size === 0) return visible // Pending steers do not own assistant work until they are delivered. return [ diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 4e66ee542754..3fc0b038e4c2 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -271,6 +271,7 @@ export type SessionPromptInput = { readonly files?: ReadonlyArray | undefined readonly agents?: ReadonlyArray | undefined readonly skills?: ReadonlyArray | undefined + readonly context?: PromptInput.Context | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined readonly delivery?: SessionInbox.Delivery | undefined readonly resume?: boolean | undefined diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index a8c4a68a6ccd..3479ed662d5f 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -493,6 +493,7 @@ const EndpointSessionPrompt = (raw: RawClient["server.session"]) => (input: Sess files: input["files"], agents: input["agents"], skills: input["skills"], + context: input["context"], metadata: input["metadata"], delivery: input["delivery"], resume: input["resume"], diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 27826bac171a..2e42df84ab1d 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -718,6 +718,7 @@ export function make(options: ClientOptions) { files: input["files"], agents: input["agents"], skills: input["skills"], + context: input["context"], metadata: input["metadata"], delivery: input["delivery"], resume: input["resume"], diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 5cf39951bb4f..f5374a5a746a 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -4117,6 +4117,12 @@ export type SessionPromptInput = { readonly id: string readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> + readonly context?: { + readonly id: string + readonly text: string + readonly description?: string + readonly metadata?: { readonly [x: string]: JsonValue } + } | null readonly metadata?: { readonly [x: string]: JsonValue } readonly delivery?: ("steer" | "queue") | null readonly resume?: boolean | null @@ -4138,6 +4144,12 @@ export type SessionPromptInput = { readonly id: string readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> + readonly context?: { + readonly id: string + readonly text: string + readonly description?: string + readonly metadata?: { readonly [x: string]: JsonValue } + } | null readonly metadata?: { readonly [x: string]: JsonValue } readonly delivery?: ("steer" | "queue") | null readonly resume?: boolean | null @@ -4159,6 +4171,12 @@ export type SessionPromptInput = { readonly id: string readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> + readonly context?: { + readonly id: string + readonly text: string + readonly description?: string + readonly metadata?: { readonly [x: string]: JsonValue } + } | null readonly metadata?: { readonly [x: string]: JsonValue } readonly delivery?: ("steer" | "queue") | null readonly resume?: boolean | null @@ -4180,6 +4198,12 @@ export type SessionPromptInput = { readonly id: string readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> + readonly context?: { + readonly id: string + readonly text: string + readonly description?: string + readonly metadata?: { readonly [x: string]: JsonValue } + } | null readonly metadata?: { readonly [x: string]: JsonValue } readonly delivery?: ("steer" | "queue") | null readonly resume?: boolean | null @@ -4201,10 +4225,43 @@ export type SessionPromptInput = { readonly id: string readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> + readonly context?: { + readonly id: string + readonly text: string + readonly description?: string + readonly metadata?: { readonly [x: string]: JsonValue } + } | null readonly metadata?: { readonly [x: string]: JsonValue } readonly delivery?: ("steer" | "queue") | null readonly resume?: boolean | null }["skills"] + readonly context?: { + readonly id?: string | null + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly skills?: ReadonlyArray<{ + readonly id: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly context?: { + readonly id: string + readonly text: string + readonly description?: string + readonly metadata?: { readonly [x: string]: JsonValue } + } | null + readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: ("steer" | "queue") | null + readonly resume?: boolean | null + }["context"] readonly metadata?: { readonly id?: string | null readonly text: string @@ -4222,6 +4279,12 @@ export type SessionPromptInput = { readonly id: string readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> + readonly context?: { + readonly id: string + readonly text: string + readonly description?: string + readonly metadata?: { readonly [x: string]: JsonValue } + } | null readonly metadata?: { readonly [x: string]: JsonValue } readonly delivery?: ("steer" | "queue") | null readonly resume?: boolean | null @@ -4243,6 +4306,12 @@ export type SessionPromptInput = { readonly id: string readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> + readonly context?: { + readonly id: string + readonly text: string + readonly description?: string + readonly metadata?: { readonly [x: string]: JsonValue } + } | null readonly metadata?: { readonly [x: string]: JsonValue } readonly delivery?: ("steer" | "queue") | null readonly resume?: boolean | null @@ -4264,6 +4333,12 @@ export type SessionPromptInput = { readonly id: string readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> + readonly context?: { + readonly id: string + readonly text: string + readonly description?: string + readonly metadata?: { readonly [x: string]: JsonValue } + } | null readonly metadata?: { readonly [x: string]: JsonValue } readonly delivery?: ("steer" | "queue") | null readonly resume?: boolean | null diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 4a1666f88b7b..7eae7053ed89 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -23,6 +23,8 @@ import type { Project, ProviderInfo, ReferenceInfo, + SessionRevertClearInput, + SessionRevertStageInput, SessionMessageInfo, SessionMessageAssistant, SessionMessageAssistantReasoning, @@ -73,6 +75,11 @@ export type CreateDataInput = { readonly onError?: (error: unknown) => void } +type PromptAdmissionInput = SessionPromptInput & { + gate?: Promise + prepare?: () => Promise | void> +} + const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") const messagePageLimit = 20 // Trailing window for event bursts that each ask for the same refetch. @@ -334,17 +341,16 @@ export function createData(config: CreateDataInput) { // to exist server-side instead of failing with "not found". const creating = new Map>() - // Per-session send chain: prompts and compactions must be admitted in - // submission order. Each waits for the previous POST to settle, so one - // failure does not block the next. - const sending = new Map>() + // Per-session mutation chain: HTTP gives no ordering across concurrent + // revert, prompt, and compaction requests, so preserve caller order. + const sessionOperations = new Map>() const messageLoads = new Map>() const compacting = new Map; request: Promise }>() onCleanup(() => compacting.clear()) // Register `promise` under `key` until it settles. A later registration // replaces an earlier one; settlement only clears its own entry. - function track(map: Map>, key: string, promise: Promise) { + function track(map: Map>, key: string, promise: Promise) { map.set(key, promise) const settle = () => { if (map.get(key) === promise) map.delete(key) @@ -352,21 +358,83 @@ export function createData(config: CreateDataInput) { void promise.then(settle, settle) } - // Capture creation before settlement clears its entry, so dependent RPCs still see a failed create. - function sendAdmission(sessionID: string, send: () => Promise, gate?: Promise) { - const created = creating.get(sessionID) - const previous = sending.get(sessionID) + function serializeSession(sessionID: string, operation: () => Promise) { + const previous = sessionOperations.get(sessionID) const request = Promise.resolve() - .then(() => Promise.all([gate, created, previous])) - .then(send) + .then(() => previous) + .then(operation) track( - sending, + sessionOperations, sessionID, - request.catch(() => undefined), + request.then( + () => undefined, + () => undefined, + ), ) return request } + // Capture creation before settlement clears its entry, so dependent RPCs still see a failed create. + function sendAdmission( + sessionID: string, + send: () => Promise, + gate?: Promise, + serialized = true, + ) { + const created = creating.get(sessionID) + const operation = () => + Promise.resolve() + .then(() => Promise.all([gate, created])) + .then(send) + return serialized ? serializeSession(sessionID, operation) : operation() + } + + function admitPrompt(input: PromptAdmissionInput, serialized: boolean) { + const { gate, prepare, ...request } = input + const id = request.id ?? SessionMessage.ID.create() + // A retry may reuse an ID that is already rendered — and possibly + // already durable. Admit optimistically only for new IDs so a failed + // retry cannot roll back acknowledged state. + const fresh = + !messageIndex.get(request.sessionID)?.has(id) && + !store.session.pending[request.sessionID]?.some((item) => item.id === id) + if (fresh) { + outbox.add(id) + admitLocal({ + id, + sessionID: request.sessionID, + time: { created: Date.now() }, + type: "user", + delivery: request.delivery ?? "steer", + // Files and skills stay off the optimistic row: their durable + // forms are server-loaded (content, mime, resolution), so they + // fill in when the echo upserts the row. + payload: { + text: request.text, + agents: request.agents?.map((agent) => ({ ...agent })), + metadata: request.metadata, + }, + }) + } + return sendAdmission( + request.sessionID, + async () => { + const prepared = await prepare?.() + const send = () => api().session.prompt({ ...request, ...prepared, id }) + // Admission is idempotent by message ID. Retry once before releasing + // the Session mutation reservation so an ambiguous response cannot + // let a later mutation overtake the admitted prompt. + return send().catch(send) + }, + gate, + serialized, + ).catch((error) => { + // Roll back only rows this call admitted and the echo has not + // acknowledged: anything else is server state. + if (fresh && outbox.delete(id)) retractLocal(request.sessionID, id) + throw error + }) + } // Upsert an admitted inbox item into pending and (for user and synthetic // items) the visible transcript. Used by the inbox.enqueued // handler and by optimistic admission; the upsert is what reconciles @@ -1048,7 +1116,8 @@ export function createData(config: CreateDataInput) { (item) => item.type === "assistant" && item.content.some( - (part) => part.type === "tool" && (part.state.status === "streaming" || part.state.status === "running"), + (part) => + part.type === "tool" && (part.state.status === "streaming" || part.state.status === "running"), ), ) ) { @@ -1077,18 +1146,31 @@ export function createData(config: CreateDataInput) { if (store.session.info[event.data.sessionID]) { setStore("session", "info", event.data.sessionID, "revert", undefined) } - // The projector also deletes inbox items enqueued at or after the boundary without a cancel event. - setStore( - "session", - "pending", - event.data.sessionID, - (store.session.pending[event.data.sessionID] ?? []).filter((item) => item.id < event.data.to), - ) + // Pending is resynced below: the projector already dropped items enqueued at or after + // the boundary, and IDs are not chronological. Keep unacknowledged submissions; + // their echo arrives with or after this event and must not disappear into the rewind. + const boundary = + store.session.message[event.data.sessionID]?.findIndex((item) => item.id === event.data.to) ?? -1 message.update(event.data.sessionID, (draft, index) => { - const position = draft.findIndex((item) => item.id >= event.data.to) - if (position === -1) return - for (const item of draft.splice(position)) index.delete(item.id) + const removed = draft.splice(boundary < 0 ? 0 : boundary) + const retained = removed.filter((item) => outbox.has(item.id)) + for (const item of removed) if (!outbox.has(item.id)) index.delete(item.id) + for (const item of retained) { + index.set(item.id, draft.length) + draft.push(item) + } }) + if (!store.session.info[event.data.sessionID]) return + result.session.pending.invalidate(event.data.sessionID) + if (boundary >= 0) { + void result.session.pending.sync(event.data.sessionID) + return + } + result.session.message.invalidate(event.data.sessionID) + void Promise.all([ + result.session.message.sync(event.data.sessionID), + result.session.pending.sync(event.data.sessionID), + ]) return case "session.compaction.delta": message.update(event.data.sessionID, (draft) => { @@ -1378,6 +1460,22 @@ export function createData(config: CreateDataInput) { status(sessionID: string) { return store.session.active[sessionID] ?? "idle" }, + mutate( + sessionID: string, + operation: (mutation: { + prompt: (input: PromptAdmissionInput) => ReturnType + }) => Promise, + ) { + return serializeSession(sessionID, () => operation({ prompt: (input) => admitPrompt(input, false) })) + }, + revert: { + stage(input: SessionRevertStageInput) { + return serializeSession(input.sessionID, () => api().session.revert.stage(input)) + }, + clear(input: SessionRevertClearInput) { + return serializeSession(input.sessionID, () => api().session.revert.clear(input)) + }, + }, // Inputs are the pending user and synthetic items; compactions are control items. input: { list(sessionID: string) { @@ -1541,46 +1639,8 @@ export function createData(config: CreateDataInput) { // upsert that same ID with the server's payload. Server admission is // idempotent per ID, so retrying with the identical payload cannot // double-admit. - prompt(input: SessionPromptInput & { gate?: Promise; prepare?: () => Promise }) { - const { gate, prepare, ...request } = input - const id = request.id ?? SessionMessage.ID.create() - // A retry may reuse an ID that is already rendered — and possibly - // already durable. Admit optimistically only for new IDs so a failed - // retry cannot roll back acknowledged state. - const fresh = - !messageIndex.get(request.sessionID)?.has(id) && - !store.session.pending[request.sessionID]?.some((item) => item.id === id) - if (fresh) { - outbox.add(id) - admitLocal({ - id, - sessionID: request.sessionID, - time: { created: Date.now() }, - type: "user", - delivery: request.delivery ?? "steer", - // Files and skills stay off the optimistic row: their durable - // forms are server-loaded (content, mime, resolution), so they - // fill in when the echo upserts the row. - payload: { - text: request.text, - agents: request.agents?.map((agent) => ({ ...agent })), - metadata: request.metadata, - }, - }) - } - return sendAdmission( - request.sessionID, - async () => { - await prepare?.() - return api().session.prompt({ ...request, id }) - }, - gate, - ).catch((error) => { - // Roll back only rows this call admitted and the server has not - // acknowledged: anything else is server state. - if (fresh && outbox.delete(id)) retractLocal(request.sessionID, id) - throw error - }) + prompt(input: PromptAdmissionInput) { + return admitPrompt(input, true) }, sync(sessionID: string, options?: { children?: boolean }) { return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => { diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index 07b8dfaef151..30ace99d50c7 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -2,7 +2,14 @@ import { expect, test } from "bun:test" import { getEventListeners } from "node:events" import { createRoot } from "solid-js" import { createData, type CreateDataInput } from "../src/solid" -import { OpenCode, type ModelInfo, type OpenCodeEvent, type Project, type SessionInfo } from "../src/promise" +import { + OpenCode, + type ModelInfo, + type OpenCodeEvent, + type Project, + type SessionInfo, + type SessionMessageUser, +} from "../src/promise" const session = (viewed: number): SessionInfo => ({ id: "ses_refresh", @@ -829,7 +836,270 @@ test("reports optimistic sessions as creating until the request settles", async } }) -test("loads bounded message pages", async () => { +test("serializes revert mutations before prompt admission", async () => { + const releaseStage = Promise.withResolvers() + const releaseClear = Promise.withResolvers() + const requests: string[] = [] + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + if (request.url.endsWith("/revert/stage")) { + requests.push("stage:start") + await releaseStage.promise + requests.push("stage:end") + return Response.json({ data: { messageID: "msg_boundary", files: [] } }) + } + if (request.method === "DELETE" && request.url.endsWith("/revert")) { + requests.push("clear:start") + await releaseClear.promise + requests.push("clear:end") + return new Response(null, { status: 204 }) + } + if (request.url.endsWith("/prompt")) { + requests.push("prompt") + return Response.json({ + data: { + id: "msg_replacement", + sessionID: "ses_refresh", + time: { created: 0 }, + type: "user", + payload: { text: "replacement" }, + delivery: "steer", + }, + }) + } + throw new Error(`Unexpected request: ${request.url}`) + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ + api: () => api, + directory: "/project", + event: { on: () => () => {}, listen: () => () => {} }, + }), + dispose, + })) + + try { + const stage = setup.data.session.revert.stage({ sessionID: "ses_refresh", messageID: "msg_boundary" }) + await wait(() => requests.length === 1) + const prompt = setup.data.session.prompt({ id: "msg_replacement", sessionID: "ses_refresh", text: "replacement" }) + await Bun.sleep(20) + expect(requests).toEqual(["stage:start"]) + + releaseStage.resolve() + await Promise.all([stage, prompt]) + expect(requests).toEqual(["stage:start", "stage:end", "prompt"]) + + const clear = setup.data.session.revert.clear({ sessionID: "ses_refresh" }) + await wait(() => requests.at(-1) === "clear:start") + const next = setup.data.session.prompt({ + id: "msg_after_clear", + sessionID: "ses_refresh", + text: "after clear", + }) + await Bun.sleep(20) + expect(requests).toEqual(["stage:start", "stage:end", "prompt", "clear:start"]) + + releaseClear.resolve() + await Promise.all([clear, next]) + expect(requests).toEqual(["stage:start", "stage:end", "prompt", "clear:start", "clear:end", "prompt"]) + } finally { + setup.dispose() + } +}) + +test("retries one prepared prompt before releasing its session mutation reservation", async () => { + const retryEntered = Promise.withResolvers() + const releaseRetry = Promise.withResolvers() + const requests: string[] = [] + const bodies: unknown[] = [] + let prepared = 0 + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + if (request.url.endsWith("/prompt")) { + const body = await request.json() + bodies.push(body) + requests.push(bodies.length === 1 ? "prompt:first" : "prompt:retry") + if (bodies.length === 1) throw new Error("response lost") + retryEntered.resolve() + await releaseRetry.promise + return Response.json({ + data: { + id: "msg_retry", + sessionID: "ses_refresh", + time: { created: 0 }, + type: "user", + payload: { text: "retry" }, + delivery: "steer", + }, + }) + } + if (request.url.endsWith("/revert/stage")) { + requests.push("stage") + return Response.json({ data: { messageID: "msg_boundary", files: [] } }) + } + throw new Error(`Unexpected request: ${request.url}`) + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ + api: () => api, + directory: "/project", + event: { on: () => () => {}, listen: () => () => {} }, + }), + dispose, + })) + + try { + const prompt = setup.data.session.prompt({ + id: "msg_retry", + sessionID: "ses_refresh", + text: "retry", + prepare: async () => { + prepared++ + return { context: { id: "msg_context", text: "context" } } + }, + }) + await retryEntered.promise + const stage = setup.data.session.revert.stage({ sessionID: "ses_refresh", messageID: "msg_boundary" }) + await Bun.sleep(20) + expect(requests).toEqual(["prompt:first", "prompt:retry"]) + + releaseRetry.resolve() + await Promise.all([prompt, stage]) + expect(requests).toEqual(["prompt:first", "prompt:retry", "stage"]) + expect(prepared).toBe(1) + expect(bodies).toEqual([ + { id: "msg_retry", text: "retry", context: { id: "msg_context", text: "context" } }, + { id: "msg_retry", text: "retry", context: { id: "msg_context", text: "context" } }, + ]) + } finally { + releaseRetry.resolve() + setup.dispose() + } +}) + +test("holds one session mutation reservation across multiple prompt admissions", async () => { + const release = Promise.withResolvers() + const requests: string[] = [] + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + if (request.url.endsWith("/prompt")) { + const body: unknown = await request.json() + if ( + typeof body !== "object" || + body === null || + !("id" in body) || + typeof body.id !== "string" || + !("text" in body) || + typeof body.text !== "string" + ) + throw new Error("Invalid prompt request") + requests.push(body.text) + return Response.json({ + data: { + id: body.id, + sessionID: "ses_refresh", + time: { created: 0 }, + type: "user", + payload: { text: body.text }, + delivery: "queue", + }, + }) + } + if (request.url.endsWith("/revert/stage")) { + requests.push("stage") + return Response.json({ data: { messageID: "msg_boundary", files: [] } }) + } + throw new Error(`Unexpected request: ${request.url}`) + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ + api: () => api, + directory: "/project", + event: { on: () => () => {}, listen: () => () => {} }, + }), + dispose, + })) + + try { + const rewrite = setup.data.session.mutate("ses_refresh", async (mutation) => { + await mutation.prompt({ id: "msg_first", sessionID: "ses_refresh", text: "first", delivery: "queue" }) + requests.push("gap") + await release.promise + await mutation.prompt({ id: "msg_second", sessionID: "ses_refresh", text: "second", delivery: "queue" }) + }) + await wait(() => requests.at(-1) === "gap") + const stage = setup.data.session.revert.stage({ sessionID: "ses_refresh", messageID: "msg_boundary" }) + await Bun.sleep(20) + expect(requests).toEqual(["first", "gap"]) + + release.resolve() + await Promise.all([rewrite, stage]) + expect(requests).toEqual(["first", "gap", "second", "stage"]) + } finally { + setup.dispose() + } +}) + +test("continues session mutations after a revert request fails", async () => { + const requests: string[] = [] + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + if (request.method === "DELETE" && request.url.endsWith("/revert")) { + requests.push("clear") + return Response.json({ message: "failed" }, { status: 500 }) + } + if (request.url.endsWith("/prompt")) { + requests.push("prompt") + return Response.json({ + data: { + id: "msg_after_failure", + sessionID: "ses_refresh", + time: { created: 0 }, + type: "user", + payload: { text: "after failure" }, + delivery: "steer", + }, + }) + } + throw new Error(`Unexpected request: ${request.url}`) + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ + api: () => api, + directory: "/project", + event: { on: () => () => {}, listen: () => () => {} }, + }), + dispose, + })) + + try { + const clear = setup.data.session.revert.clear({ sessionID: "ses_refresh" }).catch(() => undefined) + const prompt = setup.data.session.prompt({ + id: "msg_after_failure", + sessionID: "ses_refresh", + text: "after failure", + }) + await Promise.all([clear, prompt]) + expect(requests).toEqual(["clear", "prompt"]) + } finally { + setup.dispose() + } +}) + +test("loads bounded message pages and joins an active page request", async () => { + const release = Promise.withResolvers() const requests: URL[] = [] const api = OpenCode.make({ baseUrl: "http://opencode.local", @@ -837,6 +1107,7 @@ test("loads bounded message pages", async () => { const request = input instanceof Request ? input : new Request(input, init) const url = new URL(request.url) requests.push(url) + if (requests.length === 2) await release.promise return Response.json({ data: [], cursor: requests.length === 1 ? { next: "next" } : {} }) }, }) @@ -851,9 +1122,16 @@ test("loads bounded message pages", async () => { try { await setup.data.session.message.sync("ses_refresh") - await setup.data.session.message.loadMore("ses_refresh") + const first = setup.data.session.message.loadMore("ses_refresh") + await wait(() => requests.length === 2) + let joined = false + const second = setup.data.session.message.loadMore("ses_refresh").then(() => (joined = true)) + await Bun.sleep(20) expect(requests).toHaveLength(2) + expect(joined).toBe(false) + release.resolve() + await Promise.all([first, second]) expect(Object.fromEntries(requests[0].searchParams)).toEqual({ limit: "20", order: "desc" }) expect(Object.fromEntries(requests[1].searchParams)).toEqual({ cursor: "next", limit: "20" }) } finally { @@ -1117,6 +1395,215 @@ function activityFixture(read: () => Response | Promise) { })) } +test("reloads messages when a committed revert boundary is outside the loaded page", async () => { + const listeners = new Set[0]>() + const messages = (id: string): SessionMessageUser => ({ + id, + type: "user", + text: id, + time: { created: 0 }, + }) + let messageRequests = 0 + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + const url = new URL(request.url) + if (url.pathname === "/api/session/ses_refresh/inbox") return Response.json({ data: [] }) + if (url.pathname !== "/api/session/ses_refresh/message") throw new Error(`Unexpected request: ${request.url}`) + messageRequests++ + return Response.json({ + data: messageRequests === 1 ? [messages("msg_newer")] : [messages("msg_survivor")], + cursor: {}, + }) + }, + }) + const event: CreateDataInput["event"] = { + on: () => () => {}, + listen(handler) { + listeners.add(handler) + return () => listeners.delete(handler) + }, + } + const setup = createRoot((dispose) => ({ + data: createData({ api: () => api, directory: "/project", event, connection: { status: () => "connected" } }), + dispose, + })) + + try { + setup.data.session.remember(session(0)) + await setup.data.session.message.sync("ses_refresh") + const committed: OpenCodeEvent = { + id: "evt_revert_committed", + created: 2, + type: "session.revert.committed", + durable: { aggregateID: "ses_refresh", seq: 1, version: 1 }, + data: { sessionID: "ses_refresh", to: "msg_boundary" }, + } + listeners.forEach((listener) => listener({ name: committed.type, details: committed })) + + await wait(() => setup.data.session.message.get("ses_refresh", "msg_survivor") !== undefined) + expect(messageRequests).toBe(2) + expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(["msg_survivor"]) + } finally { + setup.dispose() + } +}) + +test("restores surviving pending work after a committed revert", async () => { + const listeners = new Set[0]>() + const queued = { + id: "msg_queued", + sessionID: "ses_refresh", + time: { created: 1 }, + type: "user" as const, + payload: { text: "Queued first" }, + delivery: "queue" as const, + } + let pendingRequests = 0 + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + const url = new URL(request.url) + if (url.pathname === "/api/session/ses_refresh/inbox") { + pendingRequests++ + return Response.json({ data: [queued] }) + } + if (url.pathname === "/api/session/ses_refresh/message") + return Response.json({ + data: [{ id: "msg_boundary", type: "user", text: "Boundary", time: { created: 2 } }], + cursor: {}, + }) + throw new Error(`Unexpected request: ${request.url}`) + }, + }) + const event: CreateDataInput["event"] = { + on: () => () => {}, + listen(handler) { + listeners.add(handler) + return () => listeners.delete(handler) + }, + } + const setup = createRoot((dispose) => ({ + data: createData({ api: () => api, directory: "/project", event, connection: { status: () => "connected" } }), + dispose, + })) + + try { + setup.data.session.remember(session(0)) + await setup.data.session.message.sync("ses_refresh") + const enqueued: OpenCodeEvent = { + id: "evt_inbox_enqueued", + created: 1, + type: "session.inbox.enqueued", + durable: { aggregateID: "ses_refresh", seq: 1, version: 1 }, + data: { + sessionID: "ses_refresh", + inboxID: queued.id, + item: { type: "user", payload: queued.payload, delivery: queued.delivery }, + }, + } + const committed: OpenCodeEvent = { + id: "evt_revert_committed", + created: 3, + type: "session.revert.committed", + durable: { aggregateID: "ses_refresh", seq: 3, version: 1 }, + data: { sessionID: "ses_refresh", to: "msg_boundary" }, + } + listeners.forEach((listener) => listener({ name: enqueued.type, details: enqueued })) + listeners.forEach((listener) => listener({ name: committed.type, details: committed })) + + await wait(() => setup.data.session.pending.list("ses_refresh").some((item) => item.id === queued.id)) + expect(pendingRequests).toBe(1) + expect(setup.data.session.input.list("ses_refresh")).toEqual([queued.id]) + } finally { + setup.dispose() + } +}) + +test("keeps an unacknowledged replacement when a committed revert rewinds later history", async () => { + const listeners = new Set[0]>() + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + const url = new URL(request.url) + if (url.pathname === "/api/session/ses_refresh/message") { + return Response.json({ + data: [ + { id: "msg_mmm", type: "user", text: "Later", time: { created: 3 } }, + { id: "msg_boundary", type: "user", text: "Boundary", time: { created: 2 } }, + { id: "msg_aaa", type: "user", text: "Earlier", time: { created: 1 } }, + ], + cursor: {}, + }) + } + if (url.pathname === "/api/session/ses_refresh/inbox") return Response.json({ data: [] }) + if (url.pathname === "/api/session/ses_refresh/prompt") { + return Response.json({ + data: { + id: "msg_replacement", + sessionID: "ses_refresh", + time: { created: 4 }, + type: "user", + payload: { text: "Replacement" }, + delivery: "steer", + }, + }) + } + throw new Error(`Unexpected request: ${request.url}`) + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ + api: () => api, + directory: "/project", + event: { + on: () => () => {}, + listen(handler) { + listeners.add(handler) + return () => listeners.delete(handler) + }, + }, + connection: { status: () => "connected" }, + }), + dispose, + })) + + try { + setup.data.session.remember(session(0)) + await setup.data.session.message.sync("ses_refresh") + const admitted = setup.data.session.prompt({ + id: "msg_replacement", + sessionID: "ses_refresh", + text: "Replacement", + }) + expect(setup.data.session.message.list("ses_refresh").map((item) => item.id)).toContain("msg_replacement") + + listeners.forEach((listener) => + listener({ + name: "session.revert.committed", + details: { + id: "evt_revert_committed", + created: 5, + type: "session.revert.committed", + durable: { aggregateID: "ses_refresh", seq: 5, version: 1 }, + data: { sessionID: "ses_refresh", to: "msg_boundary" }, + }, + }), + ) + + expect(setup.data.session.message.list("ses_refresh").map((item) => item.id)).toEqual([ + "msg_aaa", + "msg_replacement", + ]) + await admitted + } finally { + setup.dispose() + } +}) + async function wait(check: () => boolean) { const started = Date.now() while (!check()) { diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 8ade6826585f..f65c94f316be 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -11,6 +11,8 @@ import { KV } from "./kv.js" import { PluginHost } from "./plugin/host.js" import { type Failure, type Generation, Service } from "./plugin/service.js" import { State } from "./state.js" +import { Location } from "./location.js" +import { PluginActivation } from "@opencode/plugin/effect/activation" export { awaitActivation, type Generation, type Interface, Service } from "./plugin/service.js" @@ -19,6 +21,7 @@ const layer = Layer.effect( Effect.gen(function* () { const bus = yield* Bus.Service const kv = yield* KV.Service + const location = yield* Location.Service const scope = yield* Scope.make() // One slot per requested definition in activation order, including ones whose setup failed, so // the prefix diff below stays index-aligned and a failed revision is not retried until it changes. @@ -47,6 +50,13 @@ const layer = Layer.effect( const load = Effect.fnUntraced(function* (plugin: Generation) { const activation: Activation = { plugin, scope: yield* Scope.fork(scope) } const inherit = yield* State.inherit() + const capability = { + active: true, + fiberID: yield* Effect.fiberId, + token: {}, + directory: location.directory, + workspaceID: location.workspaceID, + } const grouped = State.group((failure, refresh) => { activation.failure = { error: `Plugin disabled after ${failure.state}.transform failed. Check server logs for details.`, @@ -70,9 +80,11 @@ const layer = Layer.effect( Context.make(Scope.Scope, activation.scope).pipe( Context.add(Logger.CurrentLoggers, Context.get(context, Logger.CurrentLoggers)), Context.add(References.MinimumLogLevel, Context.get(context, References.MinimumLogLevel)), + Context.add(PluginActivation.Current, capability), ), ), Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }), + Effect.ensuring(Effect.sync(() => (capability.active = false))), Effect.onExit((exit) => Exit.isFailure(exit) && !activation.failure ? Scope.close(activation.scope, exit) : Effect.void, ), diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 253661d087ea..bfb82b8ebe8c 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -29,6 +29,7 @@ import { AttachmentError, BusyError, CompactionConflictError, + ContextDeliveryError, ForkEmptyError, InboxConflictError, MessageDecodeError, @@ -101,6 +102,7 @@ export { AttachmentError, BusyError, CompactionConflictError, + ContextDeliveryError, InboxConflictError, MessageDecodeError, MessageNotFoundError, diff --git a/packages/core/src/session/error.ts b/packages/core/src/session/error.ts index 60d6bada1b3e..6eee13d294ae 100644 --- a/packages/core/src/session/error.ts +++ b/packages/core/src/session/error.ts @@ -68,6 +68,10 @@ export class AttachmentError extends Schema.TaggedError()("Sess message: Schema.String, }) {} +export class ContextDeliveryError extends Schema.TaggedError()("Session.ContextDeliveryError", { + sessionID: SessionSchema.ID, +}) {} + export class CompactionConflictError extends Schema.TaggedError()( "Session.CompactionConflictError", { diff --git a/packages/core/src/session/execution.ts b/packages/core/src/session/execution.ts index eeed241ae14e..e66c8b3f121e 100644 --- a/packages/core/src/session/execution.ts +++ b/packages/core/src/session/execution.ts @@ -90,8 +90,11 @@ export const layer = Layer.effect( continuation?: SessionRunner.Continuation, promotable: SessionInbox.Promotable = "input", ): Effect.fn.Return { - const session = yield* store.get(sessionID) + // Staging checks process-local ownership under this same lock. A drain is + // already owned here, so staging either finishes before this read or fails busy. + const session = yield* SessionInbox.serialized(sessionID, store.get(sessionID)) if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) + if (session.revert) return const result = yield* SessionRunner.Service.use((runner) => runner.drain({ sessionID, force, continuation, promotable }), ).pipe( diff --git a/packages/core/src/session/prompt.ts b/packages/core/src/session/prompt.ts index 80878f354dd6..4f6ab2c85de5 100644 --- a/packages/core/src/session/prompt.ts +++ b/packages/core/src/session/prompt.ts @@ -22,6 +22,7 @@ export type Input = { files?: PromptInput.Prompt["files"] agents?: PromptInput.Prompt["agents"] skills?: PromptInput.Prompt["skills"] + context?: PromptInput.Context metadata?: Record delivery?: SessionInbox.Delivery } diff --git a/packages/core/src/session/session.ts b/packages/core/src/session/session.ts index 0738f438063f..a3be5d016e91 100644 --- a/packages/core/src/session/session.ts +++ b/packages/core/src/session/session.ts @@ -1,19 +1,23 @@ export * as Session from "./session.js" -import { DateTime, Effect, Fiber, Scope } from "effect" +import { DateTime, Deferred, Effect, Fiber, Scope } from "effect" import type { Agent } from "@opencode/schema/agent" +import type { Location } from "@opencode/schema/location" import type { Model } from "@opencode/schema/model" import type { Permission } from "@opencode/schema/permission" import { Event } from "@opencode/schema/event" import { FSUtil } from "@opencode/util/fs-util" +import { PluginActivation } from "@opencode/plugin/effect/activation" import { Bus } from "../bus.js" import { Database } from "../database/database.js" import { Instance } from "../instance/service.js" +import { Plugin } from "../plugin/service.js" import { ShellResult } from "../shell/result.js" import type { Skill } from "../skill.js" import { BusyError, CompactionConflictError, + ContextDeliveryError, InboxConflictError, MessageNotFoundError, NotFoundError, @@ -49,6 +53,9 @@ export const make = Effect.fn("Session.make")(function* () { const admission = yield* SessionInbox.Service const fs = yield* FSUtil.Service const scope = yield* Scope.Scope + const operationTails = new Map }>() + const pluginWaiters = new Map>() + const promptPreparations = new Map>() const get = Effect.fn("Session.get")(function* (sessionID: SessionSchema.ID) { const session = yield* store.get(sessionID) @@ -92,6 +99,7 @@ export const make = Effect.fn("Session.make")(function* () { input: { agent: Agent.ID }, ) { const session = yield* get(sessionID) + if (session.agent === input.agent) return yield* bus.publish(SessionEvent.AgentSelected, { sessionID, agent: input.agent, previous: session.agent }) }) const switchModel = Effect.fn("Session.switchModel")(function* ( @@ -143,38 +151,267 @@ export const make = Effect.fn("Session.make")(function* () { (sessionID: SessionSchema.ID, inboxID: SessionMessage.ID) => mutatePending(sessionID, inboxID, admission.queue), Effect.uninterruptible, ) + const withSessionReservation = ( + sessionID: SessionSchema.ID, + operation: (wait: Effect.Effect) => Effect.Effect, + ) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = operationTails.get(sessionID) + const done = Deferred.makeUnsafe() + const current = { + wait: (previous?.wait ?? Effect.void).pipe(Effect.andThen(Deferred.await(done))), + } + operationTails.set(sessionID, current) + return { previous: previous?.wait, done, current } + }), + (reservation) => operation(reservation.previous ?? Effect.void), + (reservation) => + Effect.gen(function* () { + yield* Deferred.succeed(reservation.done, undefined) + if (operationTails.get(sessionID) !== reservation.current) return + yield* reservation.current.wait.pipe( + Effect.andThen( + Effect.sync(() => { + if (operationTails.get(sessionID) === reservation.current) operationTails.delete(sessionID) + }), + ), + Effect.forkIn(scope), + ) + }), + ) + const withSessionOperation = (sessionID: SessionSchema.ID, effect: Effect.Effect) => + withSessionReservation(sessionID, (wait) => wait.pipe(Effect.andThen(effect))) + const withPluginWaiter = ( + sessionID: SessionSchema.ID, + location: Location.Ref, + effect: Effect.Effect, + ) => + Effect.acquireUseRelease( + Effect.sync(() => { + const waiting = pluginWaiters.get(sessionID) ?? new Set() + waiting.add(location) + pluginWaiters.set(sessionID, waiting) + }), + () => effect, + () => + Effect.sync(() => { + const waiting = pluginWaiters.get(sessionID) + waiting?.delete(location) + if (waiting?.size === 0) pluginWaiters.delete(sessionID) + }), + ) + const withRevertPlugins = ( + sessionID: SessionSchema.ID, + operation: (session: SessionSchema.Info) => Effect.Effect, + ) => + Effect.gen(function* () { + const session = yield* get(sessionID) + return yield* withPluginWaiter( + sessionID, + session.location, + Effect.gen(function* () { + yield* Plugin.awaitActivation + return yield* SessionInbox.serialized( + sessionID, + Effect.gen(function* () { + const latest = yield* get(sessionID) + if ( + latest.location.directory !== session.location.directory || + latest.location.workspaceID !== session.location.workspaceID + ) + return { _tag: "retry" as const } + return { _tag: "done" as const, value: yield* operation(latest) } + }), + ) + }).pipe(instances.provide(session)), + ) + }).pipe( + Effect.repeat({ while: (result): result is { readonly _tag: "retry" } => result._tag === "retry" }), + Effect.map((result) => result.value), + ) const prompt = Effect.fn("Session.prompt")((sessionID: SessionSchema.ID, input: PromptRequest) => - Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const session = yield* get(sessionID) - const messageID = input.id ?? SessionMessage.ID.create() - const admitted = yield* Effect.gen(function* () { - const existing = yield* admission.reconcile({ - id: messageID, - sessionID: session.id, - type: "user", - delivery: input.delivery ?? "steer", - }) - if (existing) return existing - const item = yield* restore( - SessionPrompt.prepare({ session, messageID, input }).pipe( - Effect.provideService(Instance.Service, instances), - Effect.provideService(FSUtil.Service, fs), + withSessionReservation(sessionID, (wait) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const messageID = input.id ?? SessionMessage.ID.create() + const checked = yield* SessionInbox.serialized( + sessionID, + Effect.gen(function* () { + const session = yield* get(sessionID) + const existing = yield* admission.reconcile({ + id: messageID, + sessionID, + type: "user", + delivery: input.delivery ?? "steer", + }) + if (!existing) return { session, admitted: undefined } + if (!session.revert) return { session, admitted: existing } + const retained = yield* store.survivesRevert({ + id: messageID, + sessionID, + boundaryID: session.revert.messageID, + }) + if (!retained) return { session, admitted: undefined } + return { session, admitted: existing } + }), + ) + if (checked.admitted) { + yield* wait + const admitted = yield* SessionInbox.serialized( + sessionID, + Effect.gen(function* () { + const session = yield* get(sessionID) + const existing = yield* admission.reconcile({ + id: messageID, + sessionID, + type: "user", + delivery: input.delivery ?? "steer", + }) + if (!existing) return + if (session.revert) { + const retained = yield* store.survivesRevert({ + id: messageID, + sessionID, + boundaryID: session.revert.messageID, + }) + if (!retained) return + yield* SessionRevert.commit(bus, session) + } + if (input.resume !== false) yield* execution.wake(sessionID) + return existing + }), + ) + if (admitted) return admitted + } + const prepared = yield* restore( + withPluginWaiter( + sessionID, + checked.session.location, + Effect.gen(function* () { + const reservation: PluginActivation.PromptPreparation = { + active: true, + fiberID: yield* Effect.fiberId, + token: {}, + sessionID, + wait, + } + return yield* Effect.acquireUseRelease( + Effect.sync(() => { + const preparations = promptPreparations.get(sessionID) ?? new Set() + preparations.add(reservation) + promptPreparations.set(sessionID, preparations) + }), + () => + // prepare awaits plugin activation under its own Instance span. + SessionPrompt.prepare({ session: checked.session, messageID, input }).pipe( + Effect.provideService(Instance.Service, instances), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(PluginActivation.PromptPreparationCurrent, reservation), + ), + () => + Effect.sync(() => { + reservation.active = false + const preparations = promptPreparations.get(sessionID) + preparations?.delete(reservation) + if (preparations?.size === 0) promptPreparations.delete(sessionID) + }), + ) + }), ), ) - // Commit a staged revert only after preparation succeeds, before admitting new work. - if (session.revert) yield* SessionRevert.commit(bus, session) - return yield* admission.admit({ - id: messageID, - sessionID: session.id, - item, - }) + if (input.context && prepared.delivery === "queue") return yield* new ContextDeliveryError({ sessionID }) + const context = input.context + ? { + id: input.context.id, + item: SessionInbox.Item.make({ + type: "synthetic", + payload: SessionInbox.SyntheticPayload.make({ + text: input.context.text, + description: input.context.description, + metadata: input.context.metadata, + }), + delivery: "steer", + }), + } + : undefined + yield* wait + return yield* SessionInbox.serialized( + sessionID, + Effect.gen(function* () { + const session = yield* get(sessionID) + const existing = yield* admission.reconcile({ + id: messageID, + sessionID, + type: "user", + delivery: prepared.delivery, + }) + if (existing) { + if (!session.revert) { + if (input.resume !== false) yield* execution.wake(sessionID) + return existing + } + const retained = yield* store.survivesRevert({ + id: messageID, + sessionID, + boundaryID: session.revert.messageID, + }) + if (retained) { + yield* SessionRevert.commit(bus, session) + if (input.resume !== false) yield* execution.wake(sessionID) + return existing + } + } + const enqueued = yield* Effect.gen(function* () { + if (session.revert) { + if (context) { + const events = yield* bus.publishAll([ + [SessionEvent.RevertEvent.Committed, { sessionID, to: session.revert.messageID }], + [SessionEvent.InboxEnqueued, { inboxID: context.id, sessionID, item: context.item }], + [SessionEvent.InboxEnqueued, { inboxID: messageID, sessionID, item: prepared }], + ]) + return events[2] + } + const events = yield* bus.publishAll([ + [SessionEvent.RevertEvent.Committed, { sessionID, to: session.revert.messageID }], + [SessionEvent.InboxEnqueued, { inboxID: messageID, sessionID, item: prepared }], + ]) + return events[1] + } + if (!context) return undefined + const events = yield* bus.publishAll([ + [SessionEvent.InboxEnqueued, { inboxID: context.id, sessionID, item: context.item }], + [SessionEvent.InboxEnqueued, { inboxID: messageID, sessionID, item: prepared }], + ]) + return events[1] + }) + const recorded = enqueued + ? SessionInbox.User.make({ + id: messageID, + sessionID, + time: { created: DateTime.makeUnsafe(enqueued.created) }, + type: "user", + payload: prepared.payload, + delivery: prepared.delivery, + }) + : yield* admission.admit({ id: messageID, sessionID, item: prepared }) + if (recorded.type !== "user") return yield* new PromptConflictError({ sessionID, messageID }) + if (input.resume !== false) yield* execution.wake(sessionID) + return recorded + }), + ) }).pipe( - Effect.catchTag("SessionInbox.LifecycleConflict", () => new PromptConflictError({ sessionID, messageID })), - ) - if (input.resume !== false) yield* execution.wake(sessionID) - return admitted - }), + Effect.catchTag( + "SessionInbox.LifecycleConflict", + (error) => new PromptConflictError({ sessionID, messageID: error.id }), + ), + Effect.catchDefect((defect) => + defect instanceof SessionInbox.LifecycleConflict + ? new PromptConflictError({ sessionID, messageID: defect.id }) + : Effect.die(defect), + ), + ), + ), ), ) const shell = Effect.fn("Session.shell")(function* ( @@ -184,6 +421,8 @@ export const make = Effect.fn("Session.make")(function* () { const session = yield* get(sessionID) // The server owns completion recording even if the submitting client disconnects. const running = yield* Effect.gen(function* () { + // Plugin-provided shell hooks and configuration only exist after activation. + yield* Plugin.awaitActivation.pipe(instances.provide(session)) const started = yield* SessionShell.start({ session, command: input.command }).pipe( Effect.provideService(Instance.Service, instances), Effect.tapError((error) => @@ -248,8 +487,15 @@ export const make = Effect.fn("Session.make")(function* () { sessionID: SessionSchema.ID, input: { id?: SessionMessage.ID; delivery?: SessionInbox.Delivery }, ) { - const session = yield* get(sessionID) - if (session.revert) yield* SessionRevert.commit(bus, session) + // Commit inside the inbox lock so it cannot interleave with serialized revert + // mutations; admitCompaction takes the same non-reentrant lock itself. + yield* SessionInbox.serialized( + sessionID, + Effect.gen(function* () { + const session = yield* get(sessionID) + if (session.revert) yield* SessionRevert.commit(bus, session) + }), + ) const inputID = input.id ?? SessionMessage.ID.create() const admitted = yield* admission .admitCompaction({ @@ -271,48 +517,74 @@ export const make = Effect.fn("Session.make")(function* () { yield* get(sessionID) yield* execution.resume(sessionID) }) - const synthetic = Effect.fn("Session.synthetic")( - ( - sessionID: SessionSchema.ID, - input: { - id?: SessionMessage.ID - text: string - description?: string - metadata?: Record - delivery?: SessionInbox.Delivery - resume?: boolean - }, - ) => - Effect.uninterruptible( - Effect.gen(function* () { - yield* get(sessionID) - const inputID = input.id ?? SessionMessage.ID.create() - const admittedInput = { - type: "synthetic", - payload: SessionInbox.SyntheticPayload.make({ - text: input.text, - description: input.description, - metadata: input.metadata, - }), - delivery: SessionInbox.Delivery.make(input.delivery ?? "steer"), - } satisfies SessionInbox.Item - const admitted = yield* admission - .admit({ - id: inputID, - sessionID, - item: admittedInput, - }) - .pipe( - Effect.catchTag( - "SessionInbox.LifecycleConflict", - () => new SyntheticConflictError({ sessionID, inputID }), - ), - ) - if (input.resume !== false && !(yield* get(sessionID)).revert) yield* execution.wake(sessionID) - return admitted - }), - ), - ) + const synthetic = Effect.fn("Session.synthetic")(( + sessionID: SessionSchema.ID, + input: { + id?: SessionMessage.ID + text: string + description?: string + metadata?: Record + delivery?: SessionInbox.Delivery + resume?: boolean + }, + ) => { + const effect = SessionInbox.serialized( + sessionID, + Effect.gen(function* () { + yield* get(sessionID) + const inputID = input.id ?? SessionMessage.ID.create() + const admittedInput = { + type: "synthetic", + payload: SessionInbox.SyntheticPayload.make({ + text: input.text, + description: input.description, + metadata: input.metadata, + }), + delivery: SessionInbox.Delivery.make(input.delivery ?? "steer"), + } satisfies SessionInbox.Item + const admitted = yield* admission + .admit({ + id: inputID, + sessionID, + item: admittedInput, + }) + .pipe( + Effect.catchTag("SessionInbox.LifecycleConflict", () => new SyntheticConflictError({ sessionID, inputID })), + ) + if (input.resume !== false && !(yield* get(sessionID)).revert) yield* execution.wake(sessionID) + return admitted + }), + ) + return Effect.uninterruptible( + Effect.gen(function* () { + const current = yield* PluginActivation.PromptPreparationCurrent + const bridgedPreparation = yield* PluginActivation.PromptPreparationBridged + const preparation = + current?.active && current.sessionID === sessionID && current.fiberID === (yield* Effect.fiberId) + ? current + : Array.from(promptPreparations.get(sessionID) ?? []).find( + (candidate) => candidate.active && candidate.token === bridgedPreparation, + ) + if (preparation) { + yield* preparation.wait + return yield* effect + } + const activation = yield* PluginActivation.Current + const waiting = pluginWaiters.get(sessionID) + const bridged = yield* PluginActivation.Bridged + if ( + activation?.active && + (activation.fiberID === (yield* Effect.fiberId) || bridged === activation.token) && + Array.from(waiting ?? []).some( + (location) => + location.directory === activation.directory && location.workspaceID === activation.workspaceID, + ) + ) + return yield* effect + return yield* withSessionOperation(sessionID, effect) + }), + ) + }) const interrupt = Effect.fn("Session.interrupt")( (sessionID: SessionSchema.ID, options?: { readonly resume?: boolean }) => Effect.uninterruptible(execution.interrupt(sessionID, options)), @@ -321,27 +593,47 @@ export const make = Effect.fn("Session.make")(function* () { sessionID: SessionSchema.ID, input: { messageID: SessionMessage.ID; files?: boolean }, ) { - const session = yield* get(sessionID) - if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID }) - return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe( - Effect.provideService(Instance.Service, instances), - Effect.provideService(Database.Service, database), - Effect.provideService(Bus.Service, bus), + return yield* withSessionOperation( + sessionID, + withRevertPlugins(sessionID, (session) => + Effect.gen(function* () { + if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID }) + return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe( + Effect.provideService(Instance.Service, instances), + Effect.provideService(Database.Service, database), + Effect.provideService(Bus.Service, bus), + ) + }), + ), ) }) const clear = Effect.fn("Session.revert.clear")(function* (sessionID: SessionSchema.ID) { - const session = yield* get(sessionID) - if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID }) - yield* SessionRevert.clear(session).pipe( - Effect.provideService(Instance.Service, instances), - Effect.provideService(Bus.Service, bus), + return yield* withSessionOperation( + sessionID, + withRevertPlugins(sessionID, (session) => + Effect.gen(function* () { + if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID }) + yield* SessionRevert.clear(session).pipe( + Effect.provideService(Instance.Service, instances), + Effect.provideService(Bus.Service, bus), + ) + return yield* execution.wake(sessionID) + }), + ), ) - return yield* execution.wake(sessionID) }) const commit = Effect.fn("Session.revert.commit")(function* (sessionID: SessionSchema.ID) { - const session = yield* get(sessionID) - if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID }) - return yield* SessionRevert.commit(bus, session) + return yield* withSessionOperation( + sessionID, + SessionInbox.serialized( + sessionID, + Effect.gen(function* () { + const session = yield* get(sessionID) + if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID }) + return yield* SessionRevert.commit(bus, session) + }), + ), + ) }) const revert = { stage, clear, commit } const operations = { diff --git a/packages/core/src/session/store.ts b/packages/core/src/session/store.ts index 53bf9e585d0c..749f9cd13924 100644 --- a/packages/core/src/session/store.ts +++ b/packages/core/src/session/store.ts @@ -11,7 +11,7 @@ import { SessionHistory } from "./history.js" import { MessageDecodeError } from "./error.js" import { SessionMessage } from "./message.js" import { Session } from "@opencode/schema/session" -import { SessionMessageTable, SessionTable } from "./sql.js" +import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js" import { fromRow } from "./info.js" const ListInputBase = { @@ -58,6 +58,11 @@ export interface Interface { readonly message: ( messageID: SessionMessage.ID, ) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined> + readonly survivesRevert: (input: { + readonly id: SessionMessage.ID + readonly sessionID: Session.ID + readonly boundaryID: SessionMessage.ID + }) => Effect.Effect /** * Top-level Sessions holding an execution claim. Recoverable background * children are resumed separately through their durable Job records. @@ -191,6 +196,29 @@ const layer = Layer.effect( } : undefined }), + survivesRevert: Effect.fn("SessionStore.survivesRevert")(function* (input) { + const boundary = yield* db + .select({ seq: SessionMessageTable.seq }) + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.boundaryID))) + .get() + .pipe(Effect.orDie) + if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${input.boundaryID}`)) + const pending = yield* db + .select({ seq: SessionInboxTable.enqueued_seq, sessionID: SessionInboxTable.session_id }) + .from(SessionInboxTable) + .where(eq(SessionInboxTable.id, input.id)) + .get() + .pipe(Effect.orDie) + if (pending) return pending.sessionID === input.sessionID && pending.seq < boundary.seq + const message = yield* db + .select({ seq: SessionMessageTable.seq, sessionID: SessionMessageTable.session_id }) + .from(SessionMessageTable) + .where(eq(SessionMessageTable.id, input.id)) + .get() + .pipe(Effect.orDie) + return message?.sessionID === input.sessionID && message.seq < boundary.seq + }), listSuspended: Effect.fn("SessionStore.listSuspended")(function* () { return yield* db .select({ sessionID: SessionTable.id }) diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts new file mode 100644 index 000000000000..81c9f5463d8e --- /dev/null +++ b/packages/core/test/plugin/promise.test.ts @@ -0,0 +1,1235 @@ +import { describe, expect } from "bun:test" +import { Message, SystemPart } from "@opencode/ai" +import { DateTime, Deferred, Effect, Fiber, Schema } from "effect" +import { Agent } from "@opencode/core/agent" +import { Model } from "@opencode/core/model" +import { Location } from "@opencode/core/location" +import { Plugin } from "@opencode/core/plugin" +import { PluginHooks } from "@opencode/core/plugin/hooks" +import { PluginHost } from "@opencode/core/plugin/host" +import { PluginPromise } from "@opencode/core/plugin/promise" +import { WebSearch } from "@opencode/core/websearch" +import { Vcs } from "@opencode/core/vcs" +import { Session } from "@opencode/core/session" +import { SessionMessage } from "@opencode/core/session/message" +import { SessionInbox } from "@opencode/core/session/inbox" +import { Tool } from "@opencode/core/tool" +import { Provider } from "@opencode/core/provider" +import { Project } from "@opencode/core/project" +import { Workspace } from "@opencode/core/workspace" +import { AbsolutePath } from "@opencode/core/schema" +import { define } from "@opencode/plugin/promise/plugin" +import type { Info } from "@opencode/plugin/promise/tool" +import { Money } from "@opencode/schema/money" +import { PersistentPty } from "@opencode/schema/persistent-pty" +import { Pty } from "@opencode/schema/pty" +import type { SessionHooks } from "@opencode/plugin/effect/session" +import { PluginActivation } from "@opencode/plugin/effect/activation" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" +import { host } from "./host" + +const it = testEffect(PluginTestLayer) + +describe("fromPromise", () => { + it.effect("validates and forwards experimental terminal reads through the protocol schema", () => + Effect.gen(function* () { + const seen: unknown[] = [] + const terminal = PersistentPty.ReadResult.make({ + ptyID: Pty.ID.make("pty_terminal"), + title: "Build", + cwd: "/workspace", + foregroundProcess: "bun", + screen: { text: "one\ntwo\nthree", cols: 80, rows: 2, cursor: { x: 3, y: 1 } }, + }) + const context = host({ + experimental: { + terminal: { + read: (input) => { + seen.push(input) + return Effect.succeed(terminal) + }, + }, + }, + }) + + yield* PluginPromise.fromPromise( + define({ + id: "promise-terminal-read", + setup: async (ctx) => { + expect(Object.keys(ctx.experimental)).toEqual(["terminal"]) + expect(Object.keys(ctx.experimental.terminal)).toEqual(["read"]) + // Exercise the runtime boundary with inputs a typed caller cannot send. + const read = ctx.experimental.terminal.read as (input: unknown) => Promise + for (const lines of [0, -1, 1.5, 65536, NaN, Infinity, "3"]) { + await expect(read({ sessionID: "ses_terminal", lines })).rejects.toBeDefined() + } + await expect(read({ lines: 3 })).rejects.toBeDefined() + expect(seen).toEqual([]) + expect(await ctx.experimental.terminal.read({ sessionID: "ses_terminal" })).toEqual(terminal) + expect(await ctx.experimental.terminal.read({ sessionID: "ses_terminal", lines: 3 })).toEqual(terminal) + await ctx.experimental.terminal.read({ sessionID: "ses_terminal", lines: 1 }) + await ctx.experimental.terminal.read({ sessionID: "ses_terminal", lines: 65535 }) + }, + }), + ).effect(context) + + expect(seen).toEqual([ + { sessionID: Session.ID.make("ses_terminal") }, + { sessionID: Session.ID.make("ses_terminal"), lines: 3 }, + { sessionID: Session.ID.make("ses_terminal"), lines: 1 }, + { sessionID: Session.ID.make("ses_terminal"), lines: 65535 }, + ]) + }), + ) + + it.effect("preserves null terminal reads and rejects daemon failures", () => + Effect.gen(function* () { + const context = host({ + experimental: { + terminal: { + read: (input) => + input.sessionID === Session.ID.make("ses_failure") + ? Effect.fail(new Error("terminal daemon unavailable")) + : Effect.succeed(null), + }, + }, + }) + + yield* PluginPromise.fromPromise( + define({ + id: "promise-terminal-null", + setup: async (ctx) => { + expect(await ctx.experimental.terminal.read({ sessionID: "ses_empty" })).toBeNull() + await expect(ctx.experimental.terminal.read({ sessionID: "ses_failure" })).rejects.toThrow( + "terminal daemon unavailable", + ) + }, + }), + ).effect(context) + }), + ) + + it.effect("exposes the host location including workspace and project metadata", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const location = yield* Location.Service + const expected = new Location.Info({ + directory: AbsolutePath.make("/worktree/packages/app"), + workspaceID: Workspace.ID.make("wrk_plugin_location"), + project: { + id: Project.ID.global, + directory: AbsolutePath.make("/worktree"), + canonical: AbsolutePath.make("/project"), + }, + }) + const host = yield* PluginHost.make(plugins).pipe( + Effect.provideService(Location.Service, { + ...location, + directory: expected.directory, + workspaceID: expected.workspaceID, + project: expected.project, + }), + ) + const seen: Location.Info[] = [] + yield* PluginPromise.fromPromise( + define({ + id: "promise-location", + setup: (ctx) => { + seen.push(ctx.location) + }, + }), + ).effect(host) + + expect(seen).toEqual([expected]) + }), + ) + + it.effect("adapts plugin storage methods", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const adapted = PluginPromise.fromPromise( + define({ + id: "promise-storage", + setup: async (ctx) => { + expect(await ctx.storage.get("missing")).toBeUndefined() + await ctx.storage.set("items/b", { order: 2 }) + await ctx.storage.set("items/a", { order: 1 }) + expect(await ctx.storage.get("items/a")).toEqual({ order: 1 }) + expect(await ctx.storage.scan({ prefix: "items/", limit: 1 })).toEqual({ + entries: [{ key: "items/a", value: { order: 1 } }], + next: "items/a", + }) + await ctx.storage.remove("items/a") + await ctx.storage.remove("items/a") + expect(await ctx.storage.get("items/a")).toBeUndefined() + }, + }), + ) + + yield* plugins.activate([{ ...adapted, revision: "1" }]) + }), + ) + + it.effect("adapts session creation through the protocol schema", () => + Effect.gen(function* () { + let seen: unknown + const context = host({ + session: { + create: (input) => { + seen = input + return Effect.succeed( + Session.Info.make({ + id: Session.ID.make("ses_protocol_adapter"), + projectID: Project.ID.make("project"), + cost: Money.USD.make(0), + tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } }, + time: { created: DateTime.makeUnsafe(10), updated: DateTime.makeUnsafe(20) }, + title: input?.title, + location: Location.Ref.make({ directory: AbsolutePath.make("/workspace") }), + }), + ) + }, + }, + }) + + yield* PluginPromise.fromPromise( + define({ + id: "promise-session-create", + setup: async (ctx) => { + // Exercise runtime validation without reflection erasing the call signature. + const create = ctx.session.create as (input: unknown) => Promise + await expect(create({ title: 42 })).rejects.toBeDefined() + const result = await ctx.session.create({ + id: null, + title: "Promise title", + agent: null, + model: null, + location: null, + }) + expect(result).toMatchObject({ + id: "ses_protocol_adapter", + title: "Promise title", + time: { created: 10, updated: 20 }, + }) + }, + }), + ).effect(context) + + expect(seen).toEqual({ title: "Promise title" }) + }), + ) + + it.effect("forwards transient session generation", () => + Effect.gen(function* () { + const context = host({ + session: { + generate: (input) => Effect.succeed({ text: `${input.sessionID}: ${input.prompt}` }), + }, + }) + + yield* PluginPromise.fromPromise( + define({ + id: "promise-session-generate", + setup: async (ctx) => { + expect(await ctx.session.generate({ sessionID: "ses_generate", prompt: "Summarize" })).toEqual({ + text: "ses_generate: Summarize", + }) + }, + }), + ).effect(context) + }), + ) + + it.effect("preserves interrupt results and rejected Promise behavior", () => + Effect.gen(function* () { + const seen: unknown[] = [] + const context = host({ + session: { + interrupt: (input) => { + if (input.sessionID === Session.ID.make("ses_failure")) { + return Effect.fail(new Error("interrupt failed")) + } + expect(input.resume).toBe(true) + return Effect.succeed({ interrupted: false }) + }, + switchAgent: (input) => Effect.sync(() => seen.push(input)), + switchModel: (input) => Effect.sync(() => seen.push(input)), + update: (input) => Effect.sync(() => seen.push(input)), + move: (input) => Effect.sync(() => seen.push(input)), + wait: (input) => Effect.sync(() => seen.push(input)), + }, + }) + + yield* PluginPromise.fromPromise( + define({ + id: "promise-session-interrupt", + setup: async (ctx) => { + expect(await ctx.session.interrupt({ sessionID: "ses_success", resume: true })).toEqual({ + interrupted: false, + }) + await expect(ctx.session.interrupt({ sessionID: "ses_failure" })).rejects.toThrow("interrupt failed") + expect(await ctx.session.switchAgent({ sessionID: "ses_success", agent: "build" })).toBeUndefined() + expect( + await ctx.session.switchModel({ + sessionID: "ses_success", + model: { providerID: "openai", id: "gpt-5" }, + }), + ).toBeUndefined() + expect(await ctx.session.update({ sessionID: "ses_success", title: "Renamed" })).toBeUndefined() + expect( + await ctx.session.move({ sessionID: "ses_success", directory: "/destination", delivery: "queue" }), + ).toBeUndefined() + expect(await ctx.session.wait({ sessionID: "ses_success" })).toBeUndefined() + }, + }), + ).effect(context) + + expect(seen).toEqual([ + { sessionID: Session.ID.make("ses_success"), agent: Agent.ID.make("build") }, + { + sessionID: Session.ID.make("ses_success"), + model: { providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-5") }, + }, + { sessionID: Session.ID.make("ses_success"), title: "Renamed" }, + { + sessionID: Session.ID.make("ses_success"), + directory: AbsolutePath.make("/destination"), + delivery: "queue", + }, + { sessionID: Session.ID.make("ses_success") }, + ]) + }), + ) + + it.effect("forwards synthetic session input", () => + Effect.gen(function* () { + const input = { + sessionID: "ses_synthetic", + id: "msg_synthetic", + text: "Background work completed", + description: null, + metadata: { shellID: "shell_1" }, + delivery: null, + resume: null, + } + let seen: unknown + const context = host({ + session: { + synthetic: (value) => { + seen = value + return Effect.succeed( + SessionInbox.Synthetic.make({ + id: SessionMessage.ID.make(input.id), + sessionID: Session.ID.make(input.sessionID), + time: { created: DateTime.makeUnsafe(0) }, + type: "synthetic", + payload: { + text: input.text, + metadata: input.metadata, + }, + delivery: "queue", + }), + ) + }, + }, + }) + + yield* PluginPromise.fromPromise( + define({ + id: "promise-session-synthetic", + setup: async (ctx) => { + await ctx.session.synthetic(input) + }, + }), + ).effect(context) + + expect(seen).toEqual({ + ...input, + description: undefined, + delivery: undefined, + resume: undefined, + }) + }), + ) + + it.effect("does not bridge Promise host calls started after setup", () => + Effect.gen(function* () { + const input = { sessionID: "ses_after_setup", text: "setup work", resume: false } + const token = {} + const called = yield* Deferred.make() + const activation: PluginActivation.State = { + active: true, + fiberID: yield* Effect.fiberId, + token, + directory: "/workspace", + } + const context = host({ + session: { + synthetic: () => + Effect.gen(function* () { + yield* Deferred.succeed(called, (yield* PluginActivation.Bridged) === token) + return SessionInbox.Synthetic.make({ + id: SessionMessage.ID.make("msg_after_setup"), + sessionID: Session.ID.make(input.sessionID), + time: { created: DateTime.makeUnsafe(0) }, + type: "synthetic", + payload: { text: input.text }, + delivery: "steer", + }) + }), + }, + }) + + yield* PluginPromise.fromPromise( + define({ + id: "promise-after-setup", + setup: (ctx) => { + queueMicrotask(() => + queueMicrotask(() => queueMicrotask(() => void ctx.session.synthetic({ ...input, text: "late work" }))), + ) + }, + }), + ) + .effect(context) + .pipe( + Effect.provideService(PluginActivation.Current, activation), + Effect.ensuring(Effect.sync(() => (activation.active = false))), + ) + + expect(yield* Deferred.await(called).pipe(Effect.timeout("1 second"))).toBe(false) + }), + ) + + it.effect("drains bridged host calls before propagating setup failure", () => + Effect.gen(function* () { + const input = { sessionID: "ses_failed_setup", text: "pending work", resume: false } + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const activation: PluginActivation.State = { + active: true, + fiberID: yield* Effect.fiberId, + token: {}, + directory: "/workspace", + } + const context = host({ + session: { + synthetic: () => + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as( + SessionInbox.Synthetic.make({ + id: SessionMessage.ID.make("msg_failed_setup"), + sessionID: Session.ID.make(input.sessionID), + time: { created: DateTime.makeUnsafe(0) }, + type: "synthetic", + payload: { text: input.text }, + delivery: "steer", + }), + ), + Effect.uninterruptible, + ), + }, + }) + + yield* Effect.gen(function* () { + const setup = yield* PluginPromise.fromPromise( + define({ + id: "promise-failed-setup", + setup: (ctx) => { + void ctx.session.synthetic(input) + throw new Error("setup failed") + }, + }), + ) + .effect(context) + .pipe(Effect.provideService(PluginActivation.Current, activation), Effect.exit, Effect.forkChild) + yield* Deferred.await(entered) + yield* Effect.yieldNow + const completedBeforeRelease = setup.pollUnsafe() + yield* Deferred.succeed(release, undefined) + const result = yield* Fiber.join(setup) + + expect(completedBeforeRelease).toBeUndefined() + expect(result._tag).toBe("Failure") + }).pipe( + Effect.ensuring( + Deferred.succeed(release, undefined).pipe(Effect.andThen(Effect.sync(() => (activation.active = false)))), + ), + ) + }), + ) + + it.effect("does not drain fire-and-forget prompts started during setup", () => + Effect.gen(function* () { + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const activation: PluginActivation.State = { + active: true, + fiberID: yield* Effect.fiberId, + token: {}, + directory: "/workspace", + } + const context = host({ + session: { + prompt: (input) => + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as( + SessionInbox.User.make({ + id: SessionMessage.ID.make("msg_setup_prompt"), + sessionID: input.sessionID, + time: { created: DateTime.makeUnsafe(0) }, + type: "user", + payload: { text: input.text }, + delivery: input.delivery ?? "steer", + }), + ), + ), + }, + }) + + const setup = yield* PluginPromise.fromPromise( + define({ + id: "promise-setup-prompt", + setup: (ctx) => { + void ctx.session.prompt({ sessionID: "ses_setup_prompt", text: "nested prompt", resume: false }) + }, + }), + ) + .effect(context) + .pipe( + Effect.provideService(PluginActivation.Current, activation), + Effect.ensuring(Effect.sync(() => (activation.active = false))), + Effect.forkChild({ startImmediately: true }), + ) + yield* Deferred.await(entered) + yield* Effect.yieldNow + const completedBeforeRelease = setup.pollUnsafe() + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(setup) + + expect(completedBeforeRelease).toBeDefined() + }), + ) + + it.effect("forwards standard client reads", () => + Effect.gen(function* () { + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin) + const seen: string[] = [] + const promisePlugin = define({ + id: "promise-client-reads", + setup: async (ctx) => { + expect(Object.keys(ctx.mcp).sort()).toEqual(["list", "reload", "transform"]) + const results = await Promise.all([ + ctx.agent.list(), + ctx.provider.list(), + ctx.model.list(), + ctx.command.list(), + ctx.integration.list(), + ctx.mcp.list(), + ctx.plugin.list(), + ctx.reference.list(), + ctx.skill.list(), + ]) + seen.push(...results.map((result) => result.location.directory)) + await expect(ctx.integration.get({ integrationID: "missing" })).rejects.toThrow( + "Integration not found: missing", + ) + }, + }) + + yield* PluginPromise.fromPromise(promisePlugin).effect(host) + + expect(seen).toHaveLength(9) + expect(new Set(seen).size).toBe(1) + }), + ) + + it.effect("forwards direct agent and model list reads", () => + Effect.gen(function* () { + const agents = yield* Agent.Service + const models = yield* Model.Service + const providers = yield* Provider.Service + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin) + yield* agents.transform((draft) => + draft.update(Agent.ID.make("reviewer"), (agent) => { + agent.description = "Reviews code" + }), + ) + yield* providers.transform((draft) => + draft.update(Provider.ID.make("test"), (provider) => { + provider.activation = "enabled" + }), + ) + yield* models.transform((draft) => + draft.update(Provider.ID.make("test"), Model.ID.make("alias"), (model) => { + model.modelID = Model.ID.make("gpt-5") + }), + ) + + yield* PluginPromise.fromPromise( + define({ + id: "promise-direct-reads", + setup: async (ctx) => { + expect((await ctx.agent.get({ agentID: Agent.ID.make("reviewer") })).data).toMatchObject({ + description: "Reviews code", + }) + await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow( + "Agent not found: missing", + ) + const models = (await ctx.model.list()).data + expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({ + modelID: "gpt-5", + }) + expect(models.find((model) => model.providerID === "test" && model.id === "missing")).toBeUndefined() + }, + }), + ).effect(host) + }), + ) + + it.effect("loads a promise plugin and registers a transform hook", () => + Effect.gen(function* () { + const agents = yield* Agent.Service + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin) + + const promisePlugin = define({ + id: "promise-example", + setup: async (ctx) => { + expect(ctx.options.mode).toBe("strict") + await ctx.agent.transform((draft) => { + draft.update("reviewer", (item) => { + item.description = "Reviews code" + item.mode = "subagent" + }) + }) + }, + }) + + const adapted = PluginPromise.fromPromise(promisePlugin) + yield* adapted.effect({ ...host, options: { mode: "strict" } }) + + expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({ + description: "Reviews code", + mode: "subagent", + }) + }), + ) + + it.effect("forwards session context hooks", () => + Effect.gen(function* () { + const plugin = yield* Plugin.Service + const hooks = yield* PluginHooks.Service + const host = yield* PluginHost.make(plugin) + yield* PluginPromise.fromPromise( + define({ + id: "promise-session-context", + setup: async (ctx) => { + await ctx.session.hook("context", (event) => { + event.system.push(SystemPart.make("Promise hook")) + delete event.tools.echo + event.options.temperature = 0.4 + event.options.reasoningEffort = "medium" + }) + }, + }), + ).effect(host) + const event: SessionHooks["context"] = { + sessionID: Session.ID.make("ses_promise_session_context"), + agent: Agent.ID.make("build"), + model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }), + system: [SystemPart.make("Initial")], + messages: [Message.user("Hello")], + tools: { echo: { description: "Echo", input: { type: "object" } } }, + options: {}, + } + + yield* hooks.trigger("session", "context", event) + + expect(event.system.map((part) => part.text)).toEqual(["Initial", "Promise hook"]) + expect(event.tools).toEqual({}) + expect(event.options).toEqual({ temperature: 0.4, reasoningEffort: "medium" }) + }), + ) + + it.effect("adapts promise session HTTP request and response hooks", () => + Effect.gen(function* () { + const plugin = yield* Plugin.Service + const hooks = yield* PluginHooks.Service + const host = yield* PluginHost.make(plugin) + yield* PluginPromise.fromPromise( + define({ + id: "promise-session-http", + setup: async (ctx) => { + await ctx.session.hook( + "http.request", + (event) => { + event.request = new Request("https://provider.test/changed", event.request) + event.request.headers.set("x-hook", "promise") + }, + { providerID: "test" }, + ) + await ctx.session.hook("http.response", async (event) => { + event.response = new Response(`${await event.response.text()}-response`, { + status: event.response.status, + }) + }) + }, + }), + ).effect(host) + const context = { + sessionID: Session.ID.make("ses_promise_session_http"), + agent: Agent.ID.make("build"), + model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }), + kind: "primary" as const, + } + + const request = yield* hooks.trigger("session", "http.request", { + ...context, + request: new Request("https://provider.test", { method: "POST", body: "payload" }), + }) + const ignored = yield* hooks.trigger("session", "http.request", { + ...context, + model: Model.Ref.make({ providerID: Provider.ID.make("other"), id: Model.ID.make("model") }), + request: new Request("https://other.test"), + }) + const response = yield* hooks.trigger("session", "http.response", { + ...context, + request: request.request, + response: new Response(request.request.headers.get("x-hook") ?? "missing"), + }) + + expect(request.request.url).toBe("https://provider.test/changed") + expect(ignored.request.url).toBe("https://other.test/") + expect(yield* hooks.has("session", "http.request", Provider.ID.make("test"))).toBe(true) + expect(yield* hooks.has("session", "http.request", Provider.ID.make("other"))).toBe(false) + expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response") + }), + ) + + it.effect("disposes a hook registration on request", () => + Effect.gen(function* () { + const agents = yield* Agent.Service + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin) + + const promisePlugin = define({ + id: "promise-dispose", + setup: async (ctx) => { + const registration = await ctx.agent.transform((draft) => { + draft.update("temp", (item) => { + item.description = "temporary" + }) + }) + await registration.dispose() + }, + }) + + const adapted = PluginPromise.fromPromise(promisePlugin) + yield* adapted.effect(host) + + expect(yield* agents.get(Agent.ID.make("temp"))).toBeUndefined() + }), + ) + + it.effect("registers a Promise VCS provider and preserves its receiver when forwarding client reads", () => + Effect.gen(function* () { + const vcs = yield* Vcs.Service + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin) + const signals: AbortSignal[] = [] + const promisePlugin = define({ + id: "promise-vcs", + setup: async (ctx) => { + await ctx.vcs.transform((draft) => { + draft.add({ + id: "custom", + name: "Custom VCS", + info: async (_input, request) => { + signals.push(request.signal) + return { branch: { current: "feature", default: "main" } } + }, + async base(_input, request) { + expect(this.id).toBe("custom") + signals.push(request.signal) + return { name: "main", ref: "refs/heads/main", source: "default" } + }, + branches: async (input, request) => { + signals.push(request.signal) + expect(input.search).toBe("feat") + return ["feature"] + }, + status: async (_input, request) => { + signals.push(request.signal) + return [{ file: "file.txt", additions: 1, deletions: 0, status: "added" }] + }, + diff: async (input, request) => { + signals.push(request.signal) + expect(input.context).toBe(2) + expect(input.maxOutputBytes).toBe(10_000_000) + return [{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }] + }, + }) + draft.default.set("custom") + }) + + expect((await ctx.vcs.get()).data.branch.current).toBe("feature") + expect((await ctx.vcs.base()).data).toEqual({ name: "main", ref: "refs/heads/main", source: "default" }) + expect((await ctx.vcs.branch.list({ search: "feat" })).data).toEqual(["feature"]) + expect((await ctx.vcs.status()).data).toHaveLength(1) + expect((await ctx.vcs.diff({ mode: "working", context: 2 })).data[0].patch).toBe("+hello") + }, + }) + + yield* PluginPromise.fromPromise(promisePlugin).effect(host) + expect((yield* vcs.info()).branch.current).toBe("feature") + expect(signals).toHaveLength(5) + expect(signals.every((signal) => signal instanceof AbortSignal)).toBeTrue() + }), + ) + + it.effect("registers a standalone web search provider", () => + Effect.gen(function* () { + const websearch = yield* WebSearch.Service + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin) + const promisePlugin = define({ + id: "promise-websearch", + setup: async (ctx) => { + await ctx.websearch.transform((draft) => { + draft.add({ + id: "promise-websearch", + name: "Promise Web Search", + execute: async (input) => [{ url: "https://example.com", content: `promise: ${input.query}`, time: {} }], + }) + }) + }, + }) + + yield* PluginPromise.fromPromise(promisePlugin).effect(host) + expect(yield* websearch.providers()).toContainEqual({ + id: WebSearch.ID.make("promise-websearch"), + name: "Promise Web Search", + }) + expect(yield* websearch.query({ query: "effect", providerID: WebSearch.ID.make("promise-websearch") })).toEqual( + new WebSearch.Response({ + providerID: WebSearch.ID.make("promise-websearch"), + results: [{ url: "https://example.com", content: "promise: effect", time: {} }], + }), + ) + }), + ) + + it.effect("runs the setup cleanup when the plugin scope closes", () => + Effect.gen(function* () { + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin) + const events: string[] = [] + const promisePlugin = define({ + id: "promise-cleanup", + setup: async () => { + events.push("setup") + return async () => { + await Promise.resolve() + events.push("cleanup") + } + }, + }) + + yield* Effect.scoped( + Effect.gen(function* () { + yield* PluginPromise.fromPromise(promisePlugin).effect(host) + expect(events).toEqual(["setup"]) + }), + ) + + expect(events).toEqual(["setup", "cleanup"]) + }), + ) + + it.effect("constructs plain Promise tool definitions in the host", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const registry = yield* Tool.Service + const host = yield* PluginHost.make(plugins) + const progress: Tool.Metadata[] = [] + const promisePlugin = define({ + id: "promise-tool", + setup: async (ctx) => { + await ctx.tool.transform((tools) => { + tools.add({ + name: "hello", + options: { codemode: false }, + description: "Hello", + input: Schema.Struct({ name: Schema.String }), + output: Schema.String, + execute: async ({ name }, context) => { + await context.progress({ phase: "greeting" }) + return { output: `Hello, ${name}!` } + }, + }) + }) + await ctx.tool.hook("execute.before", (event) => { + expect(event.tool).toBe("helllo") + expect(event).not.toHaveProperty("inputSchema") + event.tool = "hello" + }) + }, + }) + + yield* PluginPromise.fromPromise(promisePlugin).effect(host) + + const toolSet = yield* registry.snapshot() + expect(toolSet.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" })) + expect( + yield* toolSet.execute({ + sessionID: Session.ID.make("ses_promise_tool"), + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.make("msg_promise_tool"), + progress: (update) => Effect.sync(() => progress.push(update)), + call: { type: "tool-call", id: "call_promise_tool", name: "helllo", input: { name: "world" } }, + }), + ).toMatchObject({ + output: "Hello, world!", + content: [{ type: "text", text: "Hello, world!" }], + }) + expect(progress).toEqual([{ phase: "greeting" }]) + }), + ) + + it.live("adapts listed and retrieved tool executors without invoking them eagerly", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const host = yield* PluginHost.make(plugins) + const calls: string[] = [] + const failure = new Tool.Error({ message: "executor failed" }) + yield* host.tool.transform((draft) => { + draft.add({ + name: "hello", + description: "Hello", + options: { namespace: "acme", codemode: false }, + input: Schema.Struct({ name: Schema.String }), + output: Schema.String, + execute: ({ name }, context) => { + calls.push(name) + if (name === "failure") return Effect.fail(failure) + return context.progress({ name }).pipe(Effect.as({ output: name })) + }, + }) + }) + yield* PluginPromise.fromPromise( + define({ + id: "promise-tool-reads", + setup: async (ctx) => { + const tools: Info[] = [] + await ctx.tool.transform((draft) => { + expect(draft.list().map((tool) => tool.id)).toEqual(["acme_hello"]) + tools.push(...draft.list()) + const tool = draft.get("acme_hello") + if (!tool) throw new Error("Tool was not found") + expect(tool.id).toBe("acme_hello") + tools.push(tool) + }) + expect(tools).toHaveLength(2) + expect(calls).toEqual([]) + await Promise.all( + tools.map(async (tool) => { + const progress: Tool.Metadata[] = [] + const context = { + sessionID: Session.ID.make("ses_promise_tool_reads"), + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.make("msg_promise_tool_reads"), + id: Tool.CallID.make("call_reads"), + signal: new AbortController().signal, + progress: async (update: Tool.Metadata) => { + progress.push(update) + }, + } + expect(await tool.execute({ name: "world" }, context)).toEqual({ output: "world" }) + expect(progress).toEqual([{ name: "world" }]) + await expect(tool.execute({ name: "failure" }, context)).rejects.toBe(failure) + const error = new Error("progress failed") + await expect( + tool.execute( + { name: "world" }, + { + ...context, + progress: async () => { + throw error + }, + }, + ), + ).rejects.toBe(error) + }), + ) + }, + }), + ).effect(host) + }), + ) + + it.live("reloads and disposes Promise tools while preserving older snapshots", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const registry = yield* Tool.Service + const host = yield* PluginHost.make(plugins) + const source = { description: "Original", replays: 0 } + const registrations: Array<{ reload: () => Promise; dispose: () => Promise }> = [] + yield* PluginPromise.fromPromise( + define({ + id: "promise-tool-lifecycle", + setup: async (ctx) => { + expect(Object.keys(ctx.tool).sort()).toEqual(["hook", "list", "reload", "transform"]) + const registration = await ctx.tool.transform((draft) => { + source.replays++ + const description = source.description + draft.add({ + name: "reloadable", + description, + input: Schema.Struct({}), + output: Schema.String, + options: { codemode: false }, + execute: async () => ({ output: description }), + }) + expect(draft.list().map((tool) => tool.id)).toEqual(["reloadable"]) + expect(draft.get("reloadable")?.id).toBe("reloadable") + expect(draft.get("reloadable")?.name).toBe("reloadable") + expect(draft.get("missing")).toBeUndefined() + }) + registrations.push({ reload: ctx.tool.reload, dispose: registration.dispose }) + }, + }), + ).effect(host) + const registration = registrations[0] + if (!registration) return yield* Effect.die("Promise tool registration was not captured") + const original = yield* registry.snapshot() + const execute = (snapshot: Tool.Snapshot) => + snapshot.execute({ + sessionID: Session.ID.make("ses_promise_tool_reload"), + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.make("msg_promise_tool_reload"), + call: { type: "tool-call", id: "call_promise_tool_reload", name: "reloadable", input: {} }, + }) + + source.description = "Reloaded" + yield* Effect.promise(() => registration.reload()) + const reloaded = yield* registry.snapshot() + expect(source.replays).toBe(2) + expect(reloaded.definitions).toContainEqual( + expect.objectContaining({ name: "reloadable", description: "Reloaded" }), + ) + expect(yield* execute(reloaded)).toMatchObject({ output: "Reloaded" }) + expect(yield* execute(original)).toMatchObject({ output: "Original" }) + + yield* Effect.promise(() => registration.dispose()) + yield* Effect.promise(() => registration.dispose()) + expect((yield* registry.snapshot()).definitions.some((tool) => tool.name === "reloadable")).toBe(false) + expect(yield* execute(original)).toMatchObject({ output: "Original" }) + expect(yield* execute(reloaded)).toMatchObject({ output: "Reloaded" }) + yield* Effect.promise(() => registration.reload()) + expect(source.replays).toBe(2) + expect((yield* registry.snapshot()).definitions.some((tool) => tool.name === "reloadable")).toBe(false) + }), + ) + + it.live("adapts tool updates, executor wrapping, and removal across replay and disposal", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const registry = yield* Tool.Service + const host = yield* PluginHost.make(plugins) + const progress: Tool.Metadata[] = [] + let greeting = "Hello" + const registrations: Array<{ dispose: () => Promise }> = [] + yield* host.tool.transform((draft) => { + const text = greeting + draft.add({ + name: "hello", + description: "Hello", + options: { namespace: "acme", codemode: false }, + input: Schema.Struct({ name: Schema.String }), + output: Schema.String, + execute: ({ name }, context) => + context.progress({ phase: "original" }).pipe(Effect.as({ output: `${text}, ${name}!` })), + }) + draft.add({ + name: "temporary", + description: "Temporary", + input: Schema.Struct({}), + options: { codemode: false }, + execute: () => Effect.succeed({ content: "temporary" }), + }) + }) + yield* PluginPromise.fromPromise( + define({ + id: "promise-tool-mutations", + setup: async (ctx) => { + registrations.push( + await ctx.tool.transform((draft) => { + draft.update("missing", () => { + throw new Error("must not create a tool") + }) + draft.update("acme_hello", (tool) => { + const execute = tool.execute + tool.description = "Wrapped" + delete tool.output + tool.execute = async (input, context) => { + const result = await execute(input, context) + return { content: `${result.output} Wrapped.` } + } + }) + draft.remove("temporary") + }), + ) + greeting = "Hi" + await ctx.tool.reload() + }, + }), + ).effect(host) + const snapshot = yield* registry.snapshot() + expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"]) + expect(snapshot.definitions[0]?.description).toBe("Wrapped") + expect(snapshot.definitions[0]?.outputSchema).toBeUndefined() + expect( + yield* snapshot.execute({ + sessionID: Session.ID.make("ses_promise_tool_update"), + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.make("msg_promise_tool_update"), + call: { type: "tool-call", id: "call_update", name: "acme_hello", input: { name: "world" } }, + progress: (update) => + Effect.sync(() => { + progress.push(update) + }), + }), + ).toMatchObject({ content: [{ type: "text", text: "Hi, world! Wrapped." }] }) + expect(progress).toEqual([{ phase: "original" }]) + const registration = registrations[0] + if (!registration) return yield* Effect.die("Promise tool registration was not captured") + yield* Effect.promise(() => registration.dispose()) + yield* Effect.promise(() => registration.dispose()) + const restored = yield* registry.snapshot() + expect(restored.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "temporary", "execute"]) + expect(restored.definitions[0]?.description).toBe("Hello") + }), + ) + + it.live("clears deleted tool options while retaining the namespace", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const registry = yield* Tool.Service + const host = yield* PluginHost.make(plugins) + yield* host.tool.transform((draft) => { + draft.add({ + name: "hello", + description: "Hello", + options: { namespace: "acme", codemode: false }, + input: Schema.Struct({}), + output: Schema.String, + execute: () => Effect.succeed({ output: "Hello" }), + }) + }) + const original = yield* registry.snapshot() + expect(original.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"]) + expect(original.codeModeCatalog).toEqual({ tools: [] }) + + yield* PluginPromise.fromPromise( + define({ + id: "promise-tool-options", + setup: async (ctx) => { + await ctx.tool.transform((draft) => { + draft.update("acme_hello", (tool) => { + delete tool.options + }) + expect(draft.get("acme_hello")?.options).toEqual({ namespace: "acme" }) + }) + }, + }), + ).effect(host) + + const snapshot = yield* registry.snapshot() + expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"]) + expect(snapshot.codeModeCatalog?.tools).toMatchObject([ + { type: "namespace", name: "acme", tools: [{ type: "tool", name: "hello" }] }, + ]) + expect(original.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"]) + expect( + yield* snapshot.execute({ + sessionID: Session.ID.make("ses_promise_tool_options"), + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.make("msg_promise_tool_options"), + call: { + type: "tool-call", + id: "call_options", + name: "execute", + input: { code: "return await tools.acme.hello({})" }, + }, + }), + ).toMatchObject({ + output: { output: "Hello", toolCalls: [{ tool: "acme.hello", status: "completed" }] }, + }) + }), + ) + + it.effect("returns content-only plugin results and rejected Promises through Code Mode", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const registry = yield* Tool.Service + const host = yield* PluginHost.make(plugins) + const promisePlugin = define({ + id: "content-only-tool", + setup: async (ctx) => { + await ctx.tool.transform((tools) => { + tools.add({ + name: "demo_status", + description: "Returns a status string", + input: Schema.Struct({ fail: Schema.optionalKey(Schema.Boolean) }), + execute: async ({ fail }) => { + if (fail) throw new Error("plugin request failed") + return { content: [{ type: "text", text: "hello" }] } + }, + options: { codemode: true }, + }) + }) + }, + }) + + yield* PluginPromise.fromPromise(promisePlugin).effect(host) + + const toolSet = yield* registry.snapshot() + const throughCodeMode = yield* toolSet.execute({ + sessionID: Session.ID.make("ses_content_only_tool"), + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.make("msg_content_only_tool"), + call: { + type: "tool-call", + id: "call_content_only_tool", + name: "execute", + input: { code: "return await tools.demo_status({})" }, + }, + }) + expect(throughCodeMode).toMatchObject({ + output: { output: "hello", toolCalls: [{ tool: "demo_status", status: "completed" }] }, + content: [{ type: "text", text: "hello" }], + }) + expect( + yield* toolSet.execute({ + sessionID: Session.ID.make("ses_content_only_tool"), + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.make("msg_content_only_tool"), + call: { + type: "tool-call", + id: "call_failed_tool", + name: "execute", + input: { code: "return await tools.demo_status({ fail: true })" }, + }, + }), + ).toMatchObject({ + content: [{ type: "text", text: "plugin request failed" }], + metadata: { error: true }, + }) + }), + ) +}) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index edb83d48f8a4..14f4234b9393 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -1127,6 +1127,7 @@ describe("Session.create", () => { const session = yield* Session.Service const created = yield* session.create({ location, agent: Agent.ID.make("build") }) + yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("plan") }) yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("plan") }) expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" }) diff --git a/packages/core/test/session-execution.test.ts b/packages/core/test/session-execution.test.ts index 063c5fedfbc9..b8ff68c91092 100644 --- a/packages/core/test/session-execution.test.ts +++ b/packages/core/test/session-execution.test.ts @@ -118,6 +118,72 @@ describe("SessionExecution lifecycle", () => { }), ) + for (const operation of ["resume", "wake"] as const) { + it.effect(`parks ${operation} while a revert is staged and runs after it clears`, () => + Effect.gen(function* () { + const database = yield* Database.Service + const bus = yield* Bus.Service + const store = yield* SessionStore.Service + const sessionID = Session.ID.make("ses_staged_revert") + const revert = { messageID: SessionMessage.ID.make("msg_boundary"), files: [] } + yield* seedSessions(database, [sessionID]) + yield* bus.publish(SessionEvent.RevertEvent.Staged, { sessionID, revert }) + const drained: Session.ID[] = [] + const scope = yield* Scope.make() + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)) + const context = yield* buildExecution(scope, (input) => + Effect.sync(() => { + drained.push(input.sessionID) + }), + ) + const execution = Context.get(context, SessionExecution.Service) + + yield* execution[operation](sessionID) + yield* execution.awaitIdle(sessionID) + expect(drained).toEqual([]) + expect((yield* store.get(sessionID))?.revert).toEqual(revert) + expect((yield* claims(database))[sessionID]).toBe(false) + + yield* bus.publish(SessionEvent.RevertEvent.Cleared, { sessionID }) + yield* execution[operation](sessionID) + yield* execution.awaitIdle(sessionID) + expect(drained).toEqual([sessionID]) + }), + ) + } + + it.effect("reads the revert boundary after an in-flight stage releases the inbox lock", () => + Effect.gen(function* () { + const database = yield* Database.Service + const bus = yield* Bus.Service + const sessionID = Session.ID.make("ses_staging_revert") + yield* seedSessions(database, [sessionID]) + const drained: Session.ID[] = [] + const scope = yield* Scope.make() + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)) + const context = yield* buildExecution(scope, (input) => + Effect.sync(() => { + drained.push(input.sessionID) + }), + ) + const execution = Context.get(context, SessionExecution.Service) + + yield* SessionInbox.serialized( + sessionID, + Effect.gen(function* () { + yield* execution.wake(sessionID) + expect(yield* execution.isActive(sessionID)).toBe(true) + yield* bus.publish(SessionEvent.RevertEvent.Staged, { + sessionID, + revert: { messageID: SessionMessage.ID.make("msg_boundary"), files: [] }, + }) + }), + ) + yield* execution.awaitIdle(sessionID) + expect(drained).toEqual([]) + }), + ) + it.effect("a user interrupt releases the claim so the turn never resurrects", () => Effect.gen(function* () { const database = yield* Database.Service diff --git a/packages/core/test/session-owned.test.ts b/packages/core/test/session-owned.test.ts index 327071b56771..087cd23aaf3e 100644 --- a/packages/core/test/session-owned.test.ts +++ b/packages/core/test/session-owned.test.ts @@ -733,8 +733,8 @@ describe("Session-owned handles", () => { yield* handle.revert.clear() expect(captures).toEqual([source, destination]) - expect(fixture.locations).toEqual([source, destination, destination]) - expect(fixture.activationWaits).toEqual([]) + expect(fixture.locations).toEqual([source, source, destination, destination, destination, destination]) + expect(fixture.activationWaits).toEqual([source, destination, destination]) expect((yield* handle.get()).revert).toBeUndefined() }), ) diff --git a/packages/core/test/session-prompt-hooks.test.ts b/packages/core/test/session-prompt-hooks.test.ts new file mode 100644 index 000000000000..6f6c7521f88d --- /dev/null +++ b/packages/core/test/session-prompt-hooks.test.ts @@ -0,0 +1,577 @@ +import { describe, expect, setDefaultTimeout } from "bun:test" +import path from "path" +import { Deferred, Effect, Fiber, Stream } from "effect" +import { Bus } from "@opencode/core/bus" +import { Database } from "@opencode/core/database/database" +import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" +import { Watcher } from "@opencode/core/filesystem/watcher" +import { LocationServiceMap } from "@opencode/core/location-service-map" +import { PluginHooks } from "@opencode/core/plugin/hooks" +import { Plugin } from "@opencode/core/plugin" +import { AbsolutePath } from "@opencode/core/schema" +import { Session } from "@opencode/core/session" +import { SessionExecution } from "@opencode/core/session/execution" +import { SessionEvent } from "@opencode/core/session/event" +import { SessionInbox } from "@opencode/core/session/inbox" +import { SessionMessage } from "@opencode/core/session/message" +import { SessionProjector } from "@opencode/core/session/projector" +import { Skill } from "@opencode/core/skill" +import { LayerNode } from "@opencode/util/effect/layer-node" +import { Global } from "@opencode/util/global" +import { tempGlobalLayer } from "./fixture/global" +import { offlineModels } from "./fixture/models" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +// These tests include real Location and plugin startup, not just hook callbacks. +setDefaultTimeout(15_000) + +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]), + [ + Bus.node.replace(Bus.configured({ persist: true })), + Global.node.replace(tempGlobalLayer), + Watcher.node.replace(Watcher.configured({ enabled: false })), + SessionExecution.node.replace(SessionExecution.noopLayer), + offlineModels, + ], + ), +) + +const project = Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), +) + +const setup = Effect.gen(function* () { + const tmp = yield* project + const sessions = yield* Session.Service + const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } }) + const services = LocationServiceMap.Service.get(session.location) + const hooks = yield* Effect.gen(function* () { + const plugins = yield* Plugin.Service + yield* plugins.awaitActivation + return yield* PluginHooks.Service + }).pipe(Effect.provide(services)) + return { sessions, session, hooks, services } +}) + +describe("Session prompt hooks", () => { + it.live("waits for local plugin setup before admitting even a plain-text prompt", () => + Effect.gen(function* () { + const tmp = yield* project + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, ".opencode/plugins/prompt.ts"), + `export default { + id: "prompt-readiness", + async setup(ctx) { + await ctx.session.hook("prompt", (event) => { + event.prompt.text = "Prepared by plugin" + }) + }, + }`, + ), + ) + const sessions = yield* Session.Service + const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } }) + const admitted = yield* sessions.prompt({ sessionID: session.id, text: "Original", resume: false }) + expect(admitted.payload.text).toBe("Prepared by plugin") + }), + ) + + it.live("allows cold plugin setup to admit synthetic input during revert staging", () => + Effect.gen(function* () { + const tmp = yield* project + const sessions = yield* Session.Service + const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } }) + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, ".opencode/plugins/revert.ts"), + `export default { + id: "revert-setup", + async setup(ctx) { + await ctx.session.synthetic({ + sessionID: "${session.id}", + text: "Plugin activated", + resume: false, + }) + }, + }`, + ), + ) + const database = yield* Database.Service + const bus = yield* Bus.Service + const boundaryID = SessionMessage.ID.create() + yield* bus.publish(SessionEvent.InboxEnqueued, { + inboxID: boundaryID, + sessionID: session.id, + item: SessionInbox.Item.make({ + type: "user", + payload: { text: "Boundary" }, + delivery: "steer", + }), + }) + yield* SessionInbox.promote(database.db, bus, session.id, "steer") + + yield* sessions.revert.stage({ sessionID: session.id, messageID: boundaryID, files: false }) + + expect((yield* sessions.get(session.id)).revert?.messageID).toBe(boundaryID) + expect(yield* sessions.inbox(session.id)).toMatchObject([ + { type: "synthetic", payload: { text: "Plugin activated" } }, + ]) + }), + ) + + it.live("allows a Promise prompt hook to await same-Session synthetic admission", () => + Effect.gen(function* () { + const tmp = yield* project + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, ".opencode/plugins/prompt-synthetic.ts"), + `export default { + id: "prompt-synthetic", + async setup(ctx) { + await ctx.session.hook("prompt", async (event) => { + await ctx.session.synthetic({ + sessionID: event.sessionID, + text: "Admitted by Promise hook", + resume: false, + }) + event.prompt.text += " prepared" + }) + }, + }`, + ), + ) + const sessions = yield* Session.Service + const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } }) + + const prompt = yield* sessions.prompt({ sessionID: session.id, text: "Original", resume: false }) + + expect(yield* sessions.inbox(session.id)).toMatchObject([ + { type: "synthetic", payload: { text: "Admitted by Promise hook" } }, + { id: prompt.id, type: "user", payload: { text: "Original prepared" } }, + ]) + }), + ) + + it.live("keeps concurrent Promise prompt hooks reentrant for the same Session", () => + Effect.gen(function* () { + const tmp = yield* project + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, ".opencode/plugins/concurrent-prompt-synthetic.ts"), + `export default { + id: "concurrent-prompt-synthetic", + async setup(ctx) { + let entered = 0 + let release + const ready = new Promise((resolve) => release = resolve) + await ctx.session.hook("prompt", async (event) => { + entered++ + if (entered === 2) release() + await ready + await ctx.session.synthetic({ + sessionID: event.sessionID, + text: "Hook: " + event.prompt.text, + resume: false, + }) + }) + }, + }`, + ), + ) + const sessions = yield* Session.Service + const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } }) + + yield* Effect.all( + [ + sessions.prompt({ sessionID: session.id, text: "First", resume: false }), + sessions.prompt({ sessionID: session.id, text: "Second", resume: false }), + ], + { concurrency: "unbounded" }, + ) + + const inbox = yield* sessions.inbox(session.id) + const text = inbox.map((item) => ("text" in item.payload ? item.payload.text : undefined)) + expect(inbox.map((item) => item.type)).toEqual(["synthetic", "user", "synthetic", "user"]) + expect(text[0]).toBe(`Hook: ${text[1]}`) + expect(text[2]).toBe(`Hook: ${text[3]}`) + }), + ) + + it.live("drains detached Promise hook admissions before the capability expires", () => + Effect.gen(function* () { + const tmp = yield* project + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, ".opencode/plugins/detached-prompt-synthetic.ts"), + `export default { + id: "detached-prompt-synthetic", + async setup(ctx) { + await ctx.session.hook("prompt", (event) => { + void ctx.session.synthetic({ + sessionID: event.sessionID, + text: "Detached", + resume: false, + }) + }) + }, + }`, + ), + ) + const sessions = yield* Session.Service + const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } }) + + const prompt = yield* sessions.prompt({ sessionID: session.id, text: "Prompt", resume: false }) + + expect(yield* sessions.inbox(session.id)).toMatchObject([ + { type: "synthetic", payload: { text: "Detached" } }, + { id: prompt.id, type: "user" }, + ]) + }), + ) + + it.live("expires Promise hook admission after draining on success and failure", () => + Effect.gen(function* () { + const tmp = yield* project + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, ".opencode/plugins/late-prompt-synthetic.ts"), + `export default { + id: "late-prompt-synthetic", + async setup(ctx) { + await ctx.session.hook("prompt", (event) => { + queueMicrotask(() => queueMicrotask(() => void ctx.session.synthetic({ + sessionID: event.sessionID, + text: "Late: " + event.prompt.text, + resume: false, + }))) + if (event.prompt.text === "Fail") throw new Error("Hook failed") + }) + }, + }`, + ), + ) + const sessions = yield* Session.Service + const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } }) + + const prompt = yield* sessions.prompt({ sessionID: session.id, text: "Success", resume: false }) + const firstBarrier = yield* sessions.synthetic({ sessionID: session.id, text: "First barrier", resume: false }) + expect( + (yield* sessions.prompt({ sessionID: session.id, text: "Fail", resume: false }).pipe(Effect.exit))._tag, + ).toBe("Failure") + const secondBarrier = yield* sessions.synthetic({ sessionID: session.id, text: "Second barrier", resume: false }) + + expect(yield* sessions.inbox(session.id)).toMatchObject([ + { id: prompt.id, type: "user" }, + { type: "synthetic", payload: { text: "Late: Success" } }, + { id: firstBarrier.id, type: "synthetic" }, + { type: "synthetic", payload: { text: "Late: Fail" } }, + { id: secondBarrier.id, type: "synthetic" }, + ]) + }), + ) + + it.live("does not bridge unrelated Promise work while a prompt hook is active", () => + Effect.gen(function* () { + const tmp = yield* project + const sessions = yield* Session.Service + const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } }) + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, ".opencode/plugins/unrelated-prompt-synthetic.ts"), + `export default { + id: "unrelated-prompt-synthetic", + async setup(ctx) { + let start + const started = new Promise((resolve) => start = resolve) + void started.then(() => ctx.session.synthetic({ + sessionID: "${session.id}", + text: "Unrelated", + resume: false, + })) + await ctx.session.hook("prompt", async () => { + start() + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + }, + }`, + ), + ) + + const prompt = yield* sessions.prompt({ sessionID: session.id, text: "Prompt", resume: false }) + const barrier = yield* sessions.synthetic({ sessionID: session.id, text: "Barrier", resume: false }) + + expect(yield* sessions.inbox(session.id)).toMatchObject([ + { id: prompt.id, type: "user" }, + { type: "synthetic", payload: { text: "Unrelated" } }, + { id: barrier.id, type: "synthetic" }, + ]) + }), + ) + + it.live("persists ordered draft edits and resolves added files and skills without mutating the caller", () => + Effect.gen(function* () { + const fixture = yield* setup + const skills = yield* Skill.Service.pipe(Effect.provide(fixture.services)) + const skill = Skill.Info.make({ + id: Skill.ID.make("policy"), + name: Skill.Name.make("Policy"), + description: "Company policy", + path: AbsolutePath.make(path.join(fixture.session.location.directory, "policy.md")), + content: "Follow company policy.", + }) + yield* skills.transform((draft) => draft.add(skill)) + const input = { + sessionID: fixture.session.id, + id: SessionMessage.ID.create(), + text: "secret", + files: [ + { + uri: "data:text/plain;base64,b3JpZ2luYWw=", + name: "original.txt", + mention: { start: 0, end: 6, text: "secret" }, + }, + ], + metadata: { source: "api" }, + resume: false, + } + yield* fixture.hooks.register("session", "prompt", (event) => + Effect.sync(() => { + expect(event.sessionID).toBe(input.sessionID) + expect(event.messageID).toBe(input.id) + event.prompt.text = "Redacted" + const file = event.prompt.files?.[0] + if (file) { + file.uri = "data:text/plain;base64,cG9saWN5" + file.name = "policy.txt" + delete file.mention + } + event.prompt.skills = [{ id: skill.id }] + event.prompt.agents = [{ name: "reviewer" }] + event.metadata ??= {} + event.metadata.source = "plugin" + event.delivery = "queue" + }), + ) + yield* fixture.hooks.register("session", "prompt", (event) => + Effect.sync(() => { + expect(event.prompt.text).toBe("Redacted") + event.prompt.text += " with policy" + }), + ) + const admitted = yield* fixture.sessions.prompt(input) + expect(admitted).toMatchObject({ + id: input.id, + delivery: "queue", + payload: { + text: "Redacted with policy", + metadata: { source: "plugin" }, + files: [{ name: "policy.txt", data: "cG9saWN5", mime: "text/plain" }], + agents: [{ name: "reviewer" }], + skills: [{ id: skill.id, name: skill.name, text: Skill.toModelOutput(skill, []) }], + }, + }) + expect(input.text).toBe("secret") + expect(input.files).toEqual([ + { + uri: "data:text/plain;base64,b3JpZ2luYWw=", + name: "original.txt", + mention: { start: 0, end: 6, text: "secret" }, + }, + ]) + expect(input.metadata).toEqual({ source: "api" }) + const database = yield* Database.Service + const bus = yield* Bus.Service + expect(yield* SessionInbox.find(database.db, input.id)).toEqual(admitted) + const log = yield* fixture.sessions.log({ sessionID: input.sessionID }).pipe(Stream.runCollect) + expect(JSON.stringify(log)).not.toContain("secret") + yield* SessionInbox.promote(database.db, bus, input.sessionID, "input") + expect(yield* fixture.sessions.messages({ sessionID: input.sessionID })).toMatchObject([ + { id: input.id, type: "user", text: "Redacted with policy", metadata: { source: "plugin" } }, + ]) + }), + ) + + it.live("skips hooks and payload resolution on pending and delivered retries, including conflicts", () => + Effect.gen(function* () { + const fixture = yield* setup + const calls: string[] = [] + yield* fixture.hooks.register("session", "prompt", (event) => + Effect.sync(() => { + calls.push(event.prompt.text) + event.prompt.text = "First admission" + }), + ) + const input = { sessionID: fixture.session.id, id: SessionMessage.ID.create(), text: "Original", resume: false } + const first = yield* fixture.sessions.prompt(input) + const retry = { ...input, text: "Ignored", files: [{ uri: "file:///missing-retry-file" }] } + expect(yield* fixture.sessions.prompt(retry)).toEqual(first) + const database = yield* Database.Service + const bus = yield* Bus.Service + yield* SessionInbox.promote(database.db, bus, input.sessionID, "steer") + expect((yield* fixture.sessions.prompt(retry)).payload).toEqual(first.payload) + const other = yield* fixture.sessions.create({ location: fixture.session.location }) + expect((yield* fixture.sessions.prompt({ ...retry, sessionID: other.id }).pipe(Effect.flip))._tag).toBe( + "Session.PromptConflictError", + ) + const synthetic = yield* fixture.sessions.synthetic({ + sessionID: input.sessionID, + text: "Synthetic", + resume: false, + }) + expect((yield* fixture.sessions.prompt({ ...retry, id: synthetic.id }).pipe(Effect.flip))._tag).toBe( + "Session.PromptConflictError", + ) + expect(calls).toEqual(["Original"]) + }), + ) + + it.live("leaves a staged revert untouched on failed boundary replacement and preparation", () => + Effect.gen(function* () { + const fixture = yield* setup + const database = yield* Database.Service + const bus = yield* Bus.Service + const first = yield* fixture.sessions.prompt({ sessionID: fixture.session.id, text: "Boundary", resume: false }) + yield* SessionInbox.promote(database.db, bus, fixture.session.id, "steer") + yield* bus.publish(SessionEvent.RevertEvent.Staged, { + sessionID: fixture.session.id, + revert: { messageID: first.id, files: [] }, + }) + const failing = yield* fixture.hooks.register("session", "prompt", () => Effect.die(new Error("Broken hook"))) + expect( + (yield* fixture.sessions + .prompt({ + sessionID: fixture.session.id, + id: first.id, + text: "Replacement", + resume: false, + }) + .pipe(Effect.exit))._tag, + ).toBe("Failure") + expect( + (yield* fixture.sessions + .prompt({ sessionID: fixture.session.id, text: "Fail", resume: false }) + .pipe(Effect.exit))._tag, + ).toBe("Failure") + expect((yield* fixture.sessions.get(fixture.session.id)).revert?.messageID).toBe(first.id) + expect(yield* fixture.sessions.messages({ sessionID: fixture.session.id })).toMatchObject([{ id: first.id }]) + yield* failing.dispose + const next = yield* fixture.sessions.prompt({ + sessionID: fixture.session.id, + text: "After revert", + resume: false, + }) + expect((yield* fixture.sessions.get(fixture.session.id)).revert).toBeUndefined() + expect(yield* fixture.sessions.messages({ sessionID: fixture.session.id })).toEqual([]) + expect(yield* fixture.sessions.inbox(fixture.session.id)).toEqual([next]) + }), + ) + + it.live("keeps first-admission-wins for concurrent transformed submissions", () => + Effect.gen(function* () { + const fixture = yield* setup + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const calls: string[] = [] + yield* fixture.hooks.register("session", "prompt", (event) => + Effect.gen(function* () { + calls.push(event.prompt.text) + event.prompt.text += " transformed" + if (calls.length === 2) yield* Deferred.succeed(entered, undefined) + yield* Deferred.await(release) + }), + ) + const input = { sessionID: fixture.session.id, id: SessionMessage.ID.create(), text: "First", resume: false } + const submissions = yield* Effect.all( + [fixture.sessions.prompt(input), fixture.sessions.prompt({ ...input, text: "Second" })], + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild) + yield* Deferred.await(entered) + expect(yield* fixture.sessions.inbox(input.sessionID)).toEqual([]) + yield* Deferred.succeed(release, undefined) + const results = yield* Fiber.join(submissions) + expect(results[0]).toEqual(results[1]) + expect(["First transformed", "Second transformed"]).toContain(results[0]?.payload.text) + expect(yield* fixture.sessions.inbox(input.sessionID)).toHaveLength(1) + expect(yield* fixture.sessions.prompt(input)).toEqual(results[0]) + expect(calls).toHaveLength(2) + }), + ) + + it.live("does not admit failed attachment preparation or an interrupted hook", () => + Effect.gen(function* () { + const fixture = yield* setup + const registration = yield* fixture.hooks.register("session", "prompt", (event) => + Effect.sync(() => { + event.prompt.files = [{ uri: "file:///missing-hook-file" }] + }), + ) + expect( + (yield* fixture.sessions + .prompt({ sessionID: fixture.session.id, text: "Original", resume: false }) + .pipe(Effect.flip))._tag, + ).toBe("Session.AttachmentError") + yield* registration.dispose + const failing = yield* fixture.hooks.register("session", "prompt", () => Effect.die(new Error("Broken hook"))) + expect( + (yield* fixture.sessions + .prompt({ sessionID: fixture.session.id, text: "Fail", resume: false }) + .pipe(Effect.exit))._tag, + ).toBe("Failure") + yield* failing.dispose + const started = yield* Deferred.make() + yield* fixture.hooks.register("session", "prompt", () => + Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), + ) + const submission = yield* fixture.sessions + .prompt({ sessionID: fixture.session.id, text: "Interrupt", resume: false }) + .pipe(Effect.forkChild) + yield* Deferred.await(started) + yield* Fiber.interrupt(submission) + expect(yield* fixture.sessions.inbox(fixture.session.id)).toEqual([]) + expect(yield* fixture.sessions.messages({ sessionID: fixture.session.id })).toEqual([]) + }), + ) + + it.live("applies a Promise plugin to command-generated prompts only in its own location", () => + Effect.gen(function* () { + const tmp = yield* project + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, ".opencode/plugins/command.ts"), + `export default { + id: "prompt-command", + async setup(ctx) { + await ctx.session.hook("prompt", (event) => { + event.prompt.text += " with plugin" + }) + await ctx.command.transform((draft) => { + draft.add({ + name: "review", + async execute(input) { + await ctx.session.prompt({ sessionID: input.sessionID, text: "Review", resume: false }) + }, + }) + }) + }, + }`, + ), + ) + const sessions = yield* Session.Service + const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } }) + const other = yield* setup + yield* sessions.command({ sessionID: session.id, command: "review", text: "" }) + expect(yield* sessions.inbox(session.id)).toMatchObject([{ payload: { text: "Review with plugin" } }]) + const untouched = yield* other.sessions.prompt({ + sessionID: other.session.id, + text: "Other location", + resume: false, + }) + expect(untouched.payload.text).toBe("Other location") + }), + ) +}) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 96a2cddcb560..9a47feb079ec 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Fiber, Layer, LayerMap, Schema, Stream } from "effect" +import { DateTime, Deferred, Effect, Fiber, Layer, LayerMap, Schema, Stream } from "effect" import path from "path" import { pathToFileURL } from "url" import { eq } from "drizzle-orm" @@ -29,6 +29,7 @@ import type { LocationServices } from "@opencode/core/location-services" import { Image } from "@opencode/core/image" import { Plugin } from "@opencode/core/plugin" import { PluginHooks } from "@opencode/core/plugin/hooks" +import { PluginActivation } from "@opencode/plugin/effect/activation" import { Snapshot } from "@opencode/core/snapshot" import { Skill } from "@opencode/core/skill" import { tmpdirScoped } from "./fixture/tmpdir" @@ -39,6 +40,12 @@ const interruptCalls: Session.ID[] = [] const interruptContinuations: Array = [] const wakeCalls: Session.ID[] = [] const activeSessions = new Set() +const wakeControl: { + started?: Deferred.Deferred + release?: Deferred.Deferred + activate?: boolean +} = {} +const pluginFlushHook: { effect: Effect.Effect } = { effect: Effect.void } const execution = Layer.succeed( SessionExecution.Service, SessionExecution.Service.of({ @@ -55,8 +62,11 @@ const execution = Layer.succeed( return activeSessions.delete(sessionID) }), wake: (sessionID) => - Effect.sync(() => { + Effect.gen(function* () { wakeCalls.push(sessionID) + if (wakeControl.started) yield* Deferred.succeed(wakeControl.started, undefined) + if (wakeControl.release) yield* Deferred.await(wakeControl.release) + if (wakeControl.activate) activeSessions.add(sessionID) }), awaitIdle: () => Effect.void, }), @@ -67,23 +77,43 @@ const locations = makeGlobalNode({ LocationServiceMap.Service, Effect.gen(function* () { const bus = yield* Bus.Service - return yield* LayerMap.make( - (_ref: Location.Ref) => - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion - Layer.mergeAll( + return yield* LayerMap.make((_ref: Location.Ref) => + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion + Layer.suspend(() => { + let ready = false + return Layer.mergeAll( LayerNode.compile(LayerNode.group([PluginHooks.node, Skill.node]), { replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus))], }), Layer.mock(Image.Service, { normalize: (_resource, content) => - Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content), + ready + ? Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content) + : Effect.die(new Error("Image service used before plugins were ready")), }), Layer.mock(Snapshot.Service, { - capture: () => Effect.undefined, - restore: () => Effect.void, + capture: () => + ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")), + restore: () => (ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready"))), + }), + Layer.mock(Plugin.Service, { + awaitActivation: Effect.gen(function* () { + const activation = { + active: true, + fiberID: yield* Effect.fiberId, + token: {}, + directory: "/project", + workspaceID: undefined, + } + return yield* Effect.sync(() => (ready = true)).pipe( + Effect.andThen(Effect.suspend(() => pluginFlushHook.effect)), + Effect.provideService(PluginActivation.Current, activation), + Effect.ensuring(Effect.sync(() => (activation.active = false))), + ) + }), }), - Layer.mock(Plugin.Service, { awaitActivation: Effect.void }), - ).pipe(Layer.fresh) as unknown as Layer.Layer, + ).pipe(Layer.fresh) as unknown as Layer.Layer + }), ) }), ), @@ -252,6 +282,60 @@ describe("Session.prompt", () => { }), ) + it.effect("admits synthetic context immediately before its user prompt", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const contextID = SessionMessage.ID.make("msg_prompt_context") + const promptID = SessionMessage.ID.make("msg_prompt_with_context") + + const prompt = yield* session.prompt({ + id: promptID, + sessionID, + text: "Inspect this", + context: { id: contextID, text: "editor context" }, + resume: false, + }) + const ignoredContextID = SessionMessage.ID.make("msg_ignored_retry_context") + const retry = yield* session.prompt({ + id: promptID, + sessionID, + text: "ignored retry", + context: { id: ignoredContextID, text: "ignored context" }, + resume: false, + }) + + expect((yield* session.inbox(sessionID)).map((item) => item.id)).toEqual([contextID, promptID]) + expect(yield* admitted(contextID)).toMatchObject({ + type: "synthetic", + payload: { text: "editor context" }, + }) + expect(yield* admitted(ignoredContextID)).toBeUndefined() + expect(retry).toEqual(prompt) + }), + ) + + it.effect("rejects queued prompts with synthetic context", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const contextID = SessionMessage.ID.make("msg_queued_context") + + const error = yield* session + .prompt({ + sessionID, + text: "Queue this", + context: { id: contextID, text: "editor context" }, + delivery: "queue", + resume: false, + }) + .pipe(Effect.flip) + + expect(error).toMatchObject({ _tag: "Session.ContextDeliveryError", sessionID }) + expect(yield* admittedCount).toBe(0) + }), + ) + it.effect("commits a staged revert before admitting a new prompt", () => Effect.gen(function* () { yield* setup @@ -285,6 +369,534 @@ describe("Session.prompt", () => { }), ) + it.effect("atomically replaces a staged boundary under the same message ID", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const bus = yield* Bus.Service + const { db } = yield* Database.Service + const boundary = yield* session.prompt({ sessionID, text: "original", resume: false }) + yield* SessionInbox.promote(db, bus, sessionID, "steer") + const stale = SessionMessage.ID.make("msg_stale_same_id_replacement") + yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie) + yield* bus.publish(SessionEvent.RevertEvent.Staged, { + sessionID, + revert: { messageID: boundary.id, files: [] }, + }) + const context = SessionMessage.ID.make("msg_replacement_context") + + const replacement = yield* session.prompt({ + id: boundary.id, + sessionID, + text: "replacement", + context: { id: context, text: "editor context" }, + delivery: "steer", + resume: false, + }) + const retry = yield* session.prompt({ + id: boundary.id, + sessionID, + text: "ignored retry", + delivery: "steer", + resume: false, + }) + const rows = yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all().pipe(Effect.orDie) + const events = yield* db + .select({ type: EventTable.type }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, sessionID)) + .orderBy(EventTable.seq) + .all() + .pipe(Effect.orDie) + + expect(replacement).toMatchObject({ + id: boundary.id, + payload: { text: "replacement" }, + delivery: "steer", + }) + expect(retry).toEqual(replacement) + expect(rows.map((row) => row.id)).not.toContainAnyValues([boundary.id, stale]) + expect(yield* admitted(boundary.id)).toEqual(replacement) + expect(yield* admitted(context)).toMatchObject({ + id: context, + type: "synthetic", + payload: { text: "editor context" }, + }) + expect(events.slice(-3).map((event) => event.type)).toEqual([ + "session.revert.committed.1", + "session.inbox.enqueued.1", + "session.inbox.enqueued.1", + ]) + expect(yield* eventCount("session.revert.committed.1")).toBe(1) + expect(yield* eventCount("session.inbox.enqueued.1")).toBe(3) + }), + ) + + it.effect("commits a staged revert while preserving a delivered retry before the boundary", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const bus = yield* Bus.Service + const { db } = yield* Database.Service + const retained = yield* session.prompt({ sessionID, text: "retained", resume: false }) + yield* SessionInbox.promote(db, bus, sessionID, "steer") + const boundary = yield* session.prompt({ sessionID, text: "boundary", resume: false }) + yield* SessionInbox.promote(db, bus, sessionID, "steer") + yield* bus.publish(SessionEvent.RevertEvent.Staged, { + sessionID, + revert: { messageID: boundary.id, files: [] }, + }) + const context = SessionMessage.ID.make("msg_ignored_retained_context") + + const retried = yield* session.prompt({ + id: retained.id, + sessionID, + text: "ignored retry", + context: { id: context, text: "ignored context" }, + resume: false, + }) + + expect(retried.payload.text).toBe("retained") + expect((yield* session.get(sessionID)).revert).toBeUndefined() + expect((yield* session.messages({ sessionID })).map((message) => message.id)).toEqual([retained.id]) + expect(yield* admitted(context)).toBeUndefined() + }), + ) + + it.effect("commits a staged revert while preserving a pending retry before the boundary", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const bus = yield* Bus.Service + const { db } = yield* Database.Service + const retained = yield* session.prompt({ + sessionID, + text: "retained", + delivery: "queue", + resume: false, + }) + const boundary = yield* session.prompt({ sessionID, text: "boundary", resume: false }) + yield* SessionInbox.promote(db, bus, sessionID, "steer") + yield* bus.publish(SessionEvent.RevertEvent.Staged, { + sessionID, + revert: { messageID: boundary.id, files: [] }, + }) + const context = SessionMessage.ID.make("msg_ignored_pending_context") + + const retried = yield* session.prompt({ + id: retained.id, + sessionID, + text: "ignored retry", + context: { id: context, text: "ignored context" }, + resume: false, + }) + + expect(retried).toEqual(retained) + expect((yield* session.get(sessionID)).revert).toBeUndefined() + expect(yield* admitted(retained.id)).toEqual(retained) + expect(yield* admitted(context)).toBeUndefined() + }), + ) + + it.effect("keeps a staged revert recoverable when replacement prompt validation fails", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const bus = yield* Bus.Service + const { db } = yield* Database.Service + const boundary = yield* session.prompt({ sessionID, text: "boundary", resume: false }) + yield* SessionInbox.promote(db, bus, sessionID, "steer") + const stale = SessionMessage.ID.make("msg_stale_after_failed_replacement") + yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie) + yield* bus.publish(SessionEvent.RevertEvent.Staged, { + sessionID, + revert: { messageID: boundary.id, files: [] }, + }) + + const error = yield* session + .prompt({ + sessionID, + text: "replacement", + files: [{ uri: "data:image/png;base64,not-base64", name: "image.png" }], + resume: false, + }) + .pipe(Effect.flip) + const rows = yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all().pipe(Effect.orDie) + + expect({ + error: error._tag, + revert: (yield* session.get(sessionID)).revert?.messageID, + boundary: rows.some((row) => row.id === boundary.id), + stale: rows.some((row) => row.id === stale), + admitted: yield* admittedCount, + }).toEqual({ + error: "Session.AttachmentError", + revert: boundary.id, + boundary: true, + stale: true, + admitted: 0, + }) + }), + ) + + it.effect("keeps a staged revert recoverable when replacement prompt admission conflicts", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const bus = yield* Bus.Service + const { db } = yield* Database.Service + const boundary = yield* session.prompt({ sessionID, text: "boundary", resume: false }) + yield* SessionInbox.promote(db, bus, sessionID, "steer") + const stale = SessionMessage.ID.make("msg_stale_after_conflicting_replacement") + yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie) + yield* bus.publish(SessionEvent.RevertEvent.Staged, { + sessionID, + revert: { messageID: boundary.id, files: [] }, + }) + const other = Session.ID.make("ses_prompt_conflict") + yield* db + .insert(SessionTable) + .values({ + id: other, + project_id: Project.ID.global, + slug: "conflict", + directory: "/project", + title: "conflict", + version: "test", + }) + .run() + .pipe(Effect.orDie) + const conflict = SessionMessage.ID.create() + yield* session.prompt({ id: conflict, sessionID: other, text: "first", resume: false }) + const context = SessionMessage.ID.make("msg_context_before_conflict") + + const error = yield* session + .prompt({ + id: conflict, + sessionID, + text: "replacement", + context: { id: context, text: "editor context" }, + resume: false, + }) + .pipe(Effect.flip) + const rows = yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all().pipe(Effect.orDie) + + expect({ + error: error._tag, + revert: (yield* session.get(sessionID)).revert?.messageID, + boundary: rows.some((row) => row.id === boundary.id), + stale: rows.some((row) => row.id === stale), + context: yield* admitted(context), + }).toEqual({ + error: "Session.PromptConflictError", + revert: boundary.id, + boundary: true, + stale: true, + context: undefined, + }) + }), + ) + + it.effect("serializes revert staging before replacement prompt admission", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const bus = yield* Bus.Service + const { db } = yield* Database.Service + const boundary = yield* session.prompt({ sessionID, text: "boundary", resume: false }) + yield* SessionInbox.promote(db, bus, sessionID, "steer") + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const locked = yield* SessionInbox.serialized( + sessionID, + Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))), + ).pipe(Effect.forkChild) + yield* Deferred.await(entered) + + const staged = yield* session.revert + .stage({ sessionID, messageID: boundary.id, files: false }) + .pipe(Effect.forkChild) + yield* Effect.yieldNow + const replacementID = SessionMessage.ID.make("msg_serialized_replacement") + const prompted = yield* session + .prompt({ id: replacementID, sessionID, text: "replacement", resume: false }) + .pipe(Effect.forkChild) + yield* Effect.yieldNow + + expect(yield* admitted(replacementID)).toBeUndefined() + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(locked) + yield* Fiber.join(staged) + const replacement = yield* Fiber.join(prompted) + + expect((yield* session.get(sessionID)).revert).toBeUndefined() + expect(replacement.payload.text).toBe("replacement") + expect(yield* admitted(replacementID)).toEqual(replacement) + }), + ) + + it.effect("starts execution before a waiting revert can stage", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const bus = yield* Bus.Service + const { db } = yield* Database.Service + const boundary = yield* session.prompt({ sessionID, text: "boundary", resume: false }) + yield* SessionInbox.promote(db, bus, sessionID, "steer") + const started = yield* Deferred.make() + const release = yield* Deferred.make() + wakeControl.started = started + wakeControl.release = release + wakeControl.activate = true + + return yield* Effect.gen(function* () { + const prompted = yield* session.prompt({ sessionID, text: "replacement" }).pipe(Effect.forkChild) + yield* Deferred.await(started) + const staged = yield* session.revert + .stage({ sessionID, messageID: boundary.id, files: false }) + .pipe(Effect.flip, Effect.forkChild) + yield* Effect.yieldNow + + expect(staged.pollUnsafe()).toBeUndefined() + yield* Deferred.succeed(release, undefined) + const replacement = yield* Fiber.join(prompted) + const error = yield* Fiber.join(staged) + + expect(error._tag).toBe("Session.BusyError") + expect(yield* admitted(replacement.id)).toEqual(replacement) + }).pipe( + Effect.ensuring( + Deferred.succeed(release, undefined).pipe( + Effect.andThen( + Effect.sync(() => { + delete wakeControl.started + delete wakeControl.release + delete wakeControl.activate + activeSessions.clear() + }), + ), + ), + ), + ) + }), + ) + + it.effect("reserves revert staging before delayed plugin activation", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const bus = yield* Bus.Service + const { db } = yield* Database.Service + const boundary = yield* session.prompt({ sessionID, text: "boundary", resume: false }) + yield* SessionInbox.promote(db, bus, sessionID, "steer") + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + pluginFlushHook.effect = Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))) + + yield* Effect.gen(function* () { + const staged = yield* session.revert + .stage({ sessionID, messageID: boundary.id, files: false }) + .pipe(Effect.forkChild) + yield* Deferred.await(entered) + const replacementID = SessionMessage.ID.make("msg_delayed_stage_replacement") + const prompted = yield* session + .prompt({ id: replacementID, sessionID, text: "replacement", resume: false }) + .pipe(Effect.forkChild) + yield* Effect.all( + Array.from({ length: 10 }, () => Effect.yieldNow), + { concurrency: 1 }, + ) + + const promptBeforeRelease = prompted.pollUnsafe() + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(staged) + const replacement = yield* Fiber.join(prompted) + + expect(promptBeforeRelease).toBeUndefined() + expect((yield* session.get(sessionID)).revert).toBeUndefined() + expect(replacement.payload.text).toBe("replacement") + expect(yield* admitted(replacementID)).toEqual(replacement) + }).pipe(Effect.ensuring(Effect.sync(() => (pluginFlushHook.effect = Effect.void)))) + }), + ) + + it.effect("reserves revert clearing before delayed plugin activation", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const bus = yield* Bus.Service + const { db } = yield* Database.Service + const retained = yield* session.prompt({ sessionID, text: "retained", resume: false }) + yield* SessionInbox.promote(db, bus, sessionID, "steer") + const boundary = yield* session.prompt({ sessionID, text: "boundary", resume: false }) + yield* SessionInbox.promote(db, bus, sessionID, "steer") + yield* session.revert.stage({ sessionID, messageID: boundary.id, files: false }) + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + pluginFlushHook.effect = Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))) + + yield* Effect.gen(function* () { + const cleared = yield* session.revert.clear(sessionID).pipe(Effect.forkChild) + yield* Deferred.await(entered) + const replacementID = SessionMessage.ID.make("msg_delayed_clear_replacement") + const prompted = yield* session + .prompt({ id: replacementID, sessionID, text: "after clear", resume: false }) + .pipe(Effect.forkChild) + yield* Effect.all( + Array.from({ length: 10 }, () => Effect.yieldNow), + { concurrency: 1 }, + ) + + const promptBeforeRelease = prompted.pollUnsafe() + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(cleared) + yield* Fiber.join(prompted) + + expect(promptBeforeRelease).toBeUndefined() + expect((yield* session.get(sessionID)).revert).toBeUndefined() + expect((yield* session.messages({ sessionID, order: "asc" })).map((message) => message.id)).toEqual([ + retained.id, + boundary.id, + ]) + expect(yield* admitted(replacementID)).toMatchObject({ id: replacementID, payload: { text: "after clear" } }) + }).pipe(Effect.ensuring(Effect.sync(() => (pluginFlushHook.effect = Effect.void)))) + }), + ) + + it.effect("reserves file prompt admission before a later revert stage", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const bus = yield* Bus.Service + const { db } = yield* Database.Service + const boundary = yield* session.prompt({ sessionID, text: "boundary", resume: false }) + yield* SessionInbox.promote(db, bus, sessionID, "steer") + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + let flushes = 0 + pluginFlushHook.effect = Effect.suspend(() => { + flushes++ + if (flushes > 1) return Effect.void + return Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))) + }) + const uri = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + + yield* Effect.gen(function* () { + const prompted = yield* session + .prompt({ sessionID, text: "inspect", files: [{ uri }], resume: false }) + .pipe(Effect.forkChild) + yield* Deferred.await(entered) + const staged = yield* session.revert + .stage({ sessionID, messageID: boundary.id, files: false }) + .pipe(Effect.forkChild) + yield* Effect.all( + Array.from({ length: 10 }, () => Effect.yieldNow), + { concurrency: 1 }, + ) + + const stageBeforeRelease = staged.pollUnsafe() + const flushesBeforeRelease = flushes + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(prompted) + yield* Fiber.join(staged) + + expect(stageBeforeRelease).toBeUndefined() + expect(flushesBeforeRelease).toBe(1) + expect((yield* session.get(sessionID)).revert?.messageID).toBe(boundary.id) + }).pipe(Effect.ensuring(Effect.sync(() => (pluginFlushHook.effect = Effect.void)))) + }), + ) + + it.effect("keeps later operations behind a failed middle reservation", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const firstEntered = yield* Deferred.make() + const secondEntered = yield* Deferred.make() + const release = yield* Deferred.make() + let flushes = 0 + pluginFlushHook.effect = Effect.suspend(() => { + flushes++ + if (flushes === 1) + return Deferred.succeed(firstEntered, undefined).pipe(Effect.andThen(Deferred.await(release))) + if (flushes === 2) + return Deferred.succeed(secondEntered, undefined).pipe( + Effect.andThen(Effect.die(new Error("middle preparation failed"))), + ) + return Effect.void + }) + + yield* Effect.gen(function* () { + const first = yield* session.prompt({ sessionID, text: "first", resume: false }).pipe(Effect.forkChild) + yield* Deferred.await(firstEntered) + const middle = yield* session.prompt({ sessionID, text: "middle", resume: false }).pipe(Effect.forkChild) + yield* Deferred.await(secondEntered) + const lastID = SessionMessage.ID.make("msg_after_failed_reservation") + const last = yield* session + .prompt({ id: lastID, sessionID, text: "last", resume: false }) + .pipe(Effect.forkChild) + yield* Effect.all( + Array.from({ length: 10 }, () => Effect.yieldNow), + { concurrency: 1 }, + ) + + expect(last.pollUnsafe()).toBeUndefined() + expect(yield* admitted(lastID)).toBeUndefined() + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(first) + expect((yield* Fiber.await(middle))._tag).toBe("Failure") + expect(yield* Fiber.join(last)).toMatchObject({ id: lastID, payload: { text: "last" } }) + }).pipe(Effect.ensuring(Effect.sync(() => (pluginFlushHook.effect = Effect.void)))) + }), + ) + + it.effect("interrupts a middle reservation without releasing later operations", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const firstEntered = yield* Deferred.make() + const middleEntered = yield* Deferred.make() + const release = yield* Deferred.make() + let flushes = 0 + pluginFlushHook.effect = Effect.suspend(() => { + flushes++ + if (flushes === 1) + return Deferred.succeed(firstEntered, undefined).pipe(Effect.andThen(Deferred.await(release))) + if (flushes === 2) return Deferred.succeed(middleEntered, undefined).pipe(Effect.andThen(Effect.never)) + return Effect.void + }) + + yield* Effect.gen(function* () { + const first = yield* session.prompt({ sessionID, text: "first", resume: false }).pipe(Effect.forkChild) + yield* Deferred.await(firstEntered) + const middle = yield* session.prompt({ sessionID, text: "middle", resume: false }).pipe(Effect.forkChild) + yield* Deferred.await(middleEntered) + const lastID = SessionMessage.ID.make("msg_after_interrupted_reservation") + const last = yield* session + .prompt({ id: lastID, sessionID, text: "last", resume: false }) + .pipe(Effect.forkChild) + + yield* Fiber.interrupt(middle).pipe(Effect.timeout("1 second")) + yield* Effect.all( + Array.from({ length: 10 }, () => Effect.yieldNow), + { concurrency: 1 }, + ) + expect(last.pollUnsafe()).toBeUndefined() + expect(yield* admitted(lastID)).toBeUndefined() + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(first) + expect(yield* Fiber.join(last)).toMatchObject({ id: lastID, payload: { text: "last" } }) + }).pipe( + Effect.ensuring( + Deferred.succeed(release, undefined).pipe( + Effect.andThen(Effect.sync(() => (pluginFlushHook.effect = Effect.void))), + ), + ), + ) + }), + ) + it.effect("holds synthetic input behind a staged revert and discards it when committed", () => Effect.gen(function* () { yield* setup @@ -314,6 +926,158 @@ describe("Session.prompt", () => { }), ) + it.effect("serializes synthetic admission with revert mutations", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const locked = yield* SessionInbox.serialized( + sessionID, + Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))), + ).pipe(Effect.forkChild) + yield* Deferred.await(entered) + const synthetic = yield* session + .synthetic({ + id: SessionMessage.ID.make("msg_serialized_synthetic"), + sessionID, + text: "completion", + resume: false, + }) + .pipe(Effect.forkChild) + yield* Effect.all( + Array.from({ length: 10 }, () => Effect.yieldNow), + { concurrency: 1 }, + ) + + expect(synthetic.pollUnsafe()).toBeUndefined() + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(locked) + expect((yield* Fiber.join(synthetic)).payload.text).toBe("completion") + }), + ) + + it.effect("does not hold the inbox lock while image plugins activate", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const uri = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + pluginFlushHook.effect = session + .synthetic({ sessionID, text: "plugin activated", resume: false }) + .pipe(Effect.orDie, Effect.asVoid) + + yield* Effect.gen(function* () { + const prompt = yield* session + .prompt({ sessionID, text: "Inspect this image", files: [{ uri }], resume: false }) + .pipe(Effect.forkChild) + yield* Effect.all( + Array.from({ length: 10 }, () => Effect.yieldNow), + { concurrency: 1 }, + ) + expect(prompt.pollUnsafe()).toBeDefined() + yield* Fiber.join(prompt) + }).pipe(Effect.ensuring(Effect.sync(() => (pluginFlushHook.effect = Effect.void)))) + }), + ) + + it.effect("expires plugin activation bypass for detached work", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const bus = yield* Bus.Service + const { db } = yield* Database.Service + const boundary = yield* session.prompt({ sessionID, text: "boundary", resume: false }) + yield* SessionInbox.promote(db, bus, sessionID, "steer") + const trigger = yield* Deferred.make() + const completed = yield* Deferred.make() + pluginFlushHook.effect = Deferred.await(trigger).pipe( + Effect.andThen(session.synthetic({ sessionID, text: "detached activation", resume: false })), + Effect.andThen(Deferred.succeed(completed, undefined)), + Effect.forkDetach, + Effect.asVoid, + ) + const uri = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + + yield* session.prompt({ sessionID, text: "Inspect this image", files: [{ uri }], resume: false }) + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + pluginFlushHook.effect = Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))) + + yield* Effect.gen(function* () { + const staged = yield* session.revert + .stage({ sessionID, messageID: boundary.id, files: false }) + .pipe(Effect.forkChild) + yield* Deferred.await(entered) + yield* Deferred.succeed(trigger, undefined) + yield* Effect.all( + Array.from({ length: 10 }, () => Effect.yieldNow), + { concurrency: 1 }, + ) + const completedBeforeRelease = yield* Deferred.isDone(completed) + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(staged) + yield* Deferred.await(completed) + + expect(completedBeforeRelease).toBe(false) + }).pipe( + Effect.ensuring( + Effect.all([Deferred.succeed(trigger, undefined), Deferred.succeed(release, undefined)], { + discard: true, + }).pipe(Effect.andThen(Effect.sync(() => (pluginFlushHook.effect = Effect.void)))), + ), + ) + }), + ) + + it.effect("does not grant plugin activation bypass to detached work", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const bus = yield* Bus.Service + const { db } = yield* Database.Service + const boundary = yield* session.prompt({ sessionID, text: "boundary", resume: false }) + yield* SessionInbox.promote(db, bus, sessionID, "steer") + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const attempted = yield* Deferred.make() + const completed = yield* Deferred.make() + pluginFlushHook.effect = Deferred.succeed(attempted, undefined).pipe( + Effect.andThen(session.synthetic({ sessionID, text: "detached activation", resume: false })), + Effect.andThen(Deferred.succeed(completed, undefined)), + Effect.forkDetach({ startImmediately: true }), + Effect.andThen(Deferred.succeed(entered, undefined)), + Effect.andThen(Deferred.await(release)), + Effect.asVoid, + ) + + yield* Effect.gen(function* () { + const staged = yield* session.revert + .stage({ sessionID, messageID: boundary.id, files: false }) + .pipe(Effect.forkChild) + yield* Deferred.await(entered) + yield* Deferred.await(attempted) + yield* Effect.all( + Array.from({ length: 10 }, () => Effect.yieldNow), + { concurrency: 1 }, + ) + const completedBeforeRelease = yield* Deferred.isDone(completed) + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(staged) + yield* Deferred.await(completed) + + expect(completedBeforeRelease).toBe(false) + }).pipe( + Effect.ensuring( + Deferred.succeed(release, undefined).pipe( + Effect.andThen(Effect.sync(() => (pluginFlushHook.effect = Effect.void))), + ), + ), + ) + }), + ) + it.effect("resolves attachment MIME before admission", () => Effect.gen(function* () { yield* setup @@ -618,6 +1382,69 @@ describe("Session.prompt", () => { }), ) + it.effect("reconciles file-bearing retries before loading location plugins", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const id = SessionMessage.ID.create() + const original = yield* session.prompt({ id, sessionID, text: "First admission", resume: false }) + pluginFlushHook.effect = Effect.die(new Error("plugins loaded for a durable retry")) + + const retried = yield* session + .prompt({ + id, + sessionID, + text: "Ignored retry", + files: [{ uri: "data:image/png;base64,invalid" }], + resume: false, + }) + .pipe(Effect.ensuring(Effect.sync(() => (pluginFlushHook.effect = Effect.void)))) + + expect(retried).toEqual(original) + }), + ) + + it.effect("reconciles a queued durable retry before prompt preparation", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const id = SessionMessage.ID.create() + const original = yield* session.prompt({ id, sessionID, text: "First admission", resume: false }) + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + let flushes = 0 + pluginFlushHook.effect = Effect.suspend(() => { + flushes++ + if (flushes === 1) return Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))) + return Effect.die(new Error("plugins loaded for a queued durable retry")) + }) + + yield* Effect.gen(function* () { + const pending = yield* session.prompt({ sessionID, text: "Unrelated", resume: false }).pipe(Effect.forkChild) + yield* Deferred.await(entered) + const retried = yield* session + .prompt({ + id, + sessionID, + text: "Ignored retry", + files: [{ uri: "data:image/png;base64,invalid" }], + resume: false, + }) + .pipe(Effect.forkChild) + yield* Effect.all( + Array.from({ length: 10 }, () => Effect.yieldNow), + { concurrency: 1 }, + ) + + expect(retried.pollUnsafe()).toBeUndefined() + expect(flushes).toBe(1) + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(pending) + expect(yield* Fiber.join(retried)).toEqual(original) + }).pipe(Effect.ensuring(Effect.sync(() => (pluginFlushHook.effect = Effect.void)))) + }), + ) + it.effect("reconciles an exact retry from the promoted message without admission history", () => Effect.gen(function* () { yield* setup @@ -847,11 +1674,19 @@ describe("Session.prompt", () => { .run() .pipe(Effect.orDie) yield* session.prompt({ id: messageID, sessionID, text: "Fix the failing tests", resume: false }) + const contextID = SessionMessage.ID.make("msg_cross_session_context") const failure = yield* session - .prompt({ id: messageID, sessionID: other, text: "Fix the failing tests", resume: false }) + .prompt({ + id: messageID, + sessionID: other, + text: "Fix the failing tests", + context: { id: contextID, text: "editor context" }, + resume: false, + }) .pipe(Effect.flip) expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID: other, messageID }) + expect(yield* admitted(contextID)).toBeUndefined() }), ) @@ -1080,6 +1915,53 @@ describe("Session.prompt", () => { ) }) +describe("Session.revert", () => { + it.effect("does not hold the inbox lock while location plugins activate", () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + const session = yield* Session.Service + yield* db.insert(SessionMessageTable).values(assistantRow(messageID, 0)).run().pipe(Effect.orDie) + pluginFlushHook.effect = session + .synthetic({ sessionID, text: "plugin activated", resume: false }) + .pipe(Effect.orDie, Effect.asVoid) + + yield* Effect.gen(function* () { + const stage = yield* session.revert.stage({ sessionID, messageID }).pipe(Effect.forkChild) + yield* Effect.all( + Array.from({ length: 10 }, () => Effect.yieldNow), + { concurrency: 1 }, + ) + expect(stage.pollUnsafe()).toBeDefined() + yield* Fiber.join(stage) + }).pipe(Effect.ensuring(Effect.sync(() => (pluginFlushHook.effect = Effect.void)))) + }), + ) + + it.effect("waits for location plugins before staging", () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + const session = yield* Session.Service + yield* db.insert(SessionMessageTable).values(assistantRow(messageID, 0)).run().pipe(Effect.orDie) + yield* session.revert.stage({ sessionID, messageID }) + }), + ) + + it.effect("waits for location plugins before clearing", () => + Effect.gen(function* () { + yield* setup + const session = yield* Session.Service + const bus = yield* Bus.Service + yield* bus.publish(SessionEvent.RevertEvent.Staged, { + sessionID, + revert: { messageID, snapshot: Snapshot.ID.make("tree"), files: [] }, + }) + yield* session.revert.clear(sessionID) + }), + ) +}) + describe("Session.inbox", () => { it.effect("fails for an unknown session", () => Effect.gen(function* () { diff --git a/packages/plugin/src/effect/activation.ts b/packages/plugin/src/effect/activation.ts new file mode 100644 index 000000000000..f8a9d3d18819 --- /dev/null +++ b/packages/plugin/src/effect/activation.ts @@ -0,0 +1,37 @@ +export * as PluginActivation from "./activation.js" + +import { Context, type Effect } from "effect" + +export type State = { + active: boolean + readonly fiberID: number + readonly token: object + readonly directory: string + readonly workspaceID?: string +} + +export const Current = Context.Reference("@opencode/PluginActivation", { + defaultValue: () => undefined, +}) + +export const Bridged = Context.Reference("@opencode/PluginActivation/Bridged", { + defaultValue: () => undefined, +}) + +export type PromptPreparation = { + active: boolean + readonly fiberID: number + readonly token: object + readonly sessionID: string + readonly wait: Effect.Effect +} + +export const PromptPreparationCurrent = Context.Reference( + "@opencode/PluginActivation/PromptPreparation", + { defaultValue: () => undefined }, +) + +export const PromptPreparationBridged = Context.Reference( + "@opencode/PluginActivation/PromptPreparation/Bridged", + { defaultValue: () => undefined }, +) diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index c6895e6a65bb..01e073df8e68 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -1,13 +1,15 @@ import { Tool } from "@opencode/schema/tool" import type { Rpc } from "@opencode/schema/rpc" import type { RpcCallOptions, RpcEventPayload } from "@opencode/client/promise/api" -import { Effect, Schema, SchemaAST, Stream } from "effect" +import { Context, Effect, Schema, SchemaAST, Stream } from "effect" import type { Scope } from "effect" import { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi" +import { AsyncLocalStorage } from "node:async_hooks" import { define } from "../effect/plugin.js" import type { Plugin } from "./plugin.js" import type { Info } from "./tool.js" import type { RpcDomain, RpcHandlers } from "./rpc.js" +import { PluginActivation } from "../effect/activation.js" type HostRegistration = { readonly dispose: Effect.Effect } type Registration = { readonly dispose: () => Promise } @@ -239,6 +241,14 @@ export function fromPromise(plugin: Plugin) { const WorktreeEndpoints = ClientApi.groups["server.worktree"].endpoints const runtime = yield* Effect.context() const streams = yield* makeStreams() + const activation = Context.get(runtime, PluginActivation.Current) + const setupCalls = new Set>() + const promptHook = new AsyncLocalStorage<{ + readonly preparation: PluginActivation.PromptPreparation + readonly calls: Set> + accepting: boolean + }>() + let setupActive = true // Run a hook registration on the plugin scope and resolve once it is registered. const register = (effect: Effect.Effect): Promise => @@ -259,22 +269,69 @@ export function fromPromise(plugin: Plugin) { { signal: context.signal }, ) + // Promise setup runs host calls in new root fibers. Bridge only synthetic + // admissions, then drain them before the activation capability expires. + const runSetup = (effect: Effect.Effect) => { + const request = run( + setupActive && activation?.active + ? effect.pipe(Effect.provideService(PluginActivation.Bridged, activation.token)) + : effect, + ) + if (setupActive && activation?.active) { + setupCalls.add(request) + void request.then( + () => setupCalls.delete(request), + () => setupCalls.delete(request), + ) + } + return request + } + + const runSynthetic = (effect: Effect.Effect, input: unknown) => { + if (setupActive && activation?.active) return runSetup(effect) + const sessionID = + typeof input === "object" && input !== null && "sessionID" in input && typeof input.sessionID === "string" + ? input.sessionID + : undefined + const invocation = promptHook.getStore() + const preparation = invocation?.preparation + const request = run( + invocation?.accepting && preparation?.active && preparation.sessionID === sessionID + ? effect.pipe(Effect.provideService(PluginActivation.PromptPreparationBridged, preparation.token)) + : effect, + ) + if (invocation?.accepting && preparation?.active && preparation.sessionID === sessionID) { + invocation.calls.add(request) + void request.then( + () => invocation.calls.delete(request), + () => invocation.calls.delete(request), + ) + } + return request + } + const adaptApiMethod = ( endpoint: HttpApiEndpoint.Top, method: (input: never) => Effect.Effect, + runner: (effect: Effect.Effect, input: unknown) => Promise = run, ) => { const compiled = compileEndpoint(endpoint) - return ((input?: unknown) => - Effect.gen(function* () { - // Match the generated Promise client, whose request body crosses JSON before endpoint decoding. - const normalized = yield* Schema.encodeUnknownEffect(JsonInput)(input ?? {}).pipe( - Effect.flatMap(Schema.decodeUnknownEffect(JsonInput)), - ) - const decoded = yield* Effect.forEach(compiled.decode, (decode) => decode(normalized)) - const result = yield* method(Object.assign({}, ...decoded) as never) - if (compiled.noContent) return undefined - return yield* compiled.encode(result) - }).pipe(Effect.runPromiseWith(runtime))) as PromiseMethod + return ((input?: unknown) => { + const value = input ?? {} + return runner( + Effect.gen(function* () { + // Match the generated Promise client, whose request body crosses JSON before endpoint decoding. + const normalized = yield* Schema.encodeUnknownEffect(JsonInput)(value).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(JsonInput)), + ) + const decoded = yield* Effect.forEach(compiled.decode, (decode) => decode(normalized)) + const result = yield* method(Object.assign({}, ...decoded) as never) + if (compiled.noContent) return undefined + return yield* compiled.encode(result) + }), + value, + ) + }) as PromiseMethod } const transform = @@ -577,7 +634,33 @@ export function fromPromise(plugin: Plugin) { session: { hook: (name, callback, options) => register( - host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))), options), + host.session.hook( + name, + (event) => + Effect.gen(function* () { + const preparation = yield* PluginActivation.PromptPreparationCurrent + if (!preparation?.active || preparation.sessionID !== event.sessionID) + return yield* Effect.promise(() => Promise.resolve(callback(event))) + return yield* Effect.promise(() => { + const invocation = { preparation, calls: new Set>(), accepting: true } + return promptHook.run(invocation, () => { + const drain = async () => { + while (invocation.calls.size > 0) { + await Promise.allSettled(invocation.calls) + } + invocation.accepting = false + } + return Promise.resolve() + .then(() => callback(event)) + .then(drain, async (error) => { + await drain() + throw error + }) + }) + }) + }), + options, + ), ), create: adaptApiMethod(SessionEndpoints["session.create"], host.session.create), get: adaptApiMethod(SessionEndpoints["session.get"], host.session.get), @@ -587,7 +670,7 @@ export function fromPromise(plugin: Plugin) { prompt: adaptApiMethod(SessionEndpoints["session.prompt"], host.session.prompt), generate: adaptApiMethod(SessionEndpoints["session.generate"], host.session.generate), command: adaptApiMethod(SessionEndpoints["session.command"], host.session.command), - synthetic: adaptApiMethod(SessionEndpoints["session.synthetic"], host.session.synthetic), + synthetic: adaptApiMethod(SessionEndpoints["session.synthetic"], host.session.synthetic, runSynthetic), interrupt: adaptApiMethod(SessionEndpoints["session.interrupt"], host.session.interrupt), update: adaptApiMethod(SessionEndpoints["session.update"], host.session.update), move: adaptApiMethod(SessionEndpoints["session.move"], host.session.move), @@ -601,7 +684,18 @@ export function fromPromise(plugin: Plugin) { } yield* Effect.acquireRelease( - Effect.promise(() => Promise.resolve(plugin.setup(context2))), + Effect.promise(async () => { + const result = await Promise.resolve() + .then(() => plugin.setup(context2)) + .then( + (cleanup) => ({ _tag: "success" as const, cleanup }), + (cause) => ({ _tag: "failure" as const, cause }), + ) + while (setupCalls.size) await Promise.allSettled(setupCalls) + setupActive = false + if (result._tag === "failure") throw result.cause + return result.cleanup + }), (cleanup) => (cleanup ? Effect.promise(() => Promise.resolve(cleanup())) : Effect.void), ) }), diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index e4fbd2f5d3c6..3887c3eea4f6 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -48,6 +48,106 @@ "summary": "Get server info" } }, + "/api/pair": { + "post": { + "tags": ["server"], + "operationId": "server.pair", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "PairingCode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PairingCode" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + } + }, + "description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.", + "summary": "Create pairing code" + } + }, + "/auth/connect/{code}": { + "get": { + "tags": ["server"], + "operationId": "server.connect", + "parameters": [ + { + "name": "code", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "PairingSession", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PairingSession" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + }, + { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + ] + } + } + } + } + }, + "description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.", + "summary": "Redeem pairing code" + } + }, "/api/location": { "get": { "tags": ["location"], @@ -2031,7 +2131,7 @@ } } }, - "description": "Durably admit one session input and schedule agent-loop execution unless resume is false.", + "description": "Durably admit optional synthetic context followed by one user input and schedule agent-loop execution unless resume is false. Context requires steer delivery.", "summary": "Send message", "requestBody": { "content": { @@ -2071,6 +2171,16 @@ "$ref": "#/components/schemas/PromptInput.SkillAttachment" } }, + "context": { + "anyOf": [ + { + "$ref": "#/components/schemas/PromptInput.Context" + }, + { + "type": "null" + } + ] + }, "metadata": { "type": "object" }, @@ -4508,6 +4618,21 @@ "pattern": "^frm_" }, "required": true + }, + { + "name": "message", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false } ], "security": [], @@ -4591,21 +4716,6 @@ "pattern": "^frm_" }, "required": true - }, - { - "name": "message", - "in": "query", - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "required": false } ], "security": [], @@ -7197,6 +7307,125 @@ "summary": "List MCP resources" } }, + "/api/credential": { + "get": { + "tags": ["credential"], + "operationId": "credential.list", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Credential.Entry" + } + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + } + }, + "description": "List every stored integration credential, including its secret value.", + "summary": "List credentials" + }, + "post": { + "tags": ["credential"], + "operationId": "credential.create", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Credential.Entry" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + }, + "409": { + "description": "ConflictError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictErrorEncoded" + } + } + } + } + }, + "description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.", + "summary": "Create credential", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Credential.CreateInput" + } + } + }, + "required": true + } + } + }, "/api/credential/{credentialID}": { "patch": { "tags": ["credential"], @@ -13631,6 +13860,9 @@ "method": { "type": "string", "enum": ["key", "oauth"] + }, + "status": { + "$ref": "#/components/schemas/Connection.Status" } }, "required": ["type", "id", "label", "method"], @@ -13645,6 +13877,9 @@ }, "name": { "type": "string" + }, + "status": { + "$ref": "#/components/schemas/Connection.Status" } }, "required": ["type", "name"], @@ -13660,6 +13895,128 @@ } ] }, + "Connection.Status": { + "type": "object", + "properties": { + "status": { + "anyOf": [ + { + "type": "string", + "enum": ["needs_auth"] + } + ] + }, + "message": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["status", "message"], + "additionalProperties": false + }, + "Credential.CreateInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "integrationID": { + "type": "string" + }, + "label": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/Credential.Value" + }, + "activate": { + "type": "boolean" + } + }, + "required": ["integrationID", "value"], + "additionalProperties": false + }, + "Credential.Entry": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "integrationID": { + "type": "string" + }, + "label": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "value": { + "$ref": "#/components/schemas/Credential.Value" + } + }, + "required": ["id", "integrationID", "label", "active", "value"], + "additionalProperties": false + }, + "Credential.Key": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["key"] + }, + "key": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "configuration": { + "$ref": "#/components/schemas/Form.Answer_1" + } + }, + "required": ["type", "key"], + "additionalProperties": false + }, + "Credential.OAuth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["oauth"] + }, + "methodID": { + "type": "string" + }, + "refresh": { + "type": "string" + }, + "access": { + "type": "string" + }, + "expires": { + "type": "integer", + "minimum": 0 + }, + "metadata": { + "type": "object" + } + }, + "required": ["type", "methodID", "refresh", "access", "expires"], + "additionalProperties": false + }, + "Credential.Value": { + "anyOf": [ + { + "$ref": "#/components/schemas/Credential.OAuth" + }, + { + "$ref": "#/components/schemas/Credential.Key" + } + ] + }, "FileDiff.Info": { "type": "object", "properties": { @@ -13746,6 +14103,12 @@ "$ref": "#/components/schemas/Form.Value" } }, + "Form.Answer_1": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Form.Value" + } + }, "Form.BooleanField": { "type": "object", "properties": { @@ -15709,6 +16072,29 @@ "Money.USDPerMillionTokens": { "type": "number" }, + "PairingCode": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "expires_in": { + "type": "integer" + } + }, + "required": ["code", "expires_in"], + "additionalProperties": false + }, + "PairingSession": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + }, + "required": ["token"], + "additionalProperties": false + }, "Permission.Effect": { "type": "string", "enum": ["allow", "deny", "ask"] @@ -16472,6 +16858,26 @@ "required": ["id", "name"], "additionalProperties": false }, + "PromptInput.Context": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + }, + "description": { + "type": "string" + }, + "metadata": { + "type": "object" + } + }, + "required": ["id", "text"], + "additionalProperties": false + }, "PromptInput.FileAttachment": { "type": "object", "properties": { @@ -18234,6 +18640,16 @@ "type": "integer", "minimum": 100, "maximum": 599 + }, + "response": { + "type": "object", + "properties": { + "body": { + "type": "string" + } + }, + "required": ["body"], + "additionalProperties": false } }, "required": ["type", "message"], @@ -18653,6 +19069,9 @@ "exit": { "type": "number" }, + "signal": { + "type": "string" + }, "metadata": { "type": "object" }, diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index d6cc9358bcf7..3e61f55b6563 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -394,6 +394,7 @@ export const makeSessionGroup = < payload: Schema.Struct({ id: SessionMessage.ID.pipe(Schema.optional), ...PromptInput.Prompt.fields, + context: PromptInput.Context.pipe(Schema.optional), metadata: SessionInbox.UserPayload.fields.metadata, delivery: SessionInbox.Delivery.pipe(Schema.optional), resume: Schema.Boolean.pipe(Schema.optional), @@ -406,7 +407,8 @@ export const makeSessionGroup = < OpenApi.annotations({ identifier: "session.prompt", summary: "Send message", - description: "Durably admit one session input and schedule agent-loop execution unless resume is false.", + description: + "Durably admit optional synthetic context followed by one user input and schedule agent-loop execution unless resume is false. Context requires steer delivery.", }), ), ) diff --git a/packages/schema/src/prompt-input.ts b/packages/schema/src/prompt-input.ts index c25adbf2daef..9969c85fa970 100644 --- a/packages/schema/src/prompt-input.ts +++ b/packages/schema/src/prompt-input.ts @@ -3,6 +3,8 @@ export * as PromptInput from "./prompt-input.js" import { Schema } from "effect" import { AgentAttachment, PromptMention } from "./prompt.js" import { optional, statics } from "./schema.js" +import { SessionInbox } from "./session-inbox.js" +import { SessionMessage } from "./session-message.js" import { Skill } from "./skill.js" export interface FileAttachment extends Schema.Schema.Type {} @@ -32,3 +34,9 @@ export const Prompt = Schema.Struct({ agents: Schema.Array(AgentAttachment).pipe(optional), skills: Schema.Array(SkillAttachment).pipe(optional), }).annotate({ identifier: "PromptInput" }) + +export interface Context extends Schema.Schema.Type {} +export const Context = Schema.Struct({ + id: SessionMessage.ID, + ...SessionInbox.SyntheticPayload.fields, +}).annotate({ identifier: "PromptInput.Context" }) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index cefdef08efd9..23eb414ff7be 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -313,6 +313,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl files: ctx.payload.files, agents: ctx.payload.agents, skills: ctx.payload.skills, + context: ctx.payload.context, metadata: ctx.payload.metadata, delivery: ctx.payload.delivery, resume: ctx.payload.resume, @@ -330,6 +331,11 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl Effect.catchTag("Session.AttachmentError", (error) => Effect.fail(new InvalidRequestError({ message: error.message, field: "files" })), ), + Effect.catchTag("Session.ContextDeliveryError", () => + Effect.fail( + new InvalidRequestError({ message: "Prompt context cannot use queue delivery", field: "delivery" }), + ), + ), Effect.catchTag("Session.SkillNotFoundError", (error) => Effect.fail(new InvalidRequestError({ message: `Skill not found: ${error.skill}`, field: "skills" })), ), diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index efb195b86634..5d9c7e1dcb53 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -45,7 +45,7 @@ import { DialogProvider, useDialog } from "./ui/dialog" import { DialogIntegration } from "./component/dialog-integration" import { ErrorComponent } from "./component/error-component" import { PluginRouteMissing } from "./component/plugin-route-missing" -import { EditorContextProvider } from "./context/editor" +import { EditorContextProvider, useEditorContext } from "./context/editor" import { useEvent } from "./context/event" import { ClientProvider, useClient } from "./context/client" import { StartupLoading } from "./component/startup-loading" @@ -103,6 +103,10 @@ import { SessionTerminalsProvider } from "./context/session-terminals" import { PanelProvider, usePanel } from "./context/panel" import { SessionFrame } from "./component/session-frame" import { createTuiClipboard } from "./clipboard" +import { SessionMessage } from "@opencode/schema/session-message" +import { acknowledgePromptRetry, clearPromptRetry } from "./component/prompt/retry" +import { clearDraft } from "./component/prompt/draft-stash" +import { isDuplicateEntry } from "./prompt/history" registerOpencodeSpinner() @@ -482,6 +486,7 @@ function App() { const promptRef = usePromptRef() const plugins = usePlugin() const clipboard = useClipboard() + const editor = useEditorContext() const terminalEnvironment = useTuiTerminalEnvironment() let paletteTimer: ReturnType | undefined const afterFrame = () => { @@ -1286,8 +1291,21 @@ function App() { ) }) + event.on("session.inbox.enqueued", (evt) => { + if (evt.data.item.type !== "user") return + const retry = acknowledgePromptRetry(evt.data.sessionID, SessionMessage.ID.make(evt.data.inboxID)) + if (retry?.restored) { + const active = + route.data.type === "session" && route.data.sessionID === evt.data.sessionID ? promptRef.current : undefined + if (active && isDuplicateEntry(active.current, retry.prompt)) active.reset() + clearDraft(evt.data.sessionID, retry.prompt) + } + if (retry?.contextIncluded && retry.contextKey) editor.markSelectionSent(retry.contextKey) + }) + event.on("session.deleted", (evt) => { setOpenSessions((sessions) => sessions.filter((session) => session.id !== evt.data.sessionID)) + clearPromptRetry(evt.data.sessionID) if (route.data.type === "session" && route.data.sessionID === evt.data.sessionID) { const title = active?.id === evt.data.sessionID ? active.title : undefined route.navigate({ type: "home" }) diff --git a/packages/tui/src/component/prompt/draft-stash.ts b/packages/tui/src/component/prompt/draft-stash.ts index eaec92e8b0e7..06c80a6cad41 100644 --- a/packages/tui/src/component/prompt/draft-stash.ts +++ b/packages/tui/src/component/prompt/draft-stash.ts @@ -1,4 +1,4 @@ -import type { PromptInfo } from "../../prompt/history" +import { isDuplicateEntry, type PromptInfo } from "../../prompt/history" // Holds one in-progress draft per tab across Prompt remounts. A draft is // consumed on take: restoring it moves it out of the stash, so a stale copy @@ -16,3 +16,9 @@ export function takeDraft(sessionID: string | undefined) { export function saveDraft(sessionID: string | undefined, entry: DraftEntry) { byTab.set(sessionID, entry) } + +export function clearDraft(sessionID: string | undefined, prompt: PromptInfo) { + if (!isDuplicateEntry(byTab.get(sessionID)?.prompt, prompt)) return false + byTab.delete(sessionID) + return true +} diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index ad21de04cfd9..3244caa599f7 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -24,6 +24,7 @@ import { useSessionTabs } from "../../context/session-tabs" import { useEvent } from "../../context/event" import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor" import { normalizePromptContent, openEditor } from "../../editor" +import { SessionMessage } from "@opencode/schema/session-message" import { useExit } from "../../context/exit" import { promptOffsetWidth } from "../../prompt/display" import { expandPromptInputPastedText, realignPromptInputMentions } from "../../prompt/mention" @@ -70,6 +71,13 @@ import { directoryRecentValue } from "../../prompt/directory-completion" import { useWorkingDirectoryActions } from "../../ui/working-directory-actions" import { truncateFilePath } from "../../ui/file-path" import { PromptMetadataRow } from "./metadata" +import { + acknowledgePromptRetry, + markPromptRetryRestored, + releasePromptRetry, + rememberPromptRetry, + takePromptRetry, +} from "./retry" export type PromptProps = { sessionID?: string @@ -339,18 +347,20 @@ export function Prompt(props: PromptProps) { let promptPartTypeId = 0 const event = useEvent() - event.on("tui.prompt.append", (evt, { directory }) => { - if (directory !== (currentLocation.current?.directory ?? data.location.default().directory)) return - if (!input || input.isDestroyed) return - input.insertText(evt.data.text) - setTimeout(() => { - // setTimeout is a workaround and needs to be addressed properly + onCleanup( + event.on("tui.prompt.append", (evt, { directory }) => { + if (directory !== (currentLocation.current?.directory ?? data.location.default().directory)) return if (!input || input.isDestroyed) return - input.getLayoutNode().markDirty() - input.gotoBufferEnd() - renderer.requestRender() - }, 0) - }) + input.insertText(evt.data.text) + setTimeout(() => { + // setTimeout is a workaround and needs to be addressed properly + if (!input || input.isDestroyed) return + input.getLayoutNode().markDirty() + input.gotoBufferEnd() + renderer.requestRender() + }, 0) + }), + ) createEffect(() => { if (!input || input.isDestroyed) return @@ -1153,8 +1163,11 @@ export function Prompt(props: PromptProps) { slashHead !== undefined && (data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name) const editorSelection = editorContext() - const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined - if (delivery === "queue" && pendingEditorSelection) { + const pendingEditorContext = + editorSelection && editor.labelState() === "pending" + ? { key: editorSelectionKey(editorSelection), text: formatEditorContext(editorSelection) } + : undefined + if (delivery === "queue" && pendingEditorContext) { toast.show({ message: "Editor context cannot be queued", variant: "warning" }) return false } @@ -1188,12 +1201,13 @@ export function Prompt(props: PromptProps) { props.onSubmit?.() } const restoreEntry = () => { - if (disposed || input.isDestroyed || input.plainText !== "") return + if (disposed || input.isDestroyed || input.plainText !== "") return false input.setText(entry.text) setStore("prompt", entry) setStore("mode", entry.mode ?? "normal") restoreExtmarksFromPrompt(entry) input.cursorOffset = entry.text.length + return true } const fail = (title: string, error: unknown) => { toast.show({ title, message: errorMessage(error), variant: "error" }) @@ -1280,9 +1294,15 @@ export function Prompt(props: PromptProps) { await data.session.sync(target) session = data.session.get(target) } - if (session?.agent !== agent.id) { - await client.api.session.switchAgent({ sessionID: target, agent: agent.id }) - } + const commit = local.agent.trackSessionCommit(target, session?.agent, agent.id) + if (!commit) return + await client.api.session.switchAgent({ sessionID: target, agent: agent.id }).then( + () => commit.succeed(), + (error) => { + commit.fail() + throw error + }, + ) } const commitModel = () => { const model = { providerID: selection.providerID, id: selection.modelID, variant } @@ -1331,54 +1351,90 @@ export function Prompt(props: PromptProps) { ) } else { move.startSubmit() - if (!(await attempt("Failed to prepare session", prepareAgent))) return true - // Revert must settle before optimistic admission: its committed echo - // splices every local row at or after the boundary, which would include - // a freshly admitted prompt. - if ( - session?.revert && - !(await attempt("Failed to commit revert", () => client.api.session.revert.commit({ sessionID: target }))) - ) - return false - if (pendingEditorSelection) { - // Keep editor context hidden while admitting it before the corresponding user prompt. - const send = () => - client.api.session.synthetic({ - sessionID: target, - text: formatEditorContext(pendingEditorSelection), - resume: false, - }) - // Fold into the setup gate so the context still admits before the - // user prompt once the session exists. - if (newSession) newSession.gate = newSession.gate.then(send) - else if (!(await attempt("Failed to send editor context", send))) return false + // Admit immediately. The server commits a staged revert with this prompt, + // and the committed echo keeps this unacknowledged row. + const retryInput = { + prompt: entry, + agent: agent.id, + providerID: selection.providerID, + modelID: selection.modelID, + variant, + delivery, + contextKey: pendingEditorContext?.key, } - // The data layer admits optimistically: the prompt renders immediately - // and rolls back if the server rejects it, so submission does not wait - // on the network. On rejection the row is already rolled back; restore - // the composer unless the user has started typing something new. - data.session - .prompt({ - sessionID: target, - text: inputText, - files: entry.files, - agents: entry.agents, - skills: entry.skills?.length ? entry.skills : undefined, - delivery, - gate: newSession?.gate, - // Commit the captured selection after earlier admissions, including - // compaction setup. Cached state may still precede their SSE echoes; - // the server makes an unchanged selection a no-op. - prepare: commitModel, - }) - .catch((error) => (newSession ? newSession.recover(error) : fail("Failed to send prompt", error))) - if (pendingEditorSelection) editor.markSelectionSent() + const retry = takePromptRetry(target, retryInput) + const messageID = retry?.id ?? SessionMessage.ID.create() + const contextID = pendingEditorContext ? (retry?.contextID ?? SessionMessage.ID.create()) : undefined + let contextIncluded = false + const request = data.session.prompt({ + id: messageID, + sessionID: target, + text: inputText, + files: entry.files, + agents: entry.agents, + skills: entry.skills?.length ? entry.skills : undefined, + delivery, + gate: newSession?.gate, + prepare: async () => { + await prepareAgent() + await commitModel() + const context = + pendingEditorContext && + contextID && + editor.labelState() === "pending" && + editorSelectionKey(editor.selection()) === pendingEditorContext.key + ? { id: contextID, text: pendingEditorContext.text } + : undefined + contextIncluded = context !== undefined + return { context } + }, + }) + void request.then( + () => { + acknowledgePromptRetry(target, messageID) + if (contextIncluded && pendingEditorContext) editor.markSelectionSent(pendingEditorContext.key) + }, + (error) => { + if ( + data.session.input.has(target, messageID) || + data.session.message.get(target, messageID)?.type === "user" + ) { + acknowledgePromptRetry(target, messageID) + if (contextIncluded && pendingEditorContext) editor.markSelectionSent(pendingEditorContext.key) + return + } + if (newSession && !data.session.get(target)) { + releasePromptRetry(target, messageID) + return newSession.recover(error) + } + const remembered = rememberPromptRetry(target, { id: messageID, contextID, contextIncluded, ...retryInput }) + if (!remembered) { + if (contextIncluded && pendingEditorContext) editor.markSelectionSent(pendingEditorContext.key) + return + } + toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" }) + if (newSession) { + const active = + route.data.type === "session" && route.data.sessionID === target ? promptRef.current : undefined + if (active && !active.current.text) { + active.set(entry) + markPromptRetryRestored(target, messageID) + } + if (!active) { + saveDraft(target, { prompt: entry, cursor: entry.text.length }) + markPromptRetryRestored(target, messageID) + } + return + } + if (restoreEntry()) markPromptRetryRestored(target, messageID) + }, + ) } // Optimistic admission puts the message in the store synchronously, so // the session view renders it on arrival. if (!props.sessionID) { - if (pendingEditorSelection) editor.preserveSelectionFromNewSession() + if (pendingEditorContext) editor.preserveSelectionFromNewSession() // Text typed while session creation was in flight lives in this (home) // prompt, which unmounts on navigation and would stash it under the // home key. Re-stash it under the new session so that composer restores diff --git a/packages/tui/src/component/prompt/retry.ts b/packages/tui/src/component/prompt/retry.ts new file mode 100644 index 000000000000..5a66b6b689cd --- /dev/null +++ b/packages/tui/src/component/prompt/retry.ts @@ -0,0 +1,148 @@ +import { SessionMessage } from "@opencode/schema/session-message" +import type { SessionInbox } from "@opencode/schema/session-inbox" +import { isDuplicateEntry, type PromptInfo } from "../../prompt/history" + +export type PromptRetry = { + id: SessionMessage.ID + contextID?: SessionMessage.ID + prompt: PromptInfo + agent: string + providerID: string + modelID: string + variant?: string + delivery: SessionInbox.Delivery + contextKey?: string + contextIncluded: boolean + restored?: boolean +} + +const MAX_PROMPT_RETRIES = 10 +export const MAX_TOTAL_PROMPT_RETRIES = 20 +const MAX_PROMPT_ACKNOWLEDGEMENTS = 100 +const retries = new Map() +const claims = new Map>() +let retryOrder: Array<{ sessionID: string; messageID: SessionMessage.ID }> = [] +const acknowledgements = new Map>() +let acknowledgementOrder: Array<{ sessionID: string; messageID: SessionMessage.ID }> = [] + +export function rememberPromptRetry(sessionID: string, retry: PromptRetry) { + if (acknowledgements.get(sessionID)?.has(retry.id)) return false + const stored = { ...retry, prompt: structuredClone(retry.prompt) } + const current = retries.get(sessionID) ?? [] + if (current.some((item) => item.id === retry.id)) { + retries.set( + sessionID, + current.map((item) => (item.id === retry.id ? stored : item)), + ) + releasePromptRetry(sessionID, retry.id) + return true + } + retries.set(sessionID, [...current, stored]) + retryOrder.push({ sessionID, messageID: retry.id }) + while ((retries.get(sessionID)?.length ?? 0) > MAX_PROMPT_RETRIES) { + const oldest = retries.get(sessionID)?.find((item) => !claims.get(sessionID)?.has(item.id)) + if (!oldest) break + removePromptRetry(sessionID, oldest.id) + } + while (retryOrder.length > MAX_TOTAL_PROMPT_RETRIES) { + const oldest = retryOrder.find((item) => !claims.get(item.sessionID)?.has(item.messageID)) + if (!oldest) break + removePromptRetry(oldest.sessionID, oldest.messageID) + } + return true +} + +export function takePromptRetry( + sessionID: string, + input: Omit, +) { + const claimed = claims.get(sessionID) + const retry = retries.get(sessionID)?.find((item) => !claimed?.has(item.id) && matches(item, input)) + if (!retry) return + const current = claimed ?? new Set() + current.add(retry.id) + claims.set(sessionID, current) + retries.set( + sessionID, + (retries.get(sessionID) ?? []).map((item) => (item.id === retry.id ? { ...item, restored: false } : item)), + ) + return { ...retry, restored: false } +} + +export function releasePromptRetry(sessionID: string, messageID: SessionMessage.ID) { + const current = claims.get(sessionID) + current?.delete(messageID) + if (current?.size === 0) claims.delete(sessionID) +} + +export function markPromptRetryRestored(sessionID: string, messageID: SessionMessage.ID) { + const current = retries.get(sessionID) + if (!current?.some((item) => item.id === messageID)) return + retries.set( + sessionID, + current.map((item) => (item.id === messageID ? { ...item, restored: true } : item)), + ) +} + +export function restorePromptRetry(sessionID: string, messageID: SessionMessage.ID, restore: () => boolean) { + const current = retries.get(sessionID) + if (!current?.some((item) => item.id === messageID) || acknowledgements.get(sessionID)?.has(messageID)) { + releasePromptRetry(sessionID, messageID) + return false + } + const restored = restore() + if (restored) markPromptRetryRestored(sessionID, messageID) + releasePromptRetry(sessionID, messageID) + return restored +} + +export function acknowledgePromptRetry(sessionID: string, messageID: SessionMessage.ID) { + const retry = removePromptRetry(sessionID, messageID) + const current = acknowledgements.get(sessionID) ?? new Set() + if (current.has(messageID)) return retry + current.add(messageID) + acknowledgements.set(sessionID, current) + acknowledgementOrder.push({ sessionID, messageID }) + const oldest = acknowledgementOrder.length > MAX_PROMPT_ACKNOWLEDGEMENTS ? acknowledgementOrder.shift() : undefined + if (!oldest) return retry + const remaining = acknowledgements.get(oldest.sessionID) + remaining?.delete(oldest.messageID) + if (remaining?.size === 0) acknowledgements.delete(oldest.sessionID) + return retry +} + +export function clearPromptRetry(sessionID: string, messageID?: SessionMessage.ID) { + if (messageID) { + removePromptRetry(sessionID, messageID) + return + } + retries.delete(sessionID) + claims.delete(sessionID) + retryOrder = retryOrder.filter((item) => item.sessionID !== sessionID) + acknowledgements.delete(sessionID) + acknowledgementOrder = acknowledgementOrder.filter((item) => item.sessionID !== sessionID) +} + +function removePromptRetry(sessionID: string, messageID: SessionMessage.ID) { + const current = retries.get(sessionID) + const retry = current?.find((item) => item.id === messageID) + if (!retry) return + releasePromptRetry(sessionID, messageID) + const remaining = current?.filter((item) => item.id !== messageID) + if (remaining?.length) retries.set(sessionID, remaining) + if (!remaining?.length) retries.delete(sessionID) + retryOrder = retryOrder.filter((item) => item.sessionID !== sessionID || item.messageID !== messageID) + return retry +} + +function matches(retry: PromptRetry, input: Omit) { + return ( + isDuplicateEntry(retry.prompt, input.prompt) && + retry.agent === input.agent && + retry.providerID === input.providerID && + retry.modelID === input.modelID && + (retry.variant ?? "default") === (input.variant ?? "default") && + retry.delivery === input.delivery && + retry.contextKey === input.contextKey + ) +} diff --git a/packages/tui/src/context/editor.ts b/packages/tui/src/context/editor.ts index cf2fbbf9e62b..6e18e2e04737 100644 --- a/packages/tui/src/context/editor.ts +++ b/packages/tui/src/context/editor.ts @@ -336,8 +336,8 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create preserveSelectionFromNewSession() { preserveSelectionOnReconnect = true }, - markSelectionSent() { - if (!store.selection) return + markSelectionSent(key: string) { + if (editorSelectionKey(store.selection) !== key) return setStore("selectionSent", true) }, labelState(): EditorLabelState { diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 311f511a796d..b450e8d43095 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -26,6 +26,42 @@ import { usePermission } from "./permission" import { useLocation } from "./location" import { parse } from "../util/model" +export function createAgentSessionCommitTracker() { + type Commit = + | { status: "pending"; agent: string; token: object } + | { status: "known"; agent: string } + | { status: "indeterminate"; agent: string } + const commits = new Map() + + return { + start(sessionID: string, current: string | undefined, agent: string) { + const tracked = commits.get(sessionID) + if (tracked?.status !== "indeterminate" && (tracked?.agent ?? current) === agent) return + const token = {} + commits.set(sessionID, { status: "pending", agent, token }) + return { + succeed() { + const active = commits.get(sessionID) + if (active?.status !== "pending" || active.token !== token) return + commits.set(sessionID, { status: "known", agent }) + }, + fail() { + const active = commits.get(sessionID) + if (active?.status !== "pending" || active.token !== token) return + commits.set(sessionID, { status: "indeterminate", agent }) + }, + } + }, + observe(sessionID: string) { + const commit = commits.get(sessionID) + if (commit?.status === "known") commits.set(sessionID, { status: "indeterminate", agent: commit.agent }) + }, + delete(sessionID: string) { + commits.delete(sessionID) + }, + } +} + export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", init: () => { @@ -54,6 +90,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } function createAgent() { + const commits = createAgentSessionCommitTracker() const agents = createMemo(() => (data.location.agent.list(location.ref) ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden), ) @@ -79,6 +116,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ (first, second) => first.equals(second), ) }) + onCleanup(event.on("session.agent.selected", (evt) => commits.observe(evt.data.sessionID))) + onCleanup(event.on("session.deleted", (evt) => commits.delete(evt.data.sessionID))) return { list() { return agents() @@ -111,6 +150,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (selected) model.set(selected) }) }, + trackSessionCommit: commits.start, move(direction: 1 | -1) { batch(() => { const current = this.current() diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index 117491cc7d83..16c97f9e053a 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -3,7 +3,6 @@ import { useData } from "../../context/data" import { DialogSelect } from "../../ui/dialog-select" import { useClipboard } from "../../context/clipboard" import { useToast } from "../../ui/toast" -import { useClient } from "../../context/client" import { errorMessage } from "../../util/error" import { DialogFork } from "./dialog-fork" import type { PromptInfo } from "../../prompt/history" @@ -17,7 +16,6 @@ export function DialogMessage(props: { const data = useData() const clipboard = useClipboard() const toast = useToast() - const client = useClient() const message = createMemo(() => data.session.message.get(props.sessionID, props.messageID)) return ( @@ -34,17 +32,23 @@ export function DialogMessage(props: { title: "Revert", value: "session.revert", description: "undo messages and file changes", - onSelect: (dialog) => { + onSelect: async (dialog) => { const value = message() - if (value?.type === "user") { + const error = await data.session.revert + .stage({ sessionID: props.sessionID, messageID: props.messageID }) + .then( + () => undefined, + (error) => error, + ) + if (error) { + toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) + return + } + if (value?.type === "user") props.setPrompt?.({ ...projectedPromptInput(value), pasted: [], }) - } - void client.api.session.revert - .stage({ sessionID: props.sessionID, messageID: props.messageID }) - .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) dialog.clear() }, }, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index d20d92f42aec..ce0ac3355fcd 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -94,7 +94,9 @@ import { completeGroupBoundary, createSessionRows, legacyTurns, + loadRevertMessages, messageBoundaryIDs, + partitionRevertMessages, resolvePart, sessionRowID, turnDuration, @@ -167,18 +169,9 @@ export function Session(props: { const messages = () => data.session.message.list(route.sessionID) const messageIndexes = createMemo(() => new Map(messages().map((message, index) => [message.id, index]))) const legacy = createMemo(() => legacyTurns(messages())) - const messagesBeforeRevert = () => { - const messageID = session()?.revert?.messageID - if (!messageID) return messages() - const index = messages().findIndex((message) => message.id === messageID) - return index === -1 ? messages() : messages().slice(0, index) - } - const messagesFromRevert = () => { - const messageID = session()?.revert?.messageID - if (!messageID) return [] - const index = messages().findIndex((message) => message.id === messageID) - return index === -1 ? [] : messages().slice(index) - } + const revertMessages = createMemo(() => partitionRevertMessages(messages(), session()?.revert?.messageID)) + const messagesBeforeRevert = () => revertMessages().before + const messagesFromRevert = () => revertMessages().from const currentLocation = useLocation() const location = createMemo(() => session()?.location ?? currentLocation.ref) @@ -986,30 +979,56 @@ export function Session(props: { group: "Session", slash: { name: "undo" }, run: () => { - const message = messagesBeforeRevert().findLast( - (message): message is SessionMessageUser => message.type === "user" && !!message.text.trim(), - ) - if (!message) { - toast.show({ message: "Nothing to undo", variant: "error", duration: 3000 }) - dialog.clear() - return - } const sessionID = route.sessionID const target = prompt() void (async () => { - if (pendingDeliveries().has(message.id)) { - if (!(await mutatePending("cancel", message.id))) return - } else { - await client.api.session.interrupt({ sessionID }) - await client.api.session.wait({ sessionID }) - await client.api.session.revert.stage({ sessionID, messageID: message.id }) + const result = await data.session + .mutate(sessionID, async () => { + const boundary = session()?.revert?.messageID + const loaded = boundary + ? await loadRevertMessages({ + boundary, + messages, + more: () => data.session.message.more(sessionID), + loadMore: () => data.session.message.loadMore(sessionID), + }) + : messages() + const message = partitionRevertMessages(loaded ?? [], boundary).before.findLast( + (message): message is SessionMessageUser => message.type === "user" && !!message.text.trim(), + ) + if (!message) return { _tag: "empty" } as const + if (pendingDeliveries().has(message.id)) { + if (!(await mutatePending("cancel", message.id))) return { _tag: "cancelled" } as const + return { _tag: "done", message } as const + } + await client.api.session.interrupt({ sessionID }) + await client.api.session.wait({ sessionID }) + await client.api.session.revert.stage({ sessionID, messageID: message.id }) + return { _tag: "done", message } as const + }) + .then( + (value) => ({ type: "done" as const, value }), + (error) => ({ type: "error" as const, error }), + ) + if (result.type === "error") { + toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 }) + return + } + if (result.value._tag === "cancelled") { + dialog.clear() + return + } + if (result.value._tag === "empty") { + toast.show({ message: "Nothing to undo", variant: "error", duration: 3000 }) + dialog.clear() + return } target?.set({ - ...projectedPromptInput(message), + ...projectedPromptInput(result.value.message), pasted: [], }) - })().catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) - dialog.clear() + dialog.clear() + })() }, }, { @@ -1020,7 +1039,7 @@ export function Session(props: { slash: { name: "redo" }, run: () => { void (async () => { - const error = await client.api.session.revert.clear({ sessionID: route.sessionID }).then( + const error = await data.session.revert.clear({ sessionID: route.sessionID }).then( () => undefined, (error) => error, ) @@ -1494,12 +1513,7 @@ export function Session(props: { }} - + { if (renderer.getSelection()?.getSelectedText()) return void (async () => { - const error = await client.api.session.revert.clear({ sessionID: route.sessionID }).then( + const error = await data.session.revert.clear({ sessionID: route.sessionID }).then( () => undefined, (error) => error, ) diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 8160e4ae5fc7..814eefe59cae 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -60,9 +60,8 @@ export function createSessionRows(sessionID: Accessor, onSynced?: (sessi pending.flatMap((item) => (item.type === "user" && item.delivery === "queue" ? [item.id] : [])), ) const visible = queued.size === 0 ? messages : messages.filter((message) => !queued.has(message.id)) - const boundary = revertBoundary() const rows = reduceSessionRows( - boundary ? visible.filter((message) => message.id < boundary) : visible, + partitionRevertMessages(visible, revertBoundary()).before, inputs, turnTokens(), verbosity(), @@ -310,6 +309,28 @@ export function createSessionRows(sessionID: Accessor, onSynced?: (sessi return rows } +export function partitionRevertMessages(messages: SessionMessageInfo[], boundary?: string) { + if (!boundary) return { before: messages, from: [] } + const index = messages.findIndex((message) => message.id === boundary) + if (index < 0) return { before: [], from: [] } + return { before: messages.slice(0, index), from: messages.slice(index) } +} + +export async function loadRevertMessages(input: { + boundary: string + messages: () => SessionMessageInfo[] + more: () => boolean + loadMore: () => Promise +}): Promise { + const messages = input.messages() + const boundary = messages.findIndex((message) => message.id === input.boundary) + if (boundary >= 0 && messages.slice(0, boundary).some((message) => message.type === "user" && !!message.text.trim())) + return messages + if (!input.more()) return boundary >= 0 ? messages : undefined + await input.loadMore() + return loadRevertMessages(input) +} + export function reduceSessionRows( messages: SessionMessageInfo[], inputs = new Set(), diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 2ff9e6d92175..e25c57c92f8d 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -472,6 +472,7 @@ test("truncates committed revert messages without changing lifetime usage", asyn let tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } const calls = createFetch((url) => { if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) + if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [] }) if (url.pathname !== `/api/session/${sessionID}`) return return json({ data: { @@ -1165,6 +1166,20 @@ test("removes committed revert messages from local state", async () => { const sessionID = "session-revert" const calls = createFetch((url) => { if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) + // After the commit, the projector has already dropped msg_000 and msg_001 from the inbox. + if (url.pathname === `/api/session/${sessionID}/inbox`) + return json({ + data: [ + { + id: "msg_fff", + sessionID, + time: { created: 0 }, + type: "user", + payload: { text: "msg_fff" }, + delivery: "steer", + }, + ], + }) }, events) let data!: ReturnType @@ -1186,7 +1201,8 @@ test("removes committed revert messages from local state", async () => { )) try { - for (const [seq, inboxID] of ["msg_001", "msg_002", "msg_003"].entries()) { + data.session.remember(sessionInfo(sessionID, undefined)) + for (const [seq, inboxID] of ["msg_fff", "msg_000", "msg_001"].entries()) { emitEvent(events, { id: Event.ID.create(), created: seq, @@ -1202,17 +1218,70 @@ test("removes committed revert messages from local state", async () => { created: 3, type: "session.revert.committed", durable: durable(sessionID, 3), - data: { sessionID, to: "msg_002" }, + data: { sessionID, to: "msg_000" }, }) await wait(() => data.session.message.list(sessionID).length === 1) - expect(data.session.message.list(sessionID).map((message) => message.id)).toEqual(["msg_001"]) - expect(data.session.message.get(sessionID, "msg_002")).toBeUndefined() - expect(data.session.message.get(sessionID, "msg_003")).toBeUndefined() - // The projector also drops inbox items enqueued at or after the boundary, without a cancel event. - expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_001"]) - expect(data.session.input.list(sessionID)).toEqual(["msg_001"]) - expect(data.session.input.has(sessionID, "msg_002")).toBe(false) + expect(data.session.message.list(sessionID).map((message) => message.id)).toEqual(["msg_fff"]) + expect(data.session.message.get(sessionID, "msg_000")).toBeUndefined() + expect(data.session.message.get(sessionID, "msg_001")).toBeUndefined() + // The projector also drops inbox items enqueued at or after the boundary, without a cancel event; + // the client learns that from the pending resync rather than by comparing IDs. + await wait(() => data.session.pending.list(sessionID).length === 1) + expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_fff"]) + expect(data.session.input.list(sessionID)).toEqual(["msg_fff"]) + expect(data.session.input.has(sessionID, "msg_000")).toBe(false) + } finally { + app.renderer.destroy() + } +}) + +test("hides loaded rows when a staged revert boundary is outside the message page", async () => { + const events = createEventStream() + const sessionID = "session-revert-page" + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/message`) + return json({ data: [{ id: "msg_newer", type: "user", text: "Newer", time: { created: 2 } }], cursor: {} }) + if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [] }) + }, events) + let data!: ReturnType + let rows!: ReturnType + let client!: ReturnType + + function Probe() { + client = useClient() + data = useData() + rows = createSessionRows(() => sessionID) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => client.connection.status() === "connected") + data.session.remember(sessionInfo(sessionID, undefined)) + await data.session.message.sync(sessionID) + await wait(() => rows.some((row) => row.type === "message" && row.messageID === "msg_newer")) + + emitEvent(events, { + id: "evt_revert_staged_outside_page", + created: 3, + type: "session.revert.staged", + durable: durable(sessionID), + data: { sessionID, revert: { messageID: "msg_boundary" } }, + }) + + await wait(() => rows.length === 0) } finally { app.renderer.destroy() } diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index bc7cc3c723c3..e6461dc0f51f 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -4,13 +4,68 @@ import { createMemo, createRoot } from "solid-js" import { createStore } from "solid-js/store" import { cacheReuseDrop, + loadRevertMessages, messageBoundaryIDs, + partitionRevertMessages, reduceSessionRows, sessionRowID, turnDuration, turnTokensPerSecond, } from "../../../src/routes/session/rows" +test("fails closed when the staged revert boundary is outside the loaded page", () => { + const messages: SessionMessageInfo[] = [{ type: "user", id: "user-newer", text: "Newer", time: { created: 2 } }] + + expect(partitionRevertMessages(messages, "user-boundary")).toEqual({ before: [], from: [] }) +}) + +test("loads older pages before selecting another undo boundary", async () => { + const messages: SessionMessageInfo[] = [{ type: "user", id: "user-newer", text: "Newer", time: { created: 3 } }] + const pages: SessionMessageInfo[][] = [ + [ + { type: "user", id: "user-previous", text: "Previous", time: { created: 1 } }, + { type: "user", id: "user-boundary", text: "Boundary", time: { created: 2 } }, + ], + ] + + const result = await loadRevertMessages({ + boundary: "user-boundary", + messages: () => messages, + more: () => pages.length > 0, + loadMore: async () => { + messages.unshift(...(pages.shift() ?? [])) + }, + }) + + expect(result?.map((message) => message.id)).toEqual(["user-previous", "user-boundary", "user-newer"]) +}) + +test("loads past a page-leading boundary to find an eligible undo target", async () => { + const messages: SessionMessageInfo[] = [ + { type: "user", id: "user-boundary", text: "Boundary", time: { created: 2 } }, + { type: "user", id: "user-newer", text: "Newer", time: { created: 3 } }, + ] + const pages: SessionMessageInfo[][] = [ + [{ type: "user", id: "user-target", text: "Target", time: { created: 1 } }, assistant("assistant-empty", [])], + ] + + const result = await loadRevertMessages({ + boundary: "user-boundary", + messages: () => messages, + more: () => pages.length > 0, + loadMore: async () => { + messages.unshift(...(pages.shift() ?? [])) + }, + }) + + expect(result?.map((message) => message.id)).toEqual([ + "user-target", + "assistant-empty", + "user-boundary", + "user-newer", + ]) +}) + test("measures turn duration from the user prompt across assistant steps", () => { const first = assistant("assistant-1", []) first.time = { created: 8_000, completed: 11_000 } diff --git a/packages/tui/test/compact-admission.test.tsx b/packages/tui/test/compact-admission.test.tsx index 058b880a4953..c39b3076f10d 100644 --- a/packages/tui/test/compact-admission.test.tsx +++ b/packages/tui/test/compact-admission.test.tsx @@ -137,6 +137,7 @@ test.each(["first", "second"])( time: { released: 0 }, })), }) + if (url.pathname === `/api/session/${sessionID}/agent`) return new Response(null, { status: 204 }) if (url.pathname === `/api/session/${sessionID}/model`) { session.model = (await request.json()).model mutations.push(`model:${session.model.id}`) @@ -233,3 +234,116 @@ test.each(["first", "second"])( } }, ) + +test("a following prompt commits its selected agent when session events are delayed", async () => { + await using state = await tmpdir() + const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true }) + setup.renderer.start() + const ready = Promise.withResolvers() + const first = Promise.withResolvers() + const firstRequested = Promise.withResolvers() + const secondRequested = Promise.withResolvers() + const events = createEventStream() + const sessionID = "ses_agent_order" + const location = { directory, project: { id: "project", directory, canonical: directory } } + const session = { + id: sessionID, + projectID: "project", + title: "Agent ordering fixture", + agent: "build", + model: { providerID: "demo", id: "model" }, + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } + const mutations: string[] = [] + const calls = createFetch(async (url, request) => { + if (url.pathname === `/api/session/${sessionID}`) return json({ data: session }) + if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) + if (url.pathname === `/api/session/${sessionID}/inbox` || url.pathname === `/api/session/${sessionID}/permission`) + return json({ data: [] }) + if (url.pathname === "/api/agent") + return json({ + location, + data: ["build", "plan"].map((id) => ({ id, mode: "primary", hidden: false, permissions: [] })), + }) + if (url.pathname === "/api/provider") return json({ location, data: [{ id: "demo", name: "Demo" }] }) + if (url.pathname === "/api/model") + return json({ location, data: [{ id: "model", providerID: "demo", name: "Demo Model", variants: [] }] }) + if (url.pathname === `/api/session/${sessionID}/agent`) { + session.agent = (await request.json()).agent + mutations.push(`agent:${session.agent}`) + return new Response(null, { status: 204 }) + } + if (url.pathname === `/api/session/${sessionID}/model`) return new Response(null, { status: 204 }) + if (url.pathname === `/api/session/${sessionID}/prompt`) { + const body = await request.json() + mutations.push(`prompt:${body.text}:${session.agent}`) + if (body.text === "First prompt") { + firstRequested.resolve() + await first.promise + } + if (body.text === "Second prompt") secondRequested.resolve(session.agent) + return json({ + data: { + id: body.id, + sessionID, + type: "user", + time: { created: 10 }, + payload: { text: body.text }, + delivery: "steer", + }, + }) + } + return undefined + }, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({ animations: false }), update: async () => ({}) }, + packages: { prepare: async () => ({ directory: "" }) }, + terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }), + args: { sessionID }, + log: () => {}, + }).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))), + ) + const selectAgent = async (id: string) => { + await setup.mockInput.typeText("/agents") + setup.mockInput.pressEnter() + await setup.waitForFrame( + (frame) => frame.includes("Select agent") && setup.renderer.currentFocusedRenderable instanceof InputRenderable, + ) + await setup.mockInput.typeText(id) + await setup.renderOnce() + setup.mockInput.pressEnter() + await setup.waitForFrame( + (frame) => frame.includes(id[0].toUpperCase() + id.slice(1)) && !frame.includes("Select agent"), + ) + } + try { + await ready.promise + await setup.waitForFrame((frame) => frame.includes("Build")) + await selectAgent("plan") + await setup.mockInput.typeText("First prompt") + setup.mockInput.pressEnter() + await firstRequested.promise + await selectAgent("build") + await setup.mockInput.typeText("Second prompt") + setup.mockInput.pressEnter() + await setup.waitForFrame((frame) => frame.split("\n").slice(0, 20).join("\n").includes("Second prompt")) + expect(mutations).toEqual(["agent:plan", "prompt:First prompt:plan"]) + + first.resolve() + expect(await secondRequested.promise).toBe("build") + expect(mutations).toEqual(["agent:plan", "prompt:First prompt:plan", "agent:build", "prompt:Second prompt:build"]) + } finally { + first.resolve() + setup.renderer.destroy() + await task + await server.stop() + } +}) diff --git a/packages/tui/test/context/local.test.ts b/packages/tui/test/context/local.test.ts index 757a45b41b88..66cb94442ccf 100644 --- a/packages/tui/test/context/local.test.ts +++ b/packages/tui/test/context/local.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test" import { recentModels } from "../../src/model-preference" +import { createAgentSessionCommitTracker } from "../../src/context/local" test("moves a model to the front, deduplicates, and limits recents", () => { const recent = Array.from({ length: 12 }, (_, index) => ({ @@ -13,3 +14,49 @@ test("moves a model to the front, deduplicates, and limits recents", () => { ...recent.slice(6, 10), ]) }) + +test("tracks agent commits across prompt lifetimes and retries indeterminate state", () => { + const tracker = createAgentSessionCommitTracker() + const first = tracker.start("session", "build", "plan") + expect(first).toBeDefined() + first?.succeed() + + expect(tracker.start("session", "build", "plan")).toBeUndefined() + const restored = tracker.start("session", "build", "build") + expect(restored).toBeDefined() + restored?.fail() + + expect(tracker.start("session", "build", "build")).toBeDefined() +}) + +test("keeps an early agent event conservative until its request settles", () => { + const tracker = createAgentSessionCommitTracker() + const commit = tracker.start("session", "build", "plan") + tracker.observe("session") + commit?.succeed() + + expect(tracker.start("session", "plan", "plan")).toBeUndefined() +}) + +test("does not let an older matching event resolve a newer indeterminate agent commit", () => { + const tracker = createAgentSessionCommitTracker() + tracker.start("session", "build", "plan")?.succeed() + tracker.start("session", "build", "build")?.succeed() + tracker.start("session", "build", "plan")?.fail() + + tracker.observe("session") + + expect(tracker.start("session", "plan", "plan")).toBeDefined() +}) + +test("does not correlate older repeated agent events with a newer settled commit", () => { + const tracker = createAgentSessionCommitTracker() + tracker.start("session", "build", "plan")?.succeed() + tracker.start("session", "build", "build")?.succeed() + tracker.start("session", "build", "plan")?.succeed() + + tracker.observe("session") + tracker.observe("session") + + expect(tracker.start("session", "build", "build")).toBeDefined() +}) diff --git a/packages/tui/test/editor-context.test.tsx b/packages/tui/test/editor-context.test.tsx new file mode 100644 index 000000000000..fccad54221b1 --- /dev/null +++ b/packages/tui/test/editor-context.test.tsx @@ -0,0 +1,94 @@ +import { expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { + EditorContextProvider, + editorSelectionKey, + useEditorContext, + type EditorSelection, +} from "../src/context/editor" +import { TuiPathsProvider } from "../src/context/runtime" + +class FakeWebSocket extends EventTarget { + static current: FakeWebSocket | undefined + readyState = 0 + + constructor(_url: string) { + super() + FakeWebSocket.current = this + } + + open() { + this.readyState = 1 + this.dispatchEvent(new Event("open")) + } + + message(value: unknown) { + const event = new Event("message") + Object.defineProperty(event, "data", { value: JSON.stringify(value) }) + this.dispatchEvent(event) + } + + send(_data: string) {} + + close() { + this.readyState = 3 + this.dispatchEvent(new Event("close")) + } +} + +const selection = (text: string, line: number): EditorSelection => ({ + filePath: "/work/src/index.ts", + ranges: [ + { + text, + selection: { + start: { line, character: 0 }, + end: { line, character: text.length }, + }, + }, + ], + source: "websocket", +}) + +test("does not acknowledge an editor selection that changed while prompt admission was pending", async () => { + const mounted = Promise.withResolvers>() + + function Probe() { + const editor = useEditorContext() + mounted.resolve(editor) + return {editor.labelState()} + } + + const app = await testRender( + () => ( + + ({ url: "ws://editor.test", source: "test" }) }} + WebSocketImpl={FakeWebSocket as never} + > + + + + ), + { width: 40, height: 3 }, + ) + + try { + const editor = await mounted.promise + const socket = FakeWebSocket.current + if (!socket) throw new Error("Editor socket was not created") + socket.open() + socket.message({ method: "selection_changed", params: selection("first", 1) }) + const submitted = editorSelectionKey(editor.selection()) + if (!submitted) throw new Error("Editor selection was not received") + + socket.message({ method: "selection_changed", params: selection("second", 2) }) + editor.markSelectionSent(submitted) + + expect(editor.selection()).toEqual(selection("second", 2)) + expect(editor.labelState()).toBe("pending") + } finally { + app.renderer.destroy() + FakeWebSocket.current = undefined + } +}) diff --git a/packages/tui/test/prompt-retry.test.ts b/packages/tui/test/prompt-retry.test.ts new file mode 100644 index 000000000000..ce1d465c591b --- /dev/null +++ b/packages/tui/test/prompt-retry.test.ts @@ -0,0 +1,208 @@ +import { expect, test } from "bun:test" +import { SessionMessage } from "@opencode/schema/session-message" +import type { PromptInfo } from "../src/prompt/history" +import { + acknowledgePromptRetry, + clearPromptRetry, + MAX_TOTAL_PROMPT_RETRIES, + markPromptRetryRestored, + releasePromptRetry, + rememberPromptRetry, + restorePromptRetry, + takePromptRetry, + type PromptRetry, +} from "../src/component/prompt/retry" + +const sessionID = "ses_prompt_retry" +const prompt = (text = "retry me"): PromptInfo => ({ text, files: [], agents: [], skills: [], pasted: [] }) +const retry = (overrides: Partial = {}): PromptRetry => ({ + id: SessionMessage.ID.make("msg_retry"), + contextID: SessionMessage.ID.make("msg_retry_context"), + prompt: prompt(), + agent: "build", + providerID: "provider", + modelID: "model", + variant: "fast", + delivery: "steer", + contextKey: "selection", + contextIncluded: true, + ...overrides, +}) + +test("reuses prompt and context IDs only for an unchanged submission", () => { + clearPromptRetry(sessionID) + const remembered = retry() + rememberPromptRetry(sessionID, remembered) + remembered.prompt.text = "mutated after remembering" + + expect( + takePromptRetry(sessionID, { + prompt: prompt(), + agent: "build", + providerID: "provider", + modelID: "model", + variant: "fast", + delivery: "steer", + contextKey: "selection", + }), + ).toMatchObject({ id: "msg_retry", contextID: "msg_retry_context" }) +}) + +test("does not reuse or discard retry IDs when submission identity changes", () => { + const changes: Array> = [ + { prompt: prompt("edited") }, + { agent: "plan" }, + { providerID: "other" }, + { modelID: "other" }, + { variant: "slow" }, + { delivery: "queue" }, + { contextKey: "other selection" }, + ] + + changes.forEach((change) => { + clearPromptRetry(sessionID) + rememberPromptRetry(sessionID, retry()) + expect(takePromptRetry(sessionID, { ...retry(), ...change })).toBeUndefined() + expect(takePromptRetry(sessionID, retry())).toMatchObject({ id: "msg_retry" }) + }) +}) + +test("clears only the retry matching an acknowledged prompt", () => { + clearPromptRetry(sessionID) + rememberPromptRetry(sessionID, retry()) + clearPromptRetry(sessionID, SessionMessage.ID.make("msg_other")) + + expect(takePromptRetry(sessionID, retry())).toMatchObject({ id: "msg_retry" }) +}) + +test("retains independent retries for overlapping failed submissions", () => { + clearPromptRetry(sessionID) + rememberPromptRetry(sessionID, retry({ id: SessionMessage.ID.make("msg_first"), prompt: prompt("first") })) + rememberPromptRetry(sessionID, retry({ id: SessionMessage.ID.make("msg_second"), prompt: prompt("second") })) + + expect(takePromptRetry(sessionID, { ...retry(), prompt: prompt("first") })).toMatchObject({ id: "msg_first" }) + expect(takePromptRetry(sessionID, { ...retry(), prompt: prompt("second") })).toMatchObject({ id: "msg_second" }) +}) + +test("preserves failure order when an identical retry fails again", () => { + clearPromptRetry(sessionID) + const first = retry({ id: SessionMessage.ID.make("msg_first") }) + rememberPromptRetry(sessionID, first) + rememberPromptRetry(sessionID, retry({ id: SessionMessage.ID.make("msg_second") })) + rememberPromptRetry(sessionID, first) + + expect(takePromptRetry(sessionID, retry())).toMatchObject({ id: "msg_first" }) + expect(takePromptRetry(sessionID, retry())).toMatchObject({ id: "msg_second" }) +}) + +test("claims a retry identity once across overlapping identical submissions", () => { + clearPromptRetry(sessionID) + rememberPromptRetry(sessionID, retry()) + + expect(takePromptRetry(sessionID, retry())).toMatchObject({ id: "msg_retry" }) + expect(takePromptRetry(sessionID, retry())).toBeUndefined() +}) + +test("does not restore a retry after its durable acknowledgement arrives", () => { + clearPromptRetry(sessionID) + rememberPromptRetry(sessionID, retry()) + takePromptRetry(sessionID, retry()) + + expect(acknowledgePromptRetry(sessionID, SessionMessage.ID.make("msg_retry"))).toMatchObject({ + contextIncluded: true, + }) + expect(rememberPromptRetry(sessionID, retry())).toBe(false) + expect(takePromptRetry(sessionID, retry())).toBeUndefined() +}) + +test("releases a claimed identity when retry preparation fails", () => { + clearPromptRetry(sessionID) + rememberPromptRetry(sessionID, retry()) + expect(takePromptRetry(sessionID, retry())).toMatchObject({ id: "msg_retry", contextID: "msg_retry_context" }) + + releasePromptRetry(sessionID, SessionMessage.ID.make("msg_retry")) + + expect(takePromptRetry(sessionID, retry())).toMatchObject({ id: "msg_retry", contextID: "msg_retry_context" }) +}) + +test("retains automatic restoration ownership until durable acknowledgement", () => { + clearPromptRetry(sessionID) + rememberPromptRetry(sessionID, retry()) + markPromptRetryRestored(sessionID, SessionMessage.ID.make("msg_retry")) + + expect(acknowledgePromptRetry(sessionID, SessionMessage.ID.make("msg_retry"))).toMatchObject({ restored: true }) +}) + +test("consumes automatic restoration ownership when the retry is submitted", () => { + clearPromptRetry(sessionID) + rememberPromptRetry(sessionID, retry()) + markPromptRetryRestored(sessionID, SessionMessage.ID.make("msg_retry")) + + takePromptRetry(sessionID, retry()) + + expect(acknowledgePromptRetry(sessionID, SessionMessage.ID.make("msg_retry"))).toMatchObject({ restored: false }) +}) + +test("does not restore retry text after acknowledgement arrives during preparation", () => { + clearPromptRetry(sessionID) + rememberPromptRetry(sessionID, retry()) + takePromptRetry(sessionID, retry()) + acknowledgePromptRetry(sessionID, SessionMessage.ID.make("msg_retry")) + let restored = false + + expect( + restorePromptRetry(sessionID, SessionMessage.ID.make("msg_retry"), () => { + restored = true + return true + }), + ).toBe(false) + expect(restored).toBe(false) +}) + +test("bounds retained retries across sessions", () => { + const sessions = Array.from({ length: MAX_TOTAL_PROMPT_RETRIES + 1 }, (_, index) => `ses_retry_${index}`) + sessions.forEach((id, index) => { + clearPromptRetry(id) + rememberPromptRetry( + id, + retry({ id: SessionMessage.ID.make(`msg_retry_${index}`), prompt: prompt(`retry ${index}`) }), + ) + }) + + expect( + takePromptRetry(sessions[0]!, { + ...retry(), + prompt: prompt("retry 0"), + }), + ).toBeUndefined() + expect( + takePromptRetry(sessions.at(-1)!, { + ...retry(), + prompt: prompt(`retry ${MAX_TOTAL_PROMPT_RETRIES}`), + }), + ).toMatchObject({ id: `msg_retry_${MAX_TOTAL_PROMPT_RETRIES}` }) + sessions.forEach((id) => clearPromptRetry(id)) +}) + +test("preserves an active claim while evicting idle retries at the global bound", () => { + const sessions = Array.from({ length: MAX_TOTAL_PROMPT_RETRIES + 1 }, (_, index) => `ses_claim_${index}`) + sessions.slice(0, -1).forEach((id, index) => { + clearPromptRetry(id) + rememberPromptRetry( + id, + retry({ id: SessionMessage.ID.make(`msg_claim_${index}`), prompt: prompt(`claim ${index}`) }), + ) + }) + takePromptRetry(sessions[0]!, { ...retry(), prompt: prompt("claim 0") }) + rememberPromptRetry( + sessions.at(-1)!, + retry({ + id: SessionMessage.ID.make(`msg_claim_${MAX_TOTAL_PROMPT_RETRIES}`), + prompt: prompt(`claim ${MAX_TOTAL_PROMPT_RETRIES}`), + }), + ) + + expect(restorePromptRetry(sessions[0]!, SessionMessage.ID.make("msg_claim_0"), () => true)).toBe(true) + expect(takePromptRetry(sessions[1]!, { ...retry(), prompt: prompt("claim 1") })).toBeUndefined() + sessions.forEach((id) => clearPromptRetry(id)) +}) diff --git a/packages/tui/test/prompt/draft-stash.test.ts b/packages/tui/test/prompt/draft-stash.test.ts index 0ccea23dd4b2..7b2ab811dc0b 100644 --- a/packages/tui/test/prompt/draft-stash.test.ts +++ b/packages/tui/test/prompt/draft-stash.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { saveDraft, takeDraft } from "../../src/component/prompt/draft-stash" +import { clearDraft, saveDraft, takeDraft } from "../../src/component/prompt/draft-stash" import { emptyPrompt } from "../../src/prompt/history" // The Prompt component stashes an unsent draft in onCleanup and takes it back @@ -39,4 +39,16 @@ describe("prompt draft stash", () => { saveDraft("ses_a", second) expect(takeDraft("ses_a")).toBe(second) }) + + test("late acknowledgement clears only an unchanged automatically restored draft", () => { + const restored = draft("retry me") + saveDraft("ses_retry", restored) + expect(clearDraft("ses_retry", restored.prompt)).toBe(true) + expect(takeDraft("ses_retry")).toBeUndefined() + + const edited = draft("retry me with edits") + saveDraft("ses_retry", edited) + expect(clearDraft("ses_retry", restored.prompt)).toBe(false) + expect(takeDraft("ses_retry")).toBe(edited) + }) }) diff --git a/packages/tui/test/session-home.test.tsx b/packages/tui/test/session-home.test.tsx index 1e8afa865f4f..aad89fed0ad9 100644 --- a/packages/tui/test/session-home.test.tsx +++ b/packages/tui/test/session-home.test.tsx @@ -40,7 +40,7 @@ test.each([ const messages = Array.from({ length: 400 }, (_, index) => mode === "mixed" && index % 2 ? { - id: `message-${index}`, + id: `msg_${index}`, type: "assistant", agent: "build", model: { providerID: "demo", id: "demo-model" }, @@ -49,7 +49,7 @@ test.each([ time: { created: index, completed: index + 1 }, } : { - id: `message-${index}`, + id: `msg_${index}`, type: "user", text: `History message ${String(index).padStart(4, "0")}`, time: { created: index }, @@ -112,12 +112,12 @@ test.each([ try { await setup.waitForFrame((frame) => frame.includes("History message 0399")) const findScrollBox = (root: Renderable): ScrollBoxRenderable | undefined => - root instanceof ScrollBoxRenderable && root.getRenderable("message-399") + root instanceof ScrollBoxRenderable && root.getRenderable("msg_399") ? root : root.getChildren().map(findScrollBox).find(Boolean) const scroll = findScrollBox(setup.renderer.root) if (!scroll) throw new Error("session transcript scrollbox was not found") - const mounted = () => scroll.getChildren().filter((child) => child.id?.startsWith("message-")) + const mounted = () => scroll.getChildren().filter((child) => child.id?.startsWith("msg_")) const maximum = () => Math.max(0, scroll.scrollHeight - scroll.viewport.height) if (mode === "scrolled" || mode === "cancel" || mode === "settling-scrolled") { setup.mockInput.pressKey("F6") @@ -146,7 +146,7 @@ test.each([ let navigated = false const capture = () => { if (visible() !== frames.at(-1)) frames.push(visible()) - if (mode !== "settling-reveal" || scroll.getRenderable("message-0")) mountCounts.push(mounted().length) + if (mode !== "settling-reveal" || scroll.getRenderable("msg_0")) mountCounts.push(mounted().length) if ( mode.startsWith("settling") && !navigated && @@ -260,7 +260,7 @@ test.each([ await setup.waitForFrame((frame) => !frame.includes("Loading session history")) release.resolve() finish.resolve() - await setup.waitFor(() => Boolean(scroll.getRenderable("message-360"))) + await setup.waitFor(() => Boolean(scroll.getRenderable("msg_360"))) await setup.waitForVisualIdle() expect(setup.captureCharFrame()).toContain("History message 0379") expect(mounted()).toHaveLength(40) @@ -282,7 +282,7 @@ test.each([ durable: { aggregateID: "ses_test", seq: 1, version: 1 }, data: { sessionID: "ses_test", - inboxID: "message-live", + inboxID: "msg_live", item: { type: "user", payload: { text: "Live message after failure" }, delivery: "steer" }, }, }) diff --git a/services/www/openapi.json b/services/www/openapi.json index e4fbd2f5d3c6..3887c3eea4f6 100644 --- a/services/www/openapi.json +++ b/services/www/openapi.json @@ -48,6 +48,106 @@ "summary": "Get server info" } }, + "/api/pair": { + "post": { + "tags": ["server"], + "operationId": "server.pair", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "PairingCode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PairingCode" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + } + }, + "description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.", + "summary": "Create pairing code" + } + }, + "/auth/connect/{code}": { + "get": { + "tags": ["server"], + "operationId": "server.connect", + "parameters": [ + { + "name": "code", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "PairingSession", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PairingSession" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + }, + { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + ] + } + } + } + } + }, + "description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.", + "summary": "Redeem pairing code" + } + }, "/api/location": { "get": { "tags": ["location"], @@ -2031,7 +2131,7 @@ } } }, - "description": "Durably admit one session input and schedule agent-loop execution unless resume is false.", + "description": "Durably admit optional synthetic context followed by one user input and schedule agent-loop execution unless resume is false. Context requires steer delivery.", "summary": "Send message", "requestBody": { "content": { @@ -2071,6 +2171,16 @@ "$ref": "#/components/schemas/PromptInput.SkillAttachment" } }, + "context": { + "anyOf": [ + { + "$ref": "#/components/schemas/PromptInput.Context" + }, + { + "type": "null" + } + ] + }, "metadata": { "type": "object" }, @@ -4508,6 +4618,21 @@ "pattern": "^frm_" }, "required": true + }, + { + "name": "message", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false } ], "security": [], @@ -4591,21 +4716,6 @@ "pattern": "^frm_" }, "required": true - }, - { - "name": "message", - "in": "query", - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "required": false } ], "security": [], @@ -7197,6 +7307,125 @@ "summary": "List MCP resources" } }, + "/api/credential": { + "get": { + "tags": ["credential"], + "operationId": "credential.list", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Credential.Entry" + } + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + } + }, + "description": "List every stored integration credential, including its secret value.", + "summary": "List credentials" + }, + "post": { + "tags": ["credential"], + "operationId": "credential.create", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Credential.Entry" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + }, + "409": { + "description": "ConflictError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictErrorEncoded" + } + } + } + } + }, + "description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.", + "summary": "Create credential", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Credential.CreateInput" + } + } + }, + "required": true + } + } + }, "/api/credential/{credentialID}": { "patch": { "tags": ["credential"], @@ -13631,6 +13860,9 @@ "method": { "type": "string", "enum": ["key", "oauth"] + }, + "status": { + "$ref": "#/components/schemas/Connection.Status" } }, "required": ["type", "id", "label", "method"], @@ -13645,6 +13877,9 @@ }, "name": { "type": "string" + }, + "status": { + "$ref": "#/components/schemas/Connection.Status" } }, "required": ["type", "name"], @@ -13660,6 +13895,128 @@ } ] }, + "Connection.Status": { + "type": "object", + "properties": { + "status": { + "anyOf": [ + { + "type": "string", + "enum": ["needs_auth"] + } + ] + }, + "message": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["status", "message"], + "additionalProperties": false + }, + "Credential.CreateInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "integrationID": { + "type": "string" + }, + "label": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/Credential.Value" + }, + "activate": { + "type": "boolean" + } + }, + "required": ["integrationID", "value"], + "additionalProperties": false + }, + "Credential.Entry": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "integrationID": { + "type": "string" + }, + "label": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "value": { + "$ref": "#/components/schemas/Credential.Value" + } + }, + "required": ["id", "integrationID", "label", "active", "value"], + "additionalProperties": false + }, + "Credential.Key": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["key"] + }, + "key": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "configuration": { + "$ref": "#/components/schemas/Form.Answer_1" + } + }, + "required": ["type", "key"], + "additionalProperties": false + }, + "Credential.OAuth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["oauth"] + }, + "methodID": { + "type": "string" + }, + "refresh": { + "type": "string" + }, + "access": { + "type": "string" + }, + "expires": { + "type": "integer", + "minimum": 0 + }, + "metadata": { + "type": "object" + } + }, + "required": ["type", "methodID", "refresh", "access", "expires"], + "additionalProperties": false + }, + "Credential.Value": { + "anyOf": [ + { + "$ref": "#/components/schemas/Credential.OAuth" + }, + { + "$ref": "#/components/schemas/Credential.Key" + } + ] + }, "FileDiff.Info": { "type": "object", "properties": { @@ -13746,6 +14103,12 @@ "$ref": "#/components/schemas/Form.Value" } }, + "Form.Answer_1": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Form.Value" + } + }, "Form.BooleanField": { "type": "object", "properties": { @@ -15709,6 +16072,29 @@ "Money.USDPerMillionTokens": { "type": "number" }, + "PairingCode": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "expires_in": { + "type": "integer" + } + }, + "required": ["code", "expires_in"], + "additionalProperties": false + }, + "PairingSession": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + }, + "required": ["token"], + "additionalProperties": false + }, "Permission.Effect": { "type": "string", "enum": ["allow", "deny", "ask"] @@ -16472,6 +16858,26 @@ "required": ["id", "name"], "additionalProperties": false }, + "PromptInput.Context": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + }, + "description": { + "type": "string" + }, + "metadata": { + "type": "object" + } + }, + "required": ["id", "text"], + "additionalProperties": false + }, "PromptInput.FileAttachment": { "type": "object", "properties": { @@ -18234,6 +18640,16 @@ "type": "integer", "minimum": 100, "maximum": 599 + }, + "response": { + "type": "object", + "properties": { + "body": { + "type": "string" + } + }, + "required": ["body"], + "additionalProperties": false } }, "required": ["type", "message"], @@ -18653,6 +19069,9 @@ "exit": { "type": "number" }, + "signal": { + "type": "string" + }, "metadata": { "type": "object" }, diff --git a/services/www/public/openapi.json b/services/www/public/openapi.json index e4fbd2f5d3c6..3887c3eea4f6 100644 --- a/services/www/public/openapi.json +++ b/services/www/public/openapi.json @@ -48,6 +48,106 @@ "summary": "Get server info" } }, + "/api/pair": { + "post": { + "tags": ["server"], + "operationId": "server.pair", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "PairingCode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PairingCode" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + } + }, + "description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.", + "summary": "Create pairing code" + } + }, + "/auth/connect/{code}": { + "get": { + "tags": ["server"], + "operationId": "server.connect", + "parameters": [ + { + "name": "code", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "PairingSession", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PairingSession" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + }, + { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + ] + } + } + } + } + }, + "description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.", + "summary": "Redeem pairing code" + } + }, "/api/location": { "get": { "tags": ["location"], @@ -2031,7 +2131,7 @@ } } }, - "description": "Durably admit one session input and schedule agent-loop execution unless resume is false.", + "description": "Durably admit optional synthetic context followed by one user input and schedule agent-loop execution unless resume is false. Context requires steer delivery.", "summary": "Send message", "requestBody": { "content": { @@ -2071,6 +2171,16 @@ "$ref": "#/components/schemas/PromptInput.SkillAttachment" } }, + "context": { + "anyOf": [ + { + "$ref": "#/components/schemas/PromptInput.Context" + }, + { + "type": "null" + } + ] + }, "metadata": { "type": "object" }, @@ -4508,6 +4618,21 @@ "pattern": "^frm_" }, "required": true + }, + { + "name": "message", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false } ], "security": [], @@ -4591,21 +4716,6 @@ "pattern": "^frm_" }, "required": true - }, - { - "name": "message", - "in": "query", - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "required": false } ], "security": [], @@ -7197,6 +7307,125 @@ "summary": "List MCP resources" } }, + "/api/credential": { + "get": { + "tags": ["credential"], + "operationId": "credential.list", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Credential.Entry" + } + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + } + }, + "description": "List every stored integration credential, including its secret value.", + "summary": "List credentials" + }, + "post": { + "tags": ["credential"], + "operationId": "credential.create", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Credential.Entry" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + }, + "409": { + "description": "ConflictError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictErrorEncoded" + } + } + } + } + }, + "description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.", + "summary": "Create credential", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Credential.CreateInput" + } + } + }, + "required": true + } + } + }, "/api/credential/{credentialID}": { "patch": { "tags": ["credential"], @@ -13631,6 +13860,9 @@ "method": { "type": "string", "enum": ["key", "oauth"] + }, + "status": { + "$ref": "#/components/schemas/Connection.Status" } }, "required": ["type", "id", "label", "method"], @@ -13645,6 +13877,9 @@ }, "name": { "type": "string" + }, + "status": { + "$ref": "#/components/schemas/Connection.Status" } }, "required": ["type", "name"], @@ -13660,6 +13895,128 @@ } ] }, + "Connection.Status": { + "type": "object", + "properties": { + "status": { + "anyOf": [ + { + "type": "string", + "enum": ["needs_auth"] + } + ] + }, + "message": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["status", "message"], + "additionalProperties": false + }, + "Credential.CreateInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "integrationID": { + "type": "string" + }, + "label": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/Credential.Value" + }, + "activate": { + "type": "boolean" + } + }, + "required": ["integrationID", "value"], + "additionalProperties": false + }, + "Credential.Entry": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "integrationID": { + "type": "string" + }, + "label": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "value": { + "$ref": "#/components/schemas/Credential.Value" + } + }, + "required": ["id", "integrationID", "label", "active", "value"], + "additionalProperties": false + }, + "Credential.Key": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["key"] + }, + "key": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "configuration": { + "$ref": "#/components/schemas/Form.Answer_1" + } + }, + "required": ["type", "key"], + "additionalProperties": false + }, + "Credential.OAuth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["oauth"] + }, + "methodID": { + "type": "string" + }, + "refresh": { + "type": "string" + }, + "access": { + "type": "string" + }, + "expires": { + "type": "integer", + "minimum": 0 + }, + "metadata": { + "type": "object" + } + }, + "required": ["type", "methodID", "refresh", "access", "expires"], + "additionalProperties": false + }, + "Credential.Value": { + "anyOf": [ + { + "$ref": "#/components/schemas/Credential.OAuth" + }, + { + "$ref": "#/components/schemas/Credential.Key" + } + ] + }, "FileDiff.Info": { "type": "object", "properties": { @@ -13746,6 +14103,12 @@ "$ref": "#/components/schemas/Form.Value" } }, + "Form.Answer_1": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Form.Value" + } + }, "Form.BooleanField": { "type": "object", "properties": { @@ -15709,6 +16072,29 @@ "Money.USDPerMillionTokens": { "type": "number" }, + "PairingCode": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "expires_in": { + "type": "integer" + } + }, + "required": ["code", "expires_in"], + "additionalProperties": false + }, + "PairingSession": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + }, + "required": ["token"], + "additionalProperties": false + }, "Permission.Effect": { "type": "string", "enum": ["allow", "deny", "ask"] @@ -16472,6 +16858,26 @@ "required": ["id", "name"], "additionalProperties": false }, + "PromptInput.Context": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + }, + "description": { + "type": "string" + }, + "metadata": { + "type": "object" + } + }, + "required": ["id", "text"], + "additionalProperties": false + }, "PromptInput.FileAttachment": { "type": "object", "properties": { @@ -18234,6 +18640,16 @@ "type": "integer", "minimum": 100, "maximum": 599 + }, + "response": { + "type": "object", + "properties": { + "body": { + "type": "string" + } + }, + "required": ["body"], + "additionalProperties": false } }, "required": ["type", "message"], @@ -18653,6 +19069,9 @@ "exit": { "type": "number" }, + "signal": { + "type": "string" + }, "metadata": { "type": "object" },