a838ff4c48
Consume the new session.next.shell.started/ended events instead so direct shell calls render live, in order with the rest of the stream, and without SDK fallback polling. Track each call's source (shell event vs legacy bash tool part) so whichever arrives first wins; the other is ignored to avoid double commits when both representations reach the client.
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",
|
|
}),
|
|
])
|
|
})
|
|
})
|