Files
anomalyco_opencode/packages/opencode/test/cli/run/session.shared.test.ts
T
Simon Klee ba65426f19 run: show selected model in footer
Resolve the catalog default after the interface paints so the footer shows
the model that will handle the prompt. Preserve resumed session model and
variant selection to keep the displayed state consistent.
2026-07-06 08:36:31 +02:00

326 lines
8.4 KiB
TypeScript

import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient } from "@opencode-ai/sdk/v2"
import {
createSession,
resolveCurrentSession,
sessionHistory,
sessionVariant,
type RunSession,
type SessionMessages,
} from "@/cli/cmd/run/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",
}
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 {
return {
id,
sessionID: "session-1",
messageID,
type: "text",
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,
}
}
describe("run session shared", () => {
test("builds user prompt text from text, file, and agent parts", () => {
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"),
]),
]
const out = createSession(msgs)
expect(out.first).toBe(false)
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",
filename: undefined,
url: "file:///tmp/note.ts",
source: {
type: "file",
path: "file:///tmp/note.ts",
text: {
start: 11,
end: 19,
value: "@note.ts",
},
},
},
])
})
test("reuses existing mentions when file and agent parts have no source", () => {
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"),
]),
])
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",
},
},
},
],
})
})
test("dedupes consecutive history entries, drops blanks, and copies prompt parts", () => {
const parts = [
{
type: "agent" as const,
name: "scan",
source: {
start: 0,
end: 5,
value: "@scan",
},
},
]
const session: RunSession = {
first: false,
turns: [
{ prompt: { text: "one", parts }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: "one", parts: structuredClone(parts) }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: " ", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: "two", parts: [] }, provider: "openai", model: "gpt-5", variant: undefined },
],
}
const out = sessionHistory(session)
expect(out.map((item) => item.text)).toEqual(["one", "two"])
expect(out[0]?.parts).toEqual(parts)
expect(out[0]?.parts).not.toBe(parts)
expect(out[0]?.parts[0]).not.toBe(parts[0])
})
test("returns the latest matching variant for the active model", () => {
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: undefined },
],
}
expect(sessionVariant(session, model)).toBeUndefined()
session.turns.push({
prompt: { text: "four", parts: [] },
provider: "openai",
model: "gpt-5",
variant: "minimal",
})
expect(sessionVariant(session, model)).toBe("minimal")
})
test("restores current prompt history from stored text and file references", async () => {
const client = new OpencodeClient()
spyOn(client.v2.session, "messages").mockImplementation(() =>
Promise.resolve({
data: {
data: [
{
id: "msg_prompt",
type: "user",
text: "Review @note.ts",
files: [
{
uri: "file:///tmp/note.ts",
mime: "text/plain",
name: "note.ts",
source: { start: 7, end: 15, text: "@note.ts" },
},
],
agents: [],
time: { created: 1 },
},
],
cursor: {},
},
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}),
)
spyOn(client.v2.session, "get").mockImplementation(() =>
Promise.resolve({
data: {
data: {
id: "ses_1",
title: "Session",
version: "dev",
projectID: "proj_1",
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
time: { created: 1, updated: 1 },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
model: { providerID: "openai", id: "gpt-5", variant: "high" },
},
},
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}),
)
const out = await resolveCurrentSession(client, "ses_1")
expect(out.model).toEqual({ providerID: "openai", modelID: "gpt-5" })
expect(out.variant).toBe("high")
expect(out.turns[0]?.prompt).toEqual({
text: "Review @note.ts",
parts: [
{
type: "file",
url: "file:///tmp/note.ts",
mime: "text/plain",
filename: "note.ts",
source: {
type: "file",
path: "note.ts",
text: { start: 7, end: 15, value: "@note.ts" },
},
},
],
})
})
})