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:
parent
f93e92646c
commit
2801c6b03b
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
)
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue