| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691 |
- import { describe, expect, test } from "bun:test"
- import { replayLocalRows, replaySession } from "@/cli/cmd/run/session-replay"
- import type { SessionMessages } from "@/cli/cmd/run/session.shared"
- import type { RunProvider } from "@/cli/cmd/run/types"
- function userMessage(id: string, text: string): SessionMessages[number] {
- return {
- info: {
- id,
- sessionID: "session-1",
- role: "user",
- time: {
- created: 1,
- },
- agent: "build",
- model: {
- providerID: "openai",
- modelID: "gpt-5",
- },
- },
- parts: [
- {
- id: `${id}-text`,
- sessionID: "session-1",
- messageID: id,
- type: "text",
- text,
- },
- ],
- }
- }
- function assistantInfo(
- id: string,
- input: {
- parentID?: string
- modelID?: string
- providerID?: string
- time?: { created: number; completed?: number }
- } = {},
- ) {
- return {
- id,
- sessionID: "session-1",
- role: "assistant" as const,
- time: input.time ?? { created: 2 },
- parentID: input.parentID ?? "msg-user-1",
- modelID: input.modelID ?? "gpt-5",
- providerID: input.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,
- },
- },
- }
- }
- function assistantMessage(
- id: string,
- text: string,
- input: {
- parentID?: string
- modelID?: string
- providerID?: string
- time?: { created: number; completed?: number }
- } = {},
- ): SessionMessages[number] {
- const time = input.time ?? {
- created: 200,
- completed: 3000,
- }
- return {
- info: assistantInfo(id, {
- ...input,
- time,
- }),
- parts: [
- {
- id: `${id}-text`,
- sessionID: "session-1",
- messageID: id,
- type: "text",
- text,
- time: {
- start: time.created,
- end: time.completed,
- },
- },
- ],
- }
- }
- const provider = (name: string): RunProvider => ({
- id: "openai",
- name: "OpenAI",
- source: "api",
- env: [],
- options: {},
- models: {
- "gpt-5": {
- id: "gpt-5",
- providerID: "openai",
- api: {
- id: "openai",
- url: "https://openai.test",
- npm: "@ai-sdk/openai",
- },
- name,
- capabilities: {
- temperature: true,
- reasoning: true,
- attachment: true,
- toolcall: true,
- input: {
- text: true,
- audio: false,
- image: false,
- video: false,
- pdf: false,
- },
- output: {
- text: true,
- audio: false,
- image: false,
- video: false,
- pdf: false,
- },
- interleaved: false,
- },
- cost: {
- input: 0,
- output: 0,
- cache: {
- read: 0,
- write: 0,
- },
- },
- limit: {
- context: 128000,
- output: 8192,
- },
- status: "active",
- options: {},
- headers: {},
- release_date: "2026-01-01",
- },
- },
- })
- function runningToolMessage(id: string): SessionMessages[number] {
- return {
- info: assistantInfo(id),
- parts: [
- {
- id: `${id}-tool`,
- sessionID: "session-1",
- messageID: id,
- type: "tool",
- callID: `${id}-call`,
- tool: "bash",
- state: {
- status: "running",
- input: {
- command: "pwd",
- },
- time: {
- start: 2,
- },
- },
- },
- ],
- }
- }
- function shellUserMessage(id: string): SessionMessages[number] {
- return {
- info: {
- id,
- sessionID: "session-1",
- role: "user",
- time: {
- created: 1,
- },
- agent: "build",
- model: {
- providerID: "openai",
- modelID: "gpt-5",
- },
- },
- parts: [
- {
- id: `${id}-text`,
- sessionID: "session-1",
- messageID: id,
- type: "text",
- text: "The following tool was executed by the user",
- synthetic: true,
- },
- ],
- }
- }
- function shellAssistantMessage(id: string, parentID: string): SessionMessages[number] {
- return {
- info: assistantInfo(id, {
- parentID,
- time: {
- created: 200,
- completed: 3000,
- },
- }),
- parts: [
- {
- id: `${id}-tool`,
- sessionID: "session-1",
- messageID: id,
- type: "tool",
- callID: `${id}-call`,
- tool: "bash",
- state: {
- status: "completed",
- input: {
- command: "ls",
- },
- output: "account.ts\n",
- title: "",
- metadata: {
- output: "account.ts\n",
- },
- time: {
- start: 200,
- end: 3000,
- },
- },
- },
- ],
- }
- }
- describe("run session replay", () => {
- test("replays persisted user, assistant, and turn summary history into scrollback commits", () => {
- const out = replaySession({
- messages: [
- userMessage("msg-user-1", "Hello, whats the weather today?"),
- assistantMessage("msg-1", "What city or ZIP code should I check?"),
- ],
- permissions: [],
- questions: [],
- thinking: true,
- limits: {},
- })
- expect(out.commits).toEqual([
- expect.objectContaining({
- kind: "user",
- text: "Hello, whats the weather today?",
- phase: "start",
- source: "system",
- messageID: "msg-user-1",
- }),
- expect.objectContaining({
- kind: "assistant",
- text: "What city or ZIP code should I check?",
- phase: "progress",
- source: "assistant",
- messageID: "msg-1",
- }),
- expect.objectContaining({
- kind: "system",
- text: "▣ Build · gpt-5 · 2.8s",
- phase: "final",
- source: "system",
- messageID: "msg-1",
- summary: {
- agent: "Build",
- model: "gpt-5",
- duration: "2.8s",
- },
- }),
- ])
- expect(out.patch).toEqual(
- expect.objectContaining({
- phase: "idle",
- status: "",
- }),
- )
- })
- test("uses provider model names for replayed turn summaries when available", () => {
- const out = replaySession({
- messages: [
- userMessage("msg-user-1", "Hello, whats the weather today?"),
- assistantMessage("msg-1", "What city or ZIP code should I check?"),
- ],
- permissions: [],
- questions: [],
- thinking: true,
- limits: {},
- providers: [provider("Little Frank")],
- })
- expect(out.commits.at(-1)).toEqual(
- expect.objectContaining({
- kind: "system",
- text: "▣ Build · Little Frank · 2.8s",
- summary: {
- agent: "Build",
- model: "Little Frank",
- duration: "2.8s",
- },
- }),
- )
- })
- test("replays one turn summary for the final assistant in a multi-step turn", () => {
- const out = replaySession({
- messages: [
- userMessage("msg-user-1", "Plan and then answer"),
- assistantMessage("msg-step-1", "Working", {
- parentID: "msg-user-1",
- time: { created: 200, completed: 900 },
- }),
- assistantMessage("msg-step-2", "Done", {
- parentID: "msg-user-1",
- time: { created: 1000, completed: 3000 },
- }),
- ],
- permissions: [],
- questions: [],
- thinking: true,
- limits: {},
- })
- expect(out.commits.filter((commit) => commit.summary)).toEqual([
- expect.objectContaining({
- kind: "system",
- text: "▣ Build · gpt-5 · 2.0s",
- messageID: "msg-step-2",
- }),
- ])
- })
- test("keeps the footer in a running state for resumed active tools", () => {
- const out = replaySession({
- messages: [runningToolMessage("msg-1")],
- permissions: [],
- questions: [],
- thinking: true,
- limits: {},
- })
- expect(out.patch).toEqual(
- expect.objectContaining({
- phase: "running",
- status: "running bash",
- }),
- )
- })
- test("does not replay turn summaries for shell-mode commands", () => {
- const out = replaySession({
- messages: [
- shellUserMessage("msg-shell-user-1"),
- shellAssistantMessage("msg-shell-assistant-1", "msg-shell-user-1"),
- ],
- permissions: [],
- questions: [],
- thinking: true,
- limits: {},
- })
- expect(out.commits.some((commit) => commit.summary)).toBe(false)
- expect(out.commits).toContainEqual(
- expect.objectContaining({
- kind: "tool",
- text: "account.ts\n",
- tool: "bash",
- toolState: "completed",
- }),
- )
- })
- test("merges failed local rows ahead of later persisted prompts", () => {
- const persisted = {
- kind: "user",
- text: "successful",
- phase: "start",
- source: "system",
- messageID: "msg-user-2",
- } as const
- const failed = {
- kind: "user",
- text: "failed",
- phase: "start",
- source: "system",
- messageID: "msg-user-1",
- } as const
- const error = {
- kind: "error",
- text: "network unavailable",
- phase: "start",
- source: "system",
- messageID: "msg-user-1",
- } as const
- expect(
- replayLocalRows([userMessage("msg-user-2", "successful")], [persisted], [{ commit: failed }, { commit: error }]),
- ).toEqual([failed, error, persisted])
- })
- test("retains local errors but not duplicate local prompts once a prompt persists", () => {
- const persisted = {
- kind: "user",
- text: "failed after persistence",
- phase: "start",
- source: "system",
- messageID: "msg-user-1",
- } as const
- const error = {
- kind: "error",
- text: "connection closed",
- phase: "start",
- source: "system",
- messageID: "msg-user-1",
- } as const
- expect(
- replayLocalRows(
- [userMessage("msg-user-1", "failed after persistence")],
- [persisted],
- [{ commit: persisted }, { commit: error }],
- ),
- ).toEqual([persisted, error])
- })
- test("keeps a local turn failure below assistant output already visible for that turn", () => {
- const first = {
- kind: "user",
- text: "start",
- phase: "start",
- source: "system",
- messageID: "msg-user-1",
- } as const
- const answer = {
- kind: "assistant",
- text: "partial answer",
- phase: "progress",
- source: "assistant",
- messageID: "msg-assistant-1",
- } as const
- const error = {
- kind: "error",
- text: "stream failed",
- phase: "start",
- source: "system",
- messageID: "msg-user-1",
- } as const
- const second = {
- kind: "user",
- text: "retry",
- phase: "start",
- source: "system",
- messageID: "msg-user-2",
- } as const
- expect(
- replayLocalRows(
- [userMessage("msg-user-1", "start"), userMessage("msg-user-2", "retry")],
- [first, answer, second],
- [
- {
- commit: error,
- after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-assistant-1" },
- },
- ],
- ),
- ).toEqual([first, answer, error, second])
- })
- test("keeps a local failure above assistant output received after the failure", () => {
- const first = {
- kind: "user",
- text: "start",
- phase: "start",
- source: "system",
- messageID: "msg-user-1",
- } as const
- const error = {
- kind: "error",
- text: "request failed",
- phase: "start",
- source: "system",
- messageID: "msg-user-1",
- } as const
- const late = {
- kind: "assistant",
- text: "late answer",
- phase: "progress",
- source: "assistant",
- messageID: "msg-assistant-1",
- } as const
- expect(replayLocalRows([userMessage("msg-user-1", "start")], [first, late], [{ commit: error }])).toEqual([
- first,
- error,
- late,
- ])
- })
- test("inserts a local failure between persisted output chunks spanning that failure", () => {
- const first = {
- kind: "user",
- text: "start",
- phase: "start",
- source: "system",
- messageID: "msg-user-1",
- } as const
- const complete = {
- kind: "assistant",
- text: "before after",
- phase: "progress",
- source: "assistant",
- messageID: "msg-assistant-1",
- partID: "part-1",
- } as const
- const error = {
- kind: "error",
- text: "stream failed",
- phase: "start",
- source: "system",
- messageID: "msg-user-1",
- } as const
- expect(
- replayLocalRows(
- [userMessage("msg-user-1", "start")],
- [first, complete],
- [
- {
- commit: error,
- after: {
- kind: "assistant",
- text: "before ",
- phase: "progress",
- messageID: "msg-assistant-1",
- partID: "part-1",
- visible: "before ",
- },
- },
- ],
- ),
- ).toEqual([first, { ...complete, text: "before " }, error, { ...complete, text: "after" }])
- })
- test("places an unpersisted failed prompt before live output from that turn", () => {
- const prompt = {
- kind: "user",
- text: "start",
- phase: "start",
- source: "system",
- messageID: "msg-1",
- } as const
- const answer = {
- kind: "assistant",
- text: "partial answer",
- phase: "progress",
- source: "assistant",
- messageID: "msg-2",
- } as const
- const error = {
- kind: "error",
- text: "stream failed",
- phase: "start",
- source: "system",
- messageID: "msg-1",
- } as const
- expect(
- replayLocalRows(
- [],
- [answer],
- [
- { commit: prompt },
- {
- commit: error,
- after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-2" },
- },
- ],
- ),
- ).toEqual([prompt, answer, error])
- })
- test("anchors a failure after the visible start of a tool that later completes", () => {
- const prompt = {
- kind: "user",
- text: "run ls",
- phase: "start",
- source: "system",
- messageID: "msg-user-1",
- } as const
- const running = {
- kind: "tool",
- text: "running bash",
- phase: "start",
- source: "tool",
- messageID: "msg-assistant-1",
- partID: "part-tool-1",
- toolState: "running",
- } as const
- const completed = {
- kind: "tool",
- text: "file.txt",
- phase: "final",
- source: "tool",
- messageID: "msg-assistant-1",
- partID: "part-tool-1",
- toolState: "completed",
- } as const
- const error = {
- kind: "error",
- text: "connection lost",
- phase: "start",
- source: "system",
- messageID: "msg-user-1",
- } as const
- expect(
- replayLocalRows(
- [userMessage("msg-user-1", "run ls")],
- [prompt, running, completed],
- [
- {
- commit: error,
- after: {
- kind: "tool",
- text: "running bash",
- phase: "start",
- messageID: "msg-assistant-1",
- partID: "part-tool-1",
- toolState: "running",
- },
- },
- ],
- ),
- ).toEqual([prompt, running, error, completed])
- })
- test("retains an unpersisted local diagnostic before later persisted prompts", () => {
- const first = {
- kind: "user",
- text: "before",
- phase: "start",
- source: "system",
- messageID: "msg-user-1",
- } as const
- const error = {
- kind: "error",
- text: "failed to start new session",
- phase: "start",
- source: "system",
- messageID: "msg-user-2",
- } as const
- const second = {
- kind: "user",
- text: "after",
- phase: "start",
- source: "system",
- messageID: "msg-user-3",
- } as const
- expect(
- replayLocalRows(
- [userMessage("msg-user-1", "before"), userMessage("msg-user-3", "after")],
- [first, second],
- [{ commit: error }],
- ),
- ).toEqual([first, error, second])
- })
- })
|