mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-22 03:45:23 +00:00
Press `!` on an empty prompt to enter shell mode and run a command through session.shell instead of sending a message
596 lines
14 KiB
TypeScript
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",
|
|
}),
|
|
])
|
|
})
|
|
})
|