|
|
@@ -0,0 +1,561 @@
|
|
|
+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" },
|
|
|
+ }
|
|
|
+}
|