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) + }) })