fix(cli): update mini tests for v2 types (#36779)
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
import type { PermissionV2Request } from "@opencode-ai/client/promise"
|
||||||
import {
|
import {
|
||||||
createPermissionBodyState,
|
createPermissionBodyState,
|
||||||
permissionAlwaysLines,
|
permissionAlwaysLines,
|
||||||
@@ -10,14 +10,14 @@ import {
|
|||||||
permissionRun,
|
permissionRun,
|
||||||
} from "@opencode-ai/cli/mini/permission.shared"
|
} from "@opencode-ai/cli/mini/permission.shared"
|
||||||
|
|
||||||
function req(input: Partial<PermissionRequest> = {}): PermissionRequest {
|
function req(input: Partial<PermissionV2Request> = {}): PermissionV2Request {
|
||||||
return {
|
return {
|
||||||
id: "perm-1",
|
id: "perm-1",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
permission: "read",
|
action: "read",
|
||||||
patterns: [],
|
resources: [],
|
||||||
metadata: {},
|
metadata: {},
|
||||||
always: [],
|
save: [],
|
||||||
...input,
|
...input,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ describe("run permission shared", () => {
|
|||||||
expect(
|
expect(
|
||||||
permissionInfo(
|
permissionInfo(
|
||||||
req({
|
req({
|
||||||
permission: "bash",
|
action: "bash",
|
||||||
metadata: {
|
metadata: {
|
||||||
input: {
|
input: {
|
||||||
command: "git status --short",
|
command: "git status --short",
|
||||||
@@ -96,7 +96,7 @@ describe("run permission shared", () => {
|
|||||||
expect(
|
expect(
|
||||||
permissionInfo(
|
permissionInfo(
|
||||||
req({
|
req({
|
||||||
permission: "task",
|
action: "task",
|
||||||
metadata: {
|
metadata: {
|
||||||
description: "investigate stream",
|
description: "investigate stream",
|
||||||
subagent_type: "general",
|
subagent_type: "general",
|
||||||
@@ -111,8 +111,8 @@ describe("run permission shared", () => {
|
|||||||
expect(
|
expect(
|
||||||
permissionInfo(
|
permissionInfo(
|
||||||
req({
|
req({
|
||||||
permission: "external_directory",
|
action: "external_directory",
|
||||||
patterns: ["/tmp/work/**/*.ts", "/tmp/work/**/*.tsx"],
|
resources: ["/tmp/work/**/*.ts", "/tmp/work/**/*.tsx"],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
@@ -120,22 +120,22 @@ describe("run permission shared", () => {
|
|||||||
lines: ["- /tmp/work/**/*.ts", "- /tmp/work/**/*.tsx"],
|
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",
|
title: "Continue after repeated failures",
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(permissionInfo(req({ permission: "custom_tool" }))).toMatchObject({
|
expect(permissionInfo(req({ action: "custom_tool" }))).toMatchObject({
|
||||||
title: "Call tool custom_tool",
|
title: "Call tool custom_tool",
|
||||||
lines: ["Tool: custom_tool"],
|
lines: ["Tool: custom_tool"],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("formats always-allow copy for wildcard and explicit patterns", () => {
|
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.",
|
"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.",
|
"This will allow the following patterns until OpenCode is restarted.",
|
||||||
"- src/**/*.ts",
|
"- src/**/*.ts",
|
||||||
"- src/**/*.tsx",
|
"- src/**/*.tsx",
|
||||||
|
|||||||
@@ -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",
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
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 {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
resolveCurrentSession,
|
resolveCurrentSession,
|
||||||
@@ -9,12 +9,6 @@ import {
|
|||||||
type SessionMessages,
|
type SessionMessages,
|
||||||
} from "@opencode-ai/cli/mini/session.shared"
|
} 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 = {
|
const model = {
|
||||||
providerID: "openai",
|
providerID: "openai",
|
||||||
modelID: "gpt-5",
|
modelID: "gpt-5",
|
||||||
@@ -24,107 +18,30 @@ afterEach(() => {
|
|||||||
mock.restore()
|
mock.restore()
|
||||||
})
|
})
|
||||||
|
|
||||||
function userMessage(id: string, parts: Message["parts"], variant = "high"): Message {
|
function userMessage(id: string, text: string, input: Partial<SessionMessageUser> = {}): SessionMessageUser {
|
||||||
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 {
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sessionID: "session-1",
|
type: "user",
|
||||||
messageID,
|
|
||||||
type: "text",
|
|
||||||
text,
|
text,
|
||||||
synthetic: input.synthetic,
|
time: { created: 1 },
|
||||||
}
|
...input,
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("run session shared", () => {
|
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 = [
|
const msgs: SessionMessages = [
|
||||||
assistantMessage("msg-assistant-1", [textPart("txt-assistant-1", "msg-assistant-1", "ignore me")]),
|
userMessage("msg-user-1", "look @scan @note.ts", {
|
||||||
userMessage("msg-user-1", [
|
agents: [{ name: "scan", mention: { start: 5, end: 10, text: "@scan" } }],
|
||||||
textPart("txt-user-1", "msg-user-1", "look @scan"),
|
files: [
|
||||||
textPart("txt-user-2", "msg-user-1", "hidden", { synthetic: true }),
|
{
|
||||||
agentPart("agent-user-1", "msg-user-1", "scan", {
|
data: "",
|
||||||
start: 5,
|
mime: "text/plain",
|
||||||
end: 10,
|
source: { type: "uri", uri: "file:///tmp/note.ts" },
|
||||||
value: "@scan",
|
mention: { start: 11, end: 19, text: "@note.ts" },
|
||||||
}),
|
},
|
||||||
filePart("file-user-1", "msg-user-1", "file:///tmp/note.ts"),
|
],
|
||||||
]),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
const out = createSession(msgs)
|
const out = createSession(msgs)
|
||||||
@@ -132,15 +49,6 @@ describe("run session shared", () => {
|
|||||||
expect(out.turns).toHaveLength(1)
|
expect(out.turns).toHaveLength(1)
|
||||||
expect(out.turns[0]?.prompt.text).toBe("look @scan @note.ts")
|
expect(out.turns[0]?.prompt.text).toBe("look @scan @note.ts")
|
||||||
expect(out.turns[0]?.prompt.parts).toEqual([
|
expect(out.turns[0]?.prompt.parts).toEqual([
|
||||||
{
|
|
||||||
type: "agent",
|
|
||||||
name: "scan",
|
|
||||||
source: {
|
|
||||||
start: 5,
|
|
||||||
end: 10,
|
|
||||||
value: "@scan",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: "file",
|
type: "file",
|
||||||
mime: "text/plain",
|
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([
|
const out = createSession([
|
||||||
userMessage("msg-user-1", [
|
userMessage("msg-user-1", "look @scan @note.ts", {
|
||||||
textPart("txt-user-1", "msg-user-1", "look @scan @note.ts"),
|
agents: [{ name: "scan" }],
|
||||||
agentPart("agent-user-1", "msg-user-1", "scan"),
|
files: [{ data: "", mime: "text/plain", source: { type: "uri", uri: "file:///tmp/note.ts" } }],
|
||||||
filePart("file-user-1", "msg-user-1", "file:///tmp/note.ts"),
|
}),
|
||||||
]),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
expect(out.turns[0]?.prompt).toEqual({
|
expect(out.turns[0]?.prompt).toEqual({
|
||||||
text: "look @scan @note.ts",
|
text: "look @scan @note.ts",
|
||||||
parts: [
|
parts: [
|
||||||
{
|
|
||||||
type: "agent",
|
|
||||||
name: "scan",
|
|
||||||
source: {
|
|
||||||
start: 5,
|
|
||||||
end: 10,
|
|
||||||
value: "@scan",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: "file",
|
type: "file",
|
||||||
mime: "text/plain",
|
mime: "text/plain",
|
||||||
filename: undefined,
|
filename: undefined,
|
||||||
url: "file:///tmp/note.ts",
|
url: "file:///tmp/note.ts",
|
||||||
source: {
|
source: undefined,
|
||||||
type: "file",
|
},
|
||||||
path: "file:///tmp/note.ts",
|
{
|
||||||
text: {
|
type: "agent",
|
||||||
start: 11,
|
name: "scan",
|
||||||
end: 19,
|
source: undefined,
|
||||||
value: "@note.ts",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -513,11 +513,8 @@ describe("V2 mini transport", () => {
|
|||||||
request: {
|
request: {
|
||||||
id: "per_1",
|
id: "per_1",
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
permission: "read",
|
action: "read",
|
||||||
patterns: ["/tmp/file"],
|
resources: ["/tmp/file"],
|
||||||
metadata: {},
|
|
||||||
always: [],
|
|
||||||
tool: undefined,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
pickVariant,
|
pickVariant,
|
||||||
resolveVariant,
|
resolveVariant,
|
||||||
} from "@opencode-ai/cli/mini/variant.shared"
|
} 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 type { RunProvider } from "@opencode-ai/cli/mini/types"
|
||||||
import { testEffect } from "../../lib/effect"
|
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))
|
const it = testEffect(AppNodeBuilder.build(FSUtil.node))
|
||||||
|
|
||||||
function remap(root: string, file: string) {
|
function remap(root: string, file: string) {
|
||||||
@@ -147,14 +128,17 @@ describe("run variant shared", () => {
|
|||||||
expect(formatModelLabel(model, "high", providers)).toBe("GPT-5 · OpenAI · high")
|
expect(formatModelLabel(model, "high", providers)).toBe("GPT-5 · OpenAI · high")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("picks the latest matching variant from raw session messages", () => {
|
test("picks the latest matching variant from session history", () => {
|
||||||
const msgs: SessionMessages = [
|
const session: RunSession = {
|
||||||
userMessage("msg-1", { providerID: "openai", modelID: "gpt-5", variant: "high" }),
|
first: false,
|
||||||
userMessage("msg-2", { providerID: "anthropic", modelID: "sonnet", variant: "max" }),
|
turns: [
|
||||||
userMessage("msg-3", { providerID: "openai", modelID: "gpt-5", variant: "minimal" }),
|
{ 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", () =>
|
it.live("reads and writes saved variants through a runtime-backed app fs layer", () =>
|
||||||
|
|||||||
Reference in New Issue
Block a user