From 7c3cb9955930c16be54ed60f05e74748aab93f52 Mon Sep 17 00:00:00 2001 From: CasualDeveloper <10153929+CasualDeveloper@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:29:18 +0800 Subject: [PATCH] fix: preserve revert consistency Serialize per-session revert and admission changes, commit staged reverts atomically with their replacement admission, preserve queued work across reverts, and compare undo boundaries chronologically instead of by message ID. Plugin activation completes before session changes are blocked so plugin setup can still admit its own input. Includes the #42461 boundary fix; supersedes that PR. Closes #37751. Continues the approach from #37752. --- packages/app/src/composer/adapter.ts | 6 + packages/app/src/composer/submit.test.ts | 46 + packages/app/src/composer/submit.ts | 88 +- .../app/src/new-session/composer-adapter.ts | 10 + packages/app/src/session/composer/queue.ts | 46 +- packages/app/src/session/model.test.ts | 8 +- packages/app/src/session/revert.test.ts | 69 + packages/app/src/session/revert.ts | 80 +- packages/app/src/session/session-domain.ts | 32 +- .../timeline/controller-projection.test.ts | 23 +- .../session/timeline/controller-projection.ts | 5 +- packages/client/src/effect/api/api.ts | 1 + .../client/src/effect/generated/client.ts | 1 + .../client/src/promise/generated/client.ts | 1 + .../client/src/promise/generated/types.ts | 75 + packages/client/src/solid/data.ts | 178 ++- packages/client/test/solid-data.test.ts | 404 +++++- packages/core/src/plugin.ts | 12 + packages/core/src/session.ts | 2 + packages/core/src/session/error.ts | 4 + packages/core/src/session/prompt.ts | 1 + packages/core/src/session/session.ts | 468 +++++-- packages/core/src/session/store.ts | 30 +- packages/core/test/plugin/promise.test.ts | 1226 +++++++++++++++++ packages/core/test/session-create.test.ts | 1 + packages/core/test/session-owned.test.ts | 4 +- .../core/test/session-prompt-hooks.test.ts | 577 ++++++++ packages/core/test/session-prompt.test.ts | 906 +++++++++++- packages/plugin/src/effect/activation.ts | 37 + packages/plugin/src/promise/adapter.ts | 116 +- packages/protocol/openapi.json | 32 +- packages/protocol/src/groups/session.ts | 4 +- packages/schema/src/prompt-input.ts | 8 + packages/server/src/handlers/session.ts | 6 + packages/tui/src/app.tsx | 20 +- .../tui/src/component/prompt/draft-stash.ts | 8 +- packages/tui/src/component/prompt/index.tsx | 209 +-- packages/tui/src/component/prompt/retry.ts | 148 ++ packages/tui/src/context/editor.ts | 4 +- packages/tui/src/context/local.tsx | 40 + .../tui/src/routes/session/dialog-message.tsx | 20 +- packages/tui/src/routes/session/index.tsx | 76 +- packages/tui/src/routes/session/rows.ts | 29 +- packages/tui/test/cli/tui/data.test.tsx | 87 +- .../tui/test/cli/tui/session-rows.test.ts | 55 + packages/tui/test/compact-admission.test.tsx | 114 ++ packages/tui/test/context/local.test.ts | 48 +- packages/tui/test/editor-context.test.tsx | 94 ++ packages/tui/test/prompt-retry.test.ts | 208 +++ packages/tui/test/prompt/draft-stash.test.ts | 14 +- 50 files changed, 5248 insertions(+), 433 deletions(-) create mode 100644 packages/app/src/session/revert.test.ts create mode 100644 packages/core/test/plugin/promise.test.ts create mode 100644 packages/core/test/session-prompt-hooks.test.ts create mode 100644 packages/plugin/src/effect/activation.ts create mode 100644 packages/tui/src/component/prompt/retry.ts create mode 100644 packages/tui/test/editor-context.test.tsx create mode 100644 packages/tui/test/prompt-retry.test.ts diff --git a/packages/app/src/composer/adapter.ts b/packages/app/src/composer/adapter.ts index ef068ab233f8..4f170dc99076 100644 --- a/packages/app/src/composer/adapter.ts +++ b/packages/app/src/composer/adapter.ts @@ -75,6 +75,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 178dcd0db772..35f0ebc9c022 100644 --- a/packages/app/src/composer/submit.test.ts +++ b/packages/app/src/composer/submit.test.ts @@ -97,6 +97,13 @@ function session(input: { location: { command: { list: () => [] } }, session: { setStatus: (_sessionID, status) => input.statuses?.push(status), + mutate: async (_sessionID, operation) => + operation({ + prompt: async (value) => { + input.calls.push("prompt") + await input.prompt(value) + }, + }), prompt: async (value) => { input.calls.push("prompt") await input.prompt(value) @@ -143,6 +150,45 @@ describe("Composer submission", () => { expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }]) }) + test("reserves prompt admission before later session mutations", async () => { + const state = createMemoryComposerState({ prompt: "replace history" }).capture() + const calls: string[] = [] + const target = session({ + calls, + current: () => ({ agent: "build", model: { id: "model-1", providerID: "provider-1", variant: "balanced" } }), + prompt: async () => undefined, + }) + const prompt = target.data.session.prompt + let previous = Promise.resolve(undefined) + target.data.session.mutate = (sessionID, operation) => { + const request = previous.then(() => operation({ prompt })) + previous = request.then( + () => undefined, + () => undefined, + ) + return request + } + 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(["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() diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index e7a344a0fb3f..117dddfcd733 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -303,53 +303,55 @@ async function sendCommand( }) } -async function sendPrompt(session: ComposerSession, value: ComposerSubmission) { - 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") { - const current = session.current() - if (current?.agent !== value.selection.agent) { - await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent }) +function sendPrompt(session: ComposerSession, value: ComposerSubmission) { + 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") { + const current = session.current() + if (current?.agent !== value.selection.agent) { + await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent }) + } + if ( + current?.model?.providerID !== value.selection.model.providerID || + current.model.id !== value.selection.model.modelID || + (current.model.variant ?? "default") !== (value.selection.variant ?? "default") + ) { + await session.api.switchModel({ + sessionID: session.id, + model: { + id: value.selection.model.modelID, + providerID: value.selection.model.providerID, + variant: value.selection.variant, + }, + }) + } } - if ( - current?.model?.providerID !== value.selection.model.providerID || - current.model.id !== value.selection.model.modelID || - (current.model.variant ?? "default") !== (value.selection.variant ?? "default") - ) { - await session.api.switchModel({ - sessionID: session.id, + + 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, + agent: value.selection.agent, model: { - id: value.selection.model.modelID, - providerID: value.selection.model.providerID, - variant: value.selection.variant, + ...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, - agent: value.selection.agent, - model: { - ...value.selection.model, - ...(value.selection.variant ? { variant: value.selection.variant } : {}), }, - }, - } - await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission)) + } + await mutation.prompt(admission).catch(() => mutation.prompt(admission)) + }) } 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 a1d4e1b09fda..1cedf655827c 100644 --- a/packages/app/src/new-session/composer-adapter.ts +++ b/packages/app/src/new-session/composer-adapter.ts @@ -138,6 +138,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 de3930d788a8..bed5c510713b 100644 --- a/packages/app/src/session/composer/queue.ts +++ b/packages/app/src/session/composer/queue.ts @@ -53,25 +53,31 @@ export function createSessionQueue(input: { delivery: ComposerDelivery }, ) => { - if (change.type === "reorder") return rewrite(change.inboxIDs) - 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), @@ -97,7 +103,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") @@ -109,7 +115,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 c0a5124c34da..5249f12da813 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..05219d8e1d4b --- /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-ai/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 080e4cc2e772..4a5ec2b60c1a 100644 --- a/packages/app/src/session/revert.ts +++ b/packages/app/src/session/revert.ts @@ -7,6 +7,7 @@ import { useLanguage } from "@/runtime/i18n/language" 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 @@ -46,9 +47,7 @@ export function createSessionRevert(input: { ) } - 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") { @@ -82,40 +81,73 @@ export function createSessionRevert(input: { } 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 0f68094948f1..bf729e25d561 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 27f514e74edc..450f08420351 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", timeCreated: 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 29d79aae12ee..598364063ec2 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 45ce1d7359ae..d5adf6f3789a 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -272,6 +272,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 dafd6cda741c..c06bbe6774bc 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -490,6 +490,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 33f55761c798..98fed1ea0a84 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -699,6 +699,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 e8036b9f5417..6a86455f0f14 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -3726,6 +3726,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 @@ -3747,6 +3753,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 @@ -3768,6 +3780,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 @@ -3789,6 +3807,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 @@ -3810,10 +3834,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 @@ -3831,6 +3888,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 @@ -3852,6 +3915,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 @@ -3873,6 +3942,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 d18de9071266..0157597c8980 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -22,6 +22,8 @@ import type { Project, ProviderInfo, ReferenceInfo, + SessionRevertClearInput, + SessionRevertStageInput, SessionMessageInfo, SessionMessageAssistant, SessionMessageAssistantReasoning, @@ -68,6 +70,11 @@ export type CreateDataInput = { } } +type PromptAdmissionInput = SessionPromptInput & { + gate?: Promise + prepare?: () => Promise | void> +} + const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") const messagePageLimit = 20 @@ -293,17 +300,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) @@ -311,21 +317,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, + timeCreated: 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 @@ -1011,18 +1079,24 @@ 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. + 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) + for (const item of draft.splice(boundary < 0 ? 0 : boundary)) index.delete(item.id) }) + 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) => { @@ -1293,6 +1367,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) { @@ -1439,46 +1529,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, - timeCreated: 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 0743791514da..e22129fb22dd 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -2,7 +2,7 @@ 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 OpenCodeEvent, type Project, type SessionInfo } from "../src/promise" +import { OpenCode, type OpenCodeEvent, type Project, type SessionInfo, type SessionMessageUser } from "../src/promise" const session = (viewed: number): SessionInfo => ({ id: "ses_refresh", @@ -500,7 +500,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.url.endsWith("/revert/clear")) { + 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", + timeCreated: 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", + timeCreated: 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", + timeCreated: 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.url.endsWith("/revert/clear")) { + 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", + timeCreated: 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", @@ -508,6 +771,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" } : {} }) }, }) @@ -522,9 +786,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 { @@ -843,6 +1114,133 @@ 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", + timeCreated: 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() + } +}) + 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 24913ec63972..699f6ef75773 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-ai/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. @@ -43,6 +46,13 @@ const layer = Layer.effect( const load = Effect.fnUntraced(function* (plugin: Generation) { const child = yield* Scope.fork(scope) const inherit = yield* State.inherit() + const activation = { + active: true, + fiberID: yield* Effect.fiberId, + token: {}, + directory: location.directory, + workspaceID: location.workspaceID, + } const loaded = yield* Effect.suspend(() => plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) }), ).pipe( @@ -51,9 +61,11 @@ const layer = Layer.effect( Context.make(Scope.Scope, child).pipe( Context.add(Logger.CurrentLoggers, Context.get(context, Logger.CurrentLoggers)), Context.add(References.MinimumLogLevel, Context.get(context, References.MinimumLogLevel)), + Context.add(PluginActivation.Current, activation), ), ), Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }), + Effect.ensuring(Effect.sync(() => (activation.active = false))), Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), Effect.exit, ) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index df1e4b223684..574b57ab52b9 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -28,6 +28,7 @@ import { AttachmentError, BusyError, CompactionConflictError, + ContextDeliveryError, ForkEmptyError, InboxConflictError, MessageDecodeError, @@ -101,6 +102,7 @@ export { AttachmentError, BusyError, CompactionConflictError, + ContextDeliveryError, InboxConflictError, MessageDecodeError, MessageIncompleteError, diff --git a/packages/core/src/session/error.ts b/packages/core/src/session/error.ts index 13eaae975f0a..80cb70551405 100644 --- a/packages/core/src/session/error.ts +++ b/packages/core/src/session/error.ts @@ -101,6 +101,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/prompt.ts b/packages/core/src/session/prompt.ts index e06290bd1a60..461907f182ca 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 b67366ce0044..1ab52e84125e 100644 --- a/packages/core/src/session/session.ts +++ b/packages/core/src/session/session.ts @@ -1,18 +1,22 @@ export * as Session from "./session.js" -import { DateTime, Effect, Fiber, Schema, Scope } from "effect" +import { DateTime, Deferred, Effect, Fiber, Schema, Scope } from "effect" import type { Agent } from "@opencode-ai/schema/agent" +import type { Location } from "@opencode-ai/schema/location" import type { Model } from "@opencode-ai/schema/model" import { Event } from "@opencode-ai/schema/event" import { FSUtil } from "@opencode-ai/util/fs-util" +import { PluginActivation } from "@opencode-ai/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, MessageIncompleteError, MessageNotAssistantError, @@ -51,6 +55,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) @@ -100,6 +107,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* ( @@ -151,38 +159,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, + timeCreated: 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* ( @@ -192,6 +429,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) => @@ -256,8 +495,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({ @@ -279,48 +525,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 continue?: boolean }) => Effect.uninterruptible(execution.interrupt(sessionID, options)), @@ -329,27 +601,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 e10c16e47e5d..7ef10358e829 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-ai/schema/session" -import { SessionMessageTable, SessionTable } from "./sql.js" +import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js" import { fromRow } from "./info.js" const ListInputBase = { @@ -57,6 +57,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. @@ -187,6 +192,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..a0ee15a6c138 --- /dev/null +++ b/packages/core/test/plugin/promise.test.ts @@ -0,0 +1,1226 @@ +import { describe, expect } from "bun:test" +import { Message, SystemPart } from "@opencode-ai/ai" +import { DateTime, Deferred, Effect, Fiber, Schema } from "effect" +import { Agent } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" +import { Model } from "@opencode-ai/core/model" +import { Location } from "@opencode-ai/core/location" +import { Plugin } from "@opencode-ai/core/plugin" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { PluginPromise } from "@opencode-ai/core/plugin/promise" +import { WebSearch } from "@opencode-ai/core/websearch" +import { Vcs } from "@opencode-ai/core/vcs" +import { Session } from "@opencode-ai/core/session" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionInbox } from "@opencode-ai/core/session/inbox" +import { Tool } from "@opencode-ai/core/tool" +import { Provider } from "@opencode-ai/core/provider" +import { Project } from "@opencode-ai/core/project" +import { Workspace } from "@opencode-ai/core/workspace" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { define } from "@opencode-ai/plugin/promise/plugin" +import type { Info } from "@opencode-ai/plugin/promise/tool" +import { Money } from "@opencode-ai/schema/money" +import { PersistentPty } from "@opencode-ai/schema/persistent-pty" +import { Pty } from "@opencode-ai/schema/pty" +import type { SessionHooks } from "@opencode-ai/plugin/effect/session" +import { PluginActivation } from "@opencode-ai/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"]) + for (const lines of [0, -1, 1.5, 65536, NaN, Infinity, "3"]) { + await expect( + Reflect.apply(ctx.experimental.terminal.read, undefined, [{ sessionID: "ses_terminal", lines }]), + ).rejects.toBeDefined() + } + await expect(Reflect.apply(ctx.experimental.terminal.read, undefined, [{ 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) => { + await expect(Reflect.apply(ctx.session.create, undefined, [{ 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.continue).toBe(true) + return Effect.succeed({ interrupted: false }) + }, + switchAgent: (input) => Effect.sync(() => seen.push(input)), + switchModel: (input) => Effect.sync(() => seen.push(input)), + rename: (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", continue: 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.rename({ 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), + timeCreated: 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), + timeCreated: 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), + timeCreated: 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, + timeCreated: 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.catalog.provider.list(), + ctx.catalog.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)) + expect((await ctx.integration.get({ integrationID: "missing" })).data).toBeNull() + }, + }) + + 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 catalog = yield* Catalog.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* catalog.transform((draft) => + draft.model.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.catalog.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.generation.temperature = 0.4 + event.providerOptions.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" } } }, + generation: {}, + providerOptions: {}, + } + + yield* hooks.trigger("session", "context", event) + + expect(event.system.map((part) => part.text)).toEqual(["Initial", "Promise hook"]) + expect(event.tools).toEqual({}) + expect(event.generation).toEqual({ temperature: 0.4 }) + expect(event.providerOptions).toEqual({ 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") }), + } + + 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.branches({ 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"), + 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", "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) await ctx.session.create({ agent: undefined }) + 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: 'Expected string | null\n at ["agent"]' }], + metadata: { error: true }, + }) + }), + ) +}) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index ab49cbfae70a..b55404c85493 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -1078,6 +1078,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-owned.test.ts b/packages/core/test/session-owned.test.ts index 64ec38edb356..fca461f4207b 100644 --- a/packages/core/test/session-owned.test.ts +++ b/packages/core/test/session-owned.test.ts @@ -734,8 +734,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..553aa7d444ce --- /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-ai/core/bus" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" +import { Plugin } from "@opencode-ai/core/plugin" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Session } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionInbox } from "@opencode-ai/core/session/inbox" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { Skill } from "@opencode-ai/core/skill" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { Global } from "@opencode-ai/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", + location: 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 ba6784e40f4f..76d55ed2bdec 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-ai/core/location-services" import { Image } from "@opencode-ai/core/image" import { Plugin } from "@opencode-ai/core/plugin" import { PluginHooks } from "@opencode-ai/core/plugin/hooks" +import { PluginActivation } from "@opencode-ai/plugin/effect/activation" import { Snapshot } from "@opencode-ai/core/snapshot" import { Skill } from "@opencode-ai/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 @@ -595,6 +1359,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 @@ -824,11 +1651,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() }), ) @@ -1057,6 +1892,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 ee0f69c01441..2b07ad93902c 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -1,13 +1,15 @@ import { Tool } from "@opencode-ai/schema/tool" import type { Rpc } from "@opencode-ai/schema/rpc" import type { RpcCallOptions, RpcEventPayload } from "@opencode-ai/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 } @@ -236,6 +238,14 @@ export function fromPromise(plugin: Plugin) { const WebSearchEndpoints = ClientApi.groups["server.websearch"].endpoints const context = yield* Effect.context() const streams = yield* makeStreams() + const activation = Context.get(context, 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 => @@ -255,18 +265,65 @@ export function fromPromise(plugin: Plugin) { }), ) + // 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* () { - const decoded = yield* Effect.forEach(compiled.decode, (decode) => decode(input ?? {})) - const result = yield* method(Object.assign({}, ...decoded) as never) - if (compiled.noContent) return undefined - return yield* compiled.encode(result) - }).pipe(Effect.runPromiseWith(context))) as PromiseMethod + return ((input?: unknown) => { + const value = input ?? {} + return runner( + Effect.gen(function* () { + const decoded = yield* Effect.forEach(compiled.decode, (decode) => decode(value)) + const result = yield* method(Object.assign({}, ...decoded) as never) + if (compiled.noContent) return undefined + return yield* compiled.encode(result) + }), + value, + ) + }) as PromiseMethod } const transform = @@ -541,7 +598,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), @@ -550,7 +633,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), rename: adaptApiMethod(SessionEndpoints["session.rename"], host.session.rename), move: adaptApiMethod(SessionEndpoints["session.move"], host.session.move), @@ -564,7 +647,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 97b3282be2d7..2eddc6b7de55 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -2160,7 +2160,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": { @@ -2200,6 +2200,16 @@ "$ref": "#/components/schemas/PromptInput.SkillAttachment" } }, + "context": { + "anyOf": [ + { + "$ref": "#/components/schemas/PromptInput.Context" + }, + { + "type": "null" + } + ] + }, "metadata": { "type": "object" }, @@ -17205,6 +17215,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": { diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 442d9eafadc7..4d2e8fc115bf 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -340,6 +340,7 @@ export const makeSessionGroup = (sessionLo 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), @@ -352,7 +353,8 @@ export const makeSessionGroup = (sessionLo OpenApi.annotations({ identifier: "v2.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 a8e8db9ba314..77a8794c5e0d 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -298,6 +298,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, @@ -315,6 +316,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 a6501f751680..6f1ddc18de48 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" @@ -102,6 +102,10 @@ import { StorageProvider, useStorage } from "./context/storage" import { SessionTerminalsProvider } from "./context/session-terminals" import { SessionFrame } from "./component/session-frame" import { createTuiClipboard } from "./clipboard" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { acknowledgePromptRetry, clearPromptRetry } from "./component/prompt/retry" +import { clearDraft } from "./component/prompt/draft-stash" +import { isDuplicateEntry } from "./prompt/history" registerOpencodeSpinner() @@ -488,6 +492,7 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater" const promptRef = usePromptRef() const plugins = usePlugin() const clipboard = useClipboard() + const editor = useEditorContext() const terminalEnvironment = useTuiTerminalEnvironment() createEffect(() => { if (client.connection.status() !== "connected") return @@ -1242,8 +1247,21 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater" ) }) + 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 80c5bef72492..eb2939f0bb35 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-ai/schema/session-message" import { useExit } from "../../context/exit" import { promptOffsetWidth } from "../../prompt/display" import { expandPromptInputPastedText, realignPromptInputMentions } from "../../prompt/mention" @@ -69,6 +70,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 @@ -333,18 +341,20 @@ export function Prompt(props: PromptProps) { let promptPartTypeId = 0 const event = useEvent() - event.on("tui.prompt.append", (evt, { workspace }) => { - if (workspace !== (currentLocation.current?.workspaceID ?? data.location.default().workspaceID)) 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, { workspace }) => { + if (workspace !== (currentLocation.current?.workspaceID ?? data.location.default().workspaceID)) 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 @@ -1161,8 +1171,11 @@ export function Prompt(props: PromptProps) { return false } 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 } @@ -1194,12 +1207,13 @@ export function Prompt(props: PromptProps) { resetComposer() 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 variant = selection.variant @@ -1297,85 +1311,100 @@ export function Prompt(props: PromptProps) { dispatch(() => client.api.session.skill({ sessionID: target, skill: slashHead.name })) } else { move.startSubmit() - try { - if (!session) { - 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 }) - } - } catch (error) { - toast.show({ title: "Failed to prepare session", message: errorMessage(error), variant: "error" }) - restoreEntry() - return true - } - const model = { providerID: selection.providerID, id: selection.modelID, variant } - if (session?.revert) { - const error = await client.api.session.revert.commit({ sessionID: target }).then( - () => undefined, - (error) => error, - ) - if (error) { - toast.show({ title: "Failed to commit revert", message: errorMessage(error), variant: "error" }) - restoreEntry() - return false - } + const retryInput = { + prompt: entry, + agent: agent.id, + providerID: selection.providerID, + modelID: selection.modelID, + variant, + delivery, + contextKey: pendingEditorContext?.key, } - 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, + 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 () => { + if (!session) { + await data.session.sync(target) + session = data.session.get(target) + } + const agentCommit = local.agent.trackSessionCommit(target, session?.agent, agent.id) + if (agentCommit) { + await client.api.session.switchAgent({ sessionID: target, agent: agent.id }).then( + () => agentCommit.succeed(), + (error) => { + agentCommit.fail() + throw error + }, + ) + } + const model = { providerID: selection.providerID, id: selection.modelID, variant } + const cancelCommit = local.model.trackSessionCommit(target, model) + await client.api.session.switchModel({ sessionID: target, model }).catch((error) => { + cancelCommit() + throw new Error(`Failed to switch model: ${errorMessage(error)}`, { cause: error }) }) - if (newSession) { - // Fold into the setup gate so the context still admits before the - // user prompt once the session exists. - newSession.gate = newSession.gate.then(send) - } else { - const error = await send().then( - () => undefined, - (error) => error, - ) - if (error) { - toast.show({ title: "Failed to send editor context", message: errorMessage(error), variant: "error" }) - restoreEntry() - return false + 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 } - } - } - // 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, - prepare: () => { - // 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. - const cancelCommit = local.model.trackSessionCommit(target, model) - return client.api.session.switchModel({ sessionID: target, model }).catch((error) => { - cancelCommit() - throw new Error(`Failed to switch model: ${errorMessage(error)}`, { cause: error }) - }) - }, - }) - .catch((error) => { - if (newSession) return newSession.recover(error) toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" }) - restoreEntry() - }) - if (pendingEditorSelection) editor.markSelectionSent() + 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) + }, + ) } sessionTabs.promote(target) @@ -1383,7 +1412,7 @@ export function Prompt(props: PromptProps) { // 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..16bc9519b6d4 --- /dev/null +++ b/packages/tui/src/component/prompt/retry.ts @@ -0,0 +1,148 @@ +import { SessionMessage } from "@opencode-ai/schema/session-message" +import type { SessionInbox } from "@opencode-ai/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 7e6da553f47e..1b8abe251434 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -45,6 +45,42 @@ export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenc .map((item) => ({ providerID: item.providerID, modelID: item.modelID })) } +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: () => { @@ -75,6 +111,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), ) @@ -91,6 +128,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() @@ -107,6 +146,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) setAgentStore("current", id) }, + 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 7ecc44ce0bd6..812cb0c4fc5e 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -90,7 +90,9 @@ import { backgroundToolRowIndex, cacheReuseDrop, createSessionRows, + loadRevertMessages, messageBoundaryIDs, + partitionRevertMessages, resolvePart, sessionRowID, turnDuration, @@ -183,18 +185,9 @@ export function Session(props: { const session = createMemo(() => data.session.get(route.sessionID)) const messages = () => data.session.message.list(route.sessionID) const messageIndexes = createMemo(() => new Map(messages().map((message, index) => [message.id, index]))) - 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) @@ -941,22 +934,45 @@ 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 }) + void (async () => { + const result = await data.session + .mutate(route.sessionID, async () => { + const boundary = session()?.revert?.messageID + const loaded = boundary + ? await loadRevertMessages({ + boundary, + messages, + more: () => data.session.message.more(route.sessionID), + loadMore: () => data.session.message.loadMore(route.sessionID), + }) + : messages() + const message = partitionRevertMessages(loaded ?? [], boundary).before.findLast( + (message): message is SessionMessageUser => message.type === "user" && !!message.text.trim(), + ) + if (!message) return undefined + await client.api.session.revert.stage({ sessionID: route.sessionID, messageID: message.id }) + return message + }) + .then( + (message) => ({ type: "done" as const, message }), + (error) => ({ type: "error" as const, error }), + ) + if (result.type === "error") { + toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 }) + return + } + const message = result.message + if (!message) { + toast.show({ message: "Nothing to undo", variant: "error", duration: 3000 }) + dialog.clear() + return + } + prompt()?.set({ + ...projectedPromptInput(message), + pasted: [], + }) dialog.clear() - return - } - void client.api.session.revert - .stage({ sessionID: route.sessionID, messageID: message.id }) - .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) - prompt()?.set({ - ...projectedPromptInput(message), - pasted: [], - }) - dialog.clear() + })() }, }, { @@ -967,7 +983,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, ) @@ -2260,7 +2276,7 @@ function RevertMessage(props: { const ctx = use() const theme = useTheme("elevated") const route = useRouteData("session") - const client = useClient() + const data = useData() const toast = useToast() const renderer = useRenderer() const [hover, setHover] = createSignal(false) @@ -2272,7 +2288,7 @@ function RevertMessage(props: { onMouseUp={() => { 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 0d3c773c47ce..061dbedf09ca 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -53,12 +53,7 @@ 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, - inputs, - turnTokens(), - ) + const rows = reduceSessionRows(partitionRevertMessages(visible, revertBoundary()).before, inputs, turnTokens()) partitionPending(rows, pendingPermissions()) const position = rows.findIndex((row) => row.type === "message" && inputs.has(row.messageID)) rows.splice( @@ -284,6 +279,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(), turnTokens = false) { const isInput = (message: SessionMessageInfo) => inputs.has(message.id) const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running") diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 7776514ea54b..46a16bd4c29a 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -468,6 +468,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: { @@ -1085,6 +1086,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, + timeCreated: 0, + type: "user", + payload: { text: "msg_fff" }, + delivery: "steer", + }, + ], + }) }, events) let data!: ReturnType @@ -1106,7 +1121,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, @@ -1122,17 +1138,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 2f29fb494aaf..5dd555a44428 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -5,13 +5,68 @@ import { createStore } from "solid-js/store" import { backgroundToolRowIndex, 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 861684b97229..838f810b224e 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", + timeCreated: 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 e2f1e45f75a9..0f92c3a510e7 100644 --- a/packages/tui/test/context/local.test.ts +++ b/packages/tui/test/context/local.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { parseModel, recentModels } from "../../src/context/local" +import { createAgentSessionCommitTracker, parseModel, recentModels } from "../../src/context/local" test("parses model IDs containing slashes", () => { expect(parseModel("provider/family/model")).toEqual({ @@ -20,3 +20,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..3cc5d06e5fe0 --- /dev/null +++ b/packages/tui/test/prompt-retry.test.ts @@ -0,0 +1,208 @@ +import { expect, test } from "bun:test" +import { SessionMessage } from "@opencode-ai/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) + }) })