ba65426f19
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.
326 lines
8.4 KiB
TypeScript
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" },
|
|
},
|
|
},
|
|
],
|
|
})
|
|
})
|
|
})
|