Files
opencode/packages/app/test-browser/timeline-projection.test.ts

313 lines
11 KiB
TypeScript

import { describe, expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo, SessionStatus } from "@opencode/client/promise"
import { createRoot } from "solid-js"
import { createStore, produce } from "solid-js/store"
import {
createReactiveTimelineProjection,
reuseTimelineRows,
Timeline,
TimelineRow,
type ReasoningMode,
} from "@opencode/session-ui/timeline/projection"
import { createTimelineProjection } from "../src/session/timeline/projection"
const assistant = (id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant => ({
id,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
time: { created: 2 },
content,
})
for (const factory of [createTimelineProjection, createReactiveTimelineProjection]) {
describe(factory.name, () => {
test("only crosses the renderable boundary on text deltas, while retaining live content", () => {
createRoot((dispose) => {
let visits = 0
const [state, setState] = createStore<{ messages: SessionMessageInfo[] }>({
messages: [
{
id: "old",
type: "user",
text: "history",
get time() {
visits++
return { created: 0 }
},
},
assistant("old-answer", [{ type: "text", text: "History remains visible." }]),
{ id: "user", type: "user", text: "question", time: { created: 1 } },
assistant("answer", [{ type: "text", text: "" }]),
],
})
const projection = factory({
sessionMessages: () => state.messages,
status: () => ({ type: "busy" }),
reasoningMode: () => "compact",
shellToolDefaultOpen: () => false,
editToolDefaultOpen: () => false,
pendingInputIDs: () => new Set(),
})
const update = (text: string) =>
setState(
"messages",
3,
produce((message) => {
if (message.type === "assistant" && message.content[0].type === "text") message.content[0].text = text
}),
)
const empty = projection.rows()
visits = 0
update(" \n\t")
expect(projection.rows()).toBe(empty)
expect(visits).toBe(0)
update("The first visible answer.")
const visible = projection.rows()
expect(visible.length).toBe(empty.length + 1)
expect(visits).toBeGreaterThan(0)
visits = 0
update("The first visible answer. More streamed words.")
expect(projection.rows()).toBe(visible)
expect(visits).toBe(0)
expect(Timeline.resolveContent(projection.messageByID().get("answer"), "answer:text:0")).toMatchObject({
text: "The first visible answer. More streamed words.",
})
update("")
expect(projection.rows()).toEqual(empty)
expect(visits).toBeGreaterThan(0)
dispose()
})
})
test("matches full construction through grouping, notices, history and preference transitions", () => {
createRoot((dispose) => {
const [state, setState] = createStore<{
messages: SessionMessageInfo[]
status: SessionStatus
reasoning: ReasoningMode
shell: boolean
edit: boolean
pending: Set<string>
}>({
messages: [
assistant("answer", [
{ type: "reasoning", text: "Inspect the source", time: { created: 1 } },
{
type: "tool",
id: "read",
name: "read",
state: { status: "completed", input: {}, content: [{ type: "text", text: "read" }], metadata: {} },
time: { created: 1 },
},
{ type: "text", text: "" },
{
type: "tool",
id: "shell",
name: "shell",
state: { status: "running", input: {}, metadata: {} },
time: { created: 1 },
},
{
type: "tool",
id: "question",
name: "question",
state: { status: "running", input: {}, metadata: {} },
time: { created: 1 },
},
]),
],
status: { type: "busy" },
reasoning: "compact",
shell: false,
edit: false,
pending: new Set<string>(),
})
const projection = factory({
sessionMessages: () => state.messages,
status: () => state.status,
reasoningMode: () => state.reasoning,
shellToolDefaultOpen: () => state.shell,
editToolDefaultOpen: () => state.edit,
pendingInputIDs: () => state.pending,
})
let previous: TimelineRow.TimelineRow[] | undefined
const verify = () => {
const full = Timeline.constructSessionMessageRows(
state.messages,
state.reasoning !== "hidden",
state.status,
state.pending,
state.shell,
state.edit,
)
previous = reuseTimelineRows(previous, full.rows)
expect(projection.rows()).toEqual(previous)
expect(projection.activeMessageID()).toBe(full.activeMessageID)
expect([...projection.rowByKey().keys()]).toEqual(previous.map(TimelineRow.key))
expect([...projection.messageByID().keys()]).toEqual(state.messages.map((message) => message.id))
previous.forEach((row, index) => {
expect(projection.messageRowIndex().get(row.userMessageID)).toBe(
previous!.findIndex((item) => item.userMessageID === row.userMessageID),
)
expect(projection.messageLastRowIndex().get(row.userMessageID)).toBe(
previous!.findLastIndex((item) => item.userMessageID === row.userMessageID),
)
expect(projection.rowByKey().get(TimelineRow.key(row))).toBe(projection.rows()[index])
})
}
const change = (update: (message: SessionMessageAssistant) => void) => {
setState(
"messages",
(message) => message.id === "answer",
produce((message) => {
if (message.type === "assistant") update(message)
}),
)
verify()
}
verify()
change((message) => {
if (message.content[2].type === "text") message.content[2].text = "Split the context group"
})
change((message) => {
if (message.content[2].type === "text") message.content[2].text = " \n"
})
change((message) => {
if (message.content[0].type === "reasoning") message.content[0].text += " and its callers"
})
setState("reasoning", "hidden")
verify()
setState("reasoning", "full")
verify()
change((message) => {
if (message.content[1].type === "tool" && message.content[1].state.status === "completed")
message.content[1].state.metadata = { loaded: ["src/example.ts"] }
})
change((message) => {
if (message.content[3].type === "tool")
message.content[3].state = {
status: "completed",
input: {},
metadata: {},
content: [{ type: "text", text: "ok" }],
}
})
setState("shell", true)
verify()
change((message) => {
if (message.content[4].type === "tool")
message.content[4].state = {
status: "completed",
input: {},
metadata: {},
content: [{ type: "text", text: "answered" }],
}
})
change((message) => {
message.content.push({
type: "tool",
id: "edit",
name: "edit",
state: {
status: "completed",
input: {},
metadata: { files: [{ status: "deleted" }] },
content: [{ type: "text", text: "edited" }],
},
time: { created: 1 },
})
})
setState("edit", true)
verify()
change((message) => {
const tool = message.content.at(-1)
if (tool?.type === "tool" && tool.state.status === "completed")
tool.state.metadata = { files: [{ status: "modified" }] }
})
change((message) => {
message.content.push({ type: "reasoning", text: "Working", time: { created: 3 } })
})
expect(projection.rows().at(-1)?._tag).toBe("Thinking")
change((message) => {
const part = message.content.at(-1)
if (part?.type === "reasoning") part.time = { created: 3, completed: 4 }
})
change((message) => {
message.retry = { attempt: 1, at: 3, error: { type: "Retry", message: "retry" } }
})
change((message) => {
message.retry = undefined
message.error = { type: "Interrupted", message: "stopped" }
})
change((message) => {
message.error = { type: "Error", message: "failed" }
})
change((message) => {
message.error = undefined
message.time.completed = 5
})
setState("status", { type: "idle" })
verify()
setState(
"messages",
produce((messages) =>
messages.unshift({ id: "user", type: "user", text: "Earlier page", time: { created: 0 } }),
),
)
verify()
expect(projection.assistantMessagesByParent().get("user")?.[0].id).toBe("answer")
expect(projection.userContextByID().get("user")?.agent).toBe("build")
setState(
"messages",
produce((messages) =>
messages.push(
{ id: "notice", type: "synthetic", text: "hidden", time: { created: 6 } },
assistant("later", [{ type: "text", text: "Later work" }]),
),
),
)
verify()
setState("messages", (message) => message.id === "notice", { description: "Visible notice" })
verify()
setState(
"messages",
produce((messages) => messages.push({ id: "pending", type: "user", text: "Steer", time: { created: 8 } })),
)
setState("pending", new Set(["pending"]))
verify()
expect(projection.activeMessageID()).toBe("user")
setState("pending", new Set())
verify()
expect(projection.activeMessageID()).toBe("pending")
const all = [...state.messages]
setState("messages", (messages) => messages.slice(0, 2))
verify()
setState("messages", all)
verify()
change((message) => {
message.content = [{ type: "text", text: "Replacement" }]
})
setState("messages", [])
verify()
setState("messages", [assistant("new-session", [{ type: "text", text: "New session" }])])
verify()
dispose()
})
})
})
}