fixture.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. import { base64Encode } from "@kirincode-ai/core/util/encode"
  2. import { Event } from "@kirincode-ai/schema/event"
  3. import { SessionStatusEvent } from "@kirincode-ai/schema/session-status-event"
  4. import { SessionV1 } from "@kirincode-ai/schema/session-v1"
  5. import type {
  6. AssistantMessage,
  7. GlobalEvent,
  8. Message,
  9. Part,
  10. Session,
  11. SessionStatus,
  12. ToolPart,
  13. ToolState,
  14. UserMessage,
  15. } from "@kirincode-ai/sdk/v2/client"
  16. import { expect, type Page } from "@playwright/test"
  17. import { Schema } from "effect"
  18. import { mockKirinCodeServer } from "../../utils/mock-server"
  19. import { installSseTransport } from "../../utils/sse-transport"
  20. import { expectSessionTitle } from "../../utils/waits"
  21. export const directory = "C:/KirinCode/TimelineStability"
  22. export const projectID = "proj_timeline_stability"
  23. export const sessionID = "ses_timeline_stability"
  24. export const userID = "msg_1000_timeline_user"
  25. export const assistantID = "msg_1001_timeline_assistant"
  26. export const title = "Timeline visual stability"
  27. export const model = { providerID: "kirincode", modelID: "claude-opus-4-6", variant: "max" }
  28. type TimelinePayload = Extract<
  29. GlobalEvent["payload"],
  30. {
  31. type:
  32. | "message.updated"
  33. | "message.removed"
  34. | "message.part.updated"
  35. | "message.part.removed"
  36. | "message.part.delta"
  37. | "session.status"
  38. }
  39. >
  40. type DeepReadonly<Value> = Value extends readonly unknown[]
  41. ? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
  42. : Value extends object
  43. ? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
  44. : Value
  45. export type TimelineEvent = DeepReadonly<Omit<GlobalEvent, "payload"> & { payload: TimelinePayload }>
  46. export type EventPayload = TimelineEvent
  47. export type ToolStatus = ToolState["status"]
  48. export type TimelineMessage = { info: UserMessage; parts: Part[] } | { info: AssistantMessage; parts: Part[] }
  49. type UserPart = Extract<Part, { type: "text" | "file" | "agent" | "subtask" }>
  50. type AssistantPart = Exclude<Part, { type: "agent" | "subtask" }>
  51. type OwnedPart<Owner extends Message["role"]> = Owner extends "user" ? UserPart : AssistantPart
  52. export type PartSeed<Owner extends Message["role"]> =
  53. OwnedPart<Owner> extends infer Candidate
  54. ? Candidate extends Part
  55. ? Omit<Candidate, "sessionID" | "messageID">
  56. : never
  57. : never
  58. type ToolOptions<State extends ToolStatus> = State extends "pending"
  59. ? { output?: never; title?: never; metadata?: never; error?: never }
  60. : State extends "running"
  61. ? { title?: string; metadata?: Record<string, unknown>; output?: never; error?: never }
  62. : State extends "error"
  63. ? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
  64. : { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
  65. const decodeOptions = { errors: "all", onExcessProperty: "error" } as const
  66. const decodeMessage = Schema.decodeUnknownSync(SessionV1.WithParts)
  67. const decodePart = Schema.decodeUnknownSync(SessionV1.Part)
  68. const decodeStatus = Schema.decodeUnknownSync(SessionStatusEvent.Info)
  69. const timelineEventSchema = Schema.Union([
  70. eventSchema("message.updated", SessionV1.Event.MessageUpdated.data),
  71. eventSchema("message.removed", SessionV1.Event.MessageRemoved.data),
  72. eventSchema("message.part.updated", SessionV1.Event.PartUpdated.data),
  73. eventSchema("message.part.removed", SessionV1.Event.PartRemoved.data),
  74. eventSchema("message.part.delta", SessionV1.Event.PartDelta.data),
  75. eventSchema("session.status", SessionStatusEvent.Status.data),
  76. ])
  77. const decodeEvent = Schema.decodeUnknownSync(timelineEventSchema)
  78. let eventSequence = 0
  79. export async function setupTimeline(
  80. page: Page,
  81. input: {
  82. messages?: TimelineMessage[]
  83. settings?: Record<string, boolean>
  84. sessions?: Session[]
  85. cpuRate?: number
  86. viewport?: { width: number; height: number }
  87. eventRetry?: number
  88. reducedMotion?: boolean
  89. locale?: string
  90. deviceScaleFactor?: number
  91. seedHistory?: boolean
  92. } = {},
  93. ) {
  94. const sessions = input.sessions ?? [session()]
  95. const messages = validateTimelineMessages([
  96. ...(input.seedHistory ? historyMessages(18) : []),
  97. ...(input.messages ?? [userMessage(), assistantMessage()]),
  98. ])
  99. const active = messages.findLast((message) => message.info.role === "assistant")
  100. const initialStatus = decodeStatus(
  101. active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
  102. decodeOptions,
  103. )
  104. const transport = await installSseTransport<EventPayload>(page, {
  105. server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
  106. retry: input.eventRetry ?? 20,
  107. })
  108. await mockKirinCodeServer(page, {
  109. directory,
  110. project: project(),
  111. provider: provider(),
  112. sessions,
  113. sessionStatus: { [sessionID]: initialStatus },
  114. pageMessages: () => ({
  115. items: messages,
  116. }),
  117. })
  118. await page.addInitScript((settings) => {
  119. localStorage.setItem(
  120. "settings.v3",
  121. JSON.stringify({
  122. general: {
  123. editToolPartsExpanded: false,
  124. shellToolPartsExpanded: false,
  125. showReasoningSummaries: false,
  126. showSessionProgressBar: true,
  127. ...settings,
  128. },
  129. }),
  130. )
  131. }, input.settings ?? {})
  132. if (input.locale) {
  133. await page.addInitScript((locale) => {
  134. localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale }))
  135. }, input.locale)
  136. }
  137. if (input.reducedMotion) await page.emulateMedia({ reducedMotion: "reduce" })
  138. await page.setViewportSize(input.viewport ?? { width: 1400, height: 900 })
  139. if (input.deviceScaleFactor) {
  140. const devtools = await page.context().newCDPSession(page)
  141. const viewport = input.viewport ?? { width: 1400, height: 900 }
  142. await devtools.send("Emulation.setDeviceMetricsOverride", {
  143. width: viewport.width,
  144. height: viewport.height,
  145. deviceScaleFactor: input.deviceScaleFactor,
  146. mobile: false,
  147. })
  148. }
  149. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
  150. await transport.waitForConnection()
  151. await expectSessionTitle(page, title)
  152. if (input.cpuRate && input.cpuRate > 1) {
  153. const devtools = await page.context().newCDPSession(page)
  154. await devtools.send("Emulation.setCPUThrottlingRate", { rate: input.cpuRate })
  155. }
  156. return {
  157. transport,
  158. async send(event: TimelineEvent, delay = 0) {
  159. const valid = validateTimelineEvent(event)
  160. await transport.send(valid, { marker: describeEvent(valid) })
  161. if (delay) await page.waitForTimeout(delay)
  162. },
  163. async sendAll(sequence: { event: TimelineEvent; delay: number }[]) {
  164. for (const item of sequence) {
  165. const valid = validateTimelineEvent(item.event)
  166. await transport.send(valid, { marker: describeEvent(valid) })
  167. await page.waitForTimeout(item.delay)
  168. }
  169. },
  170. async settle(frames = 3) {
  171. await page.evaluate(
  172. (frames) =>
  173. new Promise<void>((resolve) => {
  174. let remaining = frames
  175. const tick = () => {
  176. remaining--
  177. if (remaining <= 0) return resolve()
  178. requestAnimationFrame(tick)
  179. }
  180. requestAnimationFrame(tick)
  181. }),
  182. frames,
  183. )
  184. },
  185. async waitForPart(partID: string) {
  186. await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible()
  187. },
  188. }
  189. }
  190. function describeEvent(event: EventPayload) {
  191. if (event.payload.type === "message.part.updated") {
  192. const part = event.payload.properties.part
  193. return [
  194. event.payload.type,
  195. part.id,
  196. part.type === "tool" ? part.tool : part.type,
  197. part.type === "tool" ? part.state.status : undefined,
  198. ]
  199. .filter(Boolean)
  200. .join(":")
  201. }
  202. if (event.payload.type === "session.status") {
  203. const status = event.payload.properties.status
  204. return [event.payload.type, status.type, status.type === "retry" ? status.attempt : undefined]
  205. .filter((value) => value !== undefined)
  206. .join(":")
  207. }
  208. return event.payload.type
  209. }
  210. export function event<const Type extends TimelinePayload["type"]>(
  211. type: Type,
  212. properties: Extract<TimelinePayload, { type: Type }>["properties"],
  213. ): TimelineEvent
  214. export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent {
  215. return validateTimelineEvent({
  216. directory,
  217. payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties },
  218. })
  219. }
  220. export function validateTimelineEvent(input: unknown): TimelineEvent {
  221. return decodeEvent(input, decodeOptions)
  222. }
  223. export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
  224. input.forEach((message) => decodeMessage(message, decodeOptions))
  225. const messages = [...input]
  226. const messageIDs = new Set<string>()
  227. const partIDs = new Set<string>()
  228. const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id))
  229. messages.forEach((message) => {
  230. if (messageIDs.has(message.info.id))
  231. throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`)
  232. messageIDs.add(message.info.id)
  233. if (message.info.role === "assistant" && !users.has(message.info.parentID))
  234. throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`)
  235. message.parts.forEach((part) => {
  236. if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id)
  237. throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`)
  238. if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type))
  239. throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`)
  240. if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type))
  241. throw new Error(`Timeline assistant message ${message.info.id} cannot own ${part.type} part ${part.id}`)
  242. if (partIDs.has(part.id)) throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`)
  243. partIDs.add(part.id)
  244. })
  245. })
  246. return messages
  247. }
  248. export async function waitForVisualSettle(page: Page, selectors: string[], stableFrames = 3) {
  249. await page.waitForFunction(
  250. ({ selectors, stableFrames }) => {
  251. const elements = selectors.map((selector) => document.querySelector<HTMLElement>(selector))
  252. if (elements.some((element) => !element)) return false
  253. return new Promise<boolean>((resolve) => {
  254. let stable = 0
  255. let previous = ""
  256. const sample = () => {
  257. const signature = JSON.stringify(
  258. elements.map((element) => {
  259. const rect = element!.getBoundingClientRect()
  260. return [Math.round(rect.top * 10), Math.round(rect.bottom * 10), Math.round(rect.height * 10)]
  261. }),
  262. )
  263. stable = signature === previous ? stable + 1 : 0
  264. previous = signature
  265. const ordered = elements
  266. .slice(1)
  267. .every(
  268. (element, index) =>
  269. elements[index]!.getBoundingClientRect().bottom <= element!.getBoundingClientRect().top + 0.5,
  270. )
  271. if (stable >= stableFrames && ordered) return resolve(true)
  272. requestAnimationFrame(sample)
  273. }
  274. requestAnimationFrame(sample)
  275. })
  276. },
  277. { selectors, stableFrames },
  278. )
  279. }
  280. export function historyMessages(count: number): TimelineMessage[] {
  281. return Array.from({ length: count }, (_, index) => {
  282. const value = String(index).padStart(4, "0")
  283. const historyUserID = `msg_0${value}_history_a_user`
  284. return [
  285. userMessage(undefined, { id: historyUserID, created: 1690000000000 + index * 10_000 }),
  286. assistantMessage(
  287. [
  288. {
  289. id: `prt_0${value}_history_text`,
  290. type: "text",
  291. text: `Historical response ${index}. ${"Existing session content keeps the virtual timeline realistic. ".repeat(5)}`,
  292. },
  293. ],
  294. {
  295. id: `msg_0${value}_history_b_assistant`,
  296. parentID: historyUserID,
  297. created: 1690000001000 + index * 10_000,
  298. },
  299. ),
  300. ]
  301. }).flat()
  302. }
  303. export function partUpdated(part: Part | PartSeed<"assistant">) {
  304. const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID }
  305. decodePart(owned, decodeOptions)
  306. return event("message.part.updated", {
  307. sessionID,
  308. part: owned,
  309. time: 1700000002000,
  310. })
  311. }
  312. export function partDelta(partID: string, delta: string, messageID = assistantID) {
  313. return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta })
  314. }
  315. export function messageUpdated(info: Message) {
  316. return event("message.updated", { sessionID, info })
  317. }
  318. export function status(type: SessionStatus["type"], attempt = 1) {
  319. return event("session.status", {
  320. sessionID,
  321. status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type },
  322. })
  323. }
  324. export function userMessage(
  325. parts?: PartSeed<"user">[],
  326. input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {},
  327. ): Extract<TimelineMessage, { info: { role: "user" } }> {
  328. const id = input.id ?? userID
  329. const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
  330. const message = {
  331. info: {
  332. id,
  333. sessionID,
  334. role: "user",
  335. time: { created: input.created ?? 1700000000000 },
  336. summary: input.summary ?? { diffs: [] },
  337. agent: "build",
  338. model,
  339. },
  340. parts: seeds.map((part) => ({
  341. ...part,
  342. sessionID,
  343. messageID: id,
  344. })),
  345. } satisfies Extract<TimelineMessage, { info: { role: "user" } }>
  346. decodeMessage(message, decodeOptions)
  347. return message
  348. }
  349. export function assistantMessage(
  350. parts: PartSeed<"assistant">[] = [],
  351. input: {
  352. id?: string
  353. parentID?: string
  354. completed?: boolean
  355. error?: AssistantMessage["error"]
  356. created?: number
  357. } = {},
  358. ): Extract<TimelineMessage, { info: { role: "assistant" } }> {
  359. const id = input.id ?? assistantID
  360. const message = {
  361. info: {
  362. id,
  363. sessionID,
  364. role: "assistant",
  365. time: {
  366. created: input.created ?? 1700000001000,
  367. ...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }),
  368. },
  369. parentID: input.parentID ?? userID,
  370. modelID: model.modelID,
  371. providerID: model.providerID,
  372. mode: "build",
  373. agent: "build",
  374. path: { cwd: directory, root: directory },
  375. cost: 0.01,
  376. tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
  377. variant: "max",
  378. ...(input.error ? { error: input.error } : {}),
  379. },
  380. parts: parts.map((part) => ({ ...part, sessionID, messageID: id })),
  381. } satisfies Extract<TimelineMessage, { info: { role: "assistant" } }>
  382. decodeMessage(message, decodeOptions)
  383. return message
  384. }
  385. export function userText(
  386. text: string,
  387. input: Partial<Omit<Extract<PartSeed<"user">, { type: "text" }>, "type" | "text">> = {},
  388. ): Extract<PartSeed<"user">, { type: "text" }> {
  389. return { id: "prt_user_text", type: "text", text, ...input }
  390. }
  391. export function textPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "text" }> {
  392. return { id, type: "text", text }
  393. }
  394. export function reasoningPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "reasoning" }> {
  395. return { id, type: "reasoning", text, time: { start: 1700000001000 } }
  396. }
  397. export function toolPart(
  398. id: string,
  399. tool: string,
  400. state: "pending",
  401. input: Record<string, unknown>,
  402. options?: ToolOptions<"pending">,
  403. ): Omit<ToolPart, "sessionID" | "messageID">
  404. export function toolPart(
  405. id: string,
  406. tool: string,
  407. state: "running",
  408. input: Record<string, unknown>,
  409. options?: ToolOptions<"running">,
  410. ): Omit<ToolPart, "sessionID" | "messageID">
  411. export function toolPart(
  412. id: string,
  413. tool: string,
  414. state: "completed",
  415. input: Record<string, unknown>,
  416. options?: ToolOptions<"completed">,
  417. ): Omit<ToolPart, "sessionID" | "messageID">
  418. export function toolPart(
  419. id: string,
  420. tool: string,
  421. state: "error",
  422. input: Record<string, unknown>,
  423. options?: ToolOptions<"error">,
  424. ): Omit<ToolPart, "sessionID" | "messageID">
  425. export function toolPart(
  426. id: string,
  427. tool: string,
  428. state: ToolStatus,
  429. input: Record<string, unknown>,
  430. options: ToolOptions<ToolStatus> = {},
  431. ): Omit<ToolPart, "sessionID" | "messageID"> {
  432. const base = { id, type: "tool" as const, callID: `call_${id}`, tool }
  433. if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
  434. if (state === "running")
  435. return {
  436. ...base,
  437. state: {
  438. status: state,
  439. input,
  440. title: options.title,
  441. metadata: options.metadata ?? {},
  442. time: { start: 1700000001000 },
  443. },
  444. }
  445. if (state === "error")
  446. return {
  447. ...base,
  448. state: {
  449. status: state,
  450. input,
  451. error: options.error ?? "Tool failed",
  452. metadata: options.metadata ?? {},
  453. time: { start: 1700000001000, end: 1700000002000 },
  454. },
  455. }
  456. return {
  457. ...base,
  458. state: {
  459. status: state,
  460. input,
  461. output: options.output ?? "Completed",
  462. title: options.title ?? tool,
  463. metadata: options.metadata ?? {},
  464. time: { start: 1700000001000, end: 1700000002000 },
  465. },
  466. }
  467. }
  468. export function shell(
  469. id: string,
  470. state: ToolStatus,
  471. output = "",
  472. command = `echo ${id}`,
  473. ): Omit<ToolPart, "sessionID" | "messageID"> {
  474. if (state === "pending") return toolPart(id, "bash", state, { command })
  475. if (state === "running")
  476. return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
  477. if (state === "error")
  478. return toolPart(id, "bash", state, { command }, { error: output || undefined, metadata: { command, output } })
  479. return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
  480. }
  481. export function completedAssistantInfo(info: AssistantMessage): AssistantMessage {
  482. return { ...info, time: { ...info.time, completed: 1700000003000 } }
  483. }
  484. export function project() {
  485. return {
  486. id: projectID,
  487. worktree: directory,
  488. vcs: "git",
  489. name: "timeline-stability",
  490. time: { created: 1700000000000, updated: 1700000000000 },
  491. sandboxes: [],
  492. }
  493. }
  494. export function session(input: Partial<Session> = {}): Session {
  495. return {
  496. id: sessionID,
  497. slug: "timeline-stability",
  498. projectID,
  499. directory,
  500. title,
  501. version: "dev",
  502. time: { created: 1700000000000, updated: 1700000000000 },
  503. ...input,
  504. }
  505. }
  506. function eventSchema<
  507. const Type extends TimelinePayload["type"],
  508. const Properties extends Schema.Codec<unknown, unknown>,
  509. >(type: Type, properties: Properties) {
  510. return Schema.Struct({
  511. directory: Schema.String,
  512. project: Schema.optional(Schema.String),
  513. workspace: Schema.optional(Schema.String),
  514. payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }),
  515. })
  516. }
  517. function provider() {
  518. return {
  519. all: [
  520. {
  521. id: "kirincode",
  522. name: "KirinCode",
  523. models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
  524. },
  525. ],
  526. connected: ["kirincode"],
  527. default: { providerID: "kirincode", modelID: "claude-opus-4-6" },
  528. }
  529. }