Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-clips-align.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"opencode-drive": patch
---

Reject negative recording clip holds and keep annotated export dimensions aligned with the frame renderer.
8 changes: 5 additions & 3 deletions packages/drive/src/recording/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ export function applyClips(
let offset = 0
for (const clip of clips) {
const speed = clip.speed ?? 1
const holdMs = clip.holdMs ?? 0
if (!Number.isFinite(speed) || speed <= 0) throw new Error("clip speed must be a positive finite number")
if (!Number.isFinite(holdMs) || holdMs < 0) throw new Error("clip holdMs must be a non-negative finite number")
if (!Number.isFinite(clip.fromMs) || !Number.isFinite(clip.toMs) || clip.toMs < clip.fromMs)
throw new Error("clip range must satisfy fromMs <= toMs")
const scale = (sourceAtMs: number) => offset + (Math.min(Math.max(sourceAtMs, clip.fromMs), clip.toMs) - clip.fromMs) / speed
Expand All @@ -82,10 +84,10 @@ export function applyClips(
if (last && last.atMs < clipEnd) {
edited.push({ ...last, atMs: clipEnd, playbackAtMs: clip.toMs })
}
if (last && (clip.holdMs ?? 0) > 0) {
edited.push({ ...last, atMs: clipEnd + (clip.holdMs ?? 0), playbackAtMs: clip.toMs })
if (last && holdMs > 0) {
edited.push({ ...last, atMs: clipEnd + holdMs, playbackAtMs: clip.toMs })
}
offset = clipEnd + (clip.holdMs ?? 0)
offset = clipEnd + holdMs
}
return edited
}
9 changes: 4 additions & 5 deletions packages/drive/src/recording/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { applyClips, labelAt, type RecordingAnnotation, type RecordingClip } fro
import { encodeFrames } from "./encode.js"
import { progressReporter } from "./frame-rate.js"
import { replayRecording, type ReplayOptions } from "./replay.js"
import { CellHeight, CellWidth, FooterHeight, formatTimecode, renderFrame } from "./render.js"
import { CellHeight, CellWidth, FooterHeight, formatTimecode, HeaderHeight, renderFrame } from "./render.js"
import { loadPointers, pointerAt, PointerOverlayOptions } from "./pointer.js"
import {
activeKeypresses,
Expand Down Expand Up @@ -78,9 +78,8 @@ export async function exportRecording(
}
})
const footerEnabled =
options.footer === false
? false
: options.footer !== undefined || annotations.length > 0 || (options.clips?.length ?? 0) > 0
options.footer !== false &&
(options.footer !== undefined || annotations.length > 0 || (options.clips?.length ?? 0) > 0)
const brand = typeof options.footer === "object" ? options.footer.brand : undefined
const footer = (sample: { atMs: number; playbackAtMs: number; label?: string }) =>
footerEnabled
Expand Down Expand Up @@ -153,6 +152,6 @@ export async function exportRecording(
frames: samples.length,
durationMs: final.atMs,
width: cols * CellWidth,
height: rows * CellHeight + (options.header ? 40 : 0) + (footerEnabled ? FooterHeight : 0),
height: rows * CellHeight + (options.header ? HeaderHeight : 0) + (footerEnabled ? FooterHeight : 0),
}
}
8 changes: 4 additions & 4 deletions packages/drive/src/recording/finalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ export async function finalizeRecording(
if (!(await Bun.file(timeline).exists()))
throw new Error(`OpenCode recording timeline was not created: ${timeline}`)
const { exportRecording } = await import("./export.js")
const { loadAnnotations } = await import("./marks.js")
const { loadKeypresses } = await import("./keypresses.js")
// Marks recorded during the run become footer annotations automatically.
const annotations =
options?.annotations ?? (await (await import("./marks.js")).loadAnnotations(timeline))
const keypresses =
options?.keypresses ?? (await (await import("./keypresses.js")).loadKeypresses(timeline))
const annotations = options?.annotations ?? (await loadAnnotations(timeline))
const keypresses = options?.keypresses ?? (await loadKeypresses(timeline))
await exportRecording(timeline, expected.video, { ...options, annotations, keypresses })
return expected.video
}
5 changes: 5 additions & 0 deletions packages/drive/src/recording/marks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export async function loadAnnotations(timeline: string): Promise<RecordingAnnota
const file = Bun.file(marksPath(timeline))
if (!(await file.exists())) return []
const anchor = (await stat(timeline)).birthtimeMs
// Filesystems without birthtime support report 0 (or garbage), which would
// turn every mark into a raw epoch offset. Fail loudly rather than export
// a video with wildly misplaced labels.
if (!Number.isFinite(anchor) || anchor <= 0)
throw new Error(`Recording marks need filesystem birthtime support (got ${anchor} for ${timeline})`)
const annotations: RecordingAnnotation[] = []
for (const line of (await file.text()).split("\n")) {
if (line.trim() === "") continue
Expand Down
3 changes: 2 additions & 1 deletion packages/drive/src/recording/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export interface RenderFrameOptions {
readonly pointer?: PointerFrame
}

export const HeaderHeight = 40
export const FooterHeight = 40

export function formatTimecode(ms: number) {
Expand All @@ -89,7 +90,7 @@ export function formatTimecode(ms: number) {
export function renderFrame(frame: CapturedFrame | Frontend.CapturedFrame, options: RenderFrameOptions = {}): Buffer {
const cols = Math.max(frame.cols, options.cols ?? frame.cols)
const rows = Math.max(frame.rows, options.rows ?? frame.rows)
const headerHeight = options.header ? 40 : 0
const headerHeight = options.header ? HeaderHeight : 0
const footerHeight = options.footer ? FooterHeight : 0
const canvas = createCanvas(cols * CellWidth, rows * CellHeight + headerHeight + footerHeight)
const context = canvas.getContext("2d")
Expand Down
18 changes: 18 additions & 0 deletions packages/drive/test/manual/tui-regressions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,24 @@ transition is also streamed to stderr as it runs, so a failed seed keeps its
trace even when the failure lands in the terminal verify phase (which writes
its own `state-machine-failure.json`).

## Transcript padding gauntlets

`padding-gauntlet.ts` samples the seeded network-chaos flow and records each
distinct transcript block whose top or bottom padding disappears. Use
`padding-tools.ts` for a denser controlled-shell workload with streamed
progress. Both write the offending frame JSON and a text rendering to
`PADDING_GAUNTLET_OUT` (or the retained run artifacts by default):

```sh
OPENCODE_DRIVE_SEED=42 OPENCODE_DRIVE_STEPS=24 \
bun run --cwd packages/drive drive start --name padding-gauntlet \
--script test/manual/tui-regressions/padding-gauntlet.ts --dev "$OPENCODE_DEV"

OPENCODE_DRIVE_SEED=42 OPENCODE_DRIVE_STEPS=12 \
bun run --cwd packages/drive drive start --name padding-tools \
--script test/manual/tui-regressions/padding-tools.ts --dev "$OPENCODE_DEV"
```

## Type-during-submit input destruction

Seeds 1, 7, and 99 of `network-properties.ts` all reduced to one TUI bug: the
Expand Down
43 changes: 43 additions & 0 deletions packages/drive/test/manual/tui-regressions/padding-gauntlet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Effect, Fiber, Schedule } from "effect"
import { defineScript } from "../../../src/index.js"
import gauntlet from "./network-properties.js"
import { frameText, transcriptPaddingFindings } from "./padding-support.js"

// Runs the network gauntlet while sampling raw terminal frames and flagging
// message blocks whose padding rows went missing or whose rows lost the block
// background mid-paint. Offending frames are saved next to the run artifacts.
//
// OPENCODE_DRIVE_SEED=42 OPENCODE_DRIVE_STEPS=24 \
// bun run --cwd packages/drive drive start --name padding-gauntlet \
// --script test/manual/tui-regressions/padding-gauntlet.ts --dev "$OPENCODE_DEV"

export default defineScript({
...gauntlet,
config: { ...gauntlet.config, autoupdate: false },
tuiConfig: { ...gauntlet.tuiConfig, session: { tps: true } },
tui: { ...gauntlet.tui, viewport: { cols: 130, rows: 40 } },
run: (caps) =>
Effect.gen(function* () {
const seen = new Set<string>()
let samples = 0
const sampler = yield* Effect.gen(function* () {
const frame = yield* caps.ui.capture().pipe(Effect.timeout(2_000), Effect.option)
if (frame._tag === "None") return
samples += 1
const issues = transcriptPaddingFindings(frame.value)
if (issues.length === 0) return
const key = issues.join("\n")
if (seen.has(key)) return
seen.add(key)
const name = `padding-${String(seen.size).padStart(3, "0")}`
const out = process.env.PADDING_GAUNTLET_OUT ?? caps.artifacts
yield* Effect.promise(() =>
Bun.write(`${out}/${name}.frame.json`, JSON.stringify({ issues, frame: frame.value }, null, 2)),
)
yield* Effect.promise(() => Bun.write(`${out}/${name}.txt`, frame.value.lines.map(frameText).join("\n")))
console.error(`[padding] ${name}: ${issues.join(" | ")}`)
}).pipe(Effect.repeat(Schedule.spaced(80)), Effect.forkScoped)
yield* gauntlet.run(caps).pipe(Effect.ensuring(Fiber.interrupt(sampler)))
console.log(JSON.stringify({ paddingSamples: samples, paddingFindings: seen.size }))
}).pipe(Effect.scoped),
})
46 changes: 46 additions & 0 deletions packages/drive/test/manual/tui-regressions/padding-support.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Frontend } from "../../../src/simulation/protocol.js"

type CapturedFrame = Frontend.CapturedFrame
type Line = CapturedFrame["lines"][number]

export const frameText = (line: Line) => line.spans.map((span) => span.text).join("")

// A transcript block row has its left border glyph in the first five columns.
const borderColumn = (line: Line) => {
const index = frameText(line).slice(0, 5).search(/[┃│]/)
return index === -1 ? undefined : index
}

// The composer is the last bordered block and legitimately ends on its
// agent/model row, so only transcript blocks above it are checked.
export function transcriptPaddingFindings(frame: CapturedFrame) {
const bordered = frame.lines.map((line) => borderColumn(line) !== undefined)
let end = bordered.length - 1
while (end >= 0 && !bordered[end]) end -= 1
while (end >= 0 && bordered[end]) end -= 1
const rows = frame.lines.slice(0, end + 1)
const issues: string[] = []
let start: number | undefined
// Toasts render right-aligned over the transcript; only the block's own left region counts.
const inner = (line: Line) =>
frameText(line)
.slice((borderColumn(line) ?? 0) + 1, Math.max(20, frame.cols - 50))
.trim()
const flush = (last: number) => {
if (start === undefined) return
if (start > 1 && inner(rows[start]!) !== "")
issues.push(`block ${start}-${last}: first bordered row has text: ${JSON.stringify(inner(rows[start]!))}`)
if (inner(rows[last]!) !== "")
issues.push(`block ${start}-${last}: last bordered row has text: ${JSON.stringify(inner(rows[last]!))}`)
start = undefined
}
rows.forEach((line, index) => {
if (borderColumn(line) !== undefined) {
if (start === undefined) start = index
return
}
flush(index - 1)
})
flush(rows.length - 1)
return issues
}
95 changes: 95 additions & 0 deletions packages/drive/test/manual/tui-regressions/padding-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Effect, Fiber, Random, Schedule, Stream } from "effect"
import { defineScript, Llm } from "../../../src/index.js"
import { frameText, transcriptPaddingFindings } from "./padding-support.js"

// Tool-heavy padding probe: every prompt runs a controlled shell call with
// streamed progress before a paced text reply, under latency windows and
// connection kills. Frames are sampled throughout; any transcript block that
// lost its padding row is saved to PADDING_GAUNTLET_OUT.
//
// OPENCODE_DRIVE_SEED=42 OPENCODE_DRIVE_STEPS=12 \
// bun run --cwd packages/drive drive start --name padding-tools \
// --script test/manual/tui-regressions/padding-tools.ts --dev "$OPENCODE_DEV"

const seed = Number(process.env.OPENCODE_DRIVE_SEED ?? 1)
const steps = Number(process.env.OPENCODE_DRIVE_STEPS ?? 12)
const command =
"xcodebuild test -project OpenCode.xcodeproj -scheme OpenCode -destination 'platform=iOS Simulator,id=8551E9D9-7768-4AFF-BC5D-7EFFF9FC5662' -derivedDataPath /tmp/dd"

export default defineScript({
network: true,
tools: ["shell"],
llm: { settlementTimeout: 120_000 },
config: { autoupdate: false, username: "Drive" },
tuiConfig: { session: { tps: true } },
tui: { viewport: { cols: 130, rows: 40 } },
run: ({ ui, llm, network, tools, artifacts }) =>
Effect.gen(function* () {
const out = process.env.PADDING_GAUNTLET_OUT ?? artifacts
const seen = new Set<string>()
let samples = 0
const sampler = yield* Effect.gen(function* () {
const frame = yield* ui.capture().pipe(Effect.timeout(2_000), Effect.option)
if (frame._tag === "None") return
samples += 1
const issues = transcriptPaddingFindings(frame.value)
if (issues.length === 0) return
const key = issues.join("\n")
if (seen.has(key)) return
seen.add(key)
const name = `tools-${seed}-${String(seen.size).padStart(3, "0")}`
yield* Effect.promise(() =>
Bun.write(`${out}/${name}.frame.json`, JSON.stringify({ issues, frame: frame.value }, null, 2)),
)
yield* Effect.promise(() => Bun.write(`${out}/${name}.txt`, frame.value.lines.map(frameText).join("\n")))
console.error(`[padding] ${name}: ${issues.join(" | ")}`)
}).pipe(Effect.repeat(Schedule.spaced(80)), Effect.forkScoped)

const shells = yield* tools.control("shell")
yield* llm.serve((request, index) => {
const body = JSON.stringify(request)
const marker = `T${index}X`
// The body carries the whole conversation: reply with text only when the
// newest prompt already has its shell result behind it.
const toolResultIsNewest = body.lastIndexOf("Finished") > body.lastIndexOf("run the simulator tests")
return toolResultIsNewest
? Stream.make(
Llm.text(`${marker}_WORKING `),
Llm.pause(600),
Llm.text("summarising the simulator run "),
Llm.pause(600),
Llm.text(`${marker}_DONE`),
)
: Stream.make(
Llm.toolCall({ index: 0, id: `call_shell_${index}`, name: "shell", input: { command } }),
Llm.finish("tool-calls"),
)
})

const run = Effect.fn("PaddingTools.step")(function* (step: number) {
const marker = `P${step}X`
const latency = yield* Random.nextIntBetween(0, 500)
if (latency > 150) yield* network.set({ latencyMs: latency, jitterMs: 150 })
yield* ui.submit(`${marker} run the simulator tests`)
const shell = yield* shells.take()
const lines = yield* Random.nextIntBetween(3, 12)
for (let i = 0; i < lines; i++) {
yield* shell.progress(
Array.from({ length: i + 1 }, (_, n) => `[2026-09-16 17:52:${String(n).padStart(2, "0")} +0000] Status=4294967295, isTerminal=YES`).join("\n") + "\n",
)
yield* Effect.sleep(120)
}
if ((yield* Random.nextBoolean) && step > 0) yield* network.killConnections()
yield* shell.succeed({ output: "Finished\n", exit: 0 })
yield* network.clear()
yield* ui.waitFor("_DONE", { timeout: 60_000 })
yield* Effect.sleep(yield* Random.nextIntBetween(200, 900))
})

yield* Effect.forEach(Array.from({ length: steps }, (_, step) => step), run).pipe(
Random.withSeed(seed),
Effect.ensuring(Fiber.interrupt(sampler)),
)
console.log(JSON.stringify({ seed, steps, paddingSamples: samples, paddingFindings: seen.size }))
}).pipe(Effect.scoped),
})
1 change: 1 addition & 0 deletions packages/drive/test/recording/edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,5 +96,6 @@ describe("applyClips", () => {
expect(() => applyClips(samples, [])).toThrow("clips must not be empty")
expect(() => applyClips(samples, [{ fromMs: 100, toMs: 0 }])).toThrow("fromMs <= toMs")
expect(() => applyClips(samples, [{ fromMs: 0, toMs: 1, speed: 0 }])).toThrow("positive finite")
expect(() => applyClips(samples, [{ fromMs: 0, toMs: 1, holdMs: -1 }])).toThrow("non-negative finite")
})
})
Loading