| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647 |
- import path from "path"
- import fs from "fs/promises"
- import { describe, expect, test } from "bun:test"
- import { NamedError } from "@opencode-ai/util/error"
- import { fileURLToPath } from "url"
- import { Instance } from "../../src/project/instance"
- import { ModelID, ProviderID } from "../../src/provider/schema"
- import { Session } from "../../src/session"
- import { MessageV2 } from "../../src/session/message-v2"
- import { SessionPrompt } from "../../src/session/prompt"
- import { Log } from "../../src/util/log"
- import { tmpdir } from "../fixture/fixture"
- Log.init({ print: false })
- function defer<T>() {
- let resolve!: (value: T | PromiseLike<T>) => void
- const promise = new Promise<T>((done) => {
- resolve = done
- })
- return { promise, resolve }
- }
- function chat(text: string) {
- const payload =
- [
- `data: ${JSON.stringify({
- id: "chatcmpl-1",
- object: "chat.completion.chunk",
- choices: [{ delta: { role: "assistant" } }],
- })}`,
- `data: ${JSON.stringify({
- id: "chatcmpl-1",
- object: "chat.completion.chunk",
- choices: [{ delta: { content: text } }],
- })}`,
- `data: ${JSON.stringify({
- id: "chatcmpl-1",
- object: "chat.completion.chunk",
- choices: [{ delta: {}, finish_reason: "stop" }],
- })}`,
- "data: [DONE]",
- ].join("\n\n") + "\n\n"
- const encoder = new TextEncoder()
- return new ReadableStream<Uint8Array>({
- start(ctrl) {
- ctrl.enqueue(encoder.encode(payload))
- ctrl.close()
- },
- })
- }
- function hanging(ready: () => void) {
- const encoder = new TextEncoder()
- let timer: ReturnType<typeof setTimeout> | undefined
- const first =
- `data: ${JSON.stringify({
- id: "chatcmpl-1",
- object: "chat.completion.chunk",
- choices: [{ delta: { role: "assistant" } }],
- })}` + "\n\n"
- const rest =
- [
- `data: ${JSON.stringify({
- id: "chatcmpl-1",
- object: "chat.completion.chunk",
- choices: [{ delta: { content: "late" } }],
- })}`,
- `data: ${JSON.stringify({
- id: "chatcmpl-1",
- object: "chat.completion.chunk",
- choices: [{ delta: {}, finish_reason: "stop" }],
- })}`,
- "data: [DONE]",
- ].join("\n\n") + "\n\n"
- return new ReadableStream<Uint8Array>({
- start(ctrl) {
- ctrl.enqueue(encoder.encode(first))
- ready()
- timer = setTimeout(() => {
- ctrl.enqueue(encoder.encode(rest))
- ctrl.close()
- }, 10000)
- },
- cancel() {
- if (timer) clearTimeout(timer)
- },
- })
- }
- describe("session.prompt missing file", () => {
- test("does not fail the prompt when a file part is missing", async () => {
- await using tmp = await tmpdir({
- git: true,
- config: {
- agent: {
- build: {
- model: "openai/gpt-5.2",
- },
- },
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const session = await Session.create({})
- const missing = path.join(tmp.path, "does-not-exist.ts")
- const msg = await SessionPrompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- parts: [
- { type: "text", text: "please review @does-not-exist.ts" },
- {
- type: "file",
- mime: "text/plain",
- url: `file://${missing}`,
- filename: "does-not-exist.ts",
- },
- ],
- })
- if (msg.info.role !== "user") throw new Error("expected user message")
- const hasFailure = msg.parts.some(
- (part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
- )
- expect(hasFailure).toBe(true)
- await Session.remove(session.id)
- },
- })
- })
- test("keeps stored part order stable when file resolution is async", async () => {
- await using tmp = await tmpdir({
- git: true,
- config: {
- agent: {
- build: {
- model: "openai/gpt-5.2",
- },
- },
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const session = await Session.create({})
- const missing = path.join(tmp.path, "still-missing.ts")
- const msg = await SessionPrompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- parts: [
- {
- type: "file",
- mime: "text/plain",
- url: `file://${missing}`,
- filename: "still-missing.ts",
- },
- { type: "text", text: "after-file" },
- ],
- })
- if (msg.info.role !== "user") throw new Error("expected user message")
- const stored = await MessageV2.get({
- sessionID: session.id,
- messageID: msg.info.id,
- })
- const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
- expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
- expect(text[1]?.includes("Read tool failed to read")).toBe(true)
- expect(text[2]).toBe("after-file")
- await Session.remove(session.id)
- },
- })
- })
- })
- describe("session.prompt special characters", () => {
- test("handles filenames with # character", async () => {
- await using tmp = await tmpdir({
- git: true,
- init: async (dir) => {
- await Bun.write(path.join(dir, "file#name.txt"), "special content\n")
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const session = await Session.create({})
- const template = "Read @file#name.txt"
- const parts = await SessionPrompt.resolvePromptParts(template)
- const fileParts = parts.filter((part) => part.type === "file")
- expect(fileParts.length).toBe(1)
- expect(fileParts[0].filename).toBe("file#name.txt")
- expect(fileParts[0].url).toContain("%23")
- const decodedPath = fileURLToPath(fileParts[0].url)
- expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
- const message = await SessionPrompt.prompt({
- sessionID: session.id,
- parts,
- noReply: true,
- })
- const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
- const textParts = stored.parts.filter((part) => part.type === "text")
- const hasContent = textParts.some((part) => part.text.includes("special content"))
- expect(hasContent).toBe(true)
- await Session.remove(session.id)
- },
- })
- })
- })
- describe("session.prompt regression", () => {
- test("does not loop empty assistant turns for a simple reply", async () => {
- let calls = 0
- const server = Bun.serve({
- port: 0,
- fetch(req) {
- const url = new URL(req.url)
- if (!url.pathname.endsWith("/chat/completions")) {
- return new Response("not found", { status: 404 })
- }
- calls++
- return new Response(chat("packages/opencode/src/session/processor.ts"), {
- status: 200,
- headers: { "Content-Type": "text/event-stream" },
- })
- },
- })
- try {
- await using tmp = await tmpdir({
- git: true,
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- enabled_providers: ["alibaba"],
- provider: {
- alibaba: {
- options: {
- apiKey: "test-key",
- baseURL: `${server.url.origin}/v1`,
- },
- },
- },
- agent: {
- build: {
- model: "alibaba/qwen-plus",
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const session = await Session.create({ title: "Prompt regression" })
- const result = await SessionPrompt.prompt({
- sessionID: session.id,
- agent: "build",
- parts: [{ type: "text", text: "Where is SessionProcessor?" }],
- })
- expect(result.info.role).toBe("assistant")
- expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
- const msgs = await Session.messages({ sessionID: session.id })
- expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
- expect(calls).toBe(1)
- },
- })
- } finally {
- server.stop(true)
- }
- })
- test("records aborted errors when prompt is cancelled mid-stream", async () => {
- const ready = defer<void>()
- const server = Bun.serve({
- port: 0,
- fetch(req) {
- const url = new URL(req.url)
- if (!url.pathname.endsWith("/chat/completions")) {
- return new Response("not found", { status: 404 })
- }
- return new Response(
- hanging(() => ready.resolve()),
- {
- status: 200,
- headers: { "Content-Type": "text/event-stream" },
- },
- )
- },
- })
- try {
- await using tmp = await tmpdir({
- git: true,
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- enabled_providers: ["alibaba"],
- provider: {
- alibaba: {
- options: {
- apiKey: "test-key",
- baseURL: `${server.url.origin}/v1`,
- },
- },
- },
- agent: {
- build: {
- model: "alibaba/qwen-plus",
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const session = await Session.create({ title: "Prompt cancel regression" })
- const run = SessionPrompt.prompt({
- sessionID: session.id,
- agent: "build",
- parts: [{ type: "text", text: "Cancel me" }],
- })
- await ready.promise
- await SessionPrompt.cancel(session.id)
- const result = await Promise.race([
- run,
- new Promise<never>((_, reject) =>
- setTimeout(() => reject(new Error("timed out waiting for cancel")), 1000),
- ),
- ])
- expect(result.info.role).toBe("assistant")
- if (result.info.role === "assistant") {
- expect(result.info.error?.name).toBe("MessageAbortedError")
- }
- const msgs = await Session.messages({ sessionID: session.id })
- const last = msgs.findLast((msg) => msg.info.role === "assistant")
- expect(last?.info.role).toBe("assistant")
- if (last?.info.role === "assistant") {
- expect(last.info.error?.name).toBe("MessageAbortedError")
- }
- },
- })
- } finally {
- server.stop(true)
- }
- })
- })
- describe("session.prompt agent variant", () => {
- test("applies agent variant only when using agent model", async () => {
- const prev = process.env.OPENAI_API_KEY
- process.env.OPENAI_API_KEY = "test-openai-key"
- try {
- await using tmp = await tmpdir({
- git: true,
- config: {
- agent: {
- build: {
- model: "openai/gpt-5.2",
- variant: "xhigh",
- },
- },
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const session = await Session.create({})
- const other = await SessionPrompt.prompt({
- sessionID: session.id,
- agent: "build",
- model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") },
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- })
- if (other.info.role !== "user") throw new Error("expected user message")
- expect(other.info.model.variant).toBeUndefined()
- const match = await SessionPrompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "hello again" }],
- })
- if (match.info.role !== "user") throw new Error("expected user message")
- expect(match.info.model).toEqual({
- providerID: ProviderID.make("openai"),
- modelID: ModelID.make("gpt-5.2"),
- variant: "xhigh",
- })
- expect(match.info.model.variant).toBe("xhigh")
- const override = await SessionPrompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- variant: "high",
- parts: [{ type: "text", text: "hello third" }],
- })
- if (override.info.role !== "user") throw new Error("expected user message")
- expect(override.info.model.variant).toBe("high")
- await Session.remove(session.id)
- },
- })
- } finally {
- if (prev === undefined) delete process.env.OPENAI_API_KEY
- else process.env.OPENAI_API_KEY = prev
- }
- })
- })
- // kilocode_change start
- function deferred<T>() {
- const result = {} as { promise: Promise<T>; resolve: (value: T) => void }
- result.promise = new Promise((resolve) => {
- result.resolve = resolve
- })
- return result
- }
- describe("session.prompt abort", () => {
- test("returns the interrupted assistant turn when the current prompt is cancelled", async () => {
- const dir = path.dirname(fileURLToPath(import.meta.url))
- const fixtures = (await Bun.file(path.join(dir, "../tool/fixtures/models-api.json")).json()) as Record<
- string,
- { models: Record<string, { id: string }> } & Record<string, unknown>
- >
- const model = fixtures.openai.models["gpt-5.2"]
- const started = deferred<void>()
- const payload = new TextEncoder().encode(
- [
- `data: ${JSON.stringify({
- type: "response.created",
- response: {
- id: "resp-1",
- created_at: Math.floor(Date.now() / 1000),
- model: model.id,
- service_tier: null,
- },
- })}`,
- "",
- ].join("\n\n"),
- )
- const server = Bun.serve({
- port: 0,
- fetch(req: Request) {
- const url = new URL(req.url)
- if (!url.pathname.endsWith("/responses")) {
- return new Response("unexpected request", { status: 404 })
- }
- started.resolve()
- return new Response(
- new ReadableStream<Uint8Array>({
- start(controller) {
- controller.enqueue(payload)
- },
- }),
- {
- status: 200,
- headers: { "Content-Type": "text/event-stream" },
- },
- )
- },
- })
- try {
- await using tmp = await tmpdir({
- git: true,
- init: async (root) => {
- // kilocode_change start — project config must be at root, not in .opencode/ subdirectory
- await Bun.write(
- path.join(root, "opencode.json"),
- JSON.stringify({
- $schema: "https://app.kilo.ai/config.json",
- enabled_providers: ["openai"],
- provider: {
- openai: {
- name: "OpenAI",
- env: ["OPENAI_API_KEY"],
- npm: "@ai-sdk/openai",
- api: "https://api.openai.com/v1",
- models: {
- [model.id]: model,
- },
- options: {
- apiKey: "test-openai-key",
- baseURL: `${server.url.origin}/v1`,
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const session = await Session.create({})
- const run = SessionPrompt.prompt({
- sessionID: session.id,
- model: {
- providerID: ProviderID.make("openai"),
- modelID: ModelID.make(model.id),
- },
- parts: [{ type: "text", text: "say hello" }],
- })
- await started.promise
- SessionPrompt.cancel(session.id)
- const result = await run
- expect(result.info.role).toBe("assistant")
- if (result.info.role !== "assistant") throw new Error("expected assistant message")
- // kilocode_change start — re-read from DB; the abort error is set asynchronously by the processor
- const messages = await Session.messages({ sessionID: session.id })
- const assistant = messages.find((item) => item.info.role === "assistant")
- expect(assistant).toBeDefined()
- expect(assistant?.info.id).toBe(result.info.id)
- if (assistant?.info.role === "assistant" && assistant.info.error) {
- expect(assistant.info.error.name).toBe("MessageAbortedError")
- }
- // kilocode_change end
- await Session.remove(session.id)
- },
- })
- } finally {
- server.stop(true)
- }
- }, 15000)
- })
- // kilocode_change end
- describe("session.agent-resolution", () => {
- test("unknown agent throws typed error", async () => {
- await using tmp = await tmpdir({ git: true })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const session = await Session.create({})
- const err = await SessionPrompt.prompt({
- sessionID: session.id,
- agent: "nonexistent-agent-xyz",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- }).then(
- () => undefined,
- (e) => e,
- )
- expect(err).toBeDefined()
- expect(err).not.toBeInstanceOf(TypeError)
- expect(NamedError.Unknown.isInstance(err)).toBe(true)
- if (NamedError.Unknown.isInstance(err)) {
- expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
- }
- },
- })
- }, 30000)
- test("unknown agent error includes available agent names", async () => {
- await using tmp = await tmpdir({ git: true })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const session = await Session.create({})
- const err = await SessionPrompt.prompt({
- sessionID: session.id,
- agent: "nonexistent-agent-xyz",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- }).then(
- () => undefined,
- (e) => e,
- )
- expect(NamedError.Unknown.isInstance(err)).toBe(true)
- if (NamedError.Unknown.isInstance(err)) {
- expect(err.data.message).toContain("code") // kilocode_change - "build" renamed to "code"
- }
- },
- })
- }, 30000)
- test("unknown command throws typed error with available names", async () => {
- await using tmp = await tmpdir({ git: true })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const session = await Session.create({})
- const err = await SessionPrompt.command({
- sessionID: session.id,
- command: "nonexistent-command-xyz",
- arguments: "",
- }).then(
- () => undefined,
- (e) => e,
- )
- expect(err).toBeDefined()
- expect(err).not.toBeInstanceOf(TypeError)
- expect(NamedError.Unknown.isInstance(err)).toBe(true)
- if (NamedError.Unknown.isInstance(err)) {
- expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
- expect(err.data.message).toContain("init")
- }
- },
- })
- }, 30000)
- })
|