mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 12:10:45 +03:00
452 lines
15 KiB
TypeScript
452 lines
15 KiB
TypeScript
import { base64Encode } from "@opencode/util/encode"
|
|
import type {
|
|
JsonValue,
|
|
OpenCodeEvent,
|
|
SessionMessageAssistant,
|
|
SessionMessageAssistantTool,
|
|
SessionMessageInfo,
|
|
} from "@opencode/client/promise"
|
|
import type { Page } from "@playwright/test"
|
|
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
|
|
import { createTwoFilesPatch } from "diff"
|
|
import { mockOpenCodeServer } from "../../e2e/utils/mock-server"
|
|
import { eventSource } from "./events"
|
|
|
|
const directory = "C:/OpenCode/TimelineStateRegression"
|
|
|
|
const projectID = "proj_timeline_state_regression"
|
|
|
|
const sessionID = "ses_timeline_state_regression"
|
|
|
|
const userMessageID = "msg_user_regression"
|
|
|
|
const assistantMessageID = "msg_assistant_regression"
|
|
|
|
const editPartID = "prt_0001_edit"
|
|
|
|
const title = "Timeline collapse state regression"
|
|
|
|
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
|
|
|
/** The session's scroll container. */
|
|
export const timeline = ".scroll-view__viewport:has([data-timeline-row])"
|
|
|
|
export const composer = '[data-component="composer-editor"]'
|
|
|
|
/** The streamed reply's text part; its last delta ends with "benchmark-complete". */
|
|
export const reply = `[data-timeline-part-id="${assistantMessageID}:text:0"]`
|
|
|
|
/** The oldest user prompt, "Historical prompt 0", at the top of the history. */
|
|
export const oldestPrompt = '[data-timeline-part-id="msg_0000_0000_a_user:text:0"]'
|
|
|
|
const userMessage = {
|
|
id: userMessageID,
|
|
type: "user",
|
|
time: { created: 1700000000000 },
|
|
text: "Please edit the file.",
|
|
} satisfies SessionMessageInfo
|
|
|
|
const editPart = tool({
|
|
id: editPartID,
|
|
name: "edit",
|
|
input: {
|
|
path: "src/regression.ts",
|
|
oldString: "export const value = 'before'",
|
|
newString: "export const value = 'after'",
|
|
},
|
|
output: "Edited src/regression.ts",
|
|
files: [currentFile("src/regression.ts", "export const value = 'before'\n", "export const value = 'after'\n", 1, 1)],
|
|
at: 1700000001000,
|
|
took: 1000,
|
|
})
|
|
|
|
type TimelineOptions = {
|
|
/** The mocked server's origin: a production build is its own server. */
|
|
server: string
|
|
historyTurns: number
|
|
/** Six completed file reads in every answer of the history. */
|
|
toolHeavy?: boolean
|
|
/** Reports the session as running, as while a reply streams. */
|
|
busy?: boolean
|
|
/** The workspace's changed files, for the review pane. */
|
|
vcsDiff?: unknown[]
|
|
/** Edits listed one per file and collapsed, each file opening on its own (by default they start open). */
|
|
collapsedEdits?: boolean
|
|
/** The last assistant turn's tool parts, in place of its one small edit. */
|
|
tools?: SessionMessageAssistant["content"]
|
|
/** Events arrive from a real SSE endpoint (`source`); otherwise the mock's own stream stays idle. */
|
|
stream?: boolean
|
|
}
|
|
|
|
/**
|
|
* Mocks the server for one long session under the variant's own origin, without navigating: strobe owns navigation
|
|
* and timing. Returns the session's path and, with `stream`, the event source that delivers a reply.
|
|
*/
|
|
export async function mockTimelineSession(page: Page, options: TimelineOptions) {
|
|
const messages = [
|
|
...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index))
|
|
.flat()
|
|
.map((message) =>
|
|
options.toolHeavy && message.type === "assistant"
|
|
? {
|
|
...message,
|
|
content: [
|
|
...Array.from({ length: 6 }, (_, index) =>
|
|
tool({
|
|
id: `${message.id}:read:${index}`,
|
|
name: "read",
|
|
input: { path: `src/session/module-${index}.ts` },
|
|
output: historicalSource(index, false),
|
|
at: message.time.created,
|
|
took: 100,
|
|
}),
|
|
),
|
|
...message.content,
|
|
],
|
|
}
|
|
: message,
|
|
),
|
|
userMessage,
|
|
{
|
|
id: assistantMessageID,
|
|
type: "assistant",
|
|
time: { created: 1700000001000 },
|
|
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
|
agent: "build",
|
|
cost: 0.01,
|
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
content: options.tools ?? [editPart],
|
|
} satisfies SessionMessageInfo,
|
|
]
|
|
|
|
const source = options.stream ? await eventSource(page, options.server) : undefined
|
|
|
|
await mockOpenCodeServer(page, {
|
|
server: options.server,
|
|
directory,
|
|
project: project(),
|
|
provider: provider(),
|
|
sessions: [session()],
|
|
vcsDiff: options.vcsDiff,
|
|
sessionStatus: options.busy ? { [sessionID]: { type: "busy" } } : undefined,
|
|
pageMessages: () => ({ items: messages }),
|
|
})
|
|
await page.addInitScript((general) => localStorage.setItem("settings.v3", JSON.stringify({ general })), {
|
|
editToolPartsExpanded: true,
|
|
shellToolPartsExpanded: true,
|
|
showReasoningSummaries: true,
|
|
timelineDetail: options.collapsedEdits
|
|
? { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } }
|
|
: undefined,
|
|
})
|
|
|
|
return { path: `/server/${base64Encode(options.server)}/session/${sessionID}`, source }
|
|
}
|
|
|
|
/** Answers `/vcs/diff` with `files` serialized once: tens of megabytes would otherwise be encoded per request. */
|
|
export function serveDiffs(page: Page, files: unknown[]) {
|
|
const body = JSON.stringify({
|
|
location: { directory, project: { id: projectID, directory, canonical: directory } },
|
|
data: files,
|
|
})
|
|
|
|
return page.route("**/vcs/diff**", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
headers: { "access-control-allow-origin": "*" },
|
|
body,
|
|
}),
|
|
)
|
|
}
|
|
|
|
/** A streamed reply of `deltas` markdown deltas after its first: a paragraph, a table and a code fence, repeating. */
|
|
export function streamedReply(deltas: number) {
|
|
return {
|
|
start: [
|
|
{
|
|
...envelope(),
|
|
type: "session.text.started",
|
|
durable: { aggregateID: sessionID, seq: eventSequence, version: 1 },
|
|
data: { sessionID, assistantMessageID, ordinal: 0 },
|
|
} satisfies Extract<OpenCodeEvent, { type: "session.text.started" }>,
|
|
delta(`Streaming${streamChunk(0, deltas + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``),
|
|
],
|
|
deltas: Array.from({ length: deltas }, (_, index) => delta(streamChunk(index + 1, deltas + 1))),
|
|
}
|
|
}
|
|
|
|
function performanceTurn(index: number) {
|
|
const suffix = String(index).padStart(4, "0")
|
|
const assistantID = `msg_0000_${suffix}_b_assistant`
|
|
const before = historicalSource(index, false)
|
|
const after = historicalSource(index, true)
|
|
const at = 1690000001000 + index * 2_000
|
|
const content: SessionMessageAssistant["content"] = []
|
|
|
|
if (index % 5 === 0)
|
|
content.push({
|
|
type: "reasoning",
|
|
text: `Reviewing the existing implementation. ${"constraint analysis ".repeat(20)}`,
|
|
time: { created: at, completed: at + 200 },
|
|
})
|
|
content.push({ type: "text", text: historicalMarkdown(index) })
|
|
|
|
if (index % 8 === 0)
|
|
content.push(
|
|
tool({
|
|
id: `prt_0000_${suffix}_edit`,
|
|
name: "edit",
|
|
input: { path: `src/history-${index}.ts`, oldString: before, newString: after },
|
|
output: `Edited src/history-${index}.ts`,
|
|
files: [currentFile(`src/history-${index}.ts`, before, after, 48, 48)],
|
|
at: at + 200,
|
|
took: 200,
|
|
}),
|
|
)
|
|
|
|
if (index % 12 === 0)
|
|
content.push(
|
|
tool({
|
|
id: `prt_0000_${suffix}_write`,
|
|
name: "write",
|
|
input: { path: `src/generated-${index}.tsx`, content: after },
|
|
output: `Wrote src/generated-${index}.tsx`,
|
|
files: [currentFile(`src/generated-${index}.tsx`, "", after, 32, 0)],
|
|
at: at + 400,
|
|
took: 100,
|
|
}),
|
|
)
|
|
|
|
if (index % 16 === 0)
|
|
content.push(
|
|
tool({
|
|
id: `prt_0000_${suffix}_patch`,
|
|
name: "patch",
|
|
input: { patchText: realisticPatch(index) },
|
|
output: "Success. Updated src/components/SessionCard.tsx",
|
|
files: [currentFile("src/components/SessionCard.tsx", before, after, 8, 3)],
|
|
at: at + 500,
|
|
took: 200,
|
|
}),
|
|
)
|
|
|
|
return [
|
|
{
|
|
id: `msg_0000_${suffix}_a_user`,
|
|
type: "user",
|
|
time: { created: at - 1_000 },
|
|
text: `Historical prompt ${index}`,
|
|
},
|
|
{
|
|
id: assistantID,
|
|
type: "assistant",
|
|
time: { created: at, completed: at + 500 },
|
|
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
|
agent: "build",
|
|
cost: 0.01,
|
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
finish: "stop",
|
|
content,
|
|
},
|
|
] satisfies SessionMessageInfo[]
|
|
}
|
|
|
|
/** A completed tool call: its input, its text output, and the files it changed, from `at` for `took` ms. */
|
|
function tool(input: {
|
|
id: string
|
|
name: string
|
|
input: Record<string, JsonValue>
|
|
output: string
|
|
files?: ReturnType<typeof currentFile>[]
|
|
at: number
|
|
took: number
|
|
}): SessionMessageAssistantTool {
|
|
return {
|
|
type: "tool",
|
|
id: input.id,
|
|
name: input.name,
|
|
time: { created: input.at, ran: input.at, completed: input.at + input.took },
|
|
state: {
|
|
status: "completed",
|
|
input: input.input,
|
|
content: [{ type: "text", text: input.output }],
|
|
metadata: input.files ? { files: input.files } : {},
|
|
},
|
|
}
|
|
}
|
|
|
|
let eventSequence = 0
|
|
|
|
function envelope() {
|
|
eventSequence++
|
|
|
|
return {
|
|
id: `evt_timeline_benchmark_${eventSequence}`,
|
|
created: 1700000002000 + eventSequence,
|
|
location: { directory },
|
|
}
|
|
}
|
|
|
|
function delta(text: string) {
|
|
return {
|
|
...envelope(),
|
|
type: "session.text.delta",
|
|
data: { sessionID, assistantMessageID, ordinal: 0, delta: text },
|
|
} satisfies Extract<OpenCodeEvent, { type: "session.text.delta" }>
|
|
}
|
|
|
|
function historicalMarkdown(index: number) {
|
|
const code = `import { For, Show, createSignal } from "solid-js"
|
|
|
|
type SessionRow = { id: string; title: string; active: boolean }
|
|
|
|
export function SessionList(props: { rows: SessionRow[] }) {
|
|
const [selected, setSelected] = createSignal<string>()
|
|
return (
|
|
<section aria-label="Sessions">
|
|
<For each={props.rows}>{(row) => (
|
|
<button classList={{ active: row.active }} onClick={() => setSelected(row.id)}>
|
|
<Show when={selected() === row.id} fallback={row.title}>{row.title.toUpperCase()}</Show>
|
|
</button>
|
|
)}</For>
|
|
</section>
|
|
)
|
|
}`
|
|
|
|
return `## Session renderer review ${index}
|
|
|
|
The active session keeps **semantic row identity** while reconciling measured content. See [Solid documentation](https://docs.solidjs.com/) and the inline \`measureElement(node)\` call.
|
|
|
|
| Concern | Current behavior | Verification |
|
|
| --- | --- | --- |
|
|
| streaming | appends Markdown blocks | painted frames |
|
|
| geometry | anchors visible rows | DOM coordinates |
|
|
| tools | preserves expanded state | keyed remount probe |
|
|
|
|
> Long sessions combine Markdown, syntax highlighting, tool output, and asynchronously rendered diffs.
|
|
|
|
${index % 4 === 0 ? `\`\`\`tsx\n${code}\n\`\`\`\n\n\`\`\`bash\nbun typecheck\nbun test --preload ./happydom.ts ./src/pages/session\ngit diff --check\n\`\`\`` : "- preserve the viewport anchor\n- avoid replacing stable Markdown nodes\n- process provider deltas without blocking input"}`
|
|
}
|
|
|
|
function historicalSource(index: number, updated: boolean) {
|
|
const method = updated ? "toLocaleUpperCase(props.locale)" : "toUpperCase()"
|
|
const limit = updated ? 24 : 20
|
|
|
|
return `import { createMemo, For } from "solid-js"
|
|
|
|
type Message = {
|
|
id: string
|
|
role: "user" | "assistant"
|
|
text: string
|
|
tokens: { input: number; output: number }
|
|
}
|
|
|
|
export function MessageSummary(props: { messages: Message[]; locale: string }) {
|
|
const visible = createMemo(() => props.messages.filter((message) => message.text.trim()).slice(-${limit}))
|
|
const total = createMemo(() => visible().reduce((sum, message) => sum + message.tokens.output, 0))
|
|
return (
|
|
<article data-session-index="${index}">
|
|
<header>{total().toLocaleString(props.locale)} output tokens</header>
|
|
<For each={visible()}>{(message) => <p data-role={message.role}>{message.text.${method}}</p>}</For>
|
|
</article>
|
|
)
|
|
}
|
|
`
|
|
}
|
|
|
|
function currentFile(file: string, before: string, after: string, additions: number, deletions: number) {
|
|
return {
|
|
file,
|
|
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
|
|
additions,
|
|
deletions,
|
|
status: before ? (after ? "modified" : "deleted") : "added",
|
|
}
|
|
}
|
|
|
|
function realisticPatch(index: number) {
|
|
return `*** Begin Patch
|
|
*** Update File: src/components/SessionCard.tsx
|
|
@@
|
|
-const title = props.session.title.toUpperCase()
|
|
-const messages = props.messages.slice(-20)
|
|
+const title = props.session.title.toLocaleUpperCase(props.locale)
|
|
+const messages = props.messages.filter((message) => message.text.trim()).slice(-24)
|
|
+const outputTokens = messages.reduce((sum, message) => sum + message.tokens.output, 0)
|
|
@@
|
|
- <h2>{title}</h2>
|
|
+ <h2 data-session-index="${index}">{title}</h2>
|
|
+ <span>{outputTokens.toLocaleString(props.locale)} output tokens</span>
|
|
*** End Patch`
|
|
}
|
|
|
|
function streamChunk(index: number, count: number) {
|
|
if (index === 0) return `\n\n## Implementation plan\n\nStreaming **bold analysis`
|
|
|
|
if (index === count - 1)
|
|
return `\n\`\`\`\n\n## Verification\n\n- **Typecheck:** passed\n- **Timeline geometry:** stable\n- **Streaming output:** benchmark-complete <!-- stream-${index} -->`
|
|
|
|
const section = Math.floor(index / 18) + 1
|
|
|
|
const fragments = [
|
|
` continues across three`,
|
|
` or four word`,
|
|
` provider deltas and`,
|
|
` closes in this fragment**. <!-- stream-${index} -->\n\n`,
|
|
`| Concern | State`,
|
|
` | Verification |\n|`,
|
|
` --- | ---`,
|
|
` | --- |\n|`,
|
|
` markdown | incremental |`,
|
|
` painted frames | <!-- stream-${index} -->\n\n`,
|
|
`\`\`\`tsx\nconst row: SessionRow`,
|
|
` = rows[index] ??`,
|
|
` fallback\nconst title =`,
|
|
` row.title.toLocaleUpperCase(locale)\n`,
|
|
`const selected = createMemo(()`,
|
|
` => row.id ===`,
|
|
` activeID()) // stream-${index}\n`,
|
|
`// stream-${index}\n\`\`\`\n\n### Iteration ${section}\n\nStreaming **bold analysis`,
|
|
]
|
|
|
|
return fragments[(index - 1) % fragments.length]!
|
|
}
|
|
|
|
function project() {
|
|
return {
|
|
id: projectID,
|
|
worktree: directory,
|
|
vcs: "git",
|
|
name: "timeline-state-regression",
|
|
time: { created: 1700000000000, updated: 1700000000000 },
|
|
sandboxes: [],
|
|
}
|
|
}
|
|
|
|
function session() {
|
|
return {
|
|
id: sessionID,
|
|
slug: "timeline-state-regression",
|
|
projectID,
|
|
directory,
|
|
title,
|
|
version: "dev",
|
|
time: { created: 1700000000000, updated: 1700000000000 },
|
|
}
|
|
}
|
|
|
|
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" },
|
|
}
|
|
}
|