693 lines
16 KiB
TypeScript
693 lines
16 KiB
TypeScript
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",
|
|
description: "",
|
|
},
|
|
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])
|
|
})
|
|
})
|