| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566 |
- import { base64Encode } from "@opencode-ai/core/util/encode"
- import { Event } from "@opencode-ai/schema/event"
- import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
- import { SessionV1 } from "@opencode-ai/schema/session-v1"
- import type {
- AssistantMessage,
- GlobalEvent,
- Message,
- Part,
- Session,
- SessionStatus,
- ToolPart,
- ToolState,
- UserMessage,
- } from "@opencode-ai/sdk/v2/client"
- import { expect, type Page } from "@playwright/test"
- import { Schema } from "effect"
- import { mockOpenCodeServer } from "../../utils/mock-server"
- import { installSseTransport } from "../../utils/sse-transport"
- import { expectSessionTitle } from "../../utils/waits"
- export const directory = "C:/OpenCode/TimelineStability"
- export const projectID = "proj_timeline_stability"
- export const sessionID = "ses_timeline_stability"
- export const userID = "msg_1000_timeline_user"
- export const assistantID = "msg_1001_timeline_assistant"
- export const title = "Timeline visual stability"
- export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
- type TimelinePayload = Extract<
- GlobalEvent["payload"],
- {
- type:
- | "message.updated"
- | "message.removed"
- | "message.part.updated"
- | "message.part.removed"
- | "message.part.delta"
- | "session.status"
- }
- >
- type DeepReadonly<Value> = Value extends readonly unknown[]
- ? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
- : Value extends object
- ? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
- : Value
- export type TimelineEvent = DeepReadonly<Omit<GlobalEvent, "payload"> & { payload: TimelinePayload }>
- export type EventPayload = TimelineEvent
- export type ToolStatus = ToolState["status"]
- export type TimelineMessage = { info: UserMessage; parts: Part[] } | { info: AssistantMessage; parts: Part[] }
- type UserPart = Extract<Part, { type: "text" | "file" | "agent" | "subtask" }>
- type AssistantPart = Exclude<Part, { type: "agent" | "subtask" }>
- type OwnedPart<Owner extends Message["role"]> = Owner extends "user" ? UserPart : AssistantPart
- export type PartSeed<Owner extends Message["role"]> =
- OwnedPart<Owner> extends infer Candidate
- ? Candidate extends Part
- ? Omit<Candidate, "sessionID" | "messageID">
- : never
- : never
- type ToolOptions<State extends ToolStatus> = State extends "pending"
- ? { output?: never; title?: never; metadata?: never; error?: never }
- : State extends "running"
- ? { title?: string; metadata?: Record<string, unknown>; output?: never; error?: never }
- : State extends "error"
- ? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
- : { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
- const decodeOptions = { errors: "all", onExcessProperty: "error" } as const
- const decodeMessage = Schema.decodeUnknownSync(SessionV1.WithParts)
- const decodePart = Schema.decodeUnknownSync(SessionV1.Part)
- const decodeStatus = Schema.decodeUnknownSync(SessionStatusEvent.Info)
- const timelineEventSchema = Schema.Union([
- eventSchema("message.updated", SessionV1.Event.MessageUpdated.data),
- eventSchema("message.removed", SessionV1.Event.MessageRemoved.data),
- eventSchema("message.part.updated", SessionV1.Event.PartUpdated.data),
- eventSchema("message.part.removed", SessionV1.Event.PartRemoved.data),
- eventSchema("message.part.delta", SessionV1.Event.PartDelta.data),
- eventSchema("session.status", SessionStatusEvent.Status.data),
- ])
- const decodeEvent = Schema.decodeUnknownSync(timelineEventSchema)
- let eventSequence = 0
- export async function setupTimeline(
- page: Page,
- input: {
- messages?: TimelineMessage[]
- settings?: Record<string, boolean>
- sessions?: Session[]
- cpuRate?: number
- viewport?: { width: number; height: number }
- eventRetry?: number
- reducedMotion?: boolean
- locale?: string
- deviceScaleFactor?: number
- seedHistory?: boolean
- protocol?: "v1" | "v2"
- } = {},
- ) {
- const sessions = input.sessions ?? [session()]
- const messages = validateTimelineMessages([
- ...(input.seedHistory ? historyMessages(18) : []),
- ...(input.messages ?? [userMessage(), assistantMessage()]),
- ])
- const active = messages.findLast((message) => message.info.role === "assistant")
- const initialStatus = decodeStatus(
- active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
- decodeOptions,
- )
- const transport = await installSseTransport<EventPayload>(page, {
- server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
- retry: input.eventRetry ?? 20,
- })
- await mockOpenCodeServer(page, {
- protocol: input.protocol,
- directory,
- project: project(),
- provider: provider(),
- sessions,
- sessionStatus: { [sessionID]: initialStatus },
- pageMessages: () => ({
- items: messages,
- }),
- })
- await page.addInitScript((settings) => {
- localStorage.setItem(
- "settings.v3",
- JSON.stringify({
- general: {
- editToolPartsExpanded: false,
- shellToolPartsExpanded: false,
- showReasoningSummaries: false,
- showSessionProgressBar: true,
- ...settings,
- },
- }),
- )
- if (settings.newLayoutDesigns === false) {
- localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
- }
- }, input.settings ?? {})
- if (input.locale) {
- await page.addInitScript((locale) => {
- localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale }))
- }, input.locale)
- }
- if (input.reducedMotion) await page.emulateMedia({ reducedMotion: "reduce" })
- await page.setViewportSize(input.viewport ?? { width: 1400, height: 900 })
- if (input.deviceScaleFactor) {
- const devtools = await page.context().newCDPSession(page)
- const viewport = input.viewport ?? { width: 1400, height: 900 }
- await devtools.send("Emulation.setDeviceMetricsOverride", {
- width: viewport.width,
- height: viewport.height,
- deviceScaleFactor: input.deviceScaleFactor,
- mobile: false,
- })
- }
- await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
- await transport.waitForConnection()
- await expectSessionTitle(page, title)
- if (input.cpuRate && input.cpuRate > 1) {
- const devtools = await page.context().newCDPSession(page)
- await devtools.send("Emulation.setCPUThrottlingRate", { rate: input.cpuRate })
- }
- return {
- transport,
- async send(event: TimelineEvent, delay = 0) {
- const valid = validateTimelineEvent(event)
- await transport.send(valid, { marker: describeEvent(valid) })
- if (delay) await page.waitForTimeout(delay)
- },
- async sendAll(sequence: { event: TimelineEvent; delay: number }[]) {
- for (const item of sequence) {
- const valid = validateTimelineEvent(item.event)
- await transport.send(valid, { marker: describeEvent(valid) })
- await page.waitForTimeout(item.delay)
- }
- },
- async settle(frames = 3) {
- await page.evaluate(
- (frames) =>
- new Promise<void>((resolve) => {
- let remaining = frames
- const tick = () => {
- remaining--
- if (remaining <= 0) return resolve()
- requestAnimationFrame(tick)
- }
- requestAnimationFrame(tick)
- }),
- frames,
- )
- },
- async waitForPart(partID: string) {
- await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible()
- },
- }
- }
- function describeEvent(event: EventPayload) {
- if (event.payload.type === "message.part.updated") {
- const part = event.payload.properties.part
- return [
- event.payload.type,
- part.id,
- part.type === "tool" ? part.tool : part.type,
- part.type === "tool" ? part.state.status : undefined,
- ]
- .filter(Boolean)
- .join(":")
- }
- if (event.payload.type === "session.status") {
- const status = event.payload.properties.status
- return [event.payload.type, status.type, status.type === "retry" ? status.attempt : undefined]
- .filter((value) => value !== undefined)
- .join(":")
- }
- return event.payload.type
- }
- export function event<const Type extends TimelinePayload["type"]>(
- type: Type,
- properties: Extract<TimelinePayload, { type: Type }>["properties"],
- ): TimelineEvent
- export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent {
- return validateTimelineEvent({
- directory,
- payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties },
- })
- }
- export function validateTimelineEvent(input: unknown): TimelineEvent {
- return decodeEvent(input, decodeOptions)
- }
- export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
- input.forEach((message) => decodeMessage(message, decodeOptions))
- const messages = [...input]
- const messageIDs = new Set<string>()
- const partIDs = new Set<string>()
- const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id))
- messages.forEach((message) => {
- if (messageIDs.has(message.info.id))
- throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`)
- messageIDs.add(message.info.id)
- if (message.info.role === "assistant" && !users.has(message.info.parentID))
- throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`)
- message.parts.forEach((part) => {
- if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id)
- throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`)
- if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type))
- throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`)
- if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type))
- throw new Error(`Timeline assistant message ${message.info.id} cannot own ${part.type} part ${part.id}`)
- if (partIDs.has(part.id)) throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`)
- partIDs.add(part.id)
- })
- })
- return messages
- }
- export async function waitForVisualSettle(page: Page, selectors: string[], stableFrames = 3) {
- await page.waitForFunction(
- ({ selectors, stableFrames }) => {
- const elements = selectors.map((selector) => document.querySelector<HTMLElement>(selector))
- if (elements.some((element) => !element)) return false
- return new Promise<boolean>((resolve) => {
- let stable = 0
- let previous = ""
- const sample = () => {
- const signature = JSON.stringify(
- elements.map((element) => {
- const rect = element!.getBoundingClientRect()
- return [Math.round(rect.top * 10), Math.round(rect.bottom * 10), Math.round(rect.height * 10)]
- }),
- )
- stable = signature === previous ? stable + 1 : 0
- previous = signature
- const ordered = elements
- .slice(1)
- .every(
- (element, index) =>
- elements[index]!.getBoundingClientRect().bottom <= element!.getBoundingClientRect().top + 0.5,
- )
- if (stable >= stableFrames && ordered) return resolve(true)
- requestAnimationFrame(sample)
- }
- requestAnimationFrame(sample)
- })
- },
- { selectors, stableFrames },
- )
- }
- export function historyMessages(count: number): TimelineMessage[] {
- return Array.from({ length: count }, (_, index) => {
- const value = String(index).padStart(4, "0")
- const historyUserID = `msg_0${value}_history_a_user`
- return [
- userMessage(undefined, { id: historyUserID, created: 1690000000000 + index * 10_000 }),
- assistantMessage(
- [
- {
- id: `prt_0${value}_history_text`,
- type: "text",
- text: `Historical response ${index}. ${"Existing session content keeps the virtual timeline realistic. ".repeat(5)}`,
- },
- ],
- {
- id: `msg_0${value}_history_b_assistant`,
- parentID: historyUserID,
- created: 1690000001000 + index * 10_000,
- },
- ),
- ]
- }).flat()
- }
- export function partUpdated(part: Part | PartSeed<"assistant">) {
- const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID }
- decodePart(owned, decodeOptions)
- return event("message.part.updated", {
- sessionID,
- part: owned,
- time: 1700000002000,
- })
- }
- export function partDelta(partID: string, delta: string, messageID = assistantID) {
- return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta })
- }
- export function messageUpdated(info: Message) {
- return event("message.updated", { sessionID, info })
- }
- export function status(type: SessionStatus["type"], attempt = 1) {
- return event("session.status", {
- sessionID,
- status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type },
- })
- }
- export function userMessage(
- parts?: PartSeed<"user">[],
- input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {},
- ): Extract<TimelineMessage, { info: { role: "user" } }> {
- const id = input.id ?? userID
- const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
- const message = {
- info: {
- id,
- sessionID,
- role: "user",
- time: { created: input.created ?? 1700000000000 },
- summary: input.summary ?? { diffs: [] },
- agent: "build",
- model,
- },
- parts: seeds.map((part) => ({
- ...part,
- sessionID,
- messageID: id,
- })),
- } satisfies Extract<TimelineMessage, { info: { role: "user" } }>
- decodeMessage(message, decodeOptions)
- return message
- }
- export function assistantMessage(
- parts: PartSeed<"assistant">[] = [],
- input: {
- id?: string
- parentID?: string
- completed?: boolean
- error?: AssistantMessage["error"]
- created?: number
- } = {},
- ): Extract<TimelineMessage, { info: { role: "assistant" } }> {
- const id = input.id ?? assistantID
- const message = {
- info: {
- id,
- sessionID,
- role: "assistant",
- time: {
- created: input.created ?? 1700000001000,
- ...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }),
- },
- parentID: input.parentID ?? userID,
- modelID: model.modelID,
- providerID: model.providerID,
- mode: "build",
- agent: "build",
- path: { cwd: directory, root: directory },
- cost: 0.01,
- tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
- variant: "max",
- ...(input.error ? { error: input.error } : {}),
- },
- parts: parts.map((part) => ({ ...part, sessionID, messageID: id })),
- } satisfies Extract<TimelineMessage, { info: { role: "assistant" } }>
- decodeMessage(message, decodeOptions)
- return message
- }
- export function userText(
- text: string,
- input: Partial<Omit<Extract<PartSeed<"user">, { type: "text" }>, "type" | "text">> = {},
- ): Extract<PartSeed<"user">, { type: "text" }> {
- return { id: "prt_user_text", type: "text", text, ...input }
- }
- export function textPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "text" }> {
- return { id, type: "text", text }
- }
- export function reasoningPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "reasoning" }> {
- return { id, type: "reasoning", text, time: { start: 1700000001000 } }
- }
- export function toolPart(
- id: string,
- tool: string,
- state: "pending",
- input: Record<string, unknown>,
- options?: ToolOptions<"pending">,
- ): Omit<ToolPart, "sessionID" | "messageID">
- export function toolPart(
- id: string,
- tool: string,
- state: "running",
- input: Record<string, unknown>,
- options?: ToolOptions<"running">,
- ): Omit<ToolPart, "sessionID" | "messageID">
- export function toolPart(
- id: string,
- tool: string,
- state: "completed",
- input: Record<string, unknown>,
- options?: ToolOptions<"completed">,
- ): Omit<ToolPart, "sessionID" | "messageID">
- export function toolPart(
- id: string,
- tool: string,
- state: "error",
- input: Record<string, unknown>,
- options?: ToolOptions<"error">,
- ): Omit<ToolPart, "sessionID" | "messageID">
- export function toolPart(
- id: string,
- tool: string,
- state: ToolStatus,
- input: Record<string, unknown>,
- options: ToolOptions<ToolStatus> = {},
- ): Omit<ToolPart, "sessionID" | "messageID"> {
- const base = { id, type: "tool" as const, callID: `call_${id}`, tool }
- if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
- if (state === "running")
- return {
- ...base,
- state: {
- status: state,
- input,
- title: options.title,
- metadata: options.metadata ?? {},
- time: { start: 1700000001000 },
- },
- }
- if (state === "error")
- return {
- ...base,
- state: {
- status: state,
- input,
- error: options.error ?? "Tool failed",
- metadata: options.metadata ?? {},
- time: { start: 1700000001000, end: 1700000002000 },
- },
- }
- return {
- ...base,
- state: {
- status: state,
- input,
- output: options.output ?? "Completed",
- title: options.title ?? tool,
- metadata: options.metadata ?? {},
- time: { start: 1700000001000, end: 1700000002000 },
- },
- }
- }
- export function shell(
- id: string,
- state: ToolStatus,
- output = "",
- command = `echo ${id}`,
- ): Omit<ToolPart, "sessionID" | "messageID"> {
- if (state === "pending") return toolPart(id, "bash", state, { command })
- if (state === "running")
- return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
- if (state === "error")
- return toolPart(id, "bash", state, { command }, { error: output || undefined, metadata: { command, output } })
- return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
- }
- export function completedAssistantInfo(info: AssistantMessage): AssistantMessage {
- return { ...info, time: { ...info.time, completed: 1700000003000 } }
- }
- export function project() {
- return {
- id: projectID,
- worktree: directory,
- vcs: "git",
- name: "timeline-stability",
- time: { created: 1700000000000, updated: 1700000000000 },
- sandboxes: [],
- }
- }
- export function session(input: Partial<Session> = {}): Session {
- return {
- id: sessionID,
- slug: "timeline-stability",
- projectID,
- directory,
- title,
- version: "dev",
- time: { created: 1700000000000, updated: 1700000000000 },
- ...input,
- }
- }
- function eventSchema<
- const Type extends TimelinePayload["type"],
- const Properties extends Schema.Codec<unknown, unknown>,
- >(type: Type, properties: Properties) {
- return Schema.Struct({
- directory: Schema.String,
- project: Schema.optional(Schema.String),
- workspace: Schema.optional(Schema.String),
- payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }),
- })
- }
- function provider() {
- return {
- all: [
- {
- id: "opencode",
- name: "OpenCode",
- models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
- },
- ],
- connected: ["opencode"],
- default: { providerID: "opencode", modelID: "claude-opus-4-6" },
- }
- }
|