| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312 |
- import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
- import { OpenCode } from "@opencode-ai/client/promise"
- import {
- createSession,
- resolveCurrentSession,
- sessionHistory,
- sessionVariant,
- type RunSession,
- type SessionMessages,
- } from "@opencode-ai/cli/mini/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 = OpenCode.make({ baseUrl: "https://opencode.test" })
- spyOn(client.message, "list").mockImplementation(() =>
- Promise.resolve({
- 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: {},
- }),
- )
- spyOn(client.session, "get").mockImplementation(() =>
- Promise.resolve({
- id: "ses_1",
- title: "Session",
- projectID: "proj_1",
- location: { 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" },
- }),
- )
- 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" },
- },
- },
- ],
- })
- })
- })
|