diff --git a/packages/opencode/test/cli/run/permission.shared.test.ts b/packages/opencode/test/cli/run/permission.shared.test.ts index 84ad63207c..dfb6cb7494 100644 --- a/packages/opencode/test/cli/run/permission.shared.test.ts +++ b/packages/opencode/test/cli/run/permission.shared.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { PermissionRequest } from "@opencode-ai/sdk/v2" +import type { PermissionV2Request } from "@opencode-ai/client/promise" import { createPermissionBodyState, permissionAlwaysLines, @@ -10,14 +10,14 @@ import { permissionRun, } from "@opencode-ai/cli/mini/permission.shared" -function req(input: Partial = {}): PermissionRequest { +function req(input: Partial = {}): PermissionV2Request { return { id: "perm-1", sessionID: "session-1", - permission: "read", - patterns: [], + action: "read", + resources: [], metadata: {}, - always: [], + save: [], ...input, } } @@ -80,7 +80,7 @@ describe("run permission shared", () => { expect( permissionInfo( req({ - permission: "bash", + action: "bash", metadata: { input: { command: "git status --short", @@ -96,7 +96,7 @@ describe("run permission shared", () => { expect( permissionInfo( req({ - permission: "task", + action: "task", metadata: { description: "investigate stream", subagent_type: "general", @@ -111,8 +111,8 @@ describe("run permission shared", () => { expect( permissionInfo( req({ - permission: "external_directory", - patterns: ["/tmp/work/**/*.ts", "/tmp/work/**/*.tsx"], + action: "external_directory", + resources: ["/tmp/work/**/*.ts", "/tmp/work/**/*.tsx"], }), ), ).toMatchObject({ @@ -120,22 +120,22 @@ describe("run permission shared", () => { lines: ["- /tmp/work/**/*.ts", "- /tmp/work/**/*.tsx"], }) - expect(permissionInfo(req({ permission: "doom_loop" }))).toMatchObject({ + expect(permissionInfo(req({ action: "doom_loop" }))).toMatchObject({ title: "Continue after repeated failures", }) - expect(permissionInfo(req({ permission: "custom_tool" }))).toMatchObject({ + expect(permissionInfo(req({ action: "custom_tool" }))).toMatchObject({ title: "Call tool custom_tool", lines: ["Tool: custom_tool"], }) }) test("formats always-allow copy for wildcard and explicit patterns", () => { - expect(permissionAlwaysLines(req({ permission: "bash", always: ["*"] }))).toEqual([ + expect(permissionAlwaysLines(req({ action: "bash", save: ["*"] }))).toEqual([ "This will allow bash until OpenCode is restarted.", ]) - expect(permissionAlwaysLines(req({ always: ["src/**/*.ts", "src/**/*.tsx"] }))).toEqual([ + expect(permissionAlwaysLines(req({ save: ["src/**/*.ts", "src/**/*.tsx"] }))).toEqual([ "This will allow the following patterns until OpenCode is restarted.", "- src/**/*.ts", "- src/**/*.tsx", diff --git a/packages/opencode/test/cli/run/session-data.test.ts b/packages/opencode/test/cli/run/session-data.test.ts deleted file mode 100644 index b1fa17df13..0000000000 --- a/packages/opencode/test/cli/run/session-data.test.ts +++ /dev/null @@ -1,556 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { Event } from "@opencode-ai/sdk/v2" -import { createSessionData, reduceSessionData } from "@opencode-ai/cli/mini/session-data" -import type { StreamCommit } from "@opencode-ai/cli/mini/types" - -function reduce(data: ReturnType, event: unknown, thinking = true) { - return reduceSessionData({ - data, - event: event as Event, - sessionID: "session-1", - thinking, - limits: {}, - }) -} - -function assistant(id: string, extra: Record = {}) { - return { - type: "message.updated", - properties: { - sessionID: "session-1", - info: { - id, - role: "assistant", - providerID: "openai", - modelID: "gpt-5", - tokens: { - input: 1, - output: 1, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - ...extra, - }, - }, - } -} - -function user(id: string) { - return { - type: "message.updated", - properties: { - sessionID: "session-1", - info: { - id, - role: "user", - }, - }, - } -} - -function text(input: { id: string; messageID: string; text: string; time?: Record }) { - return { - type: "message.part.updated", - properties: { - part: { - id: input.id, - messageID: input.messageID, - sessionID: "session-1", - type: "text", - text: input.text, - ...(input.time ? { time: input.time } : {}), - }, - }, - } -} - -function reasoning(input: { id: string; messageID: string; text: string; time?: Record }) { - return { - type: "message.part.updated", - properties: { - part: { - id: input.id, - messageID: input.messageID, - sessionID: "session-1", - type: "reasoning", - text: input.text, - ...(input.time ? { time: input.time } : {}), - }, - }, - } -} - -function delta(messageID: string, partID: string, value: string) { - return { - type: "message.part.delta", - properties: { - sessionID: "session-1", - messageID, - partID, - field: "text", - delta: value, - }, - } -} - -function tool(input: { id: string; messageID: string; tool: string; state: Record; callID?: string }) { - return { - type: "message.part.updated", - properties: { - part: { - id: input.id, - messageID: input.messageID, - sessionID: "session-1", - type: "tool", - tool: input.tool, - ...(input.callID ? { callID: input.callID } : {}), - state: input.state, - }, - }, - } -} - -function shellInfo(id: string, status: "running" | "exited", completed?: number) { - return { - id, - status, - command: "pwd", - cwd: "/tmp/demo", - shell: "/bin/sh", - file: `/tmp/${id}.log`, - ...(status === "exited" ? { exit: 0 } : {}), - metadata: {}, - time: { started: 1, ...(completed === undefined ? {} : { completed }) }, - } -} - -function shellStarted(id = "call-1") { - return { - type: "session.shell.started", - properties: { sessionID: "session-1", shell: shellInfo(id, "running") }, - } -} - -function shellEnded(id = "call-1") { - const output = "/tmp/demo\n" - return { - type: "session.shell.ended", - properties: { - sessionID: "session-1", - shell: shellInfo(id, "exited", 2), - output: { output, cursor: Buffer.byteLength(output), size: Buffer.byteLength(output), truncated: false }, - }, - } -} - -describe("run session data", () => { - test("buffers delayed assistant text until the role is known", () => { - let data = createSessionData() - data = reduce(data, delta("msg-1", "txt-1", "hello")).data - data = reduce(data, assistant("msg-1")).data - - const out = reduce( - data, - text({ - id: "txt-1", - messageID: "msg-1", - text: "", - time: { end: 1 }, - }), - ) - - expect(out.commits).toEqual([ - expect.objectContaining({ - kind: "assistant", - text: "hello", - partID: "txt-1", - }), - ]) - }) - - test("keeps leading whitespace buffered until real assistant content arrives", () => { - let data = createSessionData() - data = reduce(data, assistant("msg-1")).data - data = reduce(data, text({ id: "txt-1", messageID: "msg-1", text: "", time: { start: 1 } })).data - - let out = reduce(data, delta("msg-1", "txt-1", " ")) - expect(out.commits).toEqual([]) - - out = reduce(out.data, delta("msg-1", "txt-1", "Found")) - expect(out.commits).toEqual([ - expect.objectContaining({ - kind: "assistant", - text: " Found", - }), - ]) - }) - - test("drops delayed text once the message resolves to a user role", () => { - let data = createSessionData() - data = reduce(data, text({ id: "txt-user-1", messageID: "msg-user-1", text: "HELLO", time: { end: 1 } })).data - - const out = reduce(data, user("msg-user-1")) - - expect(out.commits).toEqual([]) - expect(out.data.ids.has("txt-user-1")).toBe(true) - }) - - test("suppresses reasoning commits when thinking is disabled", () => { - const out = reduce( - createSessionData(), - reasoning({ - id: "reason-1", - messageID: "msg-1", - text: "hidden", - time: { end: 1 }, - }), - false, - ) - - expect(out.commits).toEqual([]) - expect(out.data.ids.has("reason-1")).toBe(true) - }) - - test("keeps permission precedence over queued questions", () => { - let data = createSessionData() - data = reduce(data, { - type: "permission.asked", - properties: { - id: "perm-1", - sessionID: "session-1", - permission: "read", - patterns: ["/tmp/file.txt"], - metadata: {}, - always: [], - }, - }).data - - const ask = reduce(data, { - type: "question.asked", - properties: { - id: "question-1", - sessionID: "session-1", - questions: [ - { - question: "Mode?", - header: "Mode", - options: [{ label: "chunked", description: "Incremental output" }], - multiple: false, - }, - ], - }, - }) - - expect(ask.footer).toEqual({ - patch: { status: "awaiting permission" }, - view: { - type: "permission", - request: expect.objectContaining({ id: "perm-1" }), - }, - }) - - expect( - reduce(ask.data, { - type: "permission.replied", - properties: { - sessionID: "session-1", - requestID: "perm-1", - reply: "reject", - }, - }).footer, - ).toEqual({ - patch: { status: "awaiting answer" }, - view: { - type: "question", - request: expect.objectContaining({ id: "question-1" }), - }, - }) - }) - - test("refreshes the active permission view when tool input arrives later", () => { - const data = reduce(createSessionData(), { - type: "permission.asked", - properties: { - id: "perm-1", - sessionID: "session-1", - permission: "bash", - patterns: ["src/**/*.ts"], - metadata: {}, - always: [], - tool: { - messageID: "msg-1", - callID: "call-1", - }, - }, - }).data - - const out = reduce( - data, - tool({ - id: "tool-1", - messageID: "msg-1", - callID: "call-1", - tool: "bash", - state: { - status: "running", - input: { - command: "git status --short", - }, - }, - }), - ) - - expect(out.footer).toEqual({ - view: { - type: "permission", - request: expect.objectContaining({ - id: "perm-1", - metadata: expect.objectContaining({ - input: { - command: "git status --short", - }, - }), - }), - }, - }) - }) - - test("strips bash echo only from the first assistant flush", () => { - let data = createSessionData() - data = reduce(data, assistant("msg-1")).data - data = reduce( - data, - tool({ - id: "tool-1", - messageID: "msg-1", - tool: "bash", - state: { - status: "completed", - input: { - command: "printf hi", - }, - output: "echoed\n", - time: { start: 1, end: 2 }, - }, - }), - ).data - - const first = reduce( - data, - text({ - id: "txt-1", - messageID: "msg-1", - text: "echoed\nanswer", - }), - ) - - expect(first.commits).toEqual([ - expect.objectContaining({ - kind: "assistant", - text: "answer", - }), - ]) - - expect(reduce(first.data, delta("msg-1", "txt-1", "\nechoed\nagain")).commits).toEqual([ - expect.objectContaining({ - kind: "assistant", - text: "\nechoed\nagain", - }), - ]) - }) - - test("renders direct shell mode from first-class shell events", () => { - let data = createSessionData() - const started = reduce(data, shellStarted()) - - expect(started.commits).toEqual([ - expect.objectContaining({ - kind: "tool", - phase: "start", - partID: "shell:call-1", - tool: "bash", - shell: { - callID: "call-1", - command: "pwd", - }, - }), - ]) - - data = started.data - const ended = reduce(data, shellEnded()) - - expect(ended.commits).toEqual([ - expect.objectContaining({ - kind: "tool", - phase: "progress", - partID: "shell:call-1", - tool: "bash", - text: "/tmp/demo\n", - toolState: "completed", - shell: { - callID: "call-1", - command: "pwd", - }, - }), - ]) - }) - - test("suppresses legacy bash part updates once shell events claim the call", () => { - let data = reduce(createSessionData(), shellStarted()).data - - expect( - reduce( - data, - tool({ - id: "tool-1", - messageID: "msg-1", - callID: "call-1", - tool: "bash", - state: { - status: "running", - input: { - command: "pwd", - }, - time: { start: 1 }, - }, - }), - ).commits, - ).toEqual([]) - - data = reduce(data, shellEnded()).data - - expect( - reduce( - data, - tool({ - id: "tool-1", - messageID: "msg-1", - callID: "call-1", - tool: "bash", - state: { - status: "completed", - input: { - command: "pwd", - }, - output: "/tmp/demo\n", - title: "", - metadata: { - output: "/tmp/demo\n", - }, - time: { start: 1, end: 2 }, - }, - }), - ).commits, - ).toEqual([]) - }) - - test("suppresses shell events when the legacy bash part claimed the call first", () => { - let data = reduce( - createSessionData(), - tool({ - id: "tool-1", - messageID: "msg-1", - callID: "call-1", - tool: "bash", - state: { - status: "running", - input: { - command: "pwd", - }, - time: { start: 1 }, - }, - }), - ).data - - expect( - reduce(data, shellStarted()).commits, - ).toEqual([]) - - data = reduce( - data, - tool({ - id: "tool-1", - messageID: "msg-1", - callID: "call-1", - tool: "bash", - state: { - status: "completed", - input: { - command: "pwd", - }, - output: "/tmp/demo\n", - title: "", - metadata: { - output: "/tmp/demo\n", - }, - time: { start: 1, end: 2 }, - }, - }), - ).data - - expect( - reduce(data, shellEnded()).commits, - ).toEqual([]) - }) - - test("synthesizes a glob start before an error when the running update is missed", () => { - expect( - reduce( - createSessionData(), - tool({ - id: "tool-1", - messageID: "msg-1", - tool: "glob", - state: { - status: "error", - input: { - pattern: "**/*tool*", - path: "/tmp/demo/run", - }, - error: "No such file or directory: '/tmp/demo/run'", - }, - }), - ).commits, - ).toEqual([ - expect.objectContaining({ - kind: "tool", - tool: "glob", - phase: "start", - partID: "tool-1", - text: "running glob", - toolState: "running", - }), - expect.objectContaining({ - kind: "tool", - tool: "glob", - phase: "final", - partID: "tool-1", - text: "No such file or directory: '/tmp/demo/run'", - toolState: "error", - toolError: "No such file or directory: '/tmp/demo/run'", - }), - ]) - }) - - test("surfaces session errors as error commits", () => { - const out = reduce(createSessionData(), { - type: "session.error", - properties: { - sessionID: "session-1", - error: { - name: "UnknownError", - data: { - message: "permission denied", - }, - }, - }, - }) - - expect(out.commits).toEqual([ - expect.objectContaining({ - kind: "error", - text: "permission denied", - }), - ]) - }) -}) diff --git a/packages/opencode/test/cli/run/session.shared.test.ts b/packages/opencode/test/cli/run/session.shared.test.ts index 30a60958dc..dbaa489fb0 100644 --- a/packages/opencode/test/cli/run/session.shared.test.ts +++ b/packages/opencode/test/cli/run/session.shared.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { OpenCode } from "@opencode-ai/client/promise" +import { OpenCode, type SessionMessageUser } from "@opencode-ai/client/promise" import { createSession, resolveCurrentSession, @@ -9,12 +9,6 @@ import { type SessionMessages, } from "@opencode-ai/cli/mini/session.shared" -type Message = SessionMessages[number] -type Part = Message["parts"][number] -type TextPart = Extract -type AgentPart = Extract -type FilePart = Extract - const model = { providerID: "openai", modelID: "gpt-5", @@ -24,107 +18,30 @@ afterEach(() => { mock.restore() }) -function userMessage(id: string, parts: Message["parts"], variant = "high"): Message { - return { - info: { - id, - sessionID: "session-1", - role: "user", - time: { - created: 1, - }, - agent: "build", - model: { - ...model, - variant, - }, - }, - parts, - } -} - -function assistantMessage(id: string, parts: Message["parts"]): Message { - return { - info: { - id, - sessionID: "session-1", - role: "assistant", - time: { - created: 1, - }, - parentID: "msg-user-1", - modelID: "gpt-5", - providerID: "openai", - mode: "chat", - agent: "build", - path: { - cwd: "/tmp", - root: "/tmp", - }, - cost: 0, - tokens: { - input: 1, - output: 1, - reasoning: 0, - cache: { - read: 0, - write: 0, - }, - }, - }, - parts, - } -} - -function textPart(id: string, messageID: string, text: string, input: Partial = {}): TextPart { +function userMessage(id: string, text: string, input: Partial = {}): SessionMessageUser { return { id, - sessionID: "session-1", - messageID, - type: "text", + type: "user", text, - synthetic: input.synthetic, - } -} - -function agentPart(id: string, messageID: string, name: string, source?: AgentPart["source"]): AgentPart { - return { - id, - sessionID: "session-1", - messageID, - type: "agent", - name, - source, - } -} - -function filePart(id: string, messageID: string, url: string, input: Partial = {}): FilePart { - return { - id, - sessionID: "session-1", - messageID, - type: "file", - mime: input.mime ?? "text/plain", - filename: input.filename, - url, - source: input.source, + time: { created: 1 }, + ...input, } } describe("run session shared", () => { - test("builds user prompt text from text, file, and agent parts", () => { + test("builds user prompts from projected text and attachments", () => { const msgs: SessionMessages = [ - assistantMessage("msg-assistant-1", [textPart("txt-assistant-1", "msg-assistant-1", "ignore me")]), - userMessage("msg-user-1", [ - textPart("txt-user-1", "msg-user-1", "look @scan"), - textPart("txt-user-2", "msg-user-1", "hidden", { synthetic: true }), - agentPart("agent-user-1", "msg-user-1", "scan", { - start: 5, - end: 10, - value: "@scan", - }), - filePart("file-user-1", "msg-user-1", "file:///tmp/note.ts"), - ]), + userMessage("msg-user-1", "look @scan @note.ts", { + agents: [{ name: "scan", mention: { start: 5, end: 10, text: "@scan" } }], + files: [ + { + data: "", + mime: "text/plain", + source: { type: "uri", uri: "file:///tmp/note.ts" }, + mention: { start: 11, end: 19, text: "@note.ts" }, + }, + ], + }), ] const out = createSession(msgs) @@ -132,15 +49,6 @@ describe("run session shared", () => { expect(out.turns).toHaveLength(1) expect(out.turns[0]?.prompt.text).toBe("look @scan @note.ts") expect(out.turns[0]?.prompt.parts).toEqual([ - { - type: "agent", - name: "scan", - source: { - start: 5, - end: 10, - value: "@scan", - }, - }, { type: "file", mime: "text/plain", @@ -156,44 +64,40 @@ describe("run session shared", () => { }, }, }, + { + type: "agent", + name: "scan", + source: { + start: 5, + end: 10, + value: "@scan", + }, + }, ]) }) - test("reuses existing mentions when file and agent parts have no source", () => { + test("leaves attachment sources undefined when projected mentions are absent", () => { const out = createSession([ - userMessage("msg-user-1", [ - textPart("txt-user-1", "msg-user-1", "look @scan @note.ts"), - agentPart("agent-user-1", "msg-user-1", "scan"), - filePart("file-user-1", "msg-user-1", "file:///tmp/note.ts"), - ]), + userMessage("msg-user-1", "look @scan @note.ts", { + agents: [{ name: "scan" }], + files: [{ data: "", mime: "text/plain", source: { type: "uri", uri: "file:///tmp/note.ts" } }], + }), ]) expect(out.turns[0]?.prompt).toEqual({ text: "look @scan @note.ts", parts: [ - { - type: "agent", - name: "scan", - source: { - start: 5, - end: 10, - value: "@scan", - }, - }, { type: "file", mime: "text/plain", filename: undefined, url: "file:///tmp/note.ts", - source: { - type: "file", - path: "file:///tmp/note.ts", - text: { - start: 11, - end: 19, - value: "@note.ts", - }, - }, + source: undefined, + }, + { + type: "agent", + name: "scan", + source: undefined, }, ], }) diff --git a/packages/opencode/test/cli/run/stream-v2.transport.test.ts b/packages/opencode/test/cli/run/stream-v2.transport.test.ts index 78485db16c..6ce23dea7e 100644 --- a/packages/opencode/test/cli/run/stream-v2.transport.test.ts +++ b/packages/opencode/test/cli/run/stream-v2.transport.test.ts @@ -513,11 +513,8 @@ describe("V2 mini transport", () => { request: { id: "per_1", sessionID: "ses_1", - permission: "read", - patterns: ["/tmp/file"], - metadata: {}, - always: [], - tool: undefined, + action: "read", + resources: ["/tmp/file"], }, }, }) diff --git a/packages/opencode/test/cli/run/variant.shared.test.ts b/packages/opencode/test/cli/run/variant.shared.test.ts index 70e565670f..23f9500d48 100644 --- a/packages/opencode/test/cli/run/variant.shared.test.ts +++ b/packages/opencode/test/cli/run/variant.shared.test.ts @@ -11,7 +11,7 @@ import { pickVariant, resolveVariant, } from "@opencode-ai/cli/mini/variant.shared" -import type { SessionMessages } from "@opencode-ai/cli/mini/session.shared" +import type { RunSession } from "@opencode-ai/cli/mini/session.shared" import type { RunProvider } from "@opencode-ai/cli/mini/types" import { testEffect } from "../../lib/effect" @@ -79,25 +79,6 @@ const providers: RunProvider[] = [ }, ] -function userMessage( - id: string, - input: { providerID: string; modelID: string; variant?: string }, -): SessionMessages[number] { - return { - info: { - id, - sessionID: "session-1", - role: "user", - time: { - created: 1, - }, - agent: "build", - model: input, - }, - parts: [], - } -} - const it = testEffect(AppNodeBuilder.build(FSUtil.node)) function remap(root: string, file: string) { @@ -147,14 +128,17 @@ describe("run variant shared", () => { expect(formatModelLabel(model, "high", providers)).toBe("GPT-5 · OpenAI · high") }) - test("picks the latest matching variant from raw session messages", () => { - const msgs: SessionMessages = [ - userMessage("msg-1", { providerID: "openai", modelID: "gpt-5", variant: "high" }), - userMessage("msg-2", { providerID: "anthropic", modelID: "sonnet", variant: "max" }), - userMessage("msg-3", { providerID: "openai", modelID: "gpt-5", variant: "minimal" }), - ] + test("picks the latest matching variant from session history", () => { + const session: RunSession = { + first: false, + turns: [ + { prompt: { text: "one", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" }, + { prompt: { text: "two", parts: [] }, provider: "anthropic", model: "sonnet", variant: "max" }, + { prompt: { text: "three", parts: [] }, provider: "openai", model: "gpt-5", variant: "minimal" }, + ], + } - expect(pickVariant(model, msgs)).toBe("minimal") + expect(pickVariant(model, session)).toBe("minimal") }) it.live("reads and writes saved variants through a runtime-backed app fs layer", () =>