mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 03:09:48 -04:00
562 lines
19 KiB
TypeScript
562 lines
19 KiB
TypeScript
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
|
|
} = {},
|
|
) {
|
|
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, {
|
|
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,
|
|
},
|
|
}),
|
|
)
|
|
}, 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" },
|
|
}
|
|
}
|