Files
opencode/packages/opencode/test/cli/run/session-data.test.ts
Simon Klee 539b118690 run: add shell mode to prompt (#28315)
Press `!` on an empty prompt to enter shell mode and run a command
through session.shell instead of sending a message
2026-05-20 09:09:12 +02:00

596 lines
14 KiB
TypeScript

import { describe, expect, test } from "bun:test"
import type { Event } from "@opencode-ai/sdk/v2"
import { createSessionData, flushInterrupted, reduceSessionData } from "@/cli/cmd/run/session-data"
import type { StreamCommit } from "@/cli/cmd/run/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,
},
},
}
}
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, {
type: "session.next.shell.started",
properties: {
sessionID: "session-1",
timestamp: 1,
callID: "call-1",
command: "pwd",
},
})
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, {
type: "session.next.shell.ended",
properties: {
sessionID: "session-1",
timestamp: 2,
callID: "call-1",
output: "/tmp/demo\n",
},
})
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(), {
type: "session.next.shell.started",
properties: {
sessionID: "session-1",
timestamp: 1,
callID: "call-1",
command: "pwd",
},
}).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, {
type: "session.next.shell.ended",
properties: {
sessionID: "session-1",
timestamp: 2,
callID: "call-1",
output: "/tmp/demo\n",
},
}).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",
description: "",
},
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, {
type: "session.next.shell.started",
properties: {
sessionID: "session-1",
timestamp: 1,
callID: "call-1",
command: "pwd",
},
}).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",
description: "",
},
time: { start: 1, end: 2 },
},
}),
).data
expect(
reduce(data, {
type: "session.next.shell.ended",
properties: {
sessionID: "session-1",
timestamp: 2,
callID: "call-1",
output: "/tmp/demo\n",
},
}).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("flushInterrupted emits one interrupted final per live part", () => {
const data = reduce(
createSessionData(),
text({
id: "txt-1",
messageID: "msg-1",
text: "unfinished",
}),
).data
const first: StreamCommit[] = []
flushInterrupted(data, first)
expect(first).toEqual([
expect.objectContaining({ kind: "assistant", text: "unfinished", phase: "progress" }),
expect.objectContaining({ kind: "assistant", phase: "final", interrupted: true }),
])
const next: StreamCommit[] = []
flushInterrupted(data, next)
expect(next).toEqual([])
})
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",
}),
])
})
})