fix(cli): update mini tests for v2 types (#36779)

This commit is contained in:
Aiden Cline 2026-07-13 18:38:25 -05:00 committed by GitHub
commit 6963f2f6da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 64 additions and 735 deletions

View file

@ -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> = {}): PermissionRequest {
function req(input: Partial<PermissionV2Request> = {}): 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",

View file

@ -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<typeof createSessionData>, event: unknown, thinking = true) {
return reduceSessionData({
data,
event: event as Event,
sessionID: "session-1",
thinking,
limits: {},
})
}
function assistant(id: string, extra: Record<string, unknown> = {}) {
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<string, number> }) {
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<string, number> }) {
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<string, unknown>; 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",
}),
])
})
})

View file

@ -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<Part, { type: "text" }>
type AgentPart = Extract<Part, { type: "agent" }>
type FilePart = Extract<Part, { type: "file" }>
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> = {}): TextPart {
function userMessage(id: string, text: string, input: Partial<SessionMessageUser> = {}): 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> = {}): 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,
},
],
})

View file

@ -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"],
},
},
})

View file

@ -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", () =>