P2 live log undo memory (#8644)

* fix(editor): avoid undo history for read-only live tails

* Add reliability-gate evidence for read-only live-tail undo-history fix

- Records the passing vitest run that verifies live-tail appends leave
  canUndo false while ordinary external updates stay undoable, backing
  the recent editor undo-history fix.
This commit is contained in:
Jinjing 2026-07-13 19:13:19 -07:00 committed by GitHub
parent f93e92646c
commit 2801c6b03b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 332 additions and 55 deletions

View File

@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-07-12",
"updatedAt": "2026-07-13",
"policy": {
"maturityLevels": [
"experimental",
@ -172,20 +172,24 @@
"providers": ["local"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local"],
"coverageNotes": "Focused tests and real-Monaco 9/50 MiB benchmarks are platform-independent. Local macOS Electron evidence opens a synthetic 9 MiB transcript through Agent Session History at fixed 900x720 viewport, 13px font, 1x zoom, and asserts the full 9 MiB model length loaded as a font-metric-independent containment check (word-wrap pixel geometry varies ~10% across runners, so a generous content-height floor is only a collapsed/truncated-render smoke check), then verifies three five-second-cadence watcher appends with Find open and closed. Live Windows/Linux evidence remains uncollected.",
"coverageNotes": "Focused tests and real-Monaco 9/50 MiB performance and undo-retention benchmarks are platform-independent. Local macOS Electron evidence opens a synthetic 9 MiB transcript through Agent Session History at fixed 900x720 viewport, 13px font, 1x zoom, and asserts the full 9 MiB model length loaded as a font-metric-independent containment check (word-wrap pixel geometry varies ~10% across runners, so a generous content-height floor is only a collapsed/truncated-render smoke check), then verifies three five-second-cadence watcher appends with Find open and closed. Live Windows/Linux evidence remains uncollected.",
"motivatingLinks": ["https://github.com/stablyai/orca/pull/8432"],
"invariant": "Append-only external file growth changes only Monaco's model suffix, retaining the viewport, selection, Find state, and renderer liveness above the append point; arbitrary rewrites continue to replace the model content.",
"oracle": "Focused tests assert one post-mount content owner, actual outer lifecycle remount ordering across retained path models, exact end-of-model suffix edits with one model read, no-op equality, and full replacement for non-appends. With Node forced GC, real-Monaco benchmarks alternate 30 suffix and 30 replacement samples after five warmups on fresh equivalent models at 9 and 50 MiB, forcing GC and event-loop settlement between every arm. The Electron scenario alternates an e2e-only legacy setValue red control and the fixed watcher append from restored equivalent model/geometry at the measured legacy-failure cadence, asserting that the control disrupts anchor state while the fixed path preserves visible ranges, selection, complete Find state, scroll offset, renderer survival, and forced-GC heap/native-memory budgets.",
"invariant": "Append-only external file growth changes only Monaco's model suffix, retaining the viewport, selection, Find state, and renderer liveness above the append point; read-only live tails do not create undo history, while editable external updates remain undoable and arbitrary rewrites continue to replace the model content.",
"oracle": "Focused tests assert one post-mount content owner, actual outer lifecycle remount ordering across retained path models, exact end-of-model suffix edits with one model read, no-op equality, full replacement for non-appends, and real-Monaco undo behavior for read-only live tails versus editable files. With Node forced GC, real-Monaco benchmarks alternate 30 suffix and 30 replacement samples after five warmups on fresh equivalent models at 9 and 50 MiB, then compare exact Monaco undo-service and ArrayBuffer retention after five 10 MiB appends. The Electron scenario alternates an e2e-only legacy setValue red control and the fixed watcher append from restored equivalent model/geometry at the measured legacy-failure cadence, asserting that the control disrupts anchor state while the fixed path preserves visible ranges, selection, complete Find state, scroll offset, non-undoability, renderer survival, and forced-GC heap/native-memory budgets.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.test.ts src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts",
"node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.bench.ts --pool=threads",
"node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts --pool=threads",
"pnpm run test:e2e -- tests/e2e/agent-session-log-tail-stability.spec.ts --workers=1"
],
"testFiles": [
"src/renderer/src/components/editor/monaco-content-sync.test.ts",
"src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts",
"src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx",
"src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx",
"src/renderer/src/components/editor/monaco-content-sync.bench.ts",
"src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts",
"tests/e2e/agent-session-log-tail-stability.spec.ts"
],
"assertionRefs": [
@ -194,9 +198,14 @@
"assertions": [
"append-only drift reads the current model once and inserts only at the previous model end",
"identical content emits no edit and non-append drift retains full replacement plus undo stops",
"read-only live-tail appends, replacements, truncations, and stale retained-model remounts use non-undoing edits",
"a stale retained target model reconciles on mount without explicit undo stops while prior-path content and undo history remain isolated"
]
},
{
"file": "src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts",
"assertions": ["a real Monaco read-only live-tail append leaves canUndo false while an ordinary external update remains undoable"]
},
{
"file": "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx",
"assertions": ["the Monaco wrapper receives defaultValue and no controlled value prop"]
@ -209,12 +218,17 @@
"file": "src/renderer/src/components/editor/monaco-content-sync.bench.ts",
"assertions": ["with forced GC and deterministic settlement between every arm, fresh real-Monaco 9 MiB and 50 MiB models alternate 30 append and 30 replacement samples after five warmups; append p95 stays below 50/100ms and at least 2x faster"]
},
{
"file": "src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts",
"assertions": ["five 10 MiB read-only live-tail appends retain zero Monaco undo-service and ArrayBuffer bytes while the undoable control retains at least 50 MiB"]
},
{
"file": "tests/e2e/agent-session-log-tail-stability.spec.ts",
"assertions": [
"production Agent Session History opens a synthetic 9 MiB View Log and confirms the full model length loaded as font-metric-independent containment, with a generous content-height floor as a collapsed/truncated-render smoke check",
"an executable e2e-only legacy setValue control disrupts selection/Find/anchor state at each fixed-geometry five-second sample, then restores the equivalent model state before the fixed arm",
"three alternating watcher suffix appends preserve visible ranges, selection, scroll offset, Find open/query/active-match state, and exact suffix content",
"the production read-only live-tail model remains non-undoable before and after every watcher append",
"the renderer remains responsive with no render-process-gone event and forced-GC JS-heap/working-set/private-memory peak and retained budgets hold against paired legacy controls"
]
}
@ -246,12 +260,39 @@
"result": "passed",
"durationSeconds": 78,
"summary": "The production View Log journey alternated retained e2e-only legacy-red controls with fixed appends from restored equivalent state; every control detected instability while the fixed path retained viewport, selection, complete Find state, renderer liveness, and normalized forced-GC/native memory budgets."
},
{
"date": "2026-07-13",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts",
"result": "passed",
"durationSeconds": 4,
"summary": "The real-Monaco undo-history test confirmed a read-only live-tail append leaves canUndo false while an ordinary external update remains undoable."
},
{
"date": "2026-07-13",
"runner": "local",
"platform": "macos",
"command": "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts --pool=threads",
"result": "passed",
"durationSeconds": 5,
"summary": "The undoable 50 MiB control retained 104,858,630 undo-service bytes and 104,857,790 ArrayBuffer bytes; the read-only live-tail arm retained zero of both and remained non-undoable."
},
{
"date": "2026-07-13",
"runner": "local",
"platform": "macos",
"command": "pnpm run test:e2e -- tests/e2e/agent-session-log-tail-stability.spec.ts --workers=1",
"result": "passed",
"durationSeconds": 78,
"summary": "The production View Log journey preserved viewport, selection, Find state, renderer liveness, and forced-GC/native budgets across three watcher appends while canUndo remained false."
}
],
"runtimeBudget": { "p95Seconds": 600, "scope": "local focused renderer tests plus one Electron production-journey scenario" },
"flakeHistory": { "status": "unknown", "evidence": "New deterministic gate with local macOS passes; CI soak history is not yet available." },
"redGreenEvidence": { "status": "complete", "evidence": "The retained Electron gate itself executes the former read-only wrapper setValue behavior behind MODE=e2e at fixed 900x720, 13px, 1x zoom, the full 9 MiB model length loaded (with a generous content-height floor, since word-wrap pixel geometry is runner-dependent), and five-second cadence. The control retains a flat incoming value like the former controlled IPC prop. Each legacy arm must disrupt selection, Find active-match state, visible range, or scroll anchor, then restore identical model length/tail/geometry and anchor state before the fixed watcher arm; every fixed arm must preserve them. Production builds never install the control. The original exact-file dev repro also recorded renderer exit code 5." },
"performanceBudget": { "required": true, "evidence": "Every update retrieves the model value once and performs at most one equality-or-prefix comparison; a matching append submits only the suffix. The registered Node command requires --expose-gc and --pool=threads so worker GC is available; alternating fresh real-Monaco arms force GC and deterministic event-loop settlement after every operation. Observed p95 ranges: 9 MiB append 4.02-5.51ms versus replacement 81.42-83.76ms; 50 MiB append 22.72-26.60ms versus replacement 445.11-449.21ms. Electron alternates each legacy replacement control and fixed watcher suffix from equivalent restored model state/geometry, samples forced-GC JS heap plus app.getAppMetrics renderer working set and OS private memory before/after/settled, asserts retained memory within max(20MiB,10%), and requires suffix peak deltas for jsHeapMb, workingSetMb, and privateMb not exceed the paired legacy controls." },
"redGreenEvidence": { "status": "complete", "evidence": "A fail-first real-Monaco test observed canUndo=true after one read-only live-tail append, and the forced-GC 50 MiB control retained 104,858,630 bytes in Monaco's undo service. After the fix the read-only arm retained zero undo-service bytes while the editable control stayed undoable. The retained Electron gate also proves each fixed watcher arm preserves viewport, selection, Find state, and non-undoability. Production builds never install its legacy setValue control." },
"performanceBudget": { "required": true, "evidence": "Every update retrieves the model value once and performs at most one equality-or-prefix comparison; a matching append submits only the suffix. The registered Node commands require --expose-gc and --pool=threads so worker GC is available. Current p95: 9 MiB append 5.93-7.12ms versus replacement 114.09-145.89ms; 50 MiB append 26.84-34.91ms versus replacement 602.36-699.21ms. The new 50 MiB retention arm measured 104,858,630 undo-service bytes and 104,857,790 ArrayBuffer bytes for the undoable control versus zero for read-only live-tail sync. Electron forced-GC JS-heap, renderer working-set, and OS-private-memory budgets also pass." },
"promotionCriteria": [
"Collect stable soak history on macOS, Linux, and Windows.",
"Accumulate 100 consecutive deterministic gate passes or 14 days without unexplained flakes."

View File

@ -5,7 +5,8 @@ import type { OpenFile } from '@/store/slices/editor'
const lifecycle = vi.hoisted(() => ({
events: [] as string[],
models: new Map<string, { content: string; undo: string[] }>()
models: new Map<string, { content: string; undo: string[] }>(),
mountedProps: [] as { filePath: string; readOnly?: boolean; liveTail?: boolean }[]
}))
vi.mock('@/lib/lazy-with-retry', async () => {
@ -16,10 +17,20 @@ vi.mock('@/lib/lazy-with-retry', async () => {
if (!factory.toString().includes('/MonacoEditor.tsx')) {
return () => null
}
return function MockRetainedMonaco(props: { filePath: string; content: string }) {
return function MockRetainedMonaco(props: {
filePath: string
content: string
readOnly?: boolean
liveTail?: boolean
}) {
/* oxlint-disable react-hooks/exhaustive-deps -- Mount-only by design: a prop-effect would hide a missing outer React remount. */
React.useEffect(() => {
lifecycle.events.push(`mount:${props.filePath}`)
lifecycle.mountedProps.push({
filePath: props.filePath,
readOnly: props.readOnly,
liveTail: props.liveTail
})
const retained = lifecycle.models.get(props.filePath) ?? { content: '', undo: [] }
const model = {
getValue: () => retained.content,
@ -93,7 +104,7 @@ vi.mock('@/store', () => ({
import { EditorContent } from './EditorContent'
function file(filePath: string): OpenFile {
function file(filePath: string, overrides: Partial<OpenFile> = {}): OpenFile {
return {
id: filePath,
filePath,
@ -101,7 +112,8 @@ function file(filePath: string): OpenFile {
worktreeId: 'repo::/repo',
language: 'typescript',
isDirty: false,
mode: 'edit'
mode: 'edit',
...overrides
}
}
@ -136,6 +148,7 @@ afterEach(() => {
cleanup()
lifecycle.events.length = 0
lifecycle.models.clear()
lifecycle.mountedProps.length = 0
})
describe('EditorContent Monaco lifecycle boundary', () => {
@ -162,4 +175,14 @@ describe('EditorContent Monaco lifecycle boundary', () => {
undo: ['first undo']
})
})
it('passes live-tail ownership only for a read-only live log', () => {
const liveLog = file('/repo/session.jsonl', { readOnly: true, liveTail: true })
render(<EditorContent {...props(liveLog, 'session content')} />)
expect(lifecycle.mountedProps).toEqual([
{ filePath: liveLog.filePath, readOnly: true, liveTail: true }
])
})
})

View File

@ -327,6 +327,7 @@ export function EditorContent({
// the change/save callbacks so no draft, dirty state, or write can occur —
// mirrors the conflict-review read-only rendering pattern.
readOnly={activeFile.readOnly === true}
liveTail={activeFile.liveTail === true}
onContentChange={activeFile.readOnly === true ? noopEditorContentChange : handleContentChange}
onSave={activeFile.readOnly === true ? noopEditorSave : isMarkdown ? md.mdSave : handleSave}
worktreeId={activeFile.worktreeId}

View File

@ -15,7 +15,11 @@ import { registerFileSearchSelectedTextProvider } from '@/lib/file-search-select
import { useContextualCopySetup } from './useContextualCopySetup'
import { MAX_REVEAL_CONTENT_WAIT_FRAMES, performReveal } from './monaco-reveal'
import { syncContentOnMount, syncContentUpdate } from './monaco-content-sync'
import {
syncContentOnMount,
syncContentUpdate,
type MonacoContentSyncMode
} from './monaco-content-sync'
import { getMonacoCodebaseSearchQuery } from './monaco-codebase-search'
import {
beginProgrammaticContentSync,
@ -77,6 +81,7 @@ type MonacoEditorProps = {
markdownAnnotationsEnabled?: boolean
conflictDecorationsEnabled?: boolean
readOnly?: boolean
liveTail?: boolean
autoHeight?: boolean
}
@ -101,6 +106,7 @@ export default function MonacoEditor({
markdownAnnotationsEnabled = false,
conflictDecorationsEnabled = false,
readOnly = false,
liveTail = false,
autoHeight = false
}: MonacoEditorProps): React.JSX.Element {
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
@ -131,6 +137,8 @@ export default function MonacoEditor({
propsRef.current = { relativePath, language, onSave, onContentChange }
const readOnlyRef = useRef(readOnly)
readOnlyRef.current = readOnly
const contentSyncModeRef = useRef<MonacoContentSyncMode>('undoable')
contentSyncModeRef.current = readOnly && liveTail ? 'read-only-live-tail' : 'undoable'
const settings = useAppStore((s) => s.settings)
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
@ -370,7 +378,11 @@ export default function MonacoEditor({
beginProgrammaticContentSync(filePath)
isApplyingProgrammaticContentRef.current = true
try {
const didSyncOnMount = syncContentOnMount(editorInstance, contentRef.current)
const didSyncOnMount = syncContentOnMount(
editorInstance,
contentRef.current,
contentSyncModeRef.current
)
if (didSyncOnMount) {
lastSyncedContentRef.current = contentRef.current
}
@ -672,7 +684,7 @@ export default function MonacoEditor({
beginProgrammaticContentSync(filePath)
isApplyingProgrammaticContentRef.current = true
try {
syncContentUpdate(ed, content)
syncContentUpdate(ed, content, contentSyncModeRef.current)
lastSyncedContentRef.current = content
} finally {
isApplyingProgrammaticContentRef.current = false

View File

@ -9,6 +9,7 @@ function createHarness(
editorInstance: editor.IStandaloneCodeEditor
getValue: ReturnType<typeof vi.fn>
getFullModelRange: ReturnType<typeof vi.fn>
applyEdits: ReturnType<typeof vi.fn>
pushEditOperations: ReturnType<typeof vi.fn>
pushUndoStop: ReturnType<typeof vi.fn>
} {
@ -20,18 +21,27 @@ function createHarness(
endColumn: 5
}))
const pushEditOperations = vi.fn()
const applyEdits = vi.fn()
const model = {
getValue,
getEOL: () => eol,
getFullModelRange,
pushEditOperations
pushEditOperations,
applyEdits
} as unknown as editor.ITextModel
const pushUndoStop = vi.fn()
const editorInstance = {
getModel: () => model,
pushUndoStop
} as unknown as editor.IStandaloneCodeEditor
return { editorInstance, getValue, getFullModelRange, pushEditOperations, pushUndoStop }
return {
editorInstance,
getValue,
getFullModelRange,
applyEdits,
pushEditOperations,
pushUndoStop
}
}
describe('monaco content sync', () => {
@ -60,6 +70,26 @@ describe('monaco content sync', () => {
expect(harness.pushUndoStop).toHaveBeenCalledTimes(2)
})
it('inserts a read-only live-tail suffix without recording undo history', () => {
const harness = createHarness('first\nsecond\nlast')
syncContentUpdate(harness.editorInstance, 'first\nsecond\nlast\nnext', 'read-only-live-tail')
expect(harness.applyEdits).toHaveBeenCalledWith([
{
range: {
startLineNumber: 3,
startColumn: 5,
endLineNumber: 3,
endColumn: 5
},
text: '\nnext'
}
])
expect(harness.pushEditOperations).not.toHaveBeenCalled()
expect(harness.pushUndoStop).not.toHaveBeenCalled()
})
it('does nothing for identical content', () => {
const harness = createHarness('unchanged')
@ -127,6 +157,22 @@ describe('monaco content sync', () => {
expect(harness.pushUndoStop).toHaveBeenCalledTimes(2)
})
it.each([
['same-length rewrite', 'before', 'after!'],
['prefix mismatch', 'before', 'changed content'],
['truncation', 'longer content', 'short']
])('non-undoingly replaces a read-only live-tail %s', (_label, initialContent, nextContent) => {
const harness = createHarness(initialContent)
syncContentUpdate(harness.editorInstance, nextContent, 'read-only-live-tail')
expect(harness.applyEdits).toHaveBeenCalledWith([
{ range: harness.getFullModelRange.mock.results[0]?.value, text: nextContent }
])
expect(harness.pushEditOperations).not.toHaveBeenCalled()
expect(harness.pushUndoStop).not.toHaveBeenCalled()
})
it('reconciles a stale retained model on mount without undo stops', () => {
const harness = createHarness('stale')
@ -136,6 +182,16 @@ describe('monaco content sync', () => {
expect(harness.pushUndoStop).not.toHaveBeenCalled()
})
it('reconciles a stale read-only live-tail model on mount without undo history', () => {
const harness = createHarness('stale')
expect(syncContentOnMount(harness.editorInstance, 'fresh', 'read-only-live-tail')).toBe(true)
expect(harness.applyEdits).toHaveBeenCalledTimes(1)
expect(harness.pushEditOperations).not.toHaveBeenCalled()
expect(harness.pushUndoStop).not.toHaveBeenCalled()
})
it('does nothing on mount when content already matches', () => {
const harness = createHarness('same')

View File

@ -1,5 +1,7 @@
import type { editor } from 'monaco-editor'
export type MonacoContentSyncMode = 'undoable' | 'read-only-live-tail'
function normalizeToModelEol(content: string, model: editor.ITextModel): string {
const eol = model.getEOL()
// Why: Monaco normalizes model line endings, while filesystem content keeps
@ -10,28 +12,41 @@ function normalizeToModelEol(content: string, model: editor.ITextModel): string
return content.replace(/\r\n|\r|\n/g, eol)
}
/**
* Overwrite a Monaco model's text via pushEditOperations so the change
* participates in the undo stack (unlike setValue, which blows it away).
*/
function applyModelEdit(
editorInstance: editor.IStandaloneCodeEditor,
model: editor.ITextModel,
edit: editor.IIdentifiedSingleEditOperation,
mode: MonacoContentSyncMode,
withUndoStops: boolean
): void {
if (mode === 'read-only-live-tail') {
// Why: live-tail updates are machine-owned and cannot be undone by users;
// recording them would retain the growing log again in Monaco's undo service.
model.applyEdits([edit])
return
}
if (withUndoStops) {
editorInstance.pushUndoStop()
}
model.pushEditOperations([], [edit], () => null)
if (withUndoStops) {
editorInstance.pushUndoStop()
}
}
function replaceModelContent(
editorInstance: editor.IStandaloneCodeEditor,
model: editor.ITextModel,
currentContent: string,
content: string,
mode: MonacoContentSyncMode,
withUndoStops: boolean
): void {
if (currentContent === content) {
return
}
const fullRange = model.getFullModelRange()
if (withUndoStops) {
editorInstance.pushUndoStop()
}
model.pushEditOperations([], [{ range: fullRange, text: content }], () => null)
if (withUndoStops) {
editorInstance.pushUndoStop()
}
applyModelEdit(editorInstance, model, { range: fullRange, text: content }, mode, withUndoStops)
}
/**
@ -46,7 +61,8 @@ function replaceModelContent(
*/
export function syncContentOnMount(
editorInstance: editor.IStandaloneCodeEditor,
content: string
content: string,
mode: MonacoContentSyncMode = 'undoable'
): boolean {
const model = editorInstance.getModel()
if (!model) {
@ -60,7 +76,7 @@ export function syncContentOnMount(
// Why: no undo stop on mount — the retained model's text was already the
// user's last-known state, and adding an undo entry here would make Cmd+Z
// revert to the pre-remount text, which is confusing.
replaceModelContent(editorInstance, model, currentContent, normalizedContent, false)
replaceModelContent(editorInstance, model, currentContent, normalizedContent, mode, false)
return true
}
@ -74,7 +90,8 @@ export function syncContentOnMount(
*/
export function syncContentUpdate(
editorInstance: editor.IStandaloneCodeEditor,
content: string
content: string,
mode: MonacoContentSyncMode = 'undoable'
): void {
const model = editorInstance.getModel()
if (!model) {
@ -83,7 +100,7 @@ export function syncContentUpdate(
const currentContent = model.getValue()
const normalizedContent = normalizeToModelEol(content, model)
if (currentContent.length === normalizedContent.length) {
replaceModelContent(editorInstance, model, currentContent, normalizedContent, true)
replaceModelContent(editorInstance, model, currentContent, normalizedContent, mode, true)
return
}
if (
@ -93,24 +110,22 @@ export function syncContentUpdate(
// Why: preserving the existing prefix lets Monaco retain viewport,
// selection, find-widget, and tokenization state above a live-file append.
const fullRange = model.getFullModelRange()
editorInstance.pushUndoStop()
model.pushEditOperations(
[],
[
{
range: {
startLineNumber: fullRange.endLineNumber,
startColumn: fullRange.endColumn,
endLineNumber: fullRange.endLineNumber,
endColumn: fullRange.endColumn
},
text: normalizedContent.slice(currentContent.length)
}
],
() => null
applyModelEdit(
editorInstance,
model,
{
range: {
startLineNumber: fullRange.endLineNumber,
startColumn: fullRange.endColumn,
endLineNumber: fullRange.endLineNumber,
endColumn: fullRange.endColumn
},
text: normalizedContent.slice(currentContent.length)
},
mode,
true
)
editorInstance.pushUndoStop()
return
}
replaceModelContent(editorInstance, model, currentContent, normalizedContent, true)
replaceModelContent(editorInstance, model, currentContent, normalizedContent, mode, true)
}

View File

@ -0,0 +1,51 @@
// @vitest-environment happy-dom
import * as monaco from 'monaco-editor'
import { afterEach, describe, expect, it } from 'vitest'
import { syncContentUpdate } from './monaco-content-sync'
const models: monaco.editor.ITextModel[] = []
function createEditor(initialContent: string): {
editorInstance: monaco.editor.IStandaloneCodeEditor
model: monaco.editor.ITextModel
} {
const model = monaco.editor.createModel(initialContent, 'plaintext')
models.push(model)
return {
model,
editorInstance: {
getModel: () => model,
pushUndoStop: () => {
model.pushStackElement()
return true
}
} as unknown as monaco.editor.IStandaloneCodeEditor
}
}
afterEach(() => {
for (const model of models.splice(0)) {
model.dispose()
}
})
describe('Monaco external-content undo history', () => {
it('does not make a read-only live-tail append undoable', () => {
const { editorInstance, model } = createEditor('first line')
syncContentUpdate(editorInstance, 'first line\nappended', 'read-only-live-tail')
expect(model.getValue()).toBe('first line\nappended')
expect(model.canUndo()).toBe(false)
})
it('keeps ordinary external updates undoable', async () => {
const { editorInstance, model } = createEditor('editable')
syncContentUpdate(editorInstance, 'external update')
expect(model.canUndo()).toBe(true)
await model.undo()
expect(model.getValue()).toBe('editable')
})
})

View File

@ -0,0 +1,78 @@
// @vitest-environment happy-dom
import { bench, expect } from 'vitest'
import * as monaco from 'monaco-editor'
import { syncContentUpdate, type MonacoContentSyncMode } from './monaco-content-sync'
const MEBIBYTE = 1024 * 1024
const BATCH_COUNT = 5
const BATCH_BYTES = 10 * MEBIBYTE
type UndoStackElement = { heapSize: () => number }
type UndoRedoService = {
getElements: (resource: monaco.Uri) => { past: UndoStackElement[]; future: UndoStackElement[] }
}
function undoHistoryBytes(model: monaco.editor.ITextModel): number {
const service = (model as unknown as { _undoRedoService: UndoRedoService })._undoRedoService
const elements = service.getElements(model.uri)
return [...elements.past, ...elements.future].reduce(
(total, element) => total + element.heapSize(),
0
)
}
async function forceGcAndSettle(): Promise<void> {
const gc = (globalThis as { gc?: () => void }).gc
if (!gc) {
throw new Error('Forced GC unavailable; run the benchmark with node --expose-gc')
}
gc()
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
async function measureUndoRetention(mode: MonacoContentSyncMode): Promise<{
arrayBufferDelta: number
canUndo: boolean
undoBytes: number
}> {
await forceGcAndSettle()
const beforeArrayBuffers = process.memoryUsage().arrayBuffers
const model = monaco.editor.createModel('', 'plaintext')
const editorInstance = {
getModel: () => model,
pushUndoStop: () => {
model.pushStackElement()
return true
}
} as unknown as monaco.editor.IStandaloneCodeEditor
let content = ''
for (let batch = 0; batch < BATCH_COUNT; batch++) {
content += String(batch % 10).repeat(BATCH_BYTES)
syncContentUpdate(editorInstance, content, mode)
}
const canUndo = model.canUndo()
const undoBytes = undoHistoryBytes(model)
await forceGcAndSettle()
const arrayBufferDelta = process.memoryUsage().arrayBuffers - beforeArrayBuffers
model.dispose()
await forceGcAndSettle()
return { arrayBufferDelta, canUndo, undoBytes }
}
bench(
'50 MiB read-only live-tail undo retention',
async () => {
const undoable = await measureUndoRetention('undoable')
const readOnlyLiveTail = await measureUndoRetention('read-only-live-tail')
console.log(
`[monaco-content-sync] 50 MiB undo retention ${JSON.stringify({ undoable, readOnlyLiveTail })}`
)
expect(undoable.canUndo).toBe(true)
expect(undoable.undoBytes).toBeGreaterThanOrEqual(BATCH_COUNT * BATCH_BYTES)
expect(undoable.arrayBufferDelta).toBeGreaterThanOrEqual(BATCH_COUNT * BATCH_BYTES)
expect(readOnlyLiveTail.canUndo).toBe(false)
expect(readOnlyLiveTail.undoBytes).toBe(0)
expect(readOnlyLiveTail.arrayBufferDelta).toBe(0)
},
{ iterations: 1, time: 1, warmupIterations: 0, warmupTime: 0 }
)

View File

@ -1,6 +1,7 @@
import type { editor, IRange, ISelection } from 'monaco-editor'
export type MonacoE2ESnapshot = {
canUndo: boolean
contentHeight: number
scrollHeight: number
scrollTop: number
@ -74,6 +75,7 @@ export function installMonacoE2EProbe(
? lastLine
: (model?.getLineContent(lastLineNumber - 1) ?? '')
return {
canUndo: model?.canUndo() ?? false,
contentHeight: editorInstance.getContentHeight(),
scrollHeight: editorInstance.getScrollHeight(),
scrollTop: editorInstance.getScrollTop(),

View File

@ -66,6 +66,7 @@ test.describe('Agent Session History live log', () => {
// metrics (macOS baseline 2,775,880px; Linux CI ~2,498,292px), so keep only a
// generous content-height floor as a collapsed/truncated-render smoke check.
expect(baseline.valueLength).toBe(fixture.initialLength)
expect(baseline.canUndo).toBe(false)
expect(baseline.contentHeight).toBeGreaterThanOrEqual(2_000_000)
expect(baseline.visibleRanges.length).toBeGreaterThan(0)
expect(baseline.find).toMatchObject({ open: true, query: ANCHOR_TOKEN })
@ -105,6 +106,7 @@ test.describe('Agent Session History live log', () => {
const current = await readProbe(orcaPage)
console.log(`[live-log-stability] anchor ${JSON.stringify({ batch, baseline, current })}`)
expect(current.visibleRanges).toEqual(baseline.visibleRanges)
expect(current.canUndo).toBe(false)
expect(current.valueTail).toBe(suffix.trimEnd())
expect(current.selection).toEqual(baseline.selection)
expect(current.find).toEqual(baseline.find)
@ -173,14 +175,9 @@ async function seedSyntheticSession(
}
async function openSessionHistory(page: Page): Promise<void> {
await page.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('Store unavailable')
}
store.getState().setRightSidebarOpen(true)
store.getState().setRightSidebarTab('vault')
})
// Why: startup hydration can overwrite a direct store route; use the same
// activity-bar action a user takes so the Agents panel wins that race.
await page.getByRole('button', { name: 'Agents', exact: true }).click()
await page.evaluate(async () => window.api.aiVault.listSessions({ force: true }))
await page.getByRole('button', { name: 'Refresh Session History' }).click()
}
@ -233,6 +230,7 @@ async function restoreAnchorState(
}
async function readProbe(page: Page): Promise<{
canUndo: boolean
contentHeight: number
filePath: string
scrollTop: number