Something went wrong. Try again.
opencode v2 mirror
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733import { describe, expect, test } from "bun:test"import type { ModelSelection } from "@/providers/models/selection"import type { SessionMessageUser } from "@opencode/client/promise"import { Skill } from "@opencode/schema/skill"import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"import { createMemoryComposerState } from "./state"import { createComposerSubmit } from "./submit"
const selectedModel = { id: "model-1", name: "Model 1", provider: { id: "provider-1" },} as NonNullable<ReturnType<ModelSelection["current"]>>
const selection = { ready: Object.assign(() => true, { promise: undefined }), current: () => selectedModel, recent: () => [selectedModel], list: () => [selectedModel], cycle() {}, set() {}, visible: () => true, setVisibility() {}, variant: { configured: () => undefined, selected: () => "balanced", current: () => "balanced", list: () => ["balanced"], set() {}, cycle() {}, },} satisfies ModelSelection
function controls(): ComposerControls { return { agents: { available: [{ name: "build", mode: "primary" }], options: ["build"], current: "build", visible: true, select() {}, }, model: { selection, paid: true, loading: false }, session: { tabs: { active: () => undefined, all: () => [], open() {}, setActive() {} }, reviewPanel: { opened: () => false, open() {} }, }, }}
function submitInput( adapter: ActiveComposerAdapter | NewSessionComposerAdapter, notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} }, mode: "normal" | "shell" = "normal", commands: () => readonly { name: string }[] | undefined = () => [], history: string[] = [], clientCommand?: (text: string) => (() => void | Promise<void>) | undefined,) { return createComposerSubmit({ adapter, mode: () => mode, commands, clientCommand, editor: () => undefined, queueScroll() {}, addToHistory: (prompt) => history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`), removeFromHistory: (prompt) => history.push(`remove:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`), resetHistory() {}, setMode() {}, closePopover() {}, notify, comments: { capture: () => [], clear() {}, restore() {} }, })}
function session(input: { calls: string[] prompt: (value: Parameters<ComposerSession["data"]["session"]["prompt"]>[0]) => Promise<void> handoff?: ComposerSession["handoff"] statuses?: ("idle" | "running")[] current?: ComposerSession["current"] admitted?: (messageID: string) => boolean shell?: () => Promise<unknown> command?: ComposerSession["api"]["command"] switchAgent?: ComposerSession["api"]["switchAgent"] switchModel?: ComposerSession["api"]["switchModel"]}): ComposerSession { return { id: "session-1", directory: "C:/repo", handoff: input.handoff, current: input.current ?? (() => undefined), admitted: input.admitted ?? (() => false), api: { switchAgent: input.switchAgent ?? (async () => { input.calls.push("switch-agent") }), switchModel: input.switchModel ?? (async () => { input.calls.push("switch-model") }), shell: input.shell ?? (async () => undefined), command: input.command ?? (async () => undefined), }, data: { location: { command: { list: () => [] } }, session: { setStatus: (_sessionID, status) => input.statuses?.push(status), prompt: async (value) => { input.calls.push("prompt") await input.prompt(value) }, }, }, }}
describe("Composer submission", () => { test("runs a client argument command without admitting it to the session", async () => { const state = createMemoryComposerState().capture() state.set([ { type: "text", content: "/btw why this approach?", start: 0, end: 23 }, { type: "image", id: "attachment", filename: "diagram.png", mime: "image/png", blob: { id: "attachment", url: "data:image/png;base64,YQ==" }, }, ]) state.context.add({ type: "file", path: "src/retry.ts" }) const calls: string[] = [] const target = session({ calls, prompt: async () => { throw new Error("client command must not call prompt") }, }) const adapter: ActiveComposerAdapter = { kind: "active-session", state, ready: () => true, controls, working: () => false, session: () => target, interrupt: async () => undefined, submitted() {}, setEditor() {}, }
const history: string[] = [] await submitInput(adapter, undefined, "normal", undefined, history, (text) => { expect(text).toBe("/btw why this approach?") return () => { calls.push("btw") } }).submit(new Event("submit"))
expect(calls).toEqual(["btw"]) expect(history).toEqual([]) expect(state.current()).toEqual([ { type: "text", content: "", start: 0, end: 0 }, { type: "image", id: "attachment", filename: "diagram.png", mime: "image/png", blob: { id: "attachment", url: "data:image/png;base64,YQ==" }, }, ]) expect(state.context.items()).toHaveLength(1) })
test("applies the captured agent and model before a custom command without passing over its overrides", async () => { const state = createMemoryComposerState({ prompt: "/review changes" }).capture() const calls: string[] = [] const selected = controls() const agent = Promise.withResolvers<void>() const started = Promise.withResolvers<void>() const committed = Promise.withResolvers<void>() const completed = Promise.withResolvers<void>() const target = session({ calls, prompt: async () => { throw new Error("command must not call prompt") }, switchAgent: async (request) => { expect(request.agent).toBe("build") calls.push("agent") started.resolve() await agent.promise }, switchModel: async (request) => { expect(request.model).toEqual({ providerID: "provider-1", id: "model-1", variant: "balanced" }) calls.push("model") await committed.promise }, command: async (request) => { expect(request).toMatchObject({ name: "review", text: "changes", delivery: "steer" }) expect(request).not.toHaveProperty("model") expect(request).not.toHaveProperty("agent") calls.push("command") completed.resolve() }, }) selected.model.selection = { ...selection, trackSessionCommit: (_id, value) => { expect(value).toEqual({ agent: "build", model: { providerID: "provider-1", modelID: "model-1" }, variant: "balanced", }) calls.push("track") return () => calls.push("cancel") }, } const adapter: ActiveComposerAdapter = { kind: "active-session", state, ready: () => true, controls: () => selected, working: () => false, session: () => target, interrupt: async () => undefined, submitted() {}, setEditor() {}, } await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit")) await started.promise expect(calls).toEqual(["track", "agent"]) selected.agents.current = "plan" selected.model.selection = { ...selection, variant: { ...selection.variant, current: () => "high" } } agent.resolve() committed.resolve() await completed.promise expect(calls).toEqual(["track", "agent", "model", "command"]) })
test("commits the model even when cached session state already matches", async () => { const state = createMemoryComposerState({ prompt: "continue" }).capture() const calls: string[] = [] const done = Promise.withResolvers<void>() const target = session({ calls, current: () => ({ agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } }), prompt: async () => done.resolve(), }) const adapter: ActiveComposerAdapter = { kind: "active-session", state, ready: () => true, controls, working: () => false, session: () => target, interrupt: async () => undefined, submitted() {}, setEditor() {}, } await submitInput(adapter).submit(new Event("submit")) await done.promise expect(calls).toEqual(["switch-model", "prompt"]) })
test("cancels selection tracking and does not execute a command when selection fails", async () => { const state = createMemoryComposerState({ prompt: "/review changes" }).capture() const calls: string[] = [] const selected = controls() const failed = Promise.withResolvers<unknown>() const error = new Error("model unavailable") const target = session({ calls, prompt: async () => { calls.push("prompt") }, switchModel: async () => { throw error }, command: async () => { calls.push("command") }, }) selected.model.selection = { ...selection, trackSessionCommit: () => { calls.push("track") return () => { calls.push("cancel") } }, } const adapter: ActiveComposerAdapter = { kind: "active-session", state, ready: () => true, controls: () => selected, working: () => false, session: () => target, interrupt: async () => undefined, submitted() {}, setEditor() {}, } await submitInput( adapter, { missingSelection() {}, failed: (_kind, error) => failed.resolve(error) }, "normal", () => [{ name: "review" }], ).submit(new Event("submit")) expect(await failed.promise).toBe(error) expect(calls).toEqual(["track", "switch-agent", "cancel"]) expect(state.current()[0]).toMatchObject({ content: "/review changes" }) })
test("sends one captured value with explicit delivery after selection switches", async () => { const state = createMemoryComposerState({ prompt: "ship it" }).capture() const calls: string[] = [] const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>() const target = session({ calls, current: () => ({ agent: "plan", model: { id: "old", providerID: "old" } }), prompt: async (value) => admitted.resolve(value), }) const adapter: ActiveComposerAdapter = { kind: "active-session", state, ready: () => true, controls, working: () => false, session: () => target, interrupt: async () => undefined, submitted() {}, setEditor() {}, }
await submitInput(adapter).submit(new Event("submit")) const request = await admitted.promise
expect(calls).toEqual(["switch-agent", "switch-model", "prompt"]) expect(request.delivery).toBe("steer") expect(request.text).toBe("ship it") expect(request.id).toMatch(/^msg_/) expect(request.metadata).toMatchObject({ displayText: "ship it", agent: "build", model: { providerID: "provider-1", modelID: "model-1", variant: "balanced" }, }) expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }]) })
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() const calls: string[] = [] const statuses: ("idle" | "running")[] = [] const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>() const cleanupReady = Promise.withResolvers<void>() const target = session({ calls, statuses, prompt: async (value) => admitted.resolve(value) }) const adapter: NewSessionComposerAdapter = { kind: "new-session", state: draft, ready: () => true, controls, working: () => false, submitted() { calls.push("submitted") }, async start(_selection, submission) { calls.push("start") submission.retarget(promoted) return { session: target, cleanupReady: cleanupReady.promise } }, }
const submitted = submitInput(adapter).submit(new Event("submit")) const request = await admitted.promise
expect(calls).toEqual(["start", "switch-agent", "switch-model", "prompt"]) expect(statuses).toEqual(["running"]) expect(promoted.current()).toMatchObject([{ type: "text", content: "restored draft" }]) cleanupReady.resolve() await submitted
expect(calls).toEqual(["start", "switch-agent", "switch-model", "prompt", "submitted"]) expect(request.delivery).toBe("steer") expect(request.text).toBe("first prompt") expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }]) expect(promoted.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }]) })
test("hands off image-only first prompts before admission", async () => { const draft = createMemoryComposerState().capture() draft.set([ { type: "text", content: "", start: 0, end: 0 }, { type: "image", id: "attachment", filename: "image.png", mime: "image/png", blob: { id: "attachment", url: "data:image/png;base64,YQ==" }, }, ]) const handedOff = Promise.withResolvers<SessionMessageUser>() const target = session({ calls: [], handoff: { set: handedOff.resolve, clear() {} }, prompt: async () => undefined, }) const adapter: NewSessionComposerAdapter = { kind: "new-session", state: draft, ready: () => true, controls, working: () => false, submitted() {}, async start() { return { session: target, cleanupReady: Promise.resolve() } }, }
await submitInput(adapter).submit(new Event("submit"))
expect(await handedOff.promise).toMatchObject({ type: "user", text: "", files: [ { data: "", mime: "image/png", source: { type: "uri", uri: "data:image/png;base64,YQ==" }, name: "image.png", }, ], }) })
test("previews the first prompt while starting and hands it off before completing preparation", async () => { const draft = createMemoryComposerState({ prompt: "prepare my worktree" }).capture() const promoted = createMemoryComposerState().capture() const preview = Promise.withResolvers<SessionMessageUser>() const ready = Promise.withResolvers<void>() const calls: string[] = [] const handoff: SessionMessageUser[] = [] const target = session({ calls, handoff: { set: (message) => handoff.push(message), clear() {} }, prompt: async () => undefined, }) const adapter: NewSessionComposerAdapter = { kind: "new-session", state: draft, ready: () => true, controls, working: () => false, submitted() {}, async start(_selection, submission, message) { preview.resolve(message) await ready.promise submission.retarget(promoted, { preserveDraft: true }) return { session: target, cleanupReady: Promise.resolve(), async complete() { expect(handoff).toHaveLength(1) expect(handoff[0]?.id).toBe(message.id) expect(handoff[0]?.text).toBe("prepare my worktree") expect(promoted.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }]) promoted.set([{ type: "text", content: "follow up", start: 0, end: 9 }], 9) calls.push("complete") }, } }, }
const submitted = submitInput(adapter).submit(new Event("submit")) expect(await preview.promise).toMatchObject({ type: "user", text: "prepare my worktree" }) expect(calls).toEqual([]) expect(draft.current()).toMatchObject([{ content: "prepare my worktree" }]) ready.resolve() await submitted expect(calls).toContain("complete") expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }]) expect(promoted.current()).toEqual([{ type: "text", content: "follow up", start: 0, end: 9 }]) })
test("does not restore a prompt already acknowledged by the durable inbox", async () => { const state = createMemoryComposerState({ prompt: "admitted prompt" }).capture() const checked = Promise.withResolvers<void>() const attempts: string[] = [] const target = session({ calls: [], admitted: () => { checked.resolve() return true }, prompt: async (value) => { attempts.push(value.id ?? "") throw new Error("response lost") }, }) const adapter: ActiveComposerAdapter = { kind: "active-session", state, ready: () => true, controls, working: () => false, session: () => target, interrupt: async () => undefined, submitted() {}, setEditor() {}, }
await submitInput(adapter).submit(new Event("submit")) await checked.promise
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }]) expect(attempts).toHaveLength(2) expect(new Set(attempts).size).toBe(1) })
test("restores first-prompt comments into the promoted Session", async () => { const draft = createMemoryComposerState({ prompt: "first prompt" }).capture() draft.store[1]("context", "items", [ { key: "file:src/app.ts:1:1:comment", type: "file", path: "src/app.ts", comment: "Keep this comment", selection: { startLine: 1, startChar: 0, endLine: 1, endChar: 4 }, }, ]) expect(draft.context.items()).toHaveLength(1) const promoted = createMemoryComposerState().capture() const failed = Promise.withResolvers<void>() const target = session({ calls: [], prompt: async () => undefined, shell: async () => Promise.reject(new Error("send failed")), }) const adapter: NewSessionComposerAdapter = { kind: "new-session", state: draft, ready: () => true, controls, working: () => false, submitted() {}, async start(_selection, submission) { submission.retarget(promoted) return { session: target, cleanupReady: Promise.resolve() } }, }
await submitInput(adapter, { missingSelection() {}, failed: () => failed.resolve() }, "shell").submit( new Event("submit"), ) await failed.promise
expect(promoted.current()).toMatchObject([{ type: "text", content: "first prompt" }]) expect(promoted.context.items()).toMatchObject([{ type: "file", path: "src/app.ts", comment: "Keep this comment" }]) expect(promoted.mode.current()).toBe("shell") })
test.each(["retry me", "/show-me retry me"])("restores and retries an unacknowledged admission: %s", async (text) => { const state = createMemoryComposerState({ prompt: text }).capture() const attempts: string[] = [] const statuses: ("idle" | "running")[] = [] const first = Promise.withResolvers<void>() const second = Promise.withResolvers<void>() const target = session({ calls: [], statuses, prompt: async (value) => { attempts.push(value.id ?? "") expect(value.skills?.map((skill) => skill.id)).toEqual(text.startsWith("/") ? ["show-me"] : []) throw new Error("network unavailable") }, }) const adapter: ActiveComposerAdapter = { kind: "active-session", state, ready: () => true, controls, working: () => false, session: () => target, interrupt: async () => undefined, submitted() {}, setEditor() {}, } const notify = { missingSelection() {}, failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()), } const history: string[] = [] const submission = submitInput(adapter, notify, "normal", () => [], history)
await submission.submit(new Event("submit")) await first.promise await submission.submit(new Event("submit")) await second.promise
expect(attempts).toHaveLength(4) expect(new Set(attempts).size).toBe(1) expect(statuses).toEqual(["running", "idle", "running", "idle"]) expect(state.current()).toMatchObject([{ type: "text", content: text }]) // The restored prompt is the draft again, so history does not also keep it (and its attachments). expect(history).toEqual([`add:${text}`, `remove:${text}`, `add:${text}`, `remove:${text}`]) })
test("forwards structured mentions to custom commands", async () => { const state = createMemoryComposerState().capture() state.set([ { type: "text", content: "/review ", start: 0, end: 8 }, { type: "file", path: "src/app.ts", content: "@src/app.ts", start: 8, end: 19 }, { type: "text", content: " ", start: 19, end: 20 }, { type: "agent", name: "review", content: "@review", start: 20, end: 27 }, { type: "text", content: " ", start: 27, end: 28 }, { type: "skill", id: Skill.ID.make("effect"), name: Skill.Name.make("Effect"), content: "@effect", start: 28, end: 35, }, ]) const sent = Promise.withResolvers<Parameters<ComposerSession["api"]["command"]>[0]>() const target = session({ calls: [], prompt: async () => undefined, command: async (value) => sent.resolve(value), }) const adapter: ActiveComposerAdapter = { kind: "active-session", state, ready: () => true, controls, working: () => false, session: () => target, interrupt: async () => undefined, submitted() {}, setEditor() {}, }
await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit")) const request = await sent.promise
expect(request.files).toMatchObject([{ name: "app.ts", mention: { text: "@src/app.ts" } }]) expect(request.agents).toMatchObject([{ name: "review", mention: { text: "@review" } }]) expect(request.skills).toMatchObject([{ id: "effect", name: "Effect", mention: { text: "@effect" } }]) expect(request.delivery).toBe("steer") })
test("captures commands before creating a session in a new worktree", async () => { const state = createMemoryComposerState({ prompt: "/review https://github.com/example/repo/pull/1" }).capture() const catalog = [{ name: "review" }] const sent = Promise.withResolvers<"prompt" | "command">() const requests: Parameters<ComposerSession["api"]["command"]>[0][] = [] const target = session({ calls: [], prompt: async () => sent.resolve("prompt"), command: async (value) => { requests.push(value) sent.resolve("command") }, }) target.directory = "C:/new-worktree" target.data.location.command.list = () => undefined const adapter: NewSessionComposerAdapter = { kind: "new-session", state, ready: () => true, controls, working: () => false, submitted() {}, async start() { // The destination catalog has not loaded, and the source composer is leaving. catalog.splice(0) return { session: target, cleanupReady: Promise.resolve() } }, }
await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit"))
expect(await sent.promise).toBe("command") expect(requests).toEqual([ { sessionID: target.id, name: "review", text: "https://github.com/example/repo/pull/1", files: [], agents: [], skills: [], delivery: "steer", }, ]) })
test("does not run an empty shell command from hidden attachments", async () => { const state = createMemoryComposerState().capture() state.set([ { type: "text", content: "", start: 0, end: 0 }, { type: "image", id: "attachment", filename: "notes.txt", mime: "text/plain", blob: { id: "attachment", url: "data:text/plain;base64,bm90ZXM=" }, }, ]) const adapter: ActiveComposerAdapter = { kind: "active-session", state, ready: () => true, controls, working: () => false, session: () => { throw new Error("shell should not run") }, interrupt: async () => undefined, submitted() {}, setEditor() {}, }
await submitInput(adapter, undefined, "shell").submit(new Event("submit"))
expect(state.current().some((part) => part.type === "image")).toBe(true) })})