From 24cf88da97ba61f4a7682ee4b12864146f887870 Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 25 Aug 2026 21:02:43 -0400 Subject: [PATCH 01/12] Restore complete session scrollback before hydration Persist finalized transcript bytes independently of viewport scrolling and stream exact records before semantic hydration. Fall back through one semantic rebuild for missing or incomplete records, fail closed on corrupt committed data, and preserve native history across resize. --- src/core/app/app_bootstrap_runtime.zig | 49 +- src/core/app/app_render_runtime.zig | 4 +- src/core/app/app_session_runtime.zig | 594 +++++++-------- src/core/session/session_log.zig | 90 ++- src/core/session/session_resume_view.zig | 501 ------------- src/core/session/session_store.zig | 24 +- src/core/session/session_transcript.zig | 828 +++++++++++++++++++++ src/core/subagent/resume_admission.zig | 34 +- src/main.zig | 9 +- src/ui/render_engine/transcript_blocks.zig | 72 +- src/ui/transcript/io.zig | 41 + src/ui/transcript/presentation_record.zig | 155 ++++ src/ui/transcript/resume_projection.zig | 57 +- src/ui/transcript/runtime.zig | 663 +++++++++-------- src/ui/transcript/runtime_tests.zig | 49 ++ src/ui/transcript/source_preparation.zig | 12 + src/ui/transcript/store.zig | 21 - tests/e2e/tmux-helpers.ts | 15 + tests/e2e/tui-command-permissions.test.ts | 2 +- tests/e2e/tui-resume-brutal.test.ts | 80 +- tests/e2e/tui-resume.test.ts | 96 ++- 21 files changed, 2120 insertions(+), 1276 deletions(-) delete mode 100644 src/core/session/session_resume_view.zig create mode 100644 src/core/session/session_transcript.zig create mode 100644 src/ui/transcript/presentation_record.zig diff --git a/src/core/app/app_bootstrap_runtime.zig b/src/core/app/app_bootstrap_runtime.zig index 26e3d29374..651db98a3e 100644 --- a/src/core/app/app_bootstrap_runtime.zig +++ b/src/core/app/app_bootstrap_runtime.zig @@ -50,8 +50,7 @@ fn BootstrapDeps(comptime App: type) type { bool, ) anyerror!void; const InitializePersistenceFn = *const fn (*App, bool) anyerror!void; - const StageRequestedResumeViewFn = *const fn (*App) app_session_runtime.ResumeViewStage; - const PublishStagedResumeViewFn = *const fn (*App, u32) anyerror!void; + const StageRequestedSessionTranscriptFn = *const fn (*App) anyerror!void; const LoadSkillsFn = *const fn ( Allocator, []const u8, @@ -63,8 +62,7 @@ fn BootstrapDeps(comptime App: type) type { bootstrap_interactive_app: BootstrapInteractiveAppFn, configure_session_preferences: ConfigureSessionPreferencesFn, initialize_persistence: InitializePersistenceFn, - stage_requested_resume_view: StageRequestedResumeViewFn, - publish_staged_resume_view: PublishStagedResumeViewFn, + stage_requested_session_transcript: StageRequestedSessionTranscriptFn, load_mcp_runtime: mcp_runtime.LoadRuntimeFn, load_skills: LoadSkillsFn, skill_root_policy: skill_contract.RootPolicy, @@ -102,8 +100,7 @@ pub fn Runtime(comptime App: type) type { .bootstrap_interactive_app = bootstrapInteractiveAppDefault, .configure_session_preferences = configureSessionPreferencesDefault, .initialize_persistence = initializePersistenceDefault, - .stage_requested_resume_view = stageRequestedResumeViewDefault, - .publish_staged_resume_view = publishStagedResumeViewDefault, + .stage_requested_session_transcript = stageRequestedSessionTranscriptDefault, .load_mcp_runtime = capability_providers.load_mcp_runtime, .load_skills = app_runtime_setup.loadSkills, .skill_root_policy = capability_providers.skill_root_policy, @@ -128,12 +125,8 @@ pub fn Runtime(comptime App: type) type { ); } - fn stageRequestedResumeViewDefault(app: *App) app_session_runtime.ResumeViewStage { - return app_session_runtime.Runtime(App).stageRequestedResumeView(app); - } - - fn publishStagedResumeViewDefault(app: *App, entry_id: u32) !void { - try app_session_runtime.Runtime(App).publishStagedResumeView(app, entry_id); + fn stageRequestedSessionTranscriptDefault(app: *App) !void { + try app_session_runtime.Runtime(App).stageRequestedSessionTranscript(app); } fn configureSessionPreferencesDefault( @@ -317,10 +310,9 @@ pub fn Runtime(comptime App: type) type { app, app.requested_resume != null, ); - const staged_resume_view = if (app.requested_resume != null) - deps.stage_requested_resume_view(app) - else - app_session_runtime.ResumeViewStage.none; + if (app.requested_resume != null) { + try deps.stage_requested_session_transcript(app); + } const profile_mcp = try deps.load_mcp_runtime( app.alloc, app.workspace_root, @@ -466,10 +458,6 @@ pub fn Runtime(comptime App: type) type { app_session_runtime.Runtime(App).syncTerminalTitleWith(app, deps.terminal_title); } - switch (staged_resume_view) { - .none => {}, - .ready => |entry_id| try deps.publish_staged_resume_view(app, entry_id), - } app.shell.render_requests.request(.first_frame); } }; @@ -643,8 +631,7 @@ fn testDeps() BootstrapDeps(TestApp) { .bootstrap_interactive_app = bootstrapInteractiveAppForTest, .configure_session_preferences = configureSessionPreferencesForTest, .initialize_persistence = initializePersistenceForTest, - .stage_requested_resume_view = stageRequestedResumeViewForTest, - .publish_staged_resume_view = publishStagedResumeViewForTest, + .stage_requested_session_transcript = stageRequestedSessionTranscriptForTest, .load_mcp_runtime = loadMcpRuntimeForTest, .load_skills = loadSkillsForTest, .skill_root_policy = .{ @@ -739,14 +726,8 @@ fn initializePersistenceForTest( active_capture.?.initialize_required = required; } -fn stageRequestedResumeViewForTest(_: *TestApp) app_session_runtime.ResumeViewStage { - active_capture.?.recordEvent("resume_view_stage"); - return .{ .ready = 1 }; -} - -fn publishStagedResumeViewForTest(_: *TestApp, entry_id: u32) !void { - try std.testing.expectEqual(@as(u32, 1), entry_id); - active_capture.?.recordEvent("resume_view_publish"); +fn stageRequestedSessionTranscriptForTest(_: *TestApp) !void { + active_capture.?.recordEvent("session_transcript_stage"); } fn loadMcpRuntimeForTest(_: Allocator, _: []const u8, _: @import("../mcp/elicitation.zig").Capabilities) !?*mcp_runtime.McpRuntime { @@ -987,13 +968,12 @@ test "app_bootstrap_runtime stages requested sessions with the first frame pendi try std.testing.expectEqualStrings("", app.transcript.items); try std.testing.expect(!app.transcript_recorded); const events = capture.eventSlice(); - try std.testing.expectEqualStrings("resume_view_stage", events[0]); + try std.testing.expectEqualStrings("session_transcript_stage", events[0]); try std.testing.expectEqualStrings("load_mcp", events[1]); try std.testing.expectEqualStrings("load_skills", events[2]); - try std.testing.expectEqualStrings("resume_view_publish", events[3]); } -test "app_bootstrap_runtime publishes a staged resume view after startup notices" { +test "app_bootstrap_runtime streams the session transcript before startup notices" { const alloc = std.testing.allocator; var capture = TestCapture.init(alloc); capture.emit_skill_diagnostic = true; @@ -1006,12 +986,11 @@ test "app_bootstrap_runtime publishes a staged resume view after startup notices try std.testing.expectEqualSlices( []const u8, &.{ - "resume_view_stage", + "session_transcript_stage", "load_mcp", "load_skills", "welcome", "welcome", - "resume_view_publish", }, capture.eventSlice(), ); diff --git a/src/core/app/app_render_runtime.zig b/src/core/app/app_render_runtime.zig index 1a61b0bf1d..8ffb55b004 100644 --- a/src/core/app/app_render_runtime.zig +++ b/src/core/app/app_render_runtime.zig @@ -1577,8 +1577,8 @@ pub fn Runtime(comptime App: type) type { render_requests.animation_next_deadline_ms, }, ); - if (comptime @hasDecl(App, "persistResumeViewAfterFrame")) { - app.persistResumeViewAfterFrame(); + if (comptime @hasDecl(App, "persistSessionTranscriptAfterFrame")) { + app.persistSessionTranscriptAfterFrame(); } } diff --git a/src/core/app/app_session_runtime.zig b/src/core/app/app_session_runtime.zig index 5ef57d462b..4ea47b2213 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -36,7 +36,7 @@ const captured_command = @import("../tooling/captured_command.zig"); const tool_result_errors = @import("../tooling/tool_result_errors.zig"); const session_display_metadata = @import("../session/session_display_metadata.zig"); const session_log = @import("../session/session_log.zig"); -const session_resume_view = @import("../session/session_resume_view.zig"); +const session_transcript = @import("../session/session_transcript.zig"); const session_store = @import("../session/session_store.zig"); const session_summary_codec = @import("../session/session_summary_codec.zig"); const subagent_tool_host = @import("../subagent/tool_host.zig"); @@ -55,6 +55,104 @@ const ui_render = @import("../../ui/render.zig"); const Allocator = std.mem.Allocator; +const resume_stream_autowrap_enable = "\x1b[?7h"; +const resume_stream_autowrap_disable = "\x1b[?7l"; +const resume_stream_read_bytes: usize = 64 * 1024; +const resume_stream_first_read_bytes: usize = 1024; + +fn streamSessionTranscript(app: anytype, reader: anytype) !u64 { + try app.shell.writeResumeStreamBytes( + &app.metrics, + resume_stream_autowrap_enable, + ); + const payload_result = streamSessionTranscriptPayload(app, reader); + if (payload_result) |payload_bytes| { + try app.shell.writeResumeStreamBytes( + &app.metrics, + resume_stream_autowrap_disable, + ); + app.shell.adoptStreamedSessionHistory(); + return payload_bytes; + } else |payload_err| { + app.shell.writeResumeStreamBytes( + &app.metrics, + resume_stream_autowrap_disable, + ) catch |restore_err| { + debug_trace.logf( + "session", + "event=session_transcript_stream outcome=failed stage=restore payload_err={s} restore_err={s}", + .{ @errorName(payload_err), @errorName(restore_err) }, + ); + return error.TerminalModeRestoreFailed; + }; + return payload_err; + } +} + +fn streamSessionTranscriptPayload(app: anytype, reader: anytype) !u64 { + var storage: [resume_stream_read_bytes + session_transcript.max_control_sequence_bytes]u8 = undefined; + var carry_len: usize = 0; + var read_offset: u64 = 0; + var emitted_bytes: u64 = 0; + var last_emitted: ?u8 = null; + while (true) { + const read_capacity = if (read_offset == 0 and carry_len == 0) + resume_stream_first_read_bytes + else + resume_stream_read_bytes; + const read = try reader.readAt( + storage[carry_len .. carry_len + read_capacity], + read_offset, + ); + read_offset = std.math.add(u64, read_offset, @intCast(read)) catch + return error.TranscriptTooLarge; + const total = carry_len + read; + const eof = read == 0; + const safe_len = try session_transcript.validatedPrefix(storage[0..total], eof); + if (safe_len > 0) { + try app.shell.writeResumeStreamBytes( + &app.metrics, + storage[0..safe_len], + ); + if (emitted_bytes == 0) { + debug_trace.logf( + "session", + "event=session_transcript_stream outcome=first_payload bytes={d}", + .{safe_len}, + ); + } + emitted_bytes = std.math.add(u64, emitted_bytes, @intCast(safe_len)) catch + return error.TranscriptTooLarge; + last_emitted = storage[safe_len - 1]; + } + const remainder = total - safe_len; + if (remainder > session_transcript.max_control_sequence_bytes) { + return error.InvalidTranscriptGrammar; + } + if (remainder > 0) { + std.mem.copyForwards(u8, storage[0..remainder], storage[safe_len..total]); + } + carry_len = remainder; + if (eof) break; + } + if (last_emitted != '\n' and last_emitted != '\r') { + try app.shell.writeResumeStreamBytes(&app.metrics, "\r\n"); + } + return emitted_bytes; +} + +const MemoryTranscriptReader = struct { + bytes: []const u8, + + fn readAt(self: MemoryTranscriptReader, buffer: []u8, offset: u64) !usize { + const start = std.math.cast(usize, offset) orelse return error.TranscriptTooLarge; + if (start >= self.bytes.len) return 0; + const len = @min(buffer.len, self.bytes.len - start); + @memcpy(buffer[0..len], self.bytes[start..][0..len]); + return len; + } +}; + const BackgroundSessionPolicy = enum { carry_forward, stop_forget, @@ -243,11 +341,6 @@ const ResumeNotice = union(enum) { upgrade: []const u8, }; -pub const ResumeViewStage = union(enum) { - none, - ready: u32, -}; - pub const ResumeHandoffState = struct { intent: ResumeHandoffIntent, has_writable_session: bool, @@ -1087,7 +1180,9 @@ pub const Persistence = struct { degraded_warning_emitted: bool = false, pending_cancelled_command: ?PendingCancelledCommand = null, image_snapshot_temp_dir: ?[]u8 = null, - resume_view_admission: ?session_store.ResumeViewAdmission = null, + transcript_admission: ?session_store.TranscriptAdmission = null, + resume_transcript_committed_len: ?u64 = null, + resume_transcript_streamed: bool = false, resume_handoff_intent: ResumeHandoffIntent = .none, pending_live_session_policy: ?BackgroundSessionPolicy = null, @@ -1100,7 +1195,7 @@ pub const Persistence = struct { ); } if (self.pending_cancelled_command) |*pending| pending.discard(alloc); - if (self.resume_view_admission) |*admission| admission.deinit(alloc); + if (self.transcript_admission) |*admission| admission.deinit(alloc); if (self.image_snapshot_temp_dir) |path| { image_attachments.cleanupSnapshotDir(path); alloc.free(path); @@ -1122,136 +1217,142 @@ pub const Persistence = struct { pub fn Runtime(comptime App: type) type { return struct { - pub fn stageRequestedResumeView(app: *App) ResumeViewStage { - const requested = app.requested_resume orelse return .none; - const store = app.session_persistence.store orelse return .none; + pub fn stageRequestedSessionTranscript(app: *App) !void { + const requested = app.requested_resume orelse return; + const store = app.session_persistence.store orelse return; const target: session_store.ResumeTarget = switch (requested) { - .pick => return .none, + .pick => return, .last => .last, .id => |session_id| .{ .id = session_id }, }; - var admission = (subagent_resume_admission.admitResumeViewForExternalPrompt( + debug_trace.logf( + "session", + "event=session_transcript_resume stage=selection target={s}", + .{switch (target) { + .last => "last", + .id => "id", + }}, + ); + var admission = (try subagent_resume_admission.admitTranscriptForExternalPrompt( store, app.alloc, target, - ) catch |err| { - traceResumeViewUnavailable(err); - return .none; - }) orelse { - debug_trace.logf("session", "event=resume_view_cache outcome=missing", .{}); - return .none; - }; + )) orelse return; var admission_owned = true; defer if (admission_owned) admission.deinit(app.alloc); - var stage: ResumeViewStage = .none; - switch (admission.view) { - .missing, .invalid, .stale => debug_trace.logf( - "session", - "event=resume_view_cache outcome={s}", - .{@tagName(admission.view)}, - ), - .exact, .older => |view| { - switch (requested) { - .last => app.requested_resume = .{ .id = app.alloc.dupe(u8, view.session_id) catch |err| { - traceResumeViewUnavailable(err); - return .none; - } }, - .pick, .id => {}, - } - const freshness = std.meta.activeTag(admission.view); - const paint_safe = freshness == .exact and view.capture.canPaintAt( - app.shell.layout.rows, - app.shell.layout.cols, + + if (requested == .last) { + const selected_id = try app.alloc.dupe(u8, admission.sessionId()); + app.requested_resume = .{ .id = selected_id }; + } + try streamAdmittedTranscript(app, admission.transcript); + app.session_persistence.transcript_admission = admission; + admission_owned = false; + } + + fn streamAdmittedTranscript( + app: *App, + transcript: session_transcript.Admission, + ) !void { + app.session_persistence.resume_transcript_committed_len = null; + app.session_persistence.resume_transcript_streamed = false; + switch (transcript) { + .exact => |reader| { + debug_trace.logf( + "session", + "event=session_transcript_stream outcome=admitted bytes={d}", + .{reader.committed_len}, ); - if (paint_safe) { - const entry_id = app.shell.appendRawTranscriptEntryClassified( - app.alloc, - view.text, - .unknown_raw, - ) catch |err| { - traceResumeViewUnavailable(err); - return .none; - }; - stage = .{ .ready = entry_id }; + const streamed = try streamSessionTranscript(app, &reader); + if (streamed != reader.committed_len) { + return error.TruncatedSessionTranscript; } - app.session_persistence.resume_view_admission = admission; - admission_owned = false; + app.session_persistence.resume_transcript_committed_len = + reader.committed_len; + app.session_persistence.resume_transcript_streamed = true; + debug_trace.logf( + "session", + "event=session_transcript_stream outcome=exact bytes={d}", + .{streamed}, + ); + }, + .missing, .incomplete => { debug_trace.logf( "session", - "event=resume_view_cache outcome={s} freshness={s} complete={} cached={d}x{d} terminal={d}x{d} bytes={d}", - .{ - if (paint_safe) "painted" else "skipped", - @tagName(freshness), - view.capture.complete, - view.capture.terminal_cols, - view.capture.terminal_rows, - app.shell.layout.cols, - app.shell.layout.rows, - view.text.len, - }, + "event=session_transcript_stream outcome={s}", + .{@tagName(transcript)}, ); }, + .corrupt => return error.CorruptSessionTranscript, } - return stage; } - pub fn publishStagedResumeView(app: *App, entry_id: u32) !void { - app.commitStartupResumeReplayAnchor() catch |err| { - traceResumeViewUnavailable(err); - }; - _ = try app.shell.releaseStartupResumeViewEntry( - app.alloc, - entry_id, - ); - } + pub fn persistSessionTranscriptAfterFrame(app: *App) void { + var pending = app.shell.takePendingPresentationRecord(); + defer if (pending) |*value| value.deinit(app.alloc); + const restamp_ready = app.shell.takePresentationRecordRestamp(); + if (pending == null and !restamp_ready) return; - fn traceResumeViewUnavailable(err: anyerror) void { - debug_trace.logf( - "session", - "event=resume_view_cache outcome=unavailable err={s}", - .{@errorName(err)}, - ); - } - - pub fn persistResumeViewAfterFrame(app: *App) void { - if (app.stream.active) return; - if (comptime @hasField(App, "terminal")) { - if (app.terminal.alternate_screen_owner != .none) return; - } app.session_persistence.write_mutex.lockUncancelable(io_mod.getIo()); defer app.session_persistence.write_mutex.unlock(io_mod.getIo()); - const loaded = if (app.session_persistence.writable) |*value| value else return; - if (!loaded.resume_view_stale) return; - var snapshot = app.shell.snapshotVisibleTranscriptText( - app.alloc, - session_resume_view.max_text_bytes, - ) catch |err| { + const loaded = if (app.session_persistence.writable) |*value| value else { + app.shell.degradePresentationRecord(); + return; + }; + const prior_committed_len = app.shell.presentationRecordCommittedLen() orelse return; + if (pending) |*delta| { + const next_committed_len: u64 = if (prior_committed_len == 0) blk: { + loaded.publishTranscript(app.alloc, delta.bytes) catch |err| { + debug_trace.logf( + "session", + "event=session_transcript outcome=degraded stage=publish err={s}", + .{@errorName(err)}, + ); + app.shell.degradePresentationRecord(); + return; + }; + break :blk @intCast(delta.bytes.len); + } else loaded.appendTranscript( + app.alloc, + prior_committed_len, + delta.bytes, + ) catch |err| { + debug_trace.logf( + "session", + "event=session_transcript outcome=degraded stage=append err={s}", + .{@errorName(err)}, + ); + app.shell.degradePresentationRecord(); + return; + }; + app.shell.commitPresentationRecord( + delta.next_cursor, + next_committed_len, + ); debug_trace.logf( "session", - "event=resume_view_cache outcome=capture_failed err={s}", - .{@errorName(err)}, + "event=session_transcript outcome=committed delta_bytes={d} committed_bytes={d}", + .{ delta.bytes.len, next_committed_len }, ); - return; - } orelse return; - defer snapshot.deinit(app.alloc); - loaded.writeResumeView(app.alloc, .{ - .terminal_rows = snapshot.terminal_rows, - .terminal_cols = snapshot.terminal_cols, - .complete = snapshot.complete, - }, snapshot.text) catch |err| { + } else if (restamp_ready and + prior_committed_len > 0 and + loaded.transcriptNeedsRestamp()) + { + loaded.restampTranscript(app.alloc, prior_committed_len) catch |err| { + debug_trace.logf( + "session", + "event=session_transcript outcome=degraded stage=caught_up_restamp err={s}", + .{@errorName(err)}, + ); + app.shell.degradePresentationRecord(); + return; + }; debug_trace.logf( "session", - "event=resume_view_cache outcome=write_failed err={s}", - .{@errorName(err)}, + "event=session_transcript outcome=restamped committed_bytes={d}", + .{prior_committed_len}, ); - return; - }; - loaded.resume_view_stale = false; - debug_trace.logf( - "session", - "event=resume_view_cache outcome=stored complete={} terminal={d}x{d} bytes={d}", - .{ snapshot.complete, snapshot.terminal_cols, snapshot.terminal_rows, snapshot.text.len }, - ); + } } pub fn captureImageAttachment( @@ -1400,6 +1501,7 @@ pub fn Runtime(comptime App: type) type { try warnNonDurable(app, "session creation failed", err); return; }; + app.shell.beginPresentationRecord(); app.session_persistence.degraded_warning_emitted = false; app.total_input_tokens = 0; app.total_output_tokens = 0; @@ -1508,10 +1610,11 @@ pub fn Runtime(comptime App: type) type { log_options: session_log.Options, ) !void { try prepareLiveSessionTransition(app, .stop_forget, log_options); + try app.shell.requestTerminalReset(&app.metrics); + try app.commitStartupResumeReplayAnchor(); } pub fn finishLiveSessionResume(app: *App) !void { - try app.shell.requestTerminalReset(&app.metrics); app.shell.render_requests.request(.footer); } @@ -1623,9 +1726,9 @@ pub fn Runtime(comptime App: type) type { .last => .last, .id => |session_id| .{ .id = session_id }, }; - var loaded = if (app.session_persistence.resume_view_admission) |saved| admitted: { + var loaded = if (app.session_persistence.transcript_admission) |saved| admitted: { var admission = saved; - app.session_persistence.resume_view_admission = null; + app.session_persistence.transcript_admission = null; defer admission.deinit(app.alloc); const session_id = switch (resume_target) { .id => |id| id, @@ -1633,7 +1736,7 @@ pub fn Runtime(comptime App: type) type { }; const store = app.session_persistence.store orelse return error.SessionStoreUnavailable; - break :admitted try subagent_resume_admission.resumeAdmittedForExternalPrompt( + break :admitted try subagent_resume_admission.resumeTranscriptAdmittedForExternalPrompt( store, app.alloc, &admission, @@ -1807,19 +1910,44 @@ pub fn Runtime(comptime App: type) type { pub fn resumeSelectedSession(app: *App) !bool { const selected_id = app.session_persistence.session_picker.selectedId() orelse return false; + debug_trace.logf( + "session", + "event=session_transcript_resume stage=selection target=picker", + .{}, + ); const log_options = session_log.Options{ .session_lock_deadline_ms = 0, .commit_lock_deadline_ms = 0, }; - var loaded = try loadResumeTargetForWrite( - app, + const store = app.session_persistence.store orelse + return error.SessionStoreUnavailable; + var admission = (try subagent_resume_admission.admitTranscriptForExternalPrompt( + store, + app.alloc, .{ .id = selected_id }, - log_options, + )) orelse return error.SessionNotFound; + defer admission.deinit(app.alloc); + const admitted_id = admission.sessionId(); + if (admission.transcript == .corrupt) { + return error.CorruptSessionTranscript; + } + + try app.prepareLiveSessionResume(log_options); + try streamAdmittedTranscript(app, admission.transcript); + var loaded = try subagent_resume_admission.resumeTranscriptAdmittedForExternalPrompt( + store, + app.alloc, + &admission, + admitted_id, + app.workspace_root, + .{ + .seed_preferences = app.session_persistence.workspace_preferences, + .log = log_options, + }, ); var loaded_owned = true; errdefer if (loaded_owned) loaded.deinit(app.alloc); - try app.prepareLiveSessionResume(log_options); loaded_owned = false; try installResumedSession(app, &loaded, .session); requestSubagentBackgroundRecovery(app); @@ -1868,7 +1996,6 @@ pub fn Runtime(comptime App: type) type { } const active = &app.session_persistence.writable.?; try hydrateResumedSession(app, active.state, display.title, notice); - active.resume_view_stale = true; enableSessionStores(app); refreshSubagentProjectionAfterSessionInstall(app); } @@ -1920,13 +2047,55 @@ pub fn Runtime(comptime App: type) type { .app = app, .projection = &projection, }; - try writeResumeNotice(app, &sink, display_title, notice); try replayHistoryToSink(app, &sink, state.history); try writeRecoveryCheckpointToSink(app, &sink, state); const projection_finished_ns = io_mod.nanoTimestamp(); try projection.finalize(); const finalization_finished_ns = io_mod.nanoTimestamp(); - try app.installResumeProjection(&projection); + var committed_len = app.session_persistence.resume_transcript_committed_len; + if (!app.session_persistence.resume_transcript_streamed) { + const transcript_bytes = projection.publicationBytes(); + const streamed = try streamSessionTranscript( + app, + MemoryTranscriptReader{ .bytes = transcript_bytes }, + ); + if (streamed != @as(u64, @intCast(transcript_bytes.len))) { + return error.TruncatedSessionTranscript; + } + if (app.session_persistence.writable) |*loaded| { + if (loaded.publishTranscript(app.alloc, transcript_bytes)) |_| { + committed_len = @intCast(transcript_bytes.len); + } else |err| { + debug_trace.logf( + "session", + "event=session_transcript outcome=degraded stage=backfill_publish err={s}", + .{@errorName(err)}, + ); + committed_len = null; + } + } + } else if (committed_len) |len| { + if (app.session_persistence.writable) |*loaded| { + loaded.restampTranscript(app.alloc, len) catch |err| { + debug_trace.logf( + "session", + "event=session_transcript outcome=degraded stage=resume_restamp err={s}", + .{@errorName(err)}, + ); + committed_len = null; + }; + } + } + try app.installResumeProjectionRetained(&projection); + var live_sink = LiveHistorySink(App){ .app = app }; + try writeResumeNotice(app, &live_sink, display_title, notice); + if (committed_len) |len| { + app.shell.resumePresentationRecord(len); + } else { + app.shell.degradePresentationRecord(); + } + app.session_persistence.resume_transcript_committed_len = null; + app.session_persistence.resume_transcript_streamed = false; const install_finished_ns = io_mod.nanoTimestamp(); debug_trace.logf( "session", @@ -4371,6 +4540,19 @@ pub fn Runtime(comptime App: type) type { .{ loaded.active_id, @errorName(err) }, ); }; + if (app.shell.presentationRecordCanRestamp() and + loaded.transcriptNeedsRestamp()) + { + if (app.shell.presentationRecordCommittedLen()) |committed_len| { + loaded.restampTranscript(app.alloc, committed_len) catch |err| { + debug_trace.logf( + "session", + "event=session_transcript outcome=degraded stage=close_restamp err={s}", + .{@errorName(err)}, + ); + }; + } + } const should_create_handoff = resume_boundary_valid and shouldCreateResumeHandoff(.{ .intent = handoff_intent, @@ -7251,174 +7433,6 @@ test "execution replay keeps failed and unpaired persisted results visible witho ); } -test "safe last resume view paints and pins authoritative resume to its session id" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - const paths = try testPaths(alloc, &tmp); - defer { - alloc.free(paths.home); - alloc.free(paths.workspace); - } - const home = try TestHome.install(alloc, paths.home); - defer home.deinit(); - var app = try TestApp.init(alloc, paths.workspace); - defer app.deinit(); - - try configureTestPreferences(&app); - try Runtime(TestApp).initializePersistence(&app, true); - try writeSessionFixture( - alloc, - app.session_persistence.store.?, - "cached-session", - &.{}, - 0, - ); - { - var fixture = try app.session_persistence.store.?.resumeForWrite( - alloc, - "cached-session", - ); - defer fixture.deinit(alloc); - try fixture.writeResumeView(alloc, .{ - .terminal_rows = 24, - .terminal_cols = 80, - .complete = true, - }, "cached transcript\n"); - } - - app.shell.layout.rows = 24; - app.shell.layout.cols = 80; - app.requested_resume = .last; - const stage = Runtime(TestApp).stageRequestedResumeView(&app); - try std.testing.expectEqual(@as(u32, 1), stage.ready); - - try std.testing.expectEqualStrings("cached transcript\n", app.shell.transcript.items); - switch (app.requested_resume.?) { - .id => |session_id| try std.testing.expectEqualStrings("cached-session", session_id), - .pick, .last => return error.TestExpectedPinnedResumeSession, - } - try std.testing.expectError( - error.SessionBusy, - app.session_persistence.store.?.resumeTargetForWrite( - alloc, - .{ .id = "cached-session" }, - paths.workspace, - .{ .log = .{ .session_lock_deadline_ms = 0 } }, - ), - ); - try Runtime(TestApp).publishStagedResumeView(&app, stage.ready); - try std.testing.expectEqual(@as(usize, 0), app.shell.transcript.items.len); - try std.testing.expectEqual(@as(usize, 1), app.startup_resume_anchor_count); - try Runtime(TestApp).resumeRequestedSession(&app); - try std.testing.expect(app.session_persistence.resume_view_admission == null); - try std.testing.expectEqualStrings( - "cached-session", - app.session_persistence.writable.?.active_id, - ); -} - -test "resume view policy denial does not paint or retain admission" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - const paths = try testPaths(alloc, &tmp); - defer { - alloc.free(paths.home); - alloc.free(paths.workspace); - } - const home = try TestHome.install(alloc, paths.home); - defer home.deinit(); - var app = try TestApp.init(alloc, paths.workspace); - defer app.deinit(); - - try configureTestPreferences(&app); - try Runtime(TestApp).initializePersistence(&app, true); - const store = app.session_persistence.store.?; - try writeSessionFixture(alloc, store, "malformed-child", &.{}, 0); - { - var fixture = try store.resumeForWrite(alloc, "malformed-child"); - defer fixture.deinit(alloc); - try fixture.writeResumeView(alloc, .{ - .terminal_rows = 24, - .terminal_cols = 80, - .complete = true, - }, "private cached transcript\n"); - const capability = try fixture.childCapability(); - var entry = try capability.atomicReplace( - alloc, - .subagent_control, - "control.json", - "{", - ); - entry.deinit(alloc); - } - - app.requested_resume = .last; - try std.testing.expectEqual( - ResumeViewStage.none, - Runtime(TestApp).stageRequestedResumeView(&app), - ); - - try std.testing.expectEqual(@as(usize, 0), app.transcript.items.len); - try std.testing.expect(app.session_persistence.resume_view_admission == null); - var loaded = try store.resumeForWrite(alloc, "malformed-child"); - loaded.deinit(alloc); -} - -test "resume view persistence waits for main frame and retries failed writes" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - const paths = try testPaths(alloc, &tmp); - defer { - alloc.free(paths.home); - alloc.free(paths.workspace); - } - const home = try TestHome.install(alloc, paths.home); - defer home.deinit(); - var app = try TestApp.init(alloc, paths.workspace); - defer app.deinit(); - - try configureTestPreferences(&app); - try Runtime(TestApp).initializePersistence(&app, true); - try Runtime(TestApp).beginFreshPersistedSession(&app); - app.shell.layout.rows = 6; - app.shell.layout.cols = 12; - try app.shell.enableShadowVt(alloc); - try app.shell.shadow_vt.?.feed("\x1b[1;1Hcached transcript"); - app.shell.has_painted_transcript = true; - app.shell.last_visible_transcript_top_row = 1; - app.shell.last_visible_transcript_last_row = 1; - - const loaded = &app.session_persistence.writable.?; - loaded.resume_view_stale = true; - app.terminal.alternate_screen_owner = .full_transcript; - Runtime(TestApp).persistResumeViewAfterFrame(&app); - try std.testing.expect(loaded.resume_view_stale); - app.terminal.alternate_screen_owner = .none; - try loaded.log.dir.dir.createDir( - std.testing.io, - "resume-view.bin", - std.Io.File.Permissions.fromMode(0o700), - ); - Runtime(TestApp).persistResumeViewAfterFrame(&app); - try std.testing.expect(loaded.resume_view_stale); - - try loaded.log.dir.dir.deleteDir(std.testing.io, "resume-view.bin"); - Runtime(TestApp).persistResumeViewAfterFrame(&app); - try std.testing.expect(!loaded.resume_view_stale); - const stat = try loaded.log.dir.dir.statFile( - std.testing.io, - "resume-view.bin", - .{ .follow_symlinks = false }, - ); - try std.testing.expectEqual(std.Io.File.Kind.file, stat.kind); -} - test "upgrade resume restores active session with the installed version notice" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); diff --git a/src/core/session/session_log.zig b/src/core/session/session_log.zig index 04600db38c..ec9e993ca0 100644 --- a/src/core/session/session_log.zig +++ b/src/core/session/session_log.zig @@ -12,7 +12,7 @@ const session_layout = @import("session_layout.zig"); const session_projection = @import("session_projection.zig"); const session_replay = @import("session_replay.zig"); const session_display_metadata = @import("session_display_metadata.zig"); -const session_resume_view = @import("session_resume_view.zig"); +const session_transcript = @import("session_transcript.zig"); const session_usage = @import("session_usage.zig"); const session_usage_sidecar = @import("session_usage_sidecar.zig"); @@ -302,22 +302,22 @@ pub const WritableSessionDir = struct { } }; -pub const ResumeViewAdmission = struct { +pub const TranscriptAdmission = struct { writable: ?WritableSessionDir, - view: session_resume_view.LoadOutcome, + transcript: session_transcript.Admission, - pub fn deinit(self: *ResumeViewAdmission, alloc: Allocator) void { + pub fn deinit(self: *TranscriptAdmission, alloc: Allocator) void { if (self.writable) |*writable| writable.deinit(alloc); - self.view.deinit(alloc); + self.transcript.deinit(); self.* = undefined; } - pub fn sessionId(self: *const ResumeViewAdmission) []const u8 { + pub fn sessionId(self: *const TranscriptAdmission) []const u8 { return self.writable.?.session_id; } pub fn resumeForWrite( - self: *ResumeViewAdmission, + self: *TranscriptAdmission, alloc: Allocator, options: Options, ) !LoadedWritableSession { @@ -547,7 +547,7 @@ pub const LoadedWritableSession = struct { migration_source_schema_version: ?u8 = null, migration_source_bytes: ?u64 = null, usage_sidecar_reseal_pending: bool = false, - resume_view_stale: bool = false, + transcript_position: ?CommitPosition = null, /// Runtime-only provenance installed by subagent resume admission. These /// fields are never written into the session event log. external_prompt_origin: ExternalPromptOrigin = .root, @@ -571,20 +571,57 @@ pub const LoadedWritableSession = struct { self.* = undefined; } - pub fn writeResumeView( + pub fn publishTranscript( self: *LoadedWritableSession, alloc: Allocator, - capture: session_resume_view.Capture, - text: []const u8, + bytes: []const u8, ) !void { - try session_resume_view.write( + try session_transcript.publishComplete( alloc, &self.log.dir, self.active_id, - resumeViewBoundary(self.position), - capture, - text, + self.position, + bytes, + ); + self.transcript_position = self.position; + } + + pub fn appendTranscript( + self: *LoadedWritableSession, + alloc: Allocator, + prior_committed_len: u64, + bytes: []const u8, + ) !u64 { + const committed_len = try session_transcript.appendAndCommit( + alloc, + &self.log.dir, + self.active_id, + prior_committed_len, + self.position, + bytes, + ); + self.transcript_position = self.position; + return committed_len; + } + + pub fn restampTranscript( + self: *LoadedWritableSession, + alloc: Allocator, + committed_len: u64, + ) !void { + try session_transcript.restampComplete( + alloc, + &self.log.dir, + self.active_id, + committed_len, + self.position, ); + self.transcript_position = self.position; + } + + pub fn transcriptNeedsRestamp(self: *const LoadedWritableSession) bool { + const stamped = self.transcript_position orelse return true; + return !positionsEqual(stamped, self.position); } /// Returns a borrowed address that remains stable until deinit. @@ -1331,21 +1368,21 @@ pub const Root = struct { return state; } - pub fn admitResumeView( + pub fn admitTranscript( self: *Root, alloc: Allocator, session_id: []const u8, - ) !ResumeViewAdmission { + ) !TranscriptAdmission { var writable = try self.openWritableSessionDir(alloc, session_id, 0); errdefer writable.deinit(alloc); const position = try loadCurrentPositionReference(alloc, &writable.dir, session_id); return .{ .writable = writable, - .view = try session_resume_view.loadMatching( + .transcript = try session_transcript.admit( alloc, &writable.dir, session_id, - resumeViewBoundary(position), + position, ), }; } @@ -1400,14 +1437,6 @@ pub const Root = struct { } }; -fn resumeViewBoundary(position: CommitPosition) session_resume_view.Boundary { - return .{ - .log_generation = position.log_generation, - .seq = position.through_seq, - .event_log_bytes = position.through_event_log_bytes, - }; -} - fn validateLeaf(name: []const u8) !void { if (name.len == 0 or std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..") or @@ -3375,7 +3404,6 @@ fn publishFrames( err, options, ); - loaded.resume_view_stale = true; switch (prepared.kind) { .event => { const published = loadCurrentPositionReference( @@ -3798,7 +3826,6 @@ fn confirmWritableNamespace( loaded.state.deinit(alloc); loaded.state = next_state; loaded.position = position; - loaded.resume_view_stale = true; if (generation_changed) { loaded.generation_base_seq = position.through_seq; loaded.generation_base_bytes = position.through_event_log_bytes; @@ -4265,7 +4292,6 @@ fn compactCanonicalLog( events_file, io_mod.getIo(), ) catch return error.SessionLogCompactionIndeterminate; - loaded.resume_view_stale = true; options.test_controls.boundary(.after_compaction_log_rename) catch return error.SessionLogCompactionIndeterminate; io_mod.syncVerifiedDir(loaded.log.dir.dir) catch @@ -7131,7 +7157,6 @@ test "automatic compaction leaves post-rename uncertainty fenced" { var next = try stateWithTurn(alloc, loaded.state, 20); defer next.deinit(alloc); var failure = BoundaryFailure{ .target = .after_compaction_log_rename }; - loaded.resume_view_stale = false; try std.testing.expectError( error.SessionLogCompactionIndeterminate, @@ -7149,7 +7174,6 @@ test "automatic compaction leaves post-rename uncertainty fenced" { ); try std.testing.expect(loaded.namespace_confirmation_required); - try std.testing.expect(loaded.resume_view_stale); try std.testing.expectError( error.SessionCommitBoundaryUnavailable, temp.root.loadReadOnly(alloc, initial.id, .{}), @@ -7247,7 +7271,6 @@ test "log compaction rename remains fenced until writable resolution" { .{}, ); var failure = BoundaryFailure{ .target = .after_compaction_log_rename }; - loaded.resume_view_stale = false; try std.testing.expectError( error.SessionLogCompactionIndeterminate, @@ -7257,7 +7280,6 @@ test "log compaction rename remains fenced until writable resolution" { .compaction_byte_threshold = 1, }), ); - try std.testing.expect(loaded.resume_view_stale); loaded.deinit(alloc); try std.testing.expectError( error.SessionCommitBoundaryUnavailable, diff --git a/src/core/session/session_resume_view.zig b/src/core/session/session_resume_view.zig deleted file mode 100644 index c6255075e1..0000000000 --- a/src/core/session/session_resume_view.zig +++ /dev/null @@ -1,501 +0,0 @@ -const std = @import("std"); -const io_mod = @import("../shared/io.zig"); -const session_event = @import("session_event.zig"); -const session_layout = @import("session_layout.zig"); - -const Allocator = std.mem.Allocator; - -pub const max_text_bytes: usize = 128 * 1024; -const sidecar_file = "resume-view.bin"; - -const magic = "FXRV"; -const schema_version: u8 = 3; -const fixed_header_bytes = magic.len + 1 + 2 + 2 + 2 + 1 + 16 + 8 + 8 + 4; -const max_sidecar_bytes = fixed_header_bytes + 255 + max_text_bytes; - -pub const Boundary = struct { - log_generation: session_event.Identifier, - seq: u64, - event_log_bytes: u64, -}; - -pub const Capture = struct { - terminal_rows: u16, - terminal_cols: u16, - complete: bool, - - pub fn canPaintAt(self: Capture, terminal_rows: u16, terminal_cols: u16) bool { - return self.complete and - self.terminal_rows == terminal_rows and - self.terminal_cols == terminal_cols; - } -}; - -pub const View = struct { - session_id: []u8, - boundary: Boundary, - capture: Capture, - text: []u8, - - pub fn deinit(self: *View, alloc: Allocator) void { - alloc.free(self.session_id); - alloc.free(self.text); - self.* = undefined; - } -}; - -pub const LoadOutcome = union(enum) { - missing, - invalid, - stale, - exact: View, - older: View, - - pub fn deinit(self: *LoadOutcome, alloc: Allocator) void { - switch (self.*) { - .exact, .older => |*view| view.deinit(alloc), - .missing, .invalid, .stale => {}, - } - self.* = undefined; - } -}; - -fn encode( - alloc: Allocator, - session_id: []const u8, - boundary: Boundary, - capture: Capture, - text: []const u8, -) ![]u8 { - session_layout.validateSessionId(session_id) catch return error.InvalidResumeView; - if (capture.terminal_rows == 0 or capture.terminal_cols == 0) { - return error.InvalidResumeView; - } - if (text.len > max_text_bytes) return error.ResumeViewTooLarge; - if (!validVisibleText(text)) return error.InvalidResumeView; - - var out: std.Io.Writer.Allocating = .init(alloc); - defer out.deinit(); - try out.writer.writeAll(magic); - try out.writer.writeByte(schema_version); - try writeInt(&out.writer, u16, @intCast(session_id.len)); - try writeInt(&out.writer, u16, capture.terminal_rows); - try writeInt(&out.writer, u16, capture.terminal_cols); - try out.writer.writeByte(@intFromBool(capture.complete)); - try out.writer.writeAll(&boundary.log_generation); - try writeInt(&out.writer, u64, boundary.seq); - try writeInt(&out.writer, u64, boundary.event_log_bytes); - try writeInt(&out.writer, u32, @intCast(text.len)); - try out.writer.writeAll(session_id); - try out.writer.writeAll(text); - return try out.toOwnedSlice(); -} - -fn decode(alloc: Allocator, bytes: []const u8) !View { - if (bytes.len < fixed_header_bytes or bytes.len > max_sidecar_bytes) { - return error.InvalidResumeView; - } - var cursor = Cursor{ .bytes = bytes }; - if (!std.mem.eql(u8, try cursor.take(magic.len), magic)) { - return error.InvalidResumeView; - } - if ((try cursor.take(1))[0] != schema_version) return error.InvalidResumeView; - const session_id_len = try cursor.readInt(u16); - const terminal_rows = try cursor.readInt(u16); - const terminal_cols = try cursor.readInt(u16); - const complete = (try cursor.take(1))[0]; - const generation = try cursor.take(16); - const seq = try cursor.readInt(u64); - const event_log_bytes = try cursor.readInt(u64); - const text_len = try cursor.readInt(u32); - if (session_id_len == 0 or terminal_rows == 0 or terminal_cols == 0 or - complete > 1 or text_len > max_text_bytes) - { - return error.InvalidResumeView; - } - const session_id = try cursor.take(session_id_len); - const text = try cursor.take(text_len); - if (cursor.remaining() != 0 or !validVisibleText(text)) return error.InvalidResumeView; - session_layout.validateSessionId(session_id) catch return error.InvalidResumeView; - - const owned_id = try alloc.dupe(u8, session_id); - errdefer alloc.free(owned_id); - return .{ - .session_id = owned_id, - .boundary = .{ - .log_generation = generation[0..16].*, - .seq = seq, - .event_log_bytes = event_log_bytes, - }, - .capture = .{ - .terminal_rows = terminal_rows, - .terminal_cols = terminal_cols, - .complete = complete == 1, - }, - .text = try alloc.dupe(u8, text), - }; -} - -pub fn write( - alloc: Allocator, - session_dir: *io_mod.VerifiedDir, - session_id: []const u8, - boundary: Boundary, - capture: Capture, - text: []const u8, -) !void { - const encoded = try encode(alloc, session_id, boundary, capture, text); - defer alloc.free(encoded); - try io_mod.durableReplaceVerified(alloc, session_dir, sidecar_file, encoded); -} - -pub fn loadMatching( - alloc: Allocator, - session_dir: *io_mod.VerifiedDir, - session_id: []const u8, - boundary: Boundary, -) !LoadOutcome { - var file = io_mod.openExistingRegularFile( - session_dir.dir, - sidecar_file, - .read_only, - ) catch |err| { - if (err == error.OutOfMemory) return error.OutOfMemory; - if (err == error.FileNotFound) return .missing; - return .invalid; - }; - defer file.close(io_mod.getIo()); - const stat = file.stat(io_mod.getIo()) catch return .invalid; - if (!safeFile(stat) or stat.size == 0 or stat.size > max_sidecar_bytes) { - return .invalid; - } - const bytes = io_mod.readFileToEnd(alloc, &file, max_sidecar_bytes) catch |err| { - if (err == error.OutOfMemory) return error.OutOfMemory; - return .invalid; - }; - defer alloc.free(bytes); - var view = decode(alloc, bytes) catch |err| { - if (err == error.OutOfMemory) return error.OutOfMemory; - return .invalid; - }; - if (!std.mem.eql(u8, view.session_id, session_id) or - !std.mem.eql(u8, &view.boundary.log_generation, &boundary.log_generation) or - view.boundary.seq > boundary.seq or - view.boundary.event_log_bytes > boundary.event_log_bytes) - { - view.deinit(alloc); - return .stale; - } - return if (std.meta.eql(view.boundary, boundary)) - .{ .exact = view } - else - .{ .older = view }; -} - -fn safeFile(stat: std.Io.File.Stat) bool { - return stat.kind == .file and stat.nlink == 1 and - stat.permissions.toMode() & 0o777 == 0o600; -} - -fn validVisibleText(text: []const u8) bool { - if (text.len == 0 or !std.unicode.utf8ValidateSlice(text)) return false; - var has_content = false; - var index: usize = 0; - while (index < text.len) { - const byte = text[index]; - if (byte == 0x1b) { - const sequence_len = validSgrSequence(text[index..]) orelse return false; - index += sequence_len; - continue; - } - if ((byte < ' ' and byte != '\n') or byte == 0x7f) return false; - if (byte != '\n') has_content = true; - index += 1; - } - return has_content; -} - -fn validSgrSequence(text: []const u8) ?usize { - if (text.len < 4 or text[0] != 0x1b or text[1] != '[') return null; - const end = std.mem.indexOfScalar(u8, text[2..], 'm') orelse return null; - const encoded = text[2 .. end + 2]; - var values: [5]u16 = undefined; - var count: usize = 0; - var parts = std.mem.splitScalar(u8, encoded, ';'); - while (parts.next()) |part| { - if (part.len == 0 or count == values.len) return null; - values[count] = std.fmt.parseInt(u16, part, 10) catch return null; - count += 1; - } - const safe = switch (count) { - 1 => values[0] == 0 or - (values[0] >= 1 and values[0] <= 4) or - values[0] == 7 or values[0] == 9 or - (values[0] >= 30 and values[0] <= 37) or - (values[0] >= 40 and values[0] <= 47) or - (values[0] >= 90 and values[0] <= 97) or - (values[0] >= 100 and values[0] <= 107), - 3 => (values[0] == 38 or values[0] == 48) and - values[1] == 5 and values[2] <= 255, - 5 => (values[0] == 38 or values[0] == 48) and - values[1] == 2 and values[2] <= 255 and - values[3] <= 255 and values[4] <= 255, - else => false, - }; - return if (safe) end + 3 else null; -} - -fn writeInt(writer: *std.Io.Writer, comptime T: type, value: T) !void { - var bytes: [@sizeOf(T)]u8 = undefined; - std.mem.writeInt(T, &bytes, value, .little); - try writer.writeAll(&bytes); -} - -const Cursor = struct { - bytes: []const u8, - pos: usize = 0, - - fn take(self: *Cursor, len: usize) ![]const u8 { - if (len > self.remaining()) return error.InvalidResumeView; - defer self.pos += len; - return self.bytes[self.pos..][0..len]; - } - - fn readInt(self: *Cursor, comptime T: type) !T { - const raw = try self.take(@sizeOf(T)); - return std.mem.readInt(T, raw[0..@sizeOf(T)], .little); - } - - fn remaining(self: Cursor) usize { - return self.bytes.len - self.pos; - } -}; - -fn openTestVerifiedDir(dir: std.Io.Dir) !io_mod.VerifiedDir { - return .{ - .dir = try dir.openDir( - io_mod.getIo(), - ".", - .{ .iterate = true, .follow_symlinks = false }, - ), - }; -} - -const test_capture = Capture{ - .terminal_rows = 24, - .terminal_cols = 80, - .complete = true, -}; - -test "resume view codec round trips its durable boundary and visible text" { - const alloc = std.testing.allocator; - const boundary = Boundary{ - .log_generation = [_]u8{0x2a} ** 16, - .seq = 42, - .event_log_bytes = 4096, - }; - const encoded = try encode(alloc, "session-1", boundary, test_capture, "restored tail\n● 2 tool calls\n"); - defer alloc.free(encoded); - - var decoded = try decode(alloc, encoded); - defer decoded.deinit(alloc); - - try std.testing.expectEqualStrings("session-1", decoded.session_id); - try std.testing.expectEqualSlices(u8, &boundary.log_generation, &decoded.boundary.log_generation); - try std.testing.expectEqual(boundary.seq, decoded.boundary.seq); - try std.testing.expectEqual(boundary.event_log_bytes, decoded.boundary.event_log_bytes); - try std.testing.expectEqual(test_capture, decoded.capture); - try std.testing.expectEqualStrings("restored tail\n● 2 tool calls\n", decoded.text); -} - -test "resume view capture only paints at complete matching geometry" { - try std.testing.expect(test_capture.canPaintAt(24, 80)); - try std.testing.expect(!test_capture.canPaintAt(23, 80)); - try std.testing.expect(!test_capture.canPaintAt(24, 79)); - try std.testing.expect(!(Capture{ - .terminal_rows = 24, - .terminal_cols = 80, - .complete = false, - }).canPaintAt(24, 80)); -} - -test "resume view codec round trips safe terminal styling" { - const alloc = std.testing.allocator; - const boundary = Boundary{ - .log_generation = [_]u8{0x2a} ** 16, - .seq = 42, - .event_log_bytes = 4096, - }; - const styled = "\x1b[0m\x1b[1m\x1b[3m\x1b[31m\x1b[38;2;1;2;3m" ++ - "\x1b[48;5;245mgray\x1b[0m\n"; - const encoded = try encode(alloc, "session-1", boundary, test_capture, styled); - defer alloc.free(encoded); - - var decoded = try decode(alloc, encoded); - defer decoded.deinit(alloc); - try std.testing.expectEqualStrings(styled, decoded.text); -} - -test "resume view codec rejects unsafe or oversized visible text" { - const alloc = std.testing.allocator; - const boundary = Boundary{ - .log_generation = [_]u8{0x17} ** 16, - .seq = 7, - .event_log_bytes = 512, - }; - - try std.testing.expectError( - error.InvalidResumeView, - encode(alloc, "session-1", boundary, test_capture, "unsafe\x1b[2J"), - ); - try std.testing.expectError( - error.InvalidResumeView, - encode(alloc, "session-1", boundary, test_capture, "unsafe\x1b]8;;https://example.com\x1b\\link"), - ); - try std.testing.expectError( - error.InvalidResumeView, - encode(alloc, "session-1", boundary, test_capture, "unsafe\x1b[38;5;999m"), - ); - try std.testing.expectError( - error.InvalidResumeView, - encode(alloc, "session-1", boundary, test_capture, "unsafe\x1b[0;31m"), - ); - try std.testing.expectError( - error.InvalidResumeView, - encode(alloc, "session-1", boundary, test_capture, "\n\n"), - ); - try std.testing.expectError( - error.InvalidResumeView, - encode(alloc, "session-1", boundary, .{ - .terminal_rows = 0, - .terminal_cols = 80, - .complete = true, - }, "visible\n"), - ); - - const oversized = try alloc.alloc(u8, max_text_bytes + 1); - defer alloc.free(oversized); - @memset(oversized, 'x'); - try std.testing.expectError( - error.ResumeViewTooLarge, - encode(alloc, "session-1", boundary, test_capture, oversized), - ); -} - -test "resume view sidecar classifies exact and older durable boundaries" { - const alloc = std.testing.allocator; - var temp = std.testing.tmpDir(.{}); - defer temp.cleanup(); - var verified = try openTestVerifiedDir(temp.dir); - defer verified.close(); - - const boundary = Boundary{ - .log_generation = [_]u8{0x31} ** 16, - .seq = 11, - .event_log_bytes = 2048, - }; - try write(alloc, &verified, "session-1", boundary, test_capture, "cached session tail\n"); - - var restored = try loadMatching(alloc, &verified, "session-1", boundary); - defer restored.deinit(alloc); - try std.testing.expect(restored == .exact); - try std.testing.expectEqualStrings("cached session tail\n", restored.exact.text); - - const advanced = Boundary{ - .log_generation = boundary.log_generation, - .seq = boundary.seq + 1, - .event_log_bytes = boundary.event_log_bytes + 64, - }; - var older = try loadMatching(alloc, &verified, "session-1", advanced); - defer older.deinit(alloc); - try std.testing.expect(older == .older); - try std.testing.expectEqualStrings("cached session tail\n", older.older.text); -} - -test "resume view sidecar rejects forward and cross-generation boundaries" { - const alloc = std.testing.allocator; - var temp = std.testing.tmpDir(.{}); - defer temp.cleanup(); - var verified = try openTestVerifiedDir(temp.dir); - defer verified.close(); - - const stored = Boundary{ - .log_generation = [_]u8{0x31} ** 16, - .seq = 11, - .event_log_bytes = 2048, - }; - try write(alloc, &verified, "session-1", stored, test_capture, "cached session tail\n"); - - var forward_seq = try loadMatching(alloc, &verified, "session-1", .{ - .log_generation = stored.log_generation, - .seq = stored.seq - 1, - .event_log_bytes = stored.event_log_bytes, - }); - defer forward_seq.deinit(alloc); - try std.testing.expect(forward_seq == .stale); - - var forward_bytes = try loadMatching(alloc, &verified, "session-1", .{ - .log_generation = stored.log_generation, - .seq = stored.seq, - .event_log_bytes = stored.event_log_bytes - 1, - }); - defer forward_bytes.deinit(alloc); - try std.testing.expect(forward_bytes == .stale); - - var cross_generation = try loadMatching(alloc, &verified, "session-1", .{ - .log_generation = [_]u8{0x32} ** 16, - .seq = stored.seq, - .event_log_bytes = stored.event_log_bytes, - }); - defer cross_generation.deinit(alloc); - try std.testing.expect(cross_generation == .stale); -} - -test "resume view sidecar treats missing and corrupt cache data as fallback" { - const alloc = std.testing.allocator; - var temp = std.testing.tmpDir(.{}); - defer temp.cleanup(); - var verified = try openTestVerifiedDir(temp.dir); - defer verified.close(); - const boundary = Boundary{ - .log_generation = [_]u8{0x44} ** 16, - .seq = 3, - .event_log_bytes = 128, - }; - - var missing = try loadMatching(alloc, &verified, "session-1", boundary); - defer missing.deinit(alloc); - try std.testing.expect(missing == .missing); - - try io_mod.durableReplaceVerified(alloc, &verified, sidecar_file, "broken"); - var invalid = try loadMatching(alloc, &verified, "session-1", boundary); - defer invalid.deinit(alloc); - try std.testing.expect(invalid == .invalid); - - const encoded = try encode(alloc, "session-1", boundary, test_capture, "cached session tail\n"); - defer alloc.free(encoded); - encoded[magic.len] = 2; - try io_mod.durableReplaceVerified(alloc, &verified, sidecar_file, encoded); - var stale_presentation = try loadMatching(alloc, &verified, "session-1", boundary); - defer stale_presentation.deinit(alloc); - try std.testing.expect(stale_presentation == .invalid); - - encoded[magic.len] = 1; - try io_mod.durableReplaceVerified(alloc, &verified, sidecar_file, encoded); - var old_schema = try loadMatching(alloc, &verified, "session-1", boundary); - defer old_schema.deinit(alloc); - try std.testing.expect(old_schema == .invalid); - - encoded[magic.len] = schema_version; - const invalid_complete_offset = magic.len + 1 + 2 + 2 + 2; - encoded[invalid_complete_offset] = 2; - try io_mod.durableReplaceVerified(alloc, &verified, sidecar_file, encoded); - var invalid_complete = try loadMatching(alloc, &verified, "session-1", boundary); - defer invalid_complete.deinit(alloc); - try std.testing.expect(invalid_complete == .invalid); - - try write(alloc, &verified, "session-1", boundary, test_capture, "cached session tail\n"); - var wrong_session = try loadMatching(alloc, &verified, "session-2", boundary); - defer wrong_session.deinit(alloc); - try std.testing.expect(wrong_session == .stale); -} diff --git a/src/core/session/session_store.zig b/src/core/session/session_store.zig index 18b6674450..b7bfe1bb9b 100644 --- a/src/core/session/session_store.zig +++ b/src/core/session/session_store.zig @@ -141,7 +141,7 @@ pub fn imageSnapshotStorageDir( pub const ReadOnlyDetail = store_types.ReadOnlyDetail; pub const ResumeOptions = store_types.ResumeOptions; pub const ResumeTarget = store_types.ResumeTarget; -pub const ResumeViewAdmission = session_log.ResumeViewAdmission; +pub const TranscriptAdmission = session_log.TranscriptAdmission; pub const SessionMigrationResult = store_types.SessionMigrationResult; pub const SessionMigrationStatus = store_types.SessionMigrationStatus; pub const SessionRecoveryResult = store_types.SessionRecoveryResult; @@ -1002,30 +1002,24 @@ pub const Store = struct { ); } - pub fn admitResumeView( + pub fn admitTranscript( self: Store, alloc: Allocator, target: ResumeTarget, - ) !?ResumeViewAdmission { + ) !?TranscriptAdmission { var root = self.canonical_root; return switch (target) { - .id => |id| try root.admitResumeView(alloc, id), + .id => |id| try root.admitTranscript(alloc, id), .last => blk: { if (self.deferredCacheInvalidatesReads()) { var latest = try self.latestReadOnlyWorkspaceSummary(alloc); defer latest.deinit(alloc); - break :blk try root.admitResumeView( - alloc, - latest.id, - ); + break :blk try root.admitTranscript(alloc, latest.id); } var latest = (try readLatestPointer(self, alloc, self.workspace_root)) orelse return null; defer latest.deinit(alloc); - break :blk try root.admitResumeView( - alloc, - latest.session_id, - ); + break :blk try root.admitTranscript(alloc, latest.session_id); }, }; } @@ -1054,10 +1048,10 @@ pub const Store = struct { return self.finishResumedForWrite(alloc, loaded, options); } - pub fn resumeAdmittedForWrite( + pub fn resumeTranscriptAdmittedForWrite( self: Store, alloc: Allocator, - admission: *ResumeViewAdmission, + admission: *TranscriptAdmission, expected_session_id: []const u8, workspace_root: []const u8, options: ResumeOptions, @@ -8247,7 +8241,7 @@ test "writable resume last selects canonically while deferred repair is busy" { ); writer.deinit(alloc); - var admission = (try ctx.store.admitResumeView(alloc, .last)) orelse + var admission = (try ctx.store.admitTranscript(alloc, .last)) orelse return error.TestExpectedEqual; try std.testing.expectEqualStrings("dirty-resume-a", admission.sessionId()); admission.deinit(alloc); diff --git a/src/core/session/session_transcript.zig b/src/core/session/session_transcript.zig new file mode 100644 index 0000000000..39647a857d --- /dev/null +++ b/src/core/session/session_transcript.zig @@ -0,0 +1,828 @@ +const std = @import("std"); +const io_mod = @import("../shared/io.zig"); +const session_layout = @import("session_layout.zig"); +const session_replay = @import("session_replay.zig"); + +const magic = "FXTR"; +const schema_version: u8 = 1; +const fixed_metadata_bytes = magic.len + 1 + 2 + 8 + 16 + 8 + 16 + 8; +const max_metadata_bytes = fixed_metadata_bytes + 255; +const data_file = "transcript.ansi"; +const metadata_file = "transcript.meta"; + +const Metadata = struct { + session_id: []u8, + committed_len: u64, + position: session_replay.CommitPosition, + + pub fn deinit(self: *Metadata, alloc: std.mem.Allocator) void { + alloc.free(self.session_id); + self.* = undefined; + } +}; + +pub const ExactReader = struct { + file: std.Io.File, + committed_len: u64, + + pub fn readAt(self: *const ExactReader, buffer: []u8, offset: u64) !usize { + if (offset >= self.committed_len or buffer.len == 0) return 0; + const remaining = self.committed_len - offset; + const limit: usize = @intCast(@min(remaining, buffer.len)); + return self.file.readPositionalAll( + io_mod.getIo(), + buffer[0..limit], + offset, + ); + } +}; + +pub const Admission = union(enum) { + missing, + incomplete, + corrupt, + exact: ExactReader, + + pub fn deinit(self: *Admission) void { + switch (self.*) { + .exact => |reader| reader.file.close(io_mod.getIo()), + .missing, .incomplete, .corrupt => {}, + } + self.* = undefined; + } +}; + +const MetadataInput = struct { + session_id: []const u8, + committed_len: u64, + position: session_replay.CommitPosition, +}; + +fn encodeMetadata(alloc: std.mem.Allocator, input: MetadataInput) ![]u8 { + session_layout.validateSessionId(input.session_id) catch + return error.InvalidTranscriptMetadata; + if (input.committed_len == 0 or input.session_id.len > 255) { + return error.InvalidTranscriptMetadata; + } + var out: std.Io.Writer.Allocating = .init(alloc); + defer out.deinit(); + try out.writer.writeAll(magic); + try out.writer.writeByte(schema_version); + try writeInt(&out.writer, u16, @intCast(input.session_id.len)); + try writeInt(&out.writer, u64, input.committed_len); + try out.writer.writeAll(&input.position.log_generation); + try writeInt(&out.writer, u64, input.position.through_seq); + try out.writer.writeAll(&input.position.through_event_id); + try writeInt(&out.writer, u64, input.position.through_event_log_bytes); + try out.writer.writeAll(input.session_id); + return out.toOwnedSlice(); +} + +fn decodeMetadata(alloc: std.mem.Allocator, bytes: []const u8) !Metadata { + if (bytes.len < fixed_metadata_bytes or bytes.len > max_metadata_bytes) { + return error.InvalidTranscriptMetadata; + } + var cursor = Cursor{ .bytes = bytes }; + if (!std.mem.eql(u8, try cursor.take(magic.len), magic) or + (try cursor.take(1))[0] != schema_version) + { + return error.InvalidTranscriptMetadata; + } + const session_id_len = try cursor.readInt(u16); + const committed_len = try cursor.readInt(u64); + const log_generation = try cursor.take(16); + const through_seq = try cursor.readInt(u64); + const through_event_id = try cursor.take(16); + const through_event_log_bytes = try cursor.readInt(u64); + if (session_id_len == 0 or session_id_len > 255 or committed_len == 0) { + return error.InvalidTranscriptMetadata; + } + const session_id = try cursor.take(session_id_len); + if (cursor.remaining() != 0) return error.InvalidTranscriptMetadata; + session_layout.validateSessionId(session_id) catch + return error.InvalidTranscriptMetadata; + return .{ + .session_id = try alloc.dupe(u8, session_id), + .committed_len = committed_len, + .position = .{ + .log_generation = log_generation[0..16].*, + .through_seq = through_seq, + .through_event_id = through_event_id[0..16].*, + .through_event_log_bytes = through_event_log_bytes, + }, + }; +} + +pub fn publishComplete( + alloc: std.mem.Allocator, + session_dir: *io_mod.VerifiedDir, + session_id: []const u8, + position: session_replay.CommitPosition, + bytes: []const u8, +) !void { + if (bytes.len == 0 or try validatedPrefix(bytes, true) != bytes.len) { + return error.InvalidTranscriptGrammar; + } + try io_mod.durableReplaceVerified(alloc, session_dir, data_file, bytes); + const encoded = try encodeMetadata(alloc, .{ + .session_id = session_id, + .committed_len = @intCast(bytes.len), + .position = position, + }); + defer alloc.free(encoded); + try io_mod.durableReplaceVerified(alloc, session_dir, metadata_file, encoded); +} + +pub fn appendAndCommit( + alloc: std.mem.Allocator, + session_dir: *io_mod.VerifiedDir, + session_id: []const u8, + prior_committed_len: u64, + position: session_replay.CommitPosition, + bytes: []const u8, +) !u64 { + return appendAndCommitWithOps( + alloc, + session_dir, + session_id, + prior_committed_len, + position, + bytes, + .{}, + ); +} + +fn appendAndCommitWithOps( + alloc: std.mem.Allocator, + session_dir: *io_mod.VerifiedDir, + session_id: []const u8, + prior_committed_len: u64, + position: session_replay.CommitPosition, + bytes: []const u8, + metadata_ops: io_mod.DurableOps, +) !u64 { + if (prior_committed_len == 0 or bytes.len == 0 or + try validatedPrefix(bytes, true) != bytes.len) + { + return error.InvalidTranscriptAppend; + } + const next_committed_len = std.math.add( + u64, + prior_committed_len, + std.math.cast(u64, bytes.len) orelse return error.TranscriptTooLarge, + ) catch return error.TranscriptTooLarge; + + var file = try io_mod.openExistingRegularFile( + session_dir.dir, + data_file, + .read_write, + ); + defer file.close(io_mod.getIo()); + const stat = try file.stat(io_mod.getIo()); + if (!safeFile(stat)) return error.UnsafeTranscriptFile; + if (stat.size != prior_committed_len) return error.IncompleteTranscript; + try file.writePositionalAll(io_mod.getIo(), bytes, prior_committed_len); + try file.sync(io_mod.getIo()); + + const encoded = try encodeMetadata(alloc, .{ + .session_id = session_id, + .committed_len = next_committed_len, + .position = position, + }); + defer alloc.free(encoded); + try io_mod.durableReplaceVerifiedWithOps( + alloc, + session_dir, + metadata_file, + encoded, + metadata_ops, + ); + return next_committed_len; +} + +pub fn restampComplete( + alloc: std.mem.Allocator, + session_dir: *io_mod.VerifiedDir, + session_id: []const u8, + committed_len: u64, + position: session_replay.CommitPosition, +) !void { + if (committed_len == 0) return error.IncompleteTranscript; + var file = try io_mod.openExistingRegularFile( + session_dir.dir, + data_file, + .read_only, + ); + defer file.close(io_mod.getIo()); + const stat = try file.stat(io_mod.getIo()); + if (!safeFile(stat)) return error.UnsafeTranscriptFile; + if (stat.size != committed_len) return error.IncompleteTranscript; + + const encoded = try encodeMetadata(alloc, .{ + .session_id = session_id, + .committed_len = committed_len, + .position = position, + }); + defer alloc.free(encoded); + try io_mod.durableReplaceVerified(alloc, session_dir, metadata_file, encoded); +} + +pub fn admit( + alloc: std.mem.Allocator, + session_dir: *io_mod.VerifiedDir, + session_id: []const u8, + current_position: session_replay.CommitPosition, +) !Admission { + var metadata_handle = io_mod.openExistingRegularFile( + session_dir.dir, + metadata_file, + .read_only, + ) catch |err| switch (err) { + error.FileNotFound => return classifyMissingData(session_dir), + else => return .corrupt, + }; + defer metadata_handle.close(io_mod.getIo()); + const metadata_stat = metadata_handle.stat(io_mod.getIo()) catch return .corrupt; + if (!safeFile(metadata_stat) or + metadata_stat.size == 0 or + metadata_stat.size > max_metadata_bytes) + { + return .corrupt; + } + const metadata_bytes = io_mod.readFileToEnd( + alloc, + &metadata_handle, + max_metadata_bytes, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return .corrupt, + }; + defer alloc.free(metadata_bytes); + var metadata = decodeMetadata(alloc, metadata_bytes) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return .corrupt, + }; + defer metadata.deinit(alloc); + + var data = io_mod.openExistingRegularFile( + session_dir.dir, + data_file, + .read_only, + ) catch |err| switch (err) { + error.FileNotFound => return .incomplete, + else => return .corrupt, + }; + errdefer data.close(io_mod.getIo()); + const data_stat = data.stat(io_mod.getIo()) catch return .corrupt; + const admission_class = classify(.{ + .session_identity = if (std.mem.eql(u8, metadata.session_id, session_id)) + .matching + else + .mismatched, + .data_safety = if (safeFile(data_stat)) .safe else .unsafe, + .metadata = .valid, + .committed_len = metadata.committed_len, + .data_len = data_stat.size, + .stored_position = metadata.position, + .current_position = current_position, + }); + return switch (admission_class) { + .exact => .{ .exact = .{ + .file = data, + .committed_len = metadata.committed_len, + } }, + .incomplete => blk: { + data.close(io_mod.getIo()); + break :blk .incomplete; + }, + .corrupt => blk: { + data.close(io_mod.getIo()); + break :blk .corrupt; + }, + }; +} + +fn classifyMissingData(session_dir: *io_mod.VerifiedDir) Admission { + var data = io_mod.openExistingRegularFile( + session_dir.dir, + data_file, + .read_only, + ) catch |err| return switch (err) { + error.FileNotFound => .missing, + else => .corrupt, + }; + defer data.close(io_mod.getIo()); + const stat = data.stat(io_mod.getIo()) catch return .corrupt; + return if (safeFile(stat) and stat.size > 0) .incomplete else .corrupt; +} + +fn safeFile(stat: std.Io.File.Stat) bool { + return stat.kind == .file and stat.nlink == 1 and + stat.permissions.toMode() & 0o777 == 0o600; +} + +fn appendDataForTest(session_dir: *io_mod.VerifiedDir, bytes: []const u8) !void { + var file = try io_mod.openExistingRegularFile( + session_dir.dir, + data_file, + .read_write, + ); + defer file.close(io_mod.getIo()); + const offset = try file.length(io_mod.getIo()); + try file.writePositionalAll(io_mod.getIo(), bytes, offset); + try file.sync(io_mod.getIo()); +} + +fn writeInt(writer: *std.Io.Writer, comptime T: type, value: T) !void { + var bytes: [@sizeOf(T)]u8 = undefined; + std.mem.writeInt(T, &bytes, value, .little); + try writer.writeAll(&bytes); +} + +const Cursor = struct { + bytes: []const u8, + pos: usize = 0, + + fn take(self: *Cursor, len: usize) ![]const u8 { + if (len > self.remaining()) return error.InvalidTranscriptMetadata; + defer self.pos += len; + return self.bytes[self.pos..][0..len]; + } + + fn readInt(self: *Cursor, comptime T: type) !T { + const raw = try self.take(@sizeOf(T)); + return std.mem.readInt(T, raw[0..@sizeOf(T)], .little); + } + + fn remaining(self: Cursor) usize { + return self.bytes.len - self.pos; + } +}; + +const AdmissionClass = enum { + exact, + incomplete, + corrupt, +}; + +const IdentityState = enum { + matching, + mismatched, +}; + +const DataSafety = enum { + safe, + unsafe, +}; + +const MetadataState = enum { + valid, + missing, + invalid, +}; + +const AdmissionFacts = struct { + session_identity: IdentityState, + data_safety: DataSafety, + metadata: MetadataState, + committed_len: u64, + data_len: u64, + stored_position: session_replay.CommitPosition, + current_position: session_replay.CommitPosition, +}; + +fn classify(facts: AdmissionFacts) AdmissionClass { + if (facts.session_identity == .mismatched or + facts.data_safety == .unsafe or + facts.metadata == .invalid or + facts.committed_len > facts.data_len) + { + return .corrupt; + } + if (facts.metadata == .missing or + facts.committed_len == 0 or + facts.committed_len != facts.data_len or + !positionsEqual(facts.stored_position, facts.current_position)) + { + return .incomplete; + } + return .exact; +} + +fn positionsEqual( + lhs: session_replay.CommitPosition, + rhs: session_replay.CommitPosition, +) bool { + return std.meta.eql(lhs, rhs); +} + +pub const max_control_sequence_bytes: usize = 4096; + +pub const GrammarError = error{ + InvalidTranscriptGrammar, + TruncatedTranscriptSequence, +}; + +/// Returns the largest prefix ending on a complete terminal-grammar token. +/// The caller retains the suffix and prepends it to the next fixed-size read. +pub fn validatedPrefix(bytes: []const u8, eof: bool) GrammarError!usize { + var index: usize = 0; + var safe_end: usize = 0; + while (index < bytes.len) { + const byte = bytes[index]; + if (byte == 0x1b) { + const sequence_len = try controlSequenceLen(bytes[index..], eof); + if (sequence_len == null) return safe_end; + index += sequence_len.?; + safe_end = index; + continue; + } + if (byte == '\n' or byte == '\r' or byte == '\t') { + index += 1; + safe_end = index; + continue; + } + if (byte < 0x20 or byte == 0x7f) return error.InvalidTranscriptGrammar; + if (byte < 0x80) { + index += 1; + safe_end = index; + continue; + } + + const sequence_len = utf8SequenceLen(byte) orelse + return error.InvalidTranscriptGrammar; + if (sequence_len > bytes.len - index) { + if (eof) return error.TruncatedTranscriptSequence; + return safe_end; + } + const sequence = bytes[index..][0..sequence_len]; + if (!std.unicode.utf8ValidateSlice(sequence)) { + return error.InvalidTranscriptGrammar; + } + index += sequence_len; + safe_end = index; + } + return safe_end; +} + +fn utf8SequenceLen(first: u8) ?usize { + if (first >= 0xc2 and first <= 0xdf) return 2; + if (first >= 0xe0 and first <= 0xef) return 3; + if (first >= 0xf0 and first <= 0xf4) return 4; + return null; +} + +fn controlSequenceLen(bytes: []const u8, eof: bool) GrammarError!?usize { + if (bytes.len < 2) return incompleteSequence(eof); + return switch (bytes[1]) { + '[' => sgrSequenceLen(bytes, eof), + ']' => osc8SequenceLen(bytes, eof), + else => error.InvalidTranscriptGrammar, + }; +} + +fn sgrSequenceLen(bytes: []const u8, eof: bool) GrammarError!?usize { + var index: usize = 2; + while (index < bytes.len and index < max_control_sequence_bytes) : (index += 1) { + const byte = bytes[index]; + if (byte == 'm') return index + 1; + if (!std.ascii.isDigit(byte) and byte != ';' and byte != ':') { + return error.InvalidTranscriptGrammar; + } + } + if (index >= max_control_sequence_bytes) return error.InvalidTranscriptGrammar; + return incompleteSequence(eof); +} + +fn osc8SequenceLen(bytes: []const u8, eof: bool) GrammarError!?usize { + if (bytes.len < 4) return incompleteSequence(eof); + if (bytes[2] != '8' or bytes[3] != ';') return error.InvalidTranscriptGrammar; + + var index: usize = 4; + var saw_uri_separator = false; + while (index < bytes.len and index < max_control_sequence_bytes) : (index += 1) { + const byte = bytes[index]; + if (byte >= 0x80) { + const sequence_len = utf8SequenceLen(byte) orelse + return error.InvalidTranscriptGrammar; + if (sequence_len > bytes.len - index) return incompleteSequence(eof); + if (!std.unicode.utf8ValidateSlice(bytes[index..][0..sequence_len])) { + return error.InvalidTranscriptGrammar; + } + index += sequence_len - 1; + continue; + } + if (!saw_uri_separator) { + if (byte == ';') { + saw_uri_separator = true; + continue; + } + if (byte < 0x20 or byte == 0x7f or byte == 0x1b) { + return error.InvalidTranscriptGrammar; + } + continue; + } + if (byte == 0x1b) { + if (index + 1 >= bytes.len) return incompleteSequence(eof); + if (bytes[index + 1] != '\\') return error.InvalidTranscriptGrammar; + return index + 2; + } + if ((byte < 0x20 and byte != '\t') or byte == 0x7f) { + return error.InvalidTranscriptGrammar; + } + } + if (index >= max_control_sequence_bytes) return error.InvalidTranscriptGrammar; + return incompleteSequence(eof); +} + +fn incompleteSequence(eof: bool) GrammarError!?usize { + if (eof) return error.TruncatedTranscriptSequence; + return null; +} + +const test_position = session_replay.CommitPosition{ + .log_generation = [_]u8{0x11} ** 16, + .through_seq = 7, + .through_event_id = [_]u8{0x22} ** 16, + .through_event_log_bytes = 4096, +}; + +test "transcript admission is exact only for a complete matching record" { + const exact = classify(.{ + .session_identity = .matching, + .data_safety = .safe, + .metadata = .valid, + .committed_len = 128, + .data_len = 128, + .stored_position = test_position, + .current_position = test_position, + }); + try std.testing.expectEqual(AdmissionClass.exact, exact); + + var advanced = test_position; + advanced.through_seq += 1; + try std.testing.expectEqual(AdmissionClass.incomplete, classify(.{ + .session_identity = .matching, + .data_safety = .safe, + .metadata = .valid, + .committed_len = 128, + .data_len = 128, + .stored_position = test_position, + .current_position = advanced, + })); + try std.testing.expectEqual(AdmissionClass.incomplete, classify(.{ + .session_identity = .matching, + .data_safety = .safe, + .metadata = .valid, + .committed_len = 128, + .data_len = 129, + .stored_position = test_position, + .current_position = test_position, + })); + try std.testing.expectEqual(AdmissionClass.corrupt, classify(.{ + .session_identity = .matching, + .data_safety = .safe, + .metadata = .valid, + .committed_len = 129, + .data_len = 128, + .stored_position = test_position, + .current_position = test_position, + })); + try std.testing.expectEqual(AdmissionClass.corrupt, classify(.{ + .session_identity = .mismatched, + .data_safety = .safe, + .metadata = .valid, + .committed_len = 128, + .data_len = 128, + .stored_position = test_position, + .current_position = test_position, + })); +} + +test "transcript metadata round trips complete session identity and position" { + const alloc = std.testing.allocator; + const encoded = try encodeMetadata(alloc, .{ + .session_id = "session-1", + .committed_len = 8192, + .position = test_position, + }); + defer alloc.free(encoded); + + var decoded = try decodeMetadata(alloc, encoded); + defer decoded.deinit(alloc); + try std.testing.expectEqualStrings("session-1", decoded.session_id); + try std.testing.expectEqual(@as(u64, 8192), decoded.committed_len); + try std.testing.expectEqual(test_position, decoded.position); + + try std.testing.expectError( + error.InvalidTranscriptMetadata, + decodeMetadata(alloc, encoded[0 .. encoded.len - 1]), + ); +} + +test "transcript grammar releases only complete UTF-8 SGR and OSC 8 sequences" { + const styled_link = "ok \x1b[38;2;1;2;3m\x1b]8;id=fx;https://example.com\x1b\\é\x1b]8;;\x1b\\\r\n"; + try std.testing.expectEqual( + styled_link.len, + try validatedPrefix(styled_link, true), + ); + + try std.testing.expectEqual( + @as(usize, 3), + try validatedPrefix("ok \xe2", false), + ); + try std.testing.expectError( + error.TruncatedTranscriptSequence, + validatedPrefix("ok \xe2", true), + ); + try std.testing.expectEqual( + @as(usize, 3), + try validatedPrefix("ok \x1b]8;;https://example.com", false), + ); + try std.testing.expectError( + error.InvalidTranscriptGrammar, + validatedPrefix("unsafe\x1b[2J", true), + ); + try std.testing.expectError( + error.InvalidTranscriptGrammar, + validatedPrefix("unsafe\x07", true), + ); + try std.testing.expectError( + error.InvalidTranscriptGrammar, + validatedPrefix("\x1b]8;;https://example.com/\xff\x1b\\", true), + ); +} + +test "transcript record admits exact committed bytes and treats a staged tail as incomplete" { + const alloc = std.testing.allocator; + var temp = std.testing.tmpDir(.{}); + defer temp.cleanup(); + var verified = io_mod.VerifiedDir{ .dir = try temp.dir.openDir( + std.testing.io, + ".", + .{ .iterate = true, .follow_symlinks = false }, + ) }; + defer verified.close(); + + try publishComplete( + alloc, + &verified, + "session-1", + test_position, + "one\r\ntwo\r\n", + ); + var exact = try admit( + alloc, + &verified, + "session-1", + test_position, + ); + defer exact.deinit(); + try std.testing.expect(exact == .exact); + var buffer: [32]u8 = undefined; + const read = try exact.exact.readAt(&buffer, 0); + try std.testing.expectEqualStrings("one\r\ntwo\r\n", buffer[0..read]); + + try appendDataForTest(&verified, "staged"); + var incomplete = try admit( + alloc, + &verified, + "session-1", + test_position, + ); + defer incomplete.deinit(); + try std.testing.expect(incomplete == .incomplete); +} + +test "transcript append advances committed bytes only after durable data" { + const alloc = std.testing.allocator; + var temp = std.testing.tmpDir(.{}); + defer temp.cleanup(); + var verified = io_mod.VerifiedDir{ .dir = try temp.dir.openDir( + std.testing.io, + ".", + .{ .iterate = true, .follow_symlinks = false }, + ) }; + defer verified.close(); + + try publishComplete(alloc, &verified, "session-1", test_position, "one\r\n"); + var advanced = test_position; + advanced.through_seq += 1; + advanced.through_event_log_bytes += 64; + advanced.through_event_id = [_]u8{0x33} ** 16; + const committed = try appendAndCommit( + alloc, + &verified, + "session-1", + "one\r\n".len, + advanced, + "two\r\n", + ); + try std.testing.expectEqual(@as(u64, 10), committed); + + var exact = try admit(alloc, &verified, "session-1", advanced); + defer exact.deinit(); + try std.testing.expect(exact == .exact); + var buffer: [16]u8 = undefined; + const read = try exact.exact.readAt(&buffer, 0); + try std.testing.expectEqualStrings("one\r\ntwo\r\n", buffer[0..read]); +} + +test "transcript metadata can restamp unchanged complete bytes" { + const alloc = std.testing.allocator; + var temp = std.testing.tmpDir(.{}); + defer temp.cleanup(); + var verified = io_mod.VerifiedDir{ .dir = try temp.dir.openDir( + std.testing.io, + ".", + .{ .iterate = true, .follow_symlinks = false }, + ) }; + defer verified.close(); + try publishComplete(alloc, &verified, "session-1", test_position, "stable"); + var advanced = test_position; + advanced.through_seq += 1; + advanced.through_event_id = [_]u8{0x55} ** 16; + advanced.through_event_log_bytes += 32; + try restampComplete(alloc, &verified, "session-1", 6, advanced); + + var exact = try admit(alloc, &verified, "session-1", advanced); + defer exact.deinit(); + try std.testing.expect(exact == .exact); +} + +test "transcript metadata failure leaves a recoverable staged tail" { + const alloc = std.testing.allocator; + var temp = std.testing.tmpDir(.{}); + defer temp.cleanup(); + var verified = io_mod.VerifiedDir{ .dir = try temp.dir.openDir( + std.testing.io, + ".", + .{ .iterate = true, .follow_symlinks = false }, + ) }; + defer verified.close(); + try publishComplete(alloc, &verified, "session-1", test_position, "first"); + + const Failure = struct { + fn syncFile(_: ?*anyopaque, _: std.Io.File) anyerror!void { + return error.NoSpaceLeft; + } + }; + var advanced = test_position; + advanced.through_seq += 1; + advanced.through_event_id = [_]u8{0x66} ** 16; + advanced.through_event_log_bytes += 32; + try std.testing.expectError( + error.DurableReplacePreRenameFailed, + appendAndCommitWithOps( + alloc, + &verified, + "session-1", + 5, + advanced, + " second", + .{ .sync_file = Failure.syncFile }, + ), + ); + + var admission = try admit(alloc, &verified, "session-1", advanced); + defer admission.deinit(); + try std.testing.expect(admission == .incomplete); +} + +test "transcript admission fails closed for unsafe or malformed committed files" { + const alloc = std.testing.allocator; + var temp = std.testing.tmpDir(.{}); + defer temp.cleanup(); + var verified = io_mod.VerifiedDir{ .dir = try temp.dir.openDir( + std.testing.io, + ".", + .{ .iterate = true, .follow_symlinks = false }, + ) }; + defer verified.close(); + try publishComplete(alloc, &verified, "session-1", test_position, "safe"); + + var data = try io_mod.openExistingRegularFile( + verified.dir, + data_file, + .read_write, + ); + try data.setPermissions( + std.testing.io, + std.Io.File.Permissions.fromMode(0o644), + ); + data.close(std.testing.io); + var unsafe = try admit(alloc, &verified, "session-1", test_position); + defer unsafe.deinit(); + try std.testing.expect(unsafe == .corrupt); + + var writable = try verified.dir.openFile(std.testing.io, data_file, .{ .mode = .read_write }); + try writable.setPermissions( + std.testing.io, + std.Io.File.Permissions.fromMode(0o600), + ); + writable.close(std.testing.io); + try io_mod.durableReplaceVerified(alloc, &verified, metadata_file, "malformed"); + var malformed = try admit(alloc, &verified, "session-1", test_position); + defer malformed.deinit(); + try std.testing.expect(malformed == .corrupt); +} diff --git a/src/core/subagent/resume_admission.zig b/src/core/subagent/resume_admission.zig index b53fca30f5..93e34e0272 100644 --- a/src/core/subagent/resume_admission.zig +++ b/src/core/subagent/resume_admission.zig @@ -217,27 +217,27 @@ pub fn resumeForExternalPrompt( return loaded; } -pub fn admitResumeViewForExternalPrompt( +pub fn admitTranscriptForExternalPrompt( store: session_store.Store, alloc: Allocator, target: session_store.ResumeTarget, -) !?session_store.ResumeViewAdmission { - var admission = (try store.admitResumeView(alloc, target)) orelse return null; +) !?session_store.TranscriptAdmission { + var admission = (try store.admitTranscript(alloc, target)) orelse return null; errdefer admission.deinit(alloc); try ensureExternalPromptAllowed(store, alloc, admission.sessionId(), true); return admission; } -pub fn resumeAdmittedForExternalPrompt( +pub fn resumeTranscriptAdmittedForExternalPrompt( store: session_store.Store, alloc: Allocator, - admission: *session_store.ResumeViewAdmission, + admission: *session_store.TranscriptAdmission, session_id: []const u8, workspace_root: []const u8, options: session_store.ResumeOptions, ) !session_store.LoadedWritableSession { try ensureExternalPromptAllowed(store, alloc, session_id, true); - var loaded = try store.resumeAdmittedForWrite( + var loaded = try store.resumeTranscriptAdmittedForWrite( alloc, admission, session_id, @@ -406,7 +406,7 @@ test "external prompt resume rejects a canonical one-off child" { error.OneOffSessionNotResumable, "one-off-child", ); - if (admitResumeViewForExternalPrompt( + if (admitTranscriptForExternalPrompt( env.store, alloc, .{ .id = "one-off-child" }, @@ -415,7 +415,7 @@ test "external prompt resume rejects a canonical one-off child" { var admission = value; admission.deinit(alloc); } - return error.TestExpectedResumeViewAdmissionError; + return error.TestExpectedTranscriptAdmissionError; } else |err| try std.testing.expectEqual(error.OneOffSessionNotResumable, err); } @@ -742,22 +742,18 @@ const TestEnvironment = struct { loaded.deinit(alloc); } - fn createResumeViewAdmission( + fn createTranscriptAdmission( self: *TestEnvironment, alloc: Allocator, session_id: []const u8, - ) !session_store.ResumeViewAdmission { + ) !session_store.TranscriptAdmission { { var loaded = try self.store.resumeForWrite(alloc, session_id); defer loaded.deinit(alloc); - try loaded.writeResumeView(alloc, .{ - .terminal_rows = 24, - .terminal_cols = 80, - .complete = true, - }, "cached transcript\n"); + try loaded.publishTranscript(alloc, "cached transcript\r\n"); } - return (try self.store.admitResumeView(alloc, .{ .id = session_id })) orelse - error.TestExpectedResumeViewAdmission; + return (try self.store.admitTranscript(alloc, .{ .id = session_id })) orelse + error.TestExpectedTranscriptAdmission; } fn expectAdmittedResumeError( @@ -766,9 +762,9 @@ const TestEnvironment = struct { expected: anyerror, session_id: []const u8, ) !void { - var admission = try self.createResumeViewAdmission(alloc, session_id); + var admission = try self.createTranscriptAdmission(alloc, session_id); defer admission.deinit(alloc); - try expectResumeError(alloc, expected, resumeAdmittedForExternalPrompt( + try expectResumeError(alloc, expected, resumeTranscriptAdmittedForExternalPrompt( self.store, alloc, &admission, diff --git a/src/main.zig b/src/main.zig index b486428612..9092204f08 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1266,7 +1266,7 @@ const App = struct { ); } - pub fn installResumeProjection( + pub fn installResumeProjectionRetained( self: *App, projection: *ResumeProjection, ) !void { @@ -1280,7 +1280,7 @@ const App = struct { } projection.pending_diffs.clearRetainingCapacity(); self.next_diff_id = projection.next_diff_id; - projection.install(&self.shell); + projection.installRetained(&self.shell); } pub fn historicalToolActivityKind( @@ -1294,8 +1294,8 @@ const App = struct { try self.flushRequestedFrame(); } - pub fn persistResumeViewAfterFrame(self: *App) void { - SessionAppRuntime.persistResumeViewAfterFrame(self); + pub fn persistSessionTranscriptAfterFrame(self: *App) void { + SessionAppRuntime.persistSessionTranscriptAfterFrame(self); } pub fn flushDirectTerminalShutdownOutcome(self: *App) !void { @@ -4168,6 +4168,7 @@ test { _ = @import("ui/footer/surface_invalidation.zig"); _ = @import("ui/full_transcript_screen.zig"); _ = @import("ui/render_engine/frame_fixed_point.zig"); + _ = @import("ui/transcript/resume_projection.zig"); _ = @import("ui/transcript/runtime.zig"); _ = @import("ui/transcript/runtime_tests.zig"); _ = @import("core/agent/worker_runtime.zig"); diff --git a/src/ui/render_engine/transcript_blocks.zig b/src/ui/render_engine/transcript_blocks.zig index 04f4f17349..677f6e26f5 100644 --- a/src/ui/render_engine/transcript_blocks.zig +++ b/src/ui/render_engine/transcript_blocks.zig @@ -205,7 +205,7 @@ pub fn blockGapRowsBetween(prev: TranscriptBlockKind, next: TranscriptBlockKind) return default_block_gap_policy.gapBetween(prev, next); } -fn blockKindForEntry(entry: TranscriptEntry) TranscriptBlockKind { +pub fn blockKindForEntry(entry: TranscriptEntry) TranscriptBlockKind { return switch (entry) { .raw_bytes => |e| blockKindForRawClass(e.class), .semantic_notice => |e| blockKindForNoticeTone(e.tone), @@ -1872,6 +1872,16 @@ const RenderedEntries = struct { bytes: []u8, target_entry_start_line: ?usize, target_entry_start_byte: ?usize, + entry_spans: []EntryByteSpan = &.{}, +}; + +pub const EntryByteSpan = struct { + entry_id: u32, + kind: TranscriptBlockKind, + start: usize, + content_start: usize, + end: usize, + ends_with_newline: bool = false, }; const BlockSequenceState = struct { @@ -1919,6 +1929,7 @@ const RenderEntriesOptions = struct { summary_transcript_indices: []usize = &.{}, line_provenance: ?*std.ArrayList(LineProvenance) = null, entry_overrides: []const EntryRenderOverride = &.{}, + capture_entry_spans: bool = false, fn resetSummaryIndices(self: RenderEntriesOptions) void { std.debug.assert(self.summary_entry_ids.len == self.summary_transcript_indices.len); @@ -1961,6 +1972,7 @@ const RenderEntriesOptions = struct { const RenderEntriesBuilder = struct { out: std.ArrayList(u8) = .empty, + entry_spans: std.ArrayList(EntryByteSpan) = .empty, sequence: BlockSequenceState = .{}, line_index: usize = 0, target_entry_start_line: ?usize = null, @@ -1968,6 +1980,7 @@ const RenderEntriesBuilder = struct { fn deinit(self: *RenderEntriesBuilder, alloc: Allocator) void { self.out.deinit(alloc); + self.entry_spans.deinit(alloc); } fn appendSeparatorBefore( @@ -2021,7 +2034,9 @@ const RenderEntriesBuilder = struct { options: RenderEntriesOptions, checkpoint: ?*build_checkpoint.BuildCheckpoint, ) !void { + const span_start = self.out.items.len; try self.appendSeparatorBefore(alloc, block.kind, options.line_provenance); + const content_start = self.out.items.len; const entry_id = entry.id(); if (options.target_entry_id == entry_id) self.target_entry_start_line = self.line_index; @@ -2039,6 +2054,17 @@ const RenderEntriesBuilder = struct { } try self.out.appendSlice(alloc, block.bytes); + if (options.capture_entry_spans) { + try self.entry_spans.append(alloc, .{ + .entry_id = entry_id, + .kind = block.kind, + .start = span_start, + .content_start = content_start, + .end = self.out.items.len, + .ends_with_newline = self.out.items.len > span_start and + self.out.items[self.out.items.len - 1] == '\n', + }); + } try appendBlockProvenance(alloc, options.line_provenance, entry, block); for (block.bytes) |byte| { try build_checkpoint.tick(checkpoint); @@ -2049,10 +2075,18 @@ const RenderEntriesBuilder = struct { fn finish(self: *RenderEntriesBuilder, alloc: Allocator) !RenderedEntries { try self.out.appendNTimes(alloc, '\n', self.sequence.finalNewlineCount()); + if (self.entry_spans.items.len > 0) { + self.entry_spans.items[self.entry_spans.items.len - 1].end = self.out.items.len; + self.entry_spans.items[self.entry_spans.items.len - 1].ends_with_newline = + self.out.items.len > 0 and self.out.items[self.out.items.len - 1] == '\n'; + } + const entry_spans = try self.entry_spans.toOwnedSlice(alloc); + errdefer if (entry_spans.len > 0) alloc.free(entry_spans); return .{ .bytes = try self.out.toOwnedSlice(alloc), .target_entry_start_line = self.target_entry_start_line, .target_entry_start_byte = self.target_entry_start_byte, + .entry_spans = entry_spans, }; } }; @@ -2242,11 +2276,13 @@ pub const TranscriptPreparationBytes = struct { target_entry_start_byte: ?usize = null, folded_summary_indices: []usize, line_provenance: []const LineProvenance = &.{}, + entry_spans: []EntryByteSpan = &.{}, pub fn deinit(self: *TranscriptPreparationBytes, alloc: Allocator) void { alloc.free(self.bytes); alloc.free(self.folded_summary_indices); if (self.line_provenance.len > 0) alloc.free(self.line_provenance); + if (self.entry_spans.len > 0) alloc.free(self.entry_spans); self.* = undefined; } }; @@ -2297,7 +2333,7 @@ pub fn renderEntriesForPreparationInterruptible( errdefer alloc.free(summary_indices); var line_provenance: std.ArrayList(LineProvenance) = .empty; errdefer line_provenance.deinit(alloc); - const rendered = try renderEntriesInterruptible( + var rendered = try renderEntriesInterruptible( alloc, entries, cols, @@ -2313,10 +2349,12 @@ pub fn renderEntriesForPreparationInterruptible( .summary_transcript_indices = summary_indices, .line_provenance = if (options.capture_provenance) &line_provenance else null, .entry_overrides = options.entry_overrides, + .capture_entry_spans = true, }, checkpoint, ); errdefer alloc.free(rendered.bytes); + errdefer if (rendered.entry_spans.len > 0) alloc.free(rendered.entry_spans); const boundary = splitTrailingBoundaryRows(rendered.bytes); const kept_line_count = renderedHardLineCount(rendered.bytes[0..boundary.content_len]); @@ -2337,11 +2375,20 @@ pub fn renderEntriesForPreparationInterruptible( .target_entry_start_byte = rendered.target_entry_start_byte, .folded_summary_indices = summary_indices, .line_provenance = owned_provenance, + .entry_spans = rendered.entry_spans, }; } const trimmed = try alloc.dupe(u8, rendered.bytes[0..boundary.content_len]); alloc.free(rendered.bytes); + if (rendered.entry_spans.len > 0) { + rendered.entry_spans[rendered.entry_spans.len - 1].end = @min( + rendered.entry_spans[rendered.entry_spans.len - 1].end, + boundary.content_len, + ); + const last = &rendered.entry_spans[rendered.entry_spans.len - 1]; + last.ends_with_newline = last.end > last.start and trimmed[last.end - 1] == '\n'; + } return .{ .bytes = trimmed, .trailing_boundary_blank_rows = boundary.blank_rows, @@ -2349,6 +2396,7 @@ pub fn renderEntriesForPreparationInterruptible( .target_entry_start_byte = rendered.target_entry_start_byte, .folded_summary_indices = summary_indices, .line_provenance = owned_provenance, + .entry_spans = rendered.entry_spans, }; } @@ -3519,6 +3567,26 @@ test "renderEntriesToBytes normalizes raw byte entry tails and inserts block gap try std.testing.expectEqualStrings("first\n\nsecond\n", out); } +test "preparation records entry byte spans including their leading separators" { + const alloc = std.testing.allocator; + var entries: std.ArrayList(TranscriptEntry) = .empty; + defer deinitTestEntries(&entries, alloc); + try appendRawTestEntry(&entries, alloc, 1, "one", .unknown_raw); + try appendRawTestEntry(&entries, alloc, 2, "two", .unknown_raw); + + var prepared = try renderEntriesForPreparation( + alloc, + entries.items, + 80, + .{}, + .{}, + ); + defer prepared.deinit(alloc); + try std.testing.expectEqual(@as(usize, 2), prepared.entry_spans.len); + try std.testing.expectEqual(EntryByteSpan{ .entry_id = 1, .kind = .unknown_raw, .start = 0, .content_start = 0, .end = 3, .ends_with_newline = false }, prepared.entry_spans[0]); + try std.testing.expectEqual(EntryByteSpan{ .entry_id = 2, .kind = .unknown_raw, .start = 3, .content_start = 5, .end = 8, .ends_with_newline = false }, prepared.entry_spans[1]); +} + test "compact presentation hides context notices while full presentation retains them" { const alloc = std.testing.allocator; var entries: std.ArrayList(TranscriptEntry) = .empty; diff --git a/src/ui/transcript/io.zig b/src/ui/transcript/io.zig index eafbbef7ef..9255b9e9e7 100644 --- a/src/ui/transcript/io.zig +++ b/src/ui/transcript/io.zig @@ -53,6 +53,26 @@ pub fn writeFrameBytes(shell: anytype, metrics: *Metrics, bytes: []const u8) ter return recordCompleteFrame(metrics, bytes); } +/// Resume-only terminal edge. `writeFrameBytes` remains the sole physical +/// writer; this helper mirrors exactly the accepted prefix into the shadow VT. +pub fn writeResumeStreamBytes( + shell: anytype, + metrics: *Metrics, + bytes: []const u8, +) !void { + const result = writeFrameBytes(shell, metrics, bytes); + const accepted_len = switch (result) { + .complete => bytes.len, + .partial => |partial| partial.accepted_bytes, + }; + const shadow = shell.shadow_vt orelse return error.ShadowTerminalUnavailable; + if (accepted_len > 0) try shadow.feed(bytes[0..accepted_len]); + switch (result) { + .complete => {}, + .partial => |partial| return partial.err, + } +} + fn recordPartialFrame(metrics: *Metrics, accepted: []const u8, err: anyerror) terminal_diff.FrameSinkWriteResult { record_tape.recordStdout(accepted); metrics.ansi_bytes += accepted.len; @@ -97,6 +117,27 @@ test "writeFrameBytes writes the frame without feeding shadow" { try std.testing.expectEqual(@as(usize, 3), metrics.ansi_bytes); } +test "resume stream writer feeds every accepted byte to the shadow" { + const FakeShell = struct { + stdout_file: std.Io.File, + shadow_vt: ?*vt_emulator.Grid = null, + }; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var file = try tmp.dir.createFile(std.testing.io, "resume-stream.log", .{ .read = true }); + defer file.close(io_mod.getIo()); + var shadow = try vt_emulator.Grid.init(std.testing.allocator, 8, 4); + defer shadow.deinit(); + shadow.autowrap = false; + var shell = FakeShell{ .stdout_file = file, .shadow_vt = &shadow }; + var metrics: Metrics = .{}; + + try writeResumeStreamBytes(&shell, &metrics, "\x1b[?7h123456789\x1b[?7l"); + try std.testing.expect(!shadow.autowrap); + try std.testing.expectEqual(@as(u21, '9'), shadow.cellAt(2, 1).?.codepoint); + try std.testing.expectEqual(@as(usize, 19), metrics.ansi_bytes); +} + test "standalone presentation bell is written without changing the shadow grid" { const FakeShell = struct { stdout_file: std.Io.File, diff --git a/src/ui/transcript/presentation_record.zig b/src/ui/transcript/presentation_record.zig new file mode 100644 index 0000000000..9b0abb5887 --- /dev/null +++ b/src/ui/transcript/presentation_record.zig @@ -0,0 +1,155 @@ +const std = @import("std"); +const transcript_blocks = @import("../render_engine/transcript_blocks.zig"); + +pub const EntrySpan = transcript_blocks.EntryByteSpan; + +pub const Cursor = union(enum) { + start, + after: struct { + entry_id: u32, + kind: transcript_blocks.TranscriptBlockKind, + ends_with_newline: bool, + }, + at: struct { + entry_id: u32, + entry_offset: usize, + cols: u16, + }, +}; + +pub const Decision = union(enum) { + hold, + incompatible, + append: struct { + start: usize, + end: usize, + prefix_newlines: u16, + next: Cursor, + }, +}; + +pub fn decide( + cursor: Cursor, + cols: u16, + source_len: usize, + finalized_end: usize, + spans: []const EntrySpan, +) Decision { + if (cols == 0 or finalized_end > source_len) return .incompatible; + var prefix_newlines: u16 = 0; + const start = switch (cursor) { + .start => 0, + .after => |after| blk: { + if (findSpan(spans, after.entry_id)) |span| break :blk span.end; + var recovery_index: ?usize = null; + for (spans, 0..) |span, index| { + if (span.entry_id > after.entry_id) { + recovery_index = index; + break; + } + } + const index = recovery_index orelse return .incompatible; + for (spans[0..index]) |span| { + if (span.entry_id > after.entry_id) return .incompatible; + } + for (spans[index..]) |span| { + if (span.entry_id <= after.entry_id) return .incompatible; + } + const first = spans[index]; + prefix_newlines = transcript_blocks.blockSeparatorNewlineCount( + after.kind, + first.kind, + ) -| @intFromBool(after.ends_with_newline); + break :blk first.content_start; + }, + .at => |at| blk: { + if (at.cols != cols) return .incompatible; + const span = findSpan(spans, at.entry_id) orelse return .incompatible; + const span_len = span.end -| span.content_start; + if (at.entry_offset > span_len) return .incompatible; + break :blk span.content_start + at.entry_offset; + }, + }; + if (finalized_end < start) return .incompatible; + if (finalized_end == start) return .hold; + if (spans.len == 0) return .incompatible; + + var endpoint: ?EntrySpan = null; + for (spans) |span| { + if (span.start > span.content_start or span.content_start > span.end or + span.end > source_len) + { + return .incompatible; + } + if (finalized_end >= span.content_start and finalized_end <= span.end) { + endpoint = span; + break; + } + } + const span = endpoint orelse return .incompatible; + const next: Cursor = if (finalized_end == span.end) + .{ .after = .{ + .entry_id = span.entry_id, + .kind = span.kind, + .ends_with_newline = span.ends_with_newline, + } } + else + .{ .at = .{ + .entry_id = span.entry_id, + .entry_offset = finalized_end - span.content_start, + .cols = cols, + } }; + return .{ .append = .{ + .start = start, + .end = finalized_end, + .prefix_newlines = prefix_newlines, + .next = next, + } }; +} + +fn findSpan(spans: []const EntrySpan, entry_id: u32) ?EntrySpan { + for (spans) |span| { + if (span.entry_id == entry_id) return span; + } + return null; +} + +test "presentation record cursor advances finalized entry bytes exactly once" { + const spans = [_]EntrySpan{ + .{ .entry_id = 1, .kind = .assistant_turn, .start = 0, .content_start = 0, .end = 4 }, + .{ .entry_id = 2, .kind = .user_turn, .start = 4, .content_start = 4, .end = 10 }, + }; + const first = decide(.start, 80, 10, 4, &spans); + try std.testing.expect(first == .append); + try std.testing.expectEqual(@as(usize, 0), first.append.start); + try std.testing.expectEqual(@as(usize, 4), first.append.end); + try std.testing.expect(first.append.next == .after); + try std.testing.expectEqual(@as(u32, 1), first.append.next.after.entry_id); + + const second = decide(first.append.next, 80, 10, 10, &spans); + try std.testing.expect(second == .append); + try std.testing.expectEqual(@as(usize, 4), second.append.start); + try std.testing.expectEqual(@as(usize, 10), second.append.end); + try std.testing.expect(decide(second.append.next, 80, 10, 10, &spans) == .hold); + try std.testing.expect(decide(second.append.next, 40, 10, 10, &spans) == .hold); + try std.testing.expect(decide(.{ .at = .{ + .entry_id = 2, + .entry_offset = 1, + .cols = 80, + } }, 40, 10, 10, &spans) == .incompatible); +} + +test "presentation record resumes after a fully recorded cursor entry is pruned" { + const spans = [_]EntrySpan{ + .{ .entry_id = 3, .kind = .system_notice, .start = 0, .content_start = 0, .end = 5 }, + .{ .entry_id = 9, .kind = .user_turn, .start = 5, .content_start = 5, .end = 10 }, + }; + const decision = decide(.{ .after = .{ + .entry_id = 8, + .kind = .assistant_turn, + .ends_with_newline = false, + } }, 80, 10, 10, &spans); + try std.testing.expect(decision == .append); + try std.testing.expectEqual(@as(usize, 5), decision.append.start); + try std.testing.expectEqual(@as(u16, 2), decision.append.prefix_newlines); +} diff --git a/src/ui/transcript/resume_projection.zig b/src/ui/transcript/resume_projection.zig index c12f5aa8f3..4aca3d9a8a 100644 --- a/src/ui/transcript/resume_projection.zig +++ b/src/ui/transcript/resume_projection.zig @@ -394,7 +394,15 @@ pub const ResumeProjection = struct { self.finalized = true; } - pub fn install(self: *ResumeProjection, target: *TranscriptRuntime) void { + pub fn publicationBytes(self: *const ResumeProjection) []const u8 { + std.debug.assert(self.finalized); + std.debug.assert(!self.consumed); + return self.publication_source.?.bytes; + } + + /// Installs structured continuation state after historical bytes were + /// already written directly to the terminal. No pending source is armed. + pub fn installRetained(self: *ResumeProjection, target: *TranscriptRuntime) void { std.debug.assert(self.finalized); std.debug.assert(!self.consumed); std.debug.assert(target.pending_resume_source == null); @@ -421,12 +429,13 @@ pub const ResumeProjection = struct { target.replaceable_last_line = self.runtime.replaceable_last_line; target.replaceable_row = self.runtime.replaceable_row; target.replaceable_start = self.runtime.replaceable_start; - target.pending_resume_source = self.publication_source; - self.publication_source = null; target.recomputeCursorFromTranscript(); - target.reconcileDetachedInstallSource(target.pendingResumeFlow()); + target.reconcileDetachedInstallSource(self.publication_source.?.bytes); target.markTranscriptDirty(); + var publication = self.publication_source.?; + self.publication_source = null; + publication.deinit(self.alloc); self.consumed = true; self.runtime.deinit(self.alloc); } @@ -526,42 +535,32 @@ test "empty resume projection keeps layout without source conversation" { try std.testing.expectEqual(@as(usize, 1), source.entries.items.len); } -test "resume projection installs complete publication and retained continuation together" { +test "resume projection can install retained continuation without publication" { const alloc = std.testing.allocator; var source: TranscriptRuntime = .{}; source.layout = .{ - .rows = 40, - .cols = 80, - .content_bottom = 36, - .divider_top_row = 37, - .input_row = 38, - .divider_bottom_row = 39, - .hint_row = 40, + .rows = 24, + .cols = 72, + .content_bottom = 20, + .divider_top_row = 21, + .input_row = 22, + .divider_bottom_row = 23, + .hint_row = 24, }; - source.max_retained_transcript_bytes = 96; defer source.deinit(alloc); - var target: TranscriptRuntime = .{}; target.layout = source.layout; - target.max_retained_transcript_bytes = source.max_retained_transcript_bytes; defer target.deinit(alloc); - var projection = try ResumeProjection.init(alloc, &source, 42, 1); + var projection = try ResumeProjection.initEmpty(alloc, &source, 42, 1); defer projection.deinit(); - for (0..12) |index| { - var text: [64]u8 = undefined; - const line = try std.fmt.bufPrint(&text, "publication marker {d}\n", .{index}); - _ = try projection.appendRawClassified(line, .unknown_raw); - } + _ = try projection.appendRawClassified("retained marker\n", .unknown_raw); try projection.finalize(); - projection.install(&target); - - try std.testing.expect(std.mem.find(u8, target.pendingResumeFlow(), "publication marker 0") != null); - try std.testing.expect(std.mem.find(u8, target.pendingResumeFlow(), "publication marker 11") != null); - try std.testing.expect(target.entries.items.len < 12); - var prepared = try target.prepareTranscriptSource(alloc, null); - defer prepared.deinit(alloc); - try std.testing.expectEqualStrings(target.pendingResumeFlow(), prepared.bytes); + projection.installRetained(&target); + + try std.testing.expectEqual(@as(usize, 0), target.pendingResumeFlow().len); + try std.testing.expectEqual(@as(usize, 1), target.entries.items.len); + try std.testing.expect(std.mem.find(u8, target.transcript.items, "retained marker") != null); } test "resume projection transfers a complete standalone runtime" { diff --git a/src/ui/transcript/runtime.zig b/src/ui/transcript/runtime.zig index 2a733991ef..282cbcf24a 100644 --- a/src/ui/transcript/runtime.zig +++ b/src/ui/transcript/runtime.zig @@ -6,6 +6,7 @@ const io_mod = @import("../../core/shared/io.zig"); const activity_status = @import("../../core/output/activity_status.zig"); const activity_runtime = @import("../../core/output/activity_runtime.zig"); const transcript_release = @import("../../core/output/transcript_release.zig"); +const presentation_record = @import("presentation_record.zig"); const transcript_presentation = @import("../../core/output/transcript_presentation.zig"); const worker_status = @import("../../core/output/worker_status.zig"); const footer_viewport_runtime = @import("../footer/viewport.zig"); @@ -45,18 +46,6 @@ pub const RawEntryClass = transcript_blocks.RawEntryClass; pub const TranscriptEntry = transcript_blocks.TranscriptEntry; pub const TranscriptPreparationSource = source_preparation.TranscriptPreparationSource; -pub const VisibleTranscriptSnapshot = struct { - text: []u8, - terminal_rows: u16, - terminal_cols: u16, - complete: bool, - - pub fn deinit(self: *VisibleTranscriptSnapshot, alloc: Allocator) void { - alloc.free(self.text); - self.* = undefined; - } -}; - pub const ResizeObservationPhase = enum { live, settle_pending, @@ -309,6 +298,26 @@ const MaterializedEndpoint = struct { cursor_col: u16 = 1, }; +pub const PendingPresentationRecord = struct { + bytes: []u8, + next_cursor: presentation_record.Cursor, + + pub fn deinit(self: *PendingPresentationRecord, alloc: Allocator) void { + alloc.free(self.bytes); + self.* = undefined; + } +}; + +const PresentationRecordState = union(enum) { + disabled, + active: struct { + cursor: presentation_record.Cursor, + committed_len: u64, + caught_up: bool, + }, + degraded, +}; + pub const TranscriptTransition = struct { scroll_plan: render_engine.frame_scroll_plan.FrameScrollPlan, document_append: render_engine.frame_scroll_plan.FrameDocumentAppend, @@ -331,6 +340,10 @@ pub const TranscriptTransition = struct { presentation_resume_bytes: []u8 = &.{}, presentation_pending_wrap: bool = false, presentation_valid: bool = false, + presentation_record_bytes: []u8 = &.{}, + presentation_record_next_cursor: ?presentation_record.Cursor = null, + presentation_record_incompatible: bool = false, + presentation_record_caught_up: bool = false, target_cache: std.ArrayList(u8), target_cache_unchanged: bool = false, target_cache_origin_untrimmed: bool = false, @@ -360,6 +373,7 @@ pub const TranscriptTransition = struct { pub fn deinit(self: *TranscriptTransition, alloc: Allocator) void { if (self.target_flow_owned and self.target_flow.len > 0) alloc.free(self.target_flow); if (self.presentation_resume_bytes.len > 0) alloc.free(self.presentation_resume_bytes); + if (self.presentation_record_bytes.len > 0) alloc.free(self.presentation_record_bytes); if (self.document_append_bytes.len > 0) { alloc.free(self.document_append_bytes); } @@ -379,6 +393,25 @@ const TranscriptBlockKind = transcript_blocks.TranscriptBlockKind; const WalkResult = transcript_blocks.WalkResult; const walkText = transcript_blocks.walkText; +fn prependPresentationRecordNewlines( + alloc: Allocator, + bytes: []const u8, + newline_count: u16, +) ![]u8 { + const prefix_len = std.math.mul(usize, newline_count, 2) catch + return error.TranscriptRecordTooLarge; + const combined_len = std.math.add(usize, prefix_len, bytes.len) catch + return error.TranscriptRecordTooLarge; + const combined = try alloc.alloc(u8, combined_len); + var prefix_index: usize = 0; + while (prefix_index < prefix_len) : (prefix_index += 2) { + combined[prefix_index] = '\r'; + combined[prefix_index + 1] = '\n'; + } + @memcpy(combined[prefix_len..], bytes); + return combined; +} + fn formatDurationCompact(buf: []u8, duration_ms: u64) []const u8 { const total_seconds = duration_ms / std.time.ms_per_s; if (total_seconds < 60) { @@ -1496,44 +1529,6 @@ test "cached compact transcript preserves pending resume flow" { try std.testing.expect(std.mem.find(u8, source.bytes, "full resume history") != null); } -test "startup resume view release preserves later structured notices" { - const alloc = std.testing.allocator; - var runtime = TranscriptRuntime{ - .layout = .{ - .rows = 24, - .cols = 80, - .content_bottom = 20, - .divider_top_row = 21, - .input_row = 22, - .divider_bottom_row = 23, - .hint_row = 24, - }, - .owned_top_row = 1, - }; - defer runtime.deinit(alloc); - - const resume_entry_id = try runtime.appendRawTranscriptEntryClassified( - alloc, - "cached resume view\n", - .unknown_raw, - ); - _ = try runtime.appendSemanticNotice(alloc, .{ - .topic = "recording", - .tone = .warning, - .body = "terminal capture is active", - }); - try std.testing.expect(std.mem.find(u8, runtime.transcript.items, "cached resume view") != null); - try std.testing.expect(std.mem.find(u8, runtime.transcript.items, "terminal capture is active") != null); - - try std.testing.expect(try runtime.releaseStartupResumeViewEntry(alloc, resume_entry_id)); - - try std.testing.expectEqual(@as(usize, 1), runtime.entries.items.len); - try std.testing.expect(runtime.entries.items[0] == .semantic_notice); - try std.testing.expect(std.mem.find(u8, runtime.transcript.items, "cached resume view") == null); - try std.testing.expect(std.mem.find(u8, runtime.transcript.items, "terminal capture is active") != null); - try std.testing.expect(!try runtime.releaseStartupResumeViewEntry(alloc, resume_entry_id)); -} - test "pending resume source publishes its line index once" { const alloc = std.testing.allocator; var runtime = TranscriptRuntime{ .layout = testLayoutWithRows(8) }; @@ -4179,6 +4174,9 @@ pub const TranscriptRuntime = struct { worker_status: worker_status.State = .none, /// Core-owned producer state and policy for native-history release. transcript_release: transcript_release.State = .{}, + presentation_record: PresentationRecordState = .disabled, + pending_presentation_record: ?PendingPresentationRecord = null, + presentation_record_restamp_ready: bool = false, /// Sorted by entry id so exact lookup stays bounded as history grows. tool_details: std.ArrayList(ToolDetailRecord) = .empty, /// Monotonically increasing id stamped onto each new entry by the @@ -4217,6 +4215,7 @@ pub const TranscriptRuntime = struct { /// shell rows before the renderer has had a chance to respect /// `viewport_top_row`. has_painted_transcript: bool = false, + streamed_session_history_active: bool = false, footer_viewport: footer_viewport_runtime.FooterViewport = .{}, extra_input_rows: u16 = 0, footer_reserved_base_rows: u16 = 3, @@ -4286,6 +4285,95 @@ pub const TranscriptRuntime = struct { return transcript_io.disableShadowVt(self); } + pub fn adoptStreamedSessionHistory(self: *TranscriptRuntime) void { + self.streamed_session_history_active = true; + } + + pub fn beginPresentationRecord(self: *TranscriptRuntime) void { + std.debug.assert(self.pending_presentation_record == null); + self.presentation_record = .{ .active = .{ + .cursor = .start, + .committed_len = 0, + .caught_up = false, + } }; + } + + pub fn resumePresentationRecord( + self: *TranscriptRuntime, + committed_len: u64, + ) void { + std.debug.assert(committed_len > 0); + std.debug.assert(self.pending_presentation_record == null); + const cursor: presentation_record.Cursor = if (self.entries.items.len == 0) + .start + else + .{ .after = .{ + .entry_id = self.entries.items[self.entries.items.len - 1].id(), + .kind = transcript_blocks.blockKindForEntry( + self.entries.items[self.entries.items.len - 1], + ), + .ends_with_newline = false, + } }; + self.presentation_record = .{ .active = .{ + .cursor = cursor, + .committed_len = committed_len, + .caught_up = true, + } }; + } + + pub fn takePendingPresentationRecord( + self: *TranscriptRuntime, + ) ?PendingPresentationRecord { + const pending = self.pending_presentation_record orelse return null; + self.pending_presentation_record = null; + return pending; + } + + pub fn takePresentationRecordRestamp(self: *TranscriptRuntime) bool { + const ready = self.presentation_record_restamp_ready; + self.presentation_record_restamp_ready = false; + return ready; + } + + pub fn presentationRecordCommittedLen(self: *const TranscriptRuntime) ?u64 { + return switch (self.presentation_record) { + .active => |state| state.committed_len, + .disabled, .degraded => null, + }; + } + + pub fn presentationRecordCanRestamp(self: *const TranscriptRuntime) bool { + if (self.pending_presentation_record != null or + self.transcript_band_dirty or + self.render_requests.hasReason(.transcript)) + { + return false; + } + return switch (self.presentation_record) { + .active => |state| state.caught_up, + .disabled, .degraded => false, + }; + } + + pub fn commitPresentationRecord( + self: *TranscriptRuntime, + next_cursor: presentation_record.Cursor, + committed_len: u64, + ) void { + switch (self.presentation_record) { + .active => |*state| { + state.cursor = next_cursor; + state.committed_len = committed_len; + state.caught_up = true; + }, + .disabled, .degraded => {}, + } + } + + pub fn degradePresentationRecord(self: *TranscriptRuntime) void { + self.presentation_record = .degraded; + } + pub fn bindDetachedCommitAllocator( self: *TranscriptRuntime, alloc: Allocator, @@ -4310,6 +4398,7 @@ pub const TranscriptRuntime = struct { for (self.entries.items) |*entry| entry.deinit(alloc); self.entries.deinit(alloc); self.transcript_commit_state.deinit(alloc); + if (self.pending_presentation_record) |*pending| pending.deinit(alloc); self.footer_viewport.deinit(alloc); self.ui_observer.deinit(alloc); } @@ -4585,6 +4674,7 @@ pub const TranscriptRuntime = struct { } pub fn clearTranscript(self: *TranscriptRuntime, alloc: Allocator) void { + self.streamed_session_history_active = false; const pending_resume_bytes = self.releasePendingResumeSource(alloc); if (pending_resume_bytes > 0) { debug_trace.logf( @@ -4599,88 +4689,6 @@ pub const TranscriptRuntime = struct { return transcript_store.clearTranscript(self, alloc); } - pub fn snapshotVisibleTranscriptText( - self: *const TranscriptRuntime, - alloc: Allocator, - max_bytes: usize, - ) !?VisibleTranscriptSnapshot { - const grid = self.shadow_vt orelse return null; - if (!self.has_painted_transcript or - self.last_visible_transcript_top_row == 0 or - self.last_visible_transcript_last_row < self.last_visible_transcript_top_row) - { - return null; - } - const footer_gap_rows = if (!self.committed_frame_layout.footer_area.isEmpty() and - self.committed_frame_layout.footer_area.top > self.last_visible_transcript_last_row) - self.committed_frame_layout.footer_area.top - self.last_visible_transcript_last_row - 1 - else - 0; - if (footer_gap_rows >= max_bytes) return null; - const text_byte_limit = max_bytes - footer_gap_rows; - var out: std.ArrayList(u8) = .empty; - errdefer out.deinit(alloc); - var start_row = self.last_visible_transcript_top_row; - const leading_welcome_visible = if (self.entries.items.len > 0 and - self.entries.items[0] == .raw_bytes and - self.entries.items[0].raw_bytes.class == .welcome and - self.last_viewport_selection != null) - self.last_viewport_selection.?.start_line == 0 or - (self.last_viewport_selection.?.split_active and - self.last_viewport_selection.?.split_prefix_lines > 0) - else - false; - if (leading_welcome_visible) { - var saw_welcome_text = false; - while (start_row <= self.last_visible_transcript_last_row) : (start_row += 1) { - var line: std.ArrayList(u8) = .empty; - defer line.deinit(grid.alloc); - try grid.rowTextTrimmed(start_row, &line); - if (line.items.len > 0) { - saw_welcome_text = true; - } else if (saw_welcome_text) { - start_row += 1; - break; - } - } - if (!saw_welcome_text or start_row > self.last_visible_transcript_last_row) return null; - } - var selected_start: ?u16 = null; - var selected_bytes: usize = 0; - var row = self.last_visible_transcript_last_row; - while (true) { - var line: std.Io.Writer.Allocating = .init(grid.alloc); - defer line.deinit(); - try grid.rowStyledTextTrimmed(row, &line.writer); - if (line.written().len >= text_byte_limit - selected_bytes) break; - selected_bytes += line.written().len + 1; - selected_start = row; - if (row == start_row) break; - row -= 1; - } - row = selected_start orelse return null; - var has_content = false; - while (row <= self.last_visible_transcript_last_row) : (row += 1) { - var line: std.Io.Writer.Allocating = .init(grid.alloc); - defer line.deinit(); - try grid.rowStyledTextTrimmed(row, &line.writer); - has_content = has_content or line.written().len > 0; - try out.appendSlice(alloc, line.written()); - try out.append(alloc, '\n'); - } - if (!has_content) { - out.deinit(alloc); - return null; - } - try out.appendNTimes(alloc, '\n', footer_gap_rows); - return .{ - .text = try out.toOwnedSlice(alloc), - .terminal_rows = grid.rows, - .terminal_cols = grid.cols, - .complete = selected_start.? == start_row, - }; - } - pub fn resetVisualEpoch(self: *TranscriptRuntime, alloc: Allocator, welcome: []const u8) !void { return transcript_store.resetVisualEpoch(self, alloc, welcome); } @@ -5918,14 +5926,6 @@ pub const TranscriptRuntime = struct { return transcript_store.appendRawTranscriptEntryClassified(self, alloc, text, class); } - pub fn releaseStartupResumeViewEntry( - self: *TranscriptRuntime, - alloc: Allocator, - entry_id: u32, - ) !bool { - return transcript_store.releaseStartupResumeViewEntry(self, alloc, entry_id); - } - pub fn appendTurnSummaryEntry(self: *TranscriptRuntime, alloc: Allocator, summary: types.TurnSummary) !u32 { var line_buf: [128]u8 = undefined; const line = formatTurnSummaryLine(&line_buf, summary); @@ -6396,6 +6396,18 @@ pub const TranscriptRuntime = struct { metrics: *Metrics, history_row_delta: ?i32, ) !void { + if (self.streamed_session_history_active) { + self.reflow_clear_guard_rows = 0; + self.resize_history_row_delta = null; + self.pending_resize_observation = null; + self.viewport_clear_pending = false; + self.terminal_reset_pending = false; + self.footer_viewport.has_frame = false; + self.footer_viewport.clearExternalInvalidation(); + self.invalidateTranscriptAnchor("streamed_session_history_resize"); + self.markTranscriptDirty(); + return; + } try self.requestTerminalReset(metrics); self.resize_history_row_delta = history_row_delta; } @@ -8246,6 +8258,148 @@ pub const TranscriptRuntime = struct { } } + var presentation_record_bytes: []u8 = &.{}; + var presentation_record_next_cursor: ?presentation_record.Cursor = null; + var presentation_record_incompatible = false; + var presentation_record_caught_up = false; + errdefer if (presentation_record_bytes.len > 0) { + alloc.free(presentation_record_bytes); + }; + if (self.pending_presentation_record == null and + source.recorded_entries_authoritative and + source.entry_spans.len > 0) + { + switch (self.presentation_record) { + .active => |record| { + const finalized_end = self.transcript_release.finality_floor( + source.finality, + self.finalizedToolTurnWatermark(), + if (source.replaceable_last_line) + source.replaceable_start + else + null, + ) orelse source.bytes.len; + switch (presentation_record.decide( + record.cursor, + source.cols, + source.bytes.len, + finalized_end, + source.entry_spans, + )) { + .hold => presentation_record_caught_up = true, + .incompatible => { + presentation_record_incompatible = true; + switch (record.cursor) { + .start => debug_trace.logf( + "session", + "event=session_transcript outcome=degraded stage=cursor_decision cursor=start spans={d} source_bytes={d} finalized_end={d}", + .{ source.entry_spans.len, source.bytes.len, finalized_end }, + ), + .after => |cursor| debug_trace.logf( + "session", + "event=session_transcript outcome=degraded stage=cursor_decision cursor=after entry_id={d} kind={s} spans={d} first_entry_id={d} last_entry_id={d} source_bytes={d} finalized_end={d}", + .{ + cursor.entry_id, + @tagName(cursor.kind), + source.entry_spans.len, + source.entry_spans[0].entry_id, + source.entry_spans[source.entry_spans.len - 1].entry_id, + source.bytes.len, + finalized_end, + }, + ), + .at => |cursor| debug_trace.logf( + "session", + "event=session_transcript outcome=degraded stage=cursor_decision cursor=at entry_id={d} entry_offset={d} spans={d} first_entry_id={d} last_entry_id={d} source_bytes={d} finalized_end={d}", + .{ + cursor.entry_id, + cursor.entry_offset, + source.entry_spans.len, + source.entry_spans[0].entry_id, + source.entry_spans[source.entry_spans.len - 1].entry_id, + source.bytes.len, + finalized_end, + }, + ), + } + }, + .append => |append| { + debug_trace.logf( + "session", + "event=session_transcript outcome=delta start={d} end={d} prefix_newlines={d} spans={d} source_bytes={d}", + .{ + append.start, + append.end, + append.prefix_newlines, + source.entry_spans.len, + source.bytes.len, + }, + ); + presentation_record_bytes = transcript_painter.prepareTranscriptDocumentAppendBytes( + alloc, + source.bytes, + source.cols, + append.start, + append.end, + append.end == source.bytes.len, + ) catch |err| failed: { + debug_trace.logf( + "session", + "event=session_transcript outcome=degraded stage=delta_prepare err={s}", + .{@errorName(err)}, + ); + presentation_record_incompatible = true; + break :failed &.{}; + }; + if (!presentation_record_incompatible and + presentation_record_bytes.len == 0) + { + presentation_record_bytes = alloc.dupe( + u8, + source.bytes[append.start..append.end], + ) catch |err| failed: { + debug_trace.logf( + "session", + "event=session_transcript outcome=degraded stage=delta_copy err={s}", + .{@errorName(err)}, + ); + presentation_record_incompatible = true; + break :failed &.{}; + }; + } + if (!presentation_record_incompatible and + append.prefix_newlines > 0) + { + const combined = prependPresentationRecordNewlines( + alloc, + presentation_record_bytes, + append.prefix_newlines, + ) catch |err| failed: { + debug_trace.logf( + "session", + "event=session_transcript outcome=degraded stage=separator_copy err={s}", + .{@errorName(err)}, + ); + presentation_record_incompatible = true; + alloc.free(presentation_record_bytes); + presentation_record_bytes = &.{}; + break :failed null; + }; + if (combined) |owned| { + alloc.free(presentation_record_bytes); + presentation_record_bytes = owned; + } + } + if (!presentation_record_incompatible) { + presentation_record_next_cursor = append.next; + } + }, + } + }, + .disabled, .degraded => {}, + } + } + const target_flow = source.bytes; if (!borrows_pending_resume) source.bytes = &.{}; errdefer if (!borrows_pending_resume and target_flow.len > 0) alloc.free(target_flow); @@ -8507,6 +8661,10 @@ pub const TranscriptRuntime = struct { .presentation_resume_bytes = presentation_resume_bytes, .presentation_pending_wrap = presentation_pending_wrap, .presentation_valid = presentation_valid, + .presentation_record_bytes = presentation_record_bytes, + .presentation_record_next_cursor = presentation_record_next_cursor, + .presentation_record_incompatible = presentation_record_incompatible, + .presentation_record_caught_up = presentation_record_caught_up, .target_cache = target_cache, .target_cache_unchanged = borrows_pending_resume, .target_cache_origin_untrimmed = target_cache_origin_untrimmed, @@ -8722,6 +8880,38 @@ pub const TranscriptRuntime = struct { self.has_painted_transcript = true; } + if (transition.presentation_record_incompatible) { + self.degradePresentationRecord(); + debug_trace.logf( + "session", + "event=session_transcript outcome=degraded reason=presentation_incompatible", + .{}, + ); + } else if (transition.presentation_record_bytes.len > 0) { + std.debug.assert(transition.presentation_record_next_cursor != null); + if (self.pending_presentation_record == null) { + self.pending_presentation_record = .{ + .bytes = transition.presentation_record_bytes, + .next_cursor = transition.presentation_record_next_cursor.?, + }; + transition.presentation_record_bytes = &.{}; + transition.presentation_record_next_cursor = null; + } else { + self.degradePresentationRecord(); + debug_trace.logf( + "session", + "event=session_transcript outcome=degraded reason=pending_delta_overlap", + .{}, + ); + } + } else if (transition.presentation_record_caught_up) { + switch (self.presentation_record) { + .active => |*state| state.caught_up = true, + .disabled, .degraded => {}, + } + self.presentation_record_restamp_ready = true; + } + previous_state.deinit(alloc); previous_cache.deinit(alloc); debug_trace.logf( @@ -9163,6 +9353,14 @@ pub const TranscriptRuntime = struct { } } + pub fn writeResumeStreamBytes( + self: *TranscriptRuntime, + metrics: *Metrics, + bytes: []const u8, + ) !void { + return transcript_io.writeResumeStreamBytes(self, metrics, bytes); + } + fn writeFrameSink(ctx: *anyopaque, metrics: *Metrics, bytes: []const u8) render_engine.terminal_diff.FrameSinkWriteResult { const self: *TranscriptRuntime = @ptrCast(@alignCast(ctx)); return transcript_io.writeFrameBytes(self, metrics, bytes); @@ -9835,6 +10033,10 @@ pub const TranscriptRuntime = struct { } pub fn markTranscriptContentDirty(self: *TranscriptRuntime) void { + switch (self.presentation_record) { + .active => |*state| state.caught_up = false, + .disabled, .degraded => {}, + } self.full_transcript_content_revision +%= 1; self.full_transcript_projection_cache.relationships.markDirtyAll(); self.full_transcript_projection_cache.full.markDirtyAll(); @@ -10202,161 +10404,6 @@ fn resolveTranscriptRecoveryCommit( }; } -test "resume view snapshot captures visible transcript styling" { - const alloc = std.testing.allocator; - var runtime: TranscriptRuntime = .{}; - defer runtime.deinit(alloc); - runtime.layout = .{ - .rows = 6, - .cols = 12, - .content_bottom = 3, - .divider_top_row = 4, - .input_row = 5, - .divider_bottom_row = 5, - .hint_row = 6, - }; - try runtime.enableShadowVt(alloc); - try runtime.shadow_vt.?.feed( - "\x1b[1;1Hnot cached\x1b[2;1H" ++ - "\x1b]8;;https://example.com\x1b\\" ++ - "\x1b[1;3;38;2;1;2;3m\x1b[48;5;245mvisible one\x1b[0m" ++ - "\x1b]8;;\x1b\\\x1b[3;1H\x1b[31mvisible two\x1b[0m" ++ - "\x1b[4;1Hfooter", - ); - runtime.has_painted_transcript = true; - runtime.last_visible_transcript_top_row = 2; - runtime.last_visible_transcript_last_row = 3; - - var snapshot = (try runtime.snapshotVisibleTranscriptText(alloc, 128)).?; - defer snapshot.deinit(alloc); - try std.testing.expect(snapshot.complete); - try std.testing.expectEqual(@as(u16, 6), snapshot.terminal_rows); - try std.testing.expectEqual(@as(u16, 12), snapshot.terminal_cols); - try std.testing.expectEqualStrings( - "\x1b[0m\x1b[1m\x1b[3m\x1b[38;2;1;2;3m" ++ - "\x1b[48;5;245mvisible one\x1b[0m\n" ++ - "\x1b[0m\x1b[31mvisible two\x1b[0m\n", - snapshot.text, - ); -} - -test "resume view snapshot preserves the committed footer gap" { - const alloc = std.testing.allocator; - var runtime: TranscriptRuntime = .{}; - defer runtime.deinit(alloc); - runtime.layout.rows = 6; - runtime.layout.cols = 12; - runtime.committed_frame_layout.footer_area = .{ .top = 4, .bottom = 6 }; - try runtime.enableShadowVt(alloc); - try runtime.shadow_vt.?.feed("\x1b[1;1Hvisible"); - runtime.has_painted_transcript = true; - runtime.last_visible_transcript_top_row = 1; - runtime.last_visible_transcript_last_row = 1; - - var snapshot = (try runtime.snapshotVisibleTranscriptText(alloc, 128)).?; - defer snapshot.deinit(alloc); - try std.testing.expect(snapshot.complete); - try std.testing.expectEqualStrings("visible\n\n\n", snapshot.text); -} - -test "resume view snapshot omits a visible leading welcome entry" { - const alloc = std.testing.allocator; - var runtime: TranscriptRuntime = .{}; - defer runtime.deinit(alloc); - runtime.layout = .{ - .rows = 6, - .cols = 40, - .content_bottom = 4, - .divider_top_row = 5, - .input_row = 6, - .divider_bottom_row = 6, - .hint_row = 6, - }; - var metrics: Metrics = .{}; - try runtime.writeTranscriptClassified( - alloc, - &metrics, - "fx · Run /help for commands\n\n", - true, - .welcome, - ); - try runtime.enableShadowVt(alloc); - try runtime.shadow_vt.?.feed( - "\x1b[1;1Hfx · Run /help for commands" ++ - "\x1b[3;1Hvisible one\x1b[4;1Hvisible two", - ); - runtime.has_painted_transcript = true; - runtime.last_visible_transcript_top_row = 1; - runtime.last_visible_transcript_last_row = 4; - runtime.last_viewport_selection = .{ - .top_row = 1, - .bottom_row = 4, - .start_line = 0, - .partial_skip_rows = 0, - .line_count = 4, - }; - - var snapshot = (try runtime.snapshotVisibleTranscriptText(alloc, 128)).?; - defer snapshot.deinit(alloc); - try std.testing.expect(snapshot.complete); - try std.testing.expectEqualStrings("visible one\nvisible two\n", snapshot.text); -} - -test "resume view snapshot keeps one complete UTF-8 row at the byte limit" { - const alloc = std.testing.allocator; - var runtime: TranscriptRuntime = .{}; - defer runtime.deinit(alloc); - runtime.layout.rows = 6; - runtime.layout.cols = 12; - try runtime.enableShadowVt(alloc); - try runtime.shadow_vt.?.feed("\x1b[1;1Hééé"); - runtime.has_painted_transcript = true; - runtime.last_visible_transcript_top_row = 1; - runtime.last_visible_transcript_last_row = 1; - - var snapshot = (try runtime.snapshotVisibleTranscriptText(alloc, 7)).?; - defer snapshot.deinit(alloc); - try std.testing.expect(snapshot.complete); - try std.testing.expectEqualStrings("ééé\n", snapshot.text); - try std.testing.expect(std.unicode.utf8ValidateSlice(snapshot.text)); -} - -test "resume view snapshot drops oldest rows and retains the newest tail" { - const alloc = std.testing.allocator; - var runtime: TranscriptRuntime = .{}; - defer runtime.deinit(alloc); - runtime.layout.rows = 6; - runtime.layout.cols = 12; - try runtime.enableShadowVt(alloc); - try runtime.shadow_vt.?.feed( - "\x1b[1;1Holdest\x1b[2;1Høø\x1b[3;1HTAIL", - ); - runtime.has_painted_transcript = true; - runtime.last_visible_transcript_top_row = 1; - runtime.last_visible_transcript_last_row = 3; - - var snapshot = (try runtime.snapshotVisibleTranscriptText(alloc, 10)).?; - defer snapshot.deinit(alloc); - try std.testing.expect(!snapshot.complete); - try std.testing.expectEqualStrings("øø\nTAIL\n", snapshot.text); -} - -test "resume view snapshot returns no text for empty visible rows" { - const alloc = std.testing.allocator; - var runtime: TranscriptRuntime = .{}; - defer runtime.deinit(alloc); - runtime.layout.rows = 6; - runtime.layout.cols = 12; - try runtime.enableShadowVt(alloc); - runtime.has_painted_transcript = true; - runtime.last_visible_transcript_top_row = 1; - runtime.last_visible_transcript_last_row = 2; - - try std.testing.expect( - try runtime.snapshotVisibleTranscriptText(alloc, 128) == null, - ); -} - fn acceptedTranscriptRows( scroll_plan: render_engine.frame_scroll_plan.FrameScrollPlan, committed_terminal_scroll_rows: u16, @@ -11290,6 +11337,32 @@ test "terminal reset drops stale mid-scrollback footer clear before reanchor" { ); } +test "streamed session history resize invalidates only the retained tail" { + var metrics: Metrics = .{}; + var runtime = TranscriptRuntime{ + .layout = invalidationTestLayout(), + .owned_top_row = 1, + .viewport_top_row = 1, + .terminal_reset_pending = true, + .pending_resize_observation = .{ + .layout = invalidationTestLayout(), + .apply_after_ms = 0, + .expected_row = null, + .result = .unavailable, + .phase = .reset_queued, + }, + }; + defer runtime.deinit(std.testing.allocator); + runtime.adoptStreamedSessionHistory(); + + try runtime.requestTerminalResetAfterResize(&metrics, 12); + + try std.testing.expect(!runtime.terminal_reset_pending); + try std.testing.expectEqual(@as(?ResizeObservation, null), runtime.pending_resize_observation); + try std.testing.expectEqual(@as(?i32, null), runtime.resize_history_row_delta); + try std.testing.expect(runtime.render_requests.hasReason(.transcript)); +} + test "marking transcript cache dirty also schedules transcript work" { var runtime = TranscriptRuntime{}; defer runtime.deinit(std.testing.allocator); diff --git a/src/ui/transcript/runtime_tests.zig b/src/ui/transcript/runtime_tests.zig index 760b8fed73..91b9edc625 100644 --- a/src/ui/transcript/runtime_tests.zig +++ b/src/ui/transcript/runtime_tests.zig @@ -1166,6 +1166,55 @@ fn countExactHistoryLine(history: []const u8, expected: []const u8) usize { return count; } +test "stable zero-scroll frame yields the complete finalized presentation record" { + const alloc = std.testing.allocator; + var runtime = commandOutputTestRuntime(undefined); + defer runtime.deinit(alloc); + runtime.beginPresentationRecord(); + _ = try runtime.appendRawTranscriptEntryClassified( + alloc, + "single visible line\n", + .unknown_raw, + ); + + var source = try runtime.prepareTranscriptSource(alloc, null); + defer source.deinit(alloc); + var prepared = try prepareTestSourceForCurrentArea(&runtime, alloc, &source); + defer prepared.deinit(alloc); + var plan = testPaintPlan(&runtime, prepared.selection); + const scroll_plan = render_engine.frame_scroll_plan.FrameScrollPlan.none( + runtime.layout.rows, + runtime.owned_top_row, + ); + var transition = try resolveAndSealTranscriptTransitionForTest( + &runtime, + alloc, + &source, + &prepared, + render_engine.frame_layout.CommittedLayoutSnapshot.fromPaintPlan(plan), + &plan, + scroll_plan, + ); + defer transition.deinit(alloc); + try std.testing.expect(transition.document_append.isEmpty()); + + runtime.consumeTranscriptTransition(alloc, &transition, .{ + .bytes_written = 0, + .changed_cells = 0, + .full_repaint = false, + .terminal_scroll_rows_committed = 0, + .document_append_committed = false, + .committed_cursor_row = transition.cursor_row, + .committed_cursor_col = transition.cursor_col, + .shadow_state = .committed, + .next_invalidation = render_engine.paint_plan.FrameInvalidationSet.empty(), + }); + var pending = runtime.takePendingPresentationRecord() orelse + return error.MissingPresentationRecord; + defer pending.deinit(alloc); + try std.testing.expectEqualStrings("single visible line", pending.bytes); +} + fn cloneRecoveryTransitionForTest( alloc: Allocator, source: *const transcript_runtime.TranscriptTransition, diff --git a/src/ui/transcript/source_preparation.zig b/src/ui/transcript/source_preparation.zig index bd93fcce3e..688c29872b 100644 --- a/src/ui/transcript/source_preparation.zig +++ b/src/ui/transcript/source_preparation.zig @@ -204,6 +204,7 @@ fn collectFinalityNominations( pub const TranscriptPreparationSource = struct { bytes: []u8, + entry_spans: []transcript_blocks.EntryByteSpan = &.{}, folded_summary_indices: []usize, line_provenance: []const transcript_blocks.LineProvenance = &.{}, preview: render_engine.frame_layout.TranscriptFlowPreview, @@ -227,6 +228,7 @@ pub const TranscriptPreparationSource = struct { pub fn deinit(self: *TranscriptPreparationSource, alloc: Allocator) void { if (self.bytes.len > 0) alloc.free(self.bytes); + if (self.entry_spans.len > 0) alloc.free(self.entry_spans); if (self.folded_summary_indices.len > 0) alloc.free(self.folded_summary_indices); if (self.line_provenance.len > 0) alloc.free(self.line_provenance); if (self.hard_line_starts.len > 0) alloc.free(self.hard_line_starts); @@ -279,6 +281,11 @@ pub const TranscriptPreparationSource = struct { pub fn clone(self: *const TranscriptPreparationSource, alloc: Allocator) !TranscriptPreparationSource { const bytes = try alloc.dupe(u8, self.bytes); errdefer alloc.free(bytes); + const entry_spans = try alloc.dupe( + transcript_blocks.EntryByteSpan, + self.entry_spans, + ); + errdefer if (entry_spans.len > 0) alloc.free(entry_spans); const folded_summary_indices = try alloc.dupe(usize, self.folded_summary_indices); errdefer alloc.free(folded_summary_indices); const line_provenance = try alloc.dupe( @@ -301,6 +308,7 @@ pub const TranscriptPreparationSource = struct { errdefer finality.deinit(alloc); return .{ .bytes = bytes, + .entry_spans = entry_spans, .folded_summary_indices = folded_summary_indices, .line_provenance = line_provenance, .preview = self.preview, @@ -467,7 +475,9 @@ fn prepareTranscriptSourceInternal( var folded_summary_indices: []usize = &.{}; errdefer if (folded_summary_indices.len > 0) alloc.free(folded_summary_indices); var line_provenance: []const transcript_blocks.LineProvenance = &.{}; + var entry_spans: []transcript_blocks.EntryByteSpan = &.{}; errdefer if (line_provenance.len > 0) alloc.free(line_provenance); + errdefer if (entry_spans.len > 0) alloc.free(entry_spans); var trailing_boundary_blank_rows: u16 = 0; var tracked_entry_start_line: ?usize = null; var replaceable_entry_start_byte: ?usize = null; @@ -554,6 +564,7 @@ fn prepareTranscriptSourceInternal( bytes = rendered.bytes; folded_summary_indices = rendered.folded_summary_indices; line_provenance = rendered.line_provenance; + entry_spans = rendered.entry_spans; trailing_boundary_blank_rows = rendered.trailing_boundary_blank_rows; tracked_entry_start_line = rendered.target_entry_start_line; replaceable_entry_start_byte = rendered.target_entry_start_byte; @@ -710,6 +721,7 @@ fn prepareTranscriptSourceInternal( return .{ .bytes = bytes, + .entry_spans = entry_spans, .folded_summary_indices = folded_summary_indices, .line_provenance = line_provenance, .preview = preview, diff --git a/src/ui/transcript/store.zig b/src/ui/transcript/store.zig index 02635a4b4c..007bb813e7 100644 --- a/src/ui/transcript/store.zig +++ b/src/ui/transcript/store.zig @@ -2332,27 +2332,6 @@ pub fn appendRawTranscriptEntryClassified( return entry_id; } -pub fn releaseStartupResumeViewEntry( - self: anytype, - alloc: Allocator, - entry_id: u32, -) !bool { - try self.assertCanMutateTranscript(); - const entry_index = rawEntryIndex(self, entry_id) orelse return false; - - var removed = self.entries.orderedRemove(entry_index); - errdefer { - self.entries.insertAssumeCapacity(entry_index, removed); - } - - try rebuildTranscriptCacheFromEntries(self, alloc, "startup resume view release"); - self.recomputeCursorFromTranscript(); - requestTranscriptPaint(self); - - removed.deinit(alloc); - return true; -} - pub fn updateRawBytesEntry( self: anytype, alloc: Allocator, diff --git a/tests/e2e/tmux-helpers.ts b/tests/e2e/tmux-helpers.ts index 905e49c832..945bd3f2ad 100644 --- a/tests/e2e/tmux-helpers.ts +++ b/tests/e2e/tmux-helpers.ts @@ -846,6 +846,21 @@ export class TmuxSession { } } + async captureFullScrollbackToFile(path: string): Promise { + const child = Bun.spawn( + ["tmux", ...this.tmuxArgs(["capture-pane", "-t", this.name, "-p", "-S", "-"])], + { + stdout: Bun.file(path), + stderr: "pipe", + }, + ); + const stderr = await new Response(child.stderr).text(); + const code = await child.exited; + if (code !== 0) { + throw new Error(`tmux full scrollback capture failed (${code}): ${stderr}`); + } + } + /** * Complete pane history including the ANSI sequences emitted by fx. * Keep this separate from the viewport capture so transcript-order tests diff --git a/tests/e2e/tui-command-permissions.test.ts b/tests/e2e/tui-command-permissions.test.ts index d0502f37f0..085c7b7821 100644 --- a/tests/e2e/tui-command-permissions.test.ts +++ b/tests/e2e/tui-command-permissions.test.ts @@ -2558,7 +2558,7 @@ describe("effect-aware command permissions", () => { activeSession = null; rmSync( - join(root.home, ".fx", "sessions", sessionId, "resume-view.bin"), + join(root.home, ".fx", "sessions", sessionId, "transcript.meta"), { force: true }, ); activeSession = await TmuxSession.create({ diff --git a/tests/e2e/tui-resume-brutal.test.ts b/tests/e2e/tui-resume-brutal.test.ts index d168f836ba..a3608828a9 100644 --- a/tests/e2e/tui-resume-brutal.test.ts +++ b/tests/e2e/tui-resume-brutal.test.ts @@ -29,6 +29,7 @@ const TIMEOUT = 30_000; const REAL_TITLE = "RESUME_REAL_SESSION_FIRST_PROMPT"; const FOREIGN_TITLE = "RESUME_FOREIGN_NEWEST"; const FINAL_MARKER = "RESUME_REAL_SESSION_HISTORY_DONE"; +const CONTINUATION_MARKER = "RESUME_POST_RESTORE_CONTINUATION_DONE"; type Config = { label: string; @@ -86,6 +87,21 @@ function stripAnsi(text: string): string { return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ""); } +function countScrollbackLines(path: string, marker: string): number { + try { + const output = execFileSync( + "grep", + ["-F", "-c", "--", marker, path], + { encoding: "utf8" }, + ).trim(); + return Number(output); + } catch (error) { + const status = (error as { status?: number }).status; + if (status === 1) return 0; + throw error; + } +} + function percentile(values: number[], fraction: number): number { if (values.length === 0) return 0; const sorted = [...values].sort((a, b) => a - b); @@ -219,7 +235,11 @@ async function seedRealSession(paths: Paths, config: Config): Promise { installLargeCatalog(paths, config, real); writeFileSync(paths.stderr, ""); - const gateway = startFakeGateway([]); + const gateway = startFakeGateway([fakeGatewayFinalText(CONTINUATION_MARKER)]); const metrics: Metrics = { open: [], scope: [], @@ -443,7 +468,7 @@ async function runStress(config: Config): Promise { height: 32, minimumHistoryLines: Math.max( 50_000, - config.chatBatches * config.chatLinesPerBatch * 2, + config.chatBatches * config.chatLinesPerBatch * 20, ), }); await session.waitForComposer(TIMEOUT * 4); @@ -474,11 +499,34 @@ async function runStress(config: Config): Promise { () => session!.sendKeys("Enter"), () => session!.waitForText(FINAL_MARKER, TIMEOUT * 20), ); - expect(await session.captureFullScrollback()).toContain("RESUME_CHAT_000000"); - expect(await session.captureFullScrollback()).toContain( - `RESUME_CHAT_${pad(config.chatBatches * config.chatLinesPerBatch - 1)}`, + const headMarker = "RESUME_CHAT_000000"; + const middleMarker = `RESUME_CHAT_${pad(Math.floor(config.chatBatches * config.chatLinesPerBatch / 2))}`; + const tailMarker = `RESUME_CHAT_${pad(config.chatBatches * config.chatLinesPerBatch - 1)}`; + const restoredScrollbackPath = join(paths.root, "restored-scrollback.txt"); + await session.captureFullScrollbackToFile(restoredScrollbackPath); + for (const marker of [headMarker, middleMarker, tailMarker, FINAL_MARKER]) { + expect(countScrollbackLines(restoredScrollbackPath, marker)).toBe(1); + } + expect(countScrollbackLines(restoredScrollbackPath, "resume-tool-payload.txt")).toBeGreaterThan(0); + expect(readFileSync(paths.trace, "utf8")).toMatch( + /event=session_transcript_stream outcome=exact bytes=\d+/, + ); + + await session.sendText("Continue after the complete transcript restore."); + await session.waitForText(CONTINUATION_MARKER, TIMEOUT * 4); + await session.resizeWindow(90, 28); + await session.captureFullScrollbackToFile(restoredScrollbackPath); + expect(countScrollbackLines(restoredScrollbackPath, headMarker)).toBe(1); + expect(countScrollbackLines(restoredScrollbackPath, tailMarker)).toBe(1); + expect(countScrollbackLines(restoredScrollbackPath, CONTINUATION_MARKER)).toBe(1); + const postResumeTrace = readFileSync(paths.trace, "utf8"); + const exactStreamIndex = postResumeTrace.lastIndexOf( + "event=session_transcript_stream outcome=exact", + ); + expect(exactStreamIndex).toBeGreaterThanOrEqual(0); + expect(postResumeTrace.slice(exactStreamIndex)).not.toContain( + "event=session_transcript outcome=degraded", ); - expect(await session.captureFullScrollback()).toContain("resume-tool-payload.txt"); if (profiler) await profiler.exited; expect(readFileSync(paths.stderr, "utf8")).toBe(""); @@ -603,8 +651,8 @@ test.skipIf( 900_000, ); -test.skipIf(!tmuxAvailable() || process.env.FX_RESUME_50K !== "1")( - "/resume survives a fifty-thousand-entry catalog and real fifty-thousand-line tool-heavy session", +test.skipIf(!tmuxAvailable() || process.env.FX_RESUME_100K !== "1")( + "/resume restores a real one-hundred-thousand-line tool-heavy session exactly once", async () => { const profileSeconds = process.env.FX_RESUME_PROFILE === "1" && platform() === "darwin" && @@ -612,18 +660,18 @@ test.skipIf(!tmuxAvailable() || process.env.FX_RESUME_50K !== "1")( ? 30 : undefined; const paths = await runStress({ - label: "real-50k", + label: "real-100k", catalogEntries: 50_000, - chatBatches: 50, - chatLinesPerBatch: 1_000, + chatBatches: 10, + chatLinesPerBatch: 10_000, toolsPerBatch: 4, cycles: 120, profileSeconds, retainArtifacts: true, }); - console.error(`RESUME_50K_METRICS=${paths.metrics}`); + console.error(`RESUME_100K_METRICS=${paths.metrics}`); if (profileSeconds !== undefined) { - console.error(`RESUME_50K_SAMPLE=${paths.profile}`); + console.error(`RESUME_100K_SAMPLE=${paths.profile}`); } }, 1_800_000, diff --git a/tests/e2e/tui-resume.test.ts b/tests/e2e/tui-resume.test.ts index 8be9a25ad5..a2e2987d19 100644 --- a/tests/e2e/tui-resume.test.ts +++ b/tests/e2e/tui-resume.test.ts @@ -4475,6 +4475,68 @@ test.skipIf(!tmuxAvailable())( TIMEOUT * 2, ); +test.skipIf(!tmuxAvailable())( + "corrupt committed transcript fails closed before the composer", + async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-corrupt-transcript-"))); + const home = join(root, "home"); + const workspace = join(root, "workspace"); + const stderrPath = join(root, "stderr.log"); + mkdirSync(home); + mkdirSync(workspace); + const marker = "CORRUPT_TRANSCRIPT_SEED_MARKER"; + const seedGateway = startFakeGateway([fakeGatewayFinalText(marker)]); + let active: TmuxSession | null = null; + + try { + writeFileSync(stderrPath, ""); + active = await TmuxSession.create({ + cmd: FX_BIN, + cwd: workspace, + env: gatewayEnv(home, seedGateway), + stderrPath, + }); + await active.waitForComposer(TIMEOUT); + await active.sendText("Create a session whose transcript metadata will be corrupted."); + await active.waitForText(marker, TIMEOUT); + await active.sendText("/quit"); + expect(await active.waitForSessionEnd()).toBe(true); + await active.kill(); + active = null; + + const sessionId = sessionIdFromHome(home); + writeFileSync( + join(home, ".fx", "sessions", sessionId, "transcript.meta"), + "malformed", + ); + const resumeGateway = startFakeGateway([]); + try { + writeFileSync(stderrPath, ""); + active = await TmuxSession.create({ + cmd: `${FX_BIN} --resume-${sessionId}`, + cwd: workspace, + env: gatewayEnv(home, resumeGateway), + stderrPath, + remainOnExit: true, + }); + await active.waitForPane(() => active!.paneStatus().dead, TIMEOUT); + expect(active.paneStatus()).toEqual({ dead: true, status: 1 }); + expect(await active.capturePane()).not.toContain("auto ·"); + expect(readFileSync(stderrPath, "utf8")).toContain("CorruptSessionTranscript"); + await active.kill(); + active = null; + } finally { + resumeGateway.stop(); + } + } finally { + if (active) await active.kill(); + seedGateway.stop(); + rmSync(root, { recursive: true, force: true }); + } + }, + TIMEOUT * 2, +); + test.skipIf(!tmuxAvailable())( "interactive resume aliases restore history and return to a live composer", async () => { @@ -4513,15 +4575,22 @@ test.skipIf(!tmuxAvailable())( expect(readFileSync(stderrPath, "utf8")).not.toContain("AnsiBandOverflow"); const sessionId = sessionIdFromHome(home); - const resumeViewPath = join( + const transcriptPath = join( home, ".fx", "sessions", sessionId, - "resume-view.bin", + "transcript.ansi", ); - expect(existsSync(resumeViewPath)).toBe(true); - const initialResumeView = readFileSync(resumeViewPath); + const transcriptMetadataPath = join( + home, + ".fx", + "sessions", + sessionId, + "transcript.meta", + ); + expect(existsSync(transcriptPath)).toBe(true); + expect(existsSync(transcriptMetadataPath)).toBe(true); const pickerGateway = startFakeGateway([]); gateways.push(pickerGateway); @@ -4549,7 +4618,6 @@ test.skipIf(!tmuxAvailable())( await active.kill(); active = null; expect(readFileSync(stderrPath, "utf8")).toBe(""); - writeFileSync(resumeViewPath, initialResumeView); const invocations = [ ["-c"], @@ -4584,6 +4652,7 @@ test.skipIf(!tmuxAvailable())( await active.waitForComposer(TIMEOUT); const scrollback = await waitForScrollback(active, restoredMarker); expect(scrollback).toContain(restoredMarker); + expect(countOccurrences(scrollback, restoredMarker)).toBe(1); await active.sendText(`Continue session ${index}.`); await active.waitForText(followUp, TIMEOUT); expect(sessionIdFromHome(home)).toBe(sessionId); @@ -4594,25 +4663,28 @@ test.skipIf(!tmuxAvailable())( active = null; expect(readFileSync(stderrPath, "utf8")).toBe(""); const resumeTrace = readFileSync(tracePath, "utf8"); - expect(resumeTrace).toMatch( - /event=resume_view_cache (?:outcome=painted freshness=exact|outcome=skipped freshness=(?:exact|older))/, - ); + expect(resumeTrace).toMatch(/event=session_transcript_stream outcome=exact bytes=\d+/); + if (resumeTrace.includes("event=session_transcript outcome=degraded")) { + console.error( + resumeTrace + .split("\n") + .filter((line) => line.includes("event=session_transcript outcome=degraded")) + .join("\n"), + ); + } + expect(resumeTrace).not.toContain("event=session_transcript outcome=degraded"); const replay = await runFx(["replay", tapePath, "--frames"], { cwd: workspaceRoot, env: { HOME: home }, }); expect(replay.code).toBe(0); expect(replay.stderr).toBe(""); - expect(replay.stdout).not.toMatch(/Run \/help for commands/i); const firstApplicationFrame = replay.stdout .split(/(?=--- frame \d+)/) .find((frame) => /Recording:|Session:|Run \/help for commands|auto ·/.test(frame), ); expect(firstApplicationFrame).toContain(restoredMarker); - if (index === 0) { - writeFileSync(resumeViewPath, initialResumeView); - } } const markdownHome = join(root, "markdown-home"); From f87b02610c6e508fa2a316a16e2e02696e1c33fb Mon Sep 17 00:00:00 2001 From: Pranit Date: Fri, 28 Aug 2026 04:59:54 -0400 Subject: [PATCH 02/12] Replay complete session history on resize Stream the persisted transcript through the existing reset frame and keep semantic state out of the historical body. Reanchor retained state after publication so continuations and later resizes preserve exact scrollback. --- src/core/app/app_render_runtime.zig | 80 ++- src/core/app/app_session_runtime.zig | 184 ++++++ src/core/app/app_worker_runtime.zig | 130 ++++ src/core/session/session_log.zig | 11 + src/core/session/session_transcript.zig | 65 +- src/main.zig | 2 +- src/ui/render_engine/frame_builder.zig | 74 ++- src/ui/render_engine/frame_scroll_plan.zig | 88 ++- src/ui/render_engine/terminal_diff.zig | 697 +++++++++++++++++++-- src/ui/resize_tests.zig | 2 +- src/ui/transcript/presentation_record.zig | 38 +- src/ui/transcript/resume_projection.zig | 25 +- src/ui/transcript/runtime.zig | 275 +++++++- src/ui/transcript/runtime_tests.zig | 87 ++- src/ui/transcript/store.zig | 15 + src/ui/transcript/viewport_runtime.zig | 39 +- tests/e2e/tui-resume-brutal.test.ts | 113 +++- tests/e2e/tui-resume.test.ts | 20 +- 18 files changed, 1846 insertions(+), 99 deletions(-) diff --git a/src/core/app/app_render_runtime.zig b/src/core/app/app_render_runtime.zig index 8ffb55b004..f2e4c25d2f 100644 --- a/src/core/app/app_render_runtime.zig +++ b/src/core/app/app_render_runtime.zig @@ -70,6 +70,40 @@ fn set_transcript_assistant_tail_writable( ); } +const StreamedResetRoute = enum { + none, + preserve_reflow, + sequential, +}; + +fn streamedResetRoute( + requested: bool, + main_surface: bool, + source_available: bool, +) StreamedResetRoute { + if (!requested or !main_surface) return .none; + return if (source_available) .sequential else .preserve_reflow; +} + +test "streamed reset routing changes only a requested main surface with a safe source" { + try std.testing.expectEqual( + StreamedResetRoute.none, + streamedResetRoute(false, true, true), + ); + try std.testing.expectEqual( + StreamedResetRoute.none, + streamedResetRoute(true, false, true), + ); + try std.testing.expectEqual( + StreamedResetRoute.preserve_reflow, + streamedResetRoute(true, true, false), + ); + try std.testing.expectEqual( + StreamedResetRoute.sequential, + streamedResetRoute(true, true, true), + ); +} + pub const VisualEpochResetTrigger = enum { native_clear_probe, ctrl_l, @@ -1836,6 +1870,28 @@ pub fn Runtime(comptime App: type) type { const presentation_commits_transcript = child_view != null or !render_reconciliation.alternate_screen_owns_rendering; + var reset_transcript_reader: ?app_session_runtime.ResetTranscriptReader = null; + defer if (reset_transcript_reader) |*reader| reader.deinit(); + var finish_streamed_reset_attempt = false; + defer if (finish_streamed_reset_attempt) { + presentation_shell.finishStreamedResetAttempt(); + }; + const streamed_reset_requested = presentation_shell.streamedResetRequested(); + if (streamed_reset_requested and + child_view == null and + !render_reconciliation.alternate_screen_owns_rendering) + { + reset_transcript_reader = try app_session_runtime.Runtime(App).acquireResetTranscriptReader(app); + switch (streamedResetRoute( + true, + true, + reset_transcript_reader != null, + )) { + .none => unreachable, + .preserve_reflow => presentation_shell.finishStreamedResetAttempt(), + .sequential => try presentation_shell.armTerminalResetForCurrentFrame(&app.metrics), + } + } const active_committed_layout = if (render_reconciliation.alternate_screen_owns_rendering) app.terminal.alternate_frame_layout else @@ -2233,10 +2289,23 @@ pub fn Runtime(comptime App: type) type { }, .activity_result = .{ .painted = false, .row = 0, .overlay = false }, }; - const document_append = if (transcript_transition) |*transition| + var document_append = if (transcript_transition) |*transition| transition.document_append else render_engine.frame_scroll_plan.FrameDocumentAppend{}; + if (reset_transcript_reader) |*reader| { + if (footer_frame.paint.reset_terminal) { + document_append = .{ + .source = .{ .sequential = try reader.frameSource() }, + .start_row = 1, + .start_col = 1, + .reset_replay = true, + }; + finish_streamed_reset_attempt = true; + } else { + presentation_shell.finishStreamedResetAttempt(); + } + } var transcript_body: render_engine.frame_builder.TranscriptBodyDisposition = if (transcript_transition) |*transition| switch (transition.body_disposition) { .paint => .paint, @@ -2330,6 +2399,13 @@ pub fn Runtime(comptime App: type) type { }, }, ); + if (finish_streamed_reset_attempt and result.is_committed()) { + debug_trace.logf( + "session", + "event=session_transcript_reset outcome=committed bytes={d}", + .{document_append.source.declaredLen()}, + ); + } const scroll_commit = result.scrollCommit(scroll_plan); debug_trace.logf( "frame_diff", @@ -2338,7 +2414,7 @@ pub fn Runtime(comptime App: type) type { @tagName(transcript_body), counters.body_paints, counters.retained_transcript_changed_cells, - document_append.bytes.len, + document_append.source.declaredLen(), scroll_commit.planned_terminal_scroll_rows, scroll_commit.physical_terminal_scroll_rows, scroll_commit.accepted_terminal_scroll_rows, diff --git a/src/core/app/app_session_runtime.zig b/src/core/app/app_session_runtime.zig index 4ea47b2213..5c5bbd4c44 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -52,6 +52,7 @@ const shell_runtime = @import("../../ui/shell_runtime.zig"); const transcript_runtime = @import("../../ui/transcript/runtime.zig"); const ui_input = @import("../../ui/input/runtime.zig"); const ui_render = @import("../../ui/render.zig"); +const frame_scroll_plan = @import("../../ui/render_engine/frame_scroll_plan.zig"); const Allocator = std.mem.Allocator; @@ -60,6 +61,57 @@ const resume_stream_autowrap_disable = "\x1b[?7l"; const resume_stream_read_bytes: usize = 64 * 1024; const resume_stream_first_read_bytes: usize = 1024; +pub const ResetTranscriptReader = struct { + admission: session_transcript.Admission, + + pub fn frameSource(self: *ResetTranscriptReader) !frame_scroll_plan.SequentialDocumentSource { + const committed_len = switch (self.admission) { + .exact => |reader| reader.committed_len, + .missing, .incomplete, .corrupt => return error.TranscriptReaderUnavailable, + }; + return .{ + .ctx = self, + .committed_len = committed_len, + .read_at = readAt, + }; + } + + pub fn deinit(self: *ResetTranscriptReader) void { + self.admission.deinit(); + self.* = undefined; + } + + fn readAt(ctx: *anyopaque, out: []u8, offset: u64) !usize { + const self: *ResetTranscriptReader = @ptrCast(@alignCast(ctx)); + return switch (self.admission) { + .exact => |reader| reader.readAt(out, offset), + .missing, .incomplete, .corrupt => error.TranscriptReaderUnavailable, + }; + } +}; + +test "reset transcript reader lends a borrowed sequential frame source" { + var temp = std.testing.tmpDir(.{}); + defer temp.cleanup(); + var file = try temp.dir.createFile( + std.testing.io, + "transcript.ansi", + .{ .read = true }, + ); + try file.writeStreamingAll(std.testing.io, "history\r\n"); + + var reader = ResetTranscriptReader{ .admission = .{ .exact = .{ + .file = file, + .committed_len = "history\r\n".len, + } } }; + defer reader.deinit(); + const source = try reader.frameSource(); + try std.testing.expectEqual(@as(u64, "history\r\n".len), source.committed_len); + var buffer: [32]u8 = undefined; + const read = try source.readAt(&buffer, 0); + try std.testing.expectEqualStrings("history\r\n", buffer[0..read]); +} + fn streamSessionTranscript(app: anytype, reader: anytype) !u64 { try app.shell.writeResumeStreamBytes( &app.metrics, @@ -67,6 +119,20 @@ fn streamSessionTranscript(app: anytype, reader: anytype) !u64 { ); const payload_result = streamSessionTranscriptPayload(app, reader); if (payload_result) |payload_bytes| { + scrollResumePayloadIntoHistory(app) catch |padding_err| { + app.shell.writeResumeStreamBytes( + &app.metrics, + resume_stream_autowrap_disable, + ) catch |restore_err| { + debug_trace.logf( + "session", + "event=session_transcript_stream outcome=failed stage=padding_restore padding_err={s} restore_err={s}", + .{ @errorName(padding_err), @errorName(restore_err) }, + ); + return error.TerminalModeRestoreFailed; + }; + return padding_err; + }; try app.shell.writeResumeStreamBytes( &app.metrics, resume_stream_autowrap_disable, @@ -89,6 +155,16 @@ fn streamSessionTranscript(app: anytype, reader: anytype) !u64 { } } +fn scrollResumePayloadIntoHistory(app: anytype) !void { + var rows: u16 = 0; + const padding_rows: u16 = @intFromBool( + app.shell.layout.rows > app.shell.layout.content_bottom, + ); + while (rows < padding_rows) : (rows += 1) { + try app.shell.writeResumeStreamBytes(&app.metrics, "\r\n"); + } +} + fn streamSessionTranscriptPayload(app: anytype, reader: anytype) !u64 { var storage: [resume_stream_read_bytes + session_transcript.max_control_sequence_bytes]u8 = undefined; var carry_len: usize = 0; @@ -153,6 +229,60 @@ const MemoryTranscriptReader = struct { } }; +test "session transcript stream moves the payload clear of startup chrome" { + const alloc = std.testing.allocator; + const FakeShell = struct { + alloc: Allocator, + layout: types.Layout = .{ + .rows = 3, + .cols = 20, + .content_bottom = 1, + .divider_top_row = 2, + .input_row = 2, + .divider_bottom_row = 2, + .hint_row = 3, + }, + output: std.ArrayList(u8) = .empty, + adopted: bool = false, + + fn deinit(self: *@This()) void { + self.output.deinit(self.alloc); + } + + fn writeResumeStreamBytes( + self: *@This(), + _: *types.Metrics, + bytes: []const u8, + ) !void { + try self.output.appendSlice(self.alloc, bytes); + } + + fn adoptStreamedSessionHistory(self: *@This()) void { + self.adopted = true; + } + }; + const FakeApp = struct { + shell: FakeShell, + metrics: types.Metrics = .{}, + }; + var app = FakeApp{ .shell = .{ .alloc = alloc } }; + defer app.shell.deinit(); + + const payload = "payload\r\n"; + const streamed = try streamSessionTranscript( + &app, + MemoryTranscriptReader{ .bytes = payload }, + ); + + try std.testing.expectEqual(@as(u64, payload.len), streamed); + try std.testing.expectEqualStrings( + resume_stream_autowrap_enable ++ payload ++ + "\r\n" ++ resume_stream_autowrap_disable, + app.shell.output.items, + ); + try std.testing.expect(app.shell.adopted); +} + const BackgroundSessionPolicy = enum { carry_forward, stop_forget, @@ -1217,6 +1347,33 @@ pub const Persistence = struct { pub fn Runtime(comptime App: type) type { return struct { + pub fn acquireResetTranscriptReader(app: *App) !?ResetTranscriptReader { + const writable = if (app.session_persistence.writable) |*value| + value + else { + debug_trace.logf( + "session", + "event=session_transcript_reset outcome=unavailable reason=no_writer", + .{}, + ); + return null; + }; + var admission = try writable.admitActiveTranscript(app.alloc); + switch (admission) { + .exact => return .{ .admission = admission }, + .missing, .incomplete, .corrupt => { + const reason = @tagName(std.meta.activeTag(admission)); + admission.deinit(); + debug_trace.logf( + "session", + "event=session_transcript_reset outcome=unavailable reason={s}", + .{reason}, + ); + return null; + }, + } + } + pub fn stageRequestedSessionTranscript(app: *App) !void { const requested = app.requested_resume orelse return; const store = app.session_persistence.store orelse return; @@ -1329,6 +1486,33 @@ pub fn Runtime(comptime App: type) type { delta.next_cursor, next_committed_len, ); + if (comptime @hasDecl( + @TypeOf(app.shell), + "enforceStructuredRetentionAndReport", + )) { + const retention_changed = app.shell.enforceStructuredRetentionAndReport( + app.alloc, + null, + ) catch |err| blk: { + debug_trace.logf( + "session", + "event=session_transcript outcome=retention_pending err={s}", + .{@errorName(err)}, + ); + break :blk false; + }; + if (retention_changed and + app.shell.streamedSessionHistoryActive()) + { + app.shell.establishStreamedRetainedAnchor(app.alloc) catch |err| { + debug_trace.logf( + "session", + "event=session_transcript outcome=anchor_pending err={s}", + .{@errorName(err)}, + ); + }; + } + } debug_trace.logf( "session", "event=session_transcript outcome=committed delta_bytes={d} committed_bytes={d}", diff --git a/src/core/app/app_worker_runtime.zig b/src/core/app/app_worker_runtime.zig index 6f85dc8b50..74cd116474 100644 --- a/src/core/app/app_worker_runtime.zig +++ b/src/core/app/app_worker_runtime.zig @@ -109,10 +109,18 @@ const DetachedWorkerEventBatch = struct { return self.events.items[self.next_unvisited - 1 ..]; } + fn remaining(self: *const DetachedWorkerEventBatch) []const WorkerEvent { + return self.events.items[self.next_unvisited..]; + } + fn markClaimedAndRemainingTransferred(self: *DetachedWorkerEventBatch) void { self.next_unvisited = self.events.items.len; } + fn markRemainingTransferred(self: *DetachedWorkerEventBatch) void { + self.next_unvisited = self.events.items.len; + } + fn deinit(self: *DetachedWorkerEventBatch) void { while (self.next_unvisited < self.events.items.len) : (self.next_unvisited += 1) { worker_runtime.freeWorkerEvent(self.alloc, self.events.items[self.next_unvisited]); @@ -634,6 +642,7 @@ pub fn Runtime(comptime App: type) type { } fn drainEvents(app: *App, handlers: WorkerEventHandlers) !void { + if (presentationRecordFramePending(app)) return; const taken = app.worker.takeEventBatch(); var batch = DetachedWorkerEventBatch.init( std.heap.c_allocator, @@ -668,6 +677,7 @@ pub fn Runtime(comptime App: type) type { }; first_event = false; var drain_owns_current = true; + var presentation_finality_closed = false; defer if (drain_owns_current) { worker_runtime.freeWorkerEvent(batch.alloc, event); }; @@ -848,6 +858,7 @@ pub fn Runtime(comptime App: type) type { }, .api_status_text => |text| { resetStream(app, false); + presentation_finality_closed = true; app.shell.worker_status_state().set_api(text, .danger); app.shell.render_requests.request(.footer); }, @@ -883,6 +894,7 @@ pub fn Runtime(comptime App: type) type { .finish_prompt => |finished| { try flushPendingCommandOutputAtTurnBoundary(app, handlers); resetStream(app, false); + presentation_finality_closed = true; app.shell.render_requests.request(.footer); try handlers.append_history_turn(handlers.ctx, finished); }, @@ -896,10 +908,21 @@ pub fn Runtime(comptime App: type) type { break :events; } resetStream(app, false); + presentation_finality_closed = true; app.shell.render_requests.request(.footer); try handlers.error_text(handlers.ctx, notice); }, } + if (presentation_finality_closed and presentationRecordActive(app)) { + requestPresentationRecordFrame(app); + if (try retainRemainingEventSuffix( + app, + &batch, + "session_transcript_finality_frame", + )) { + break :events; + } + } } lifecycle_cleanup_attempted = true; @@ -943,6 +966,46 @@ pub fn Runtime(comptime App: type) type { ); } + fn retainRemainingEventSuffix( + app: *App, + batch: *DetachedWorkerEventBatch, + reason: []const u8, + ) !bool { + const retained = batch.remaining(); + if (retained.len == 0) return false; + try app.worker.prependOwnedEvents(batch.alloc, retained); + batch.markRemainingTransferred(); + debug_trace.logf( + "worker", + "retained detached worker event suffix reason={s} count={d}", + .{ reason, retained.len }, + ); + return true; + } + + fn presentationRecordActive(app: *const App) bool { + const Shell = @TypeOf(app.shell); + if (comptime @hasDecl(Shell, "presentationRecordActive")) { + return app.shell.presentationRecordActive(); + } + return false; + } + + fn requestPresentationRecordFrame(app: *App) void { + const Shell = @TypeOf(app.shell); + if (comptime @hasDecl(Shell, "requestPresentationRecordFrame")) { + app.shell.requestPresentationRecordFrame(); + } + } + + fn presentationRecordFramePending(app: *const App) bool { + const Shell = @TypeOf(app.shell); + if (comptime @hasDecl(Shell, "presentationRecordFramePending")) { + return app.shell.presentationRecordFramePending(); + } + return false; + } + fn applyToolLifecycle( app: *App, presenter: activity_runtime.LifecyclePresenter, @@ -1378,6 +1441,18 @@ const FakeShell = struct { return self.native_history_active; } + fn presentationRecordActive(self: *const FakeShell) bool { + return self.lifecycle.presentationRecordActive(); + } + + fn requestPresentationRecordFrame(self: *FakeShell) void { + self.lifecycle.requestPresentationRecordFrame(); + } + + fn presentationRecordFramePending(self: *const FakeShell) bool { + return self.lifecycle.presentationRecordFramePending(); + } + fn trimTrailingBlankLines(self: *FakeShell) void { self.trim_calls += 1; } @@ -3800,6 +3875,61 @@ test "core.app_worker_runtime split batches finalize lifecycle before history" { try std.testing.expect(capture.saw_finalized_fence); } +test "core.app_worker_runtime persisted transcript yields after a finished prompt" { + var app = FakeApp.init(std.testing.allocator); + defer app.deinit(); + app.worker.processing = true; + app.shell.lifecycle.beginPresentationRecord(); + + try app.worker.pushEvent(std.heap.c_allocator, .{ .finish_prompt = .{ + .turn = .{ .compacted_summary = .{ + .summary = @constCast("finished"), + .removed_turn_count = 1, + .compaction_count = 1, + } }, + } }); + try app.worker.pushEvent(std.heap.c_allocator, .{ .semantic_notice = .{ + .topic = @constCast("system"), + .tone = .neutral, + .body = @constCast("next event"), + } }); + + const Capture = struct { + histories: usize = 0, + notices: usize = 0, + + fn history(raw: *anyopaque, _: types.FinishedPrompt) !void { + const self: *@This() = @ptrCast(@alignCast(raw)); + self.histories += 1; + } + + fn notice(raw: *anyopaque, _: types.SemanticNotice) !void { + const self: *@This() = @ptrCast(@alignCast(raw)); + self.notices += 1; + } + }; + var capture = Capture{}; + var handlers = NoopBridge.handlers(&app); + handlers.ctx = @ptrCast(&capture); + handlers.append_history_turn = Capture.history; + handlers.semantic_notice = Capture.notice; + + try Runtime(FakeApp).tick(&app, noopTaskCompletion, handlers); + + try std.testing.expectEqual(@as(usize, 1), capture.histories); + try std.testing.expectEqual(@as(usize, 0), capture.notices); + try std.testing.expectEqual(@as(usize, 1), app.worker.events.items.len); + + try Runtime(FakeApp).tick(&app, noopTaskCompletion, handlers); + try std.testing.expectEqual(@as(usize, 0), capture.notices); + try std.testing.expectEqual(@as(usize, 1), app.worker.events.items.len); + + app.shell.lifecycle.degradePresentationRecord(); + try Runtime(FakeApp).tick(&app, noopTaskCompletion, handlers); + try std.testing.expectEqual(@as(usize, 1), capture.notices); + try std.testing.expectEqual(@as(usize, 0), app.worker.events.items.len); +} + test "core.app_worker_runtime reset before drain fences interrupted prefix and keeps next turn live" { var app = FakeApp.init(std.testing.allocator); defer app.deinit(); diff --git a/src/core/session/session_log.zig b/src/core/session/session_log.zig index ec9e993ca0..6738654d84 100644 --- a/src/core/session/session_log.zig +++ b/src/core/session/session_log.zig @@ -624,6 +624,17 @@ pub const LoadedWritableSession = struct { return !positionsEqual(stamped, self.position); } + pub fn admitActiveTranscript( + self: *LoadedWritableSession, + alloc: Allocator, + ) !session_transcript.Admission { + return session_transcript.admitActive( + alloc, + &self.log.dir, + self.active_id, + ); + } + /// Returns a borrowed address that remains stable until deinit. pub fn childCapability( self: *LoadedWritableSession, diff --git a/src/core/session/session_transcript.zig b/src/core/session/session_transcript.zig index 39647a857d..77220774b0 100644 --- a/src/core/session/session_transcript.zig +++ b/src/core/session/session_transcript.zig @@ -232,6 +232,28 @@ pub fn admit( session_dir: *io_mod.VerifiedDir, session_id: []const u8, current_position: session_replay.CommitPosition, +) !Admission { + return admitWithCurrentPosition( + alloc, + session_dir, + session_id, + current_position, + ); +} + +pub fn admitActive( + alloc: std.mem.Allocator, + session_dir: *io_mod.VerifiedDir, + session_id: []const u8, +) !Admission { + return admitWithCurrentPosition(alloc, session_dir, session_id, null); +} + +fn admitWithCurrentPosition( + alloc: std.mem.Allocator, + session_dir: *io_mod.VerifiedDir, + session_id: []const u8, + current_position: ?session_replay.CommitPosition, ) !Admission { var metadata_handle = io_mod.openExistingRegularFile( session_dir.dir, @@ -388,7 +410,7 @@ const AdmissionFacts = struct { committed_len: u64, data_len: u64, stored_position: session_replay.CommitPosition, - current_position: session_replay.CommitPosition, + current_position: ?session_replay.CommitPosition, }; fn classify(facts: AdmissionFacts) AdmissionClass { @@ -402,7 +424,10 @@ fn classify(facts: AdmissionFacts) AdmissionClass { if (facts.metadata == .missing or facts.committed_len == 0 or facts.committed_len != facts.data_len or - !positionsEqual(facts.stored_position, facts.current_position)) + (if (facts.current_position) |current_position| + !positionsEqual(facts.stored_position, current_position) + else + false)) { return .incomplete; } @@ -694,6 +719,42 @@ test "transcript record admits exact committed bytes and treats a staged tail as try std.testing.expect(incomplete == .incomplete); } +test "active transcript admits a complete older display prefix but rejects a staged tail" { + const alloc = std.testing.allocator; + var temp = std.testing.tmpDir(.{}); + defer temp.cleanup(); + var verified = io_mod.VerifiedDir{ .dir = try temp.dir.openDir( + std.testing.io, + ".", + .{ .iterate = true, .follow_symlinks = false }, + ) }; + defer verified.close(); + + try publishComplete( + alloc, + &verified, + "session-1", + test_position, + "one\r\ntwo\r\n", + ); + var advanced = test_position; + advanced.through_seq += 1; + advanced.through_event_log_bytes += 64; + + var cold = try admit(alloc, &verified, "session-1", advanced); + defer cold.deinit(); + try std.testing.expect(cold == .incomplete); + + var active = try admitActive(alloc, &verified, "session-1"); + defer active.deinit(); + try std.testing.expect(active == .exact); + + try appendDataForTest(&verified, "staged"); + var staged = try admitActive(alloc, &verified, "session-1"); + defer staged.deinit(); + try std.testing.expect(staged == .incomplete); +} + test "transcript append advances committed bytes only after durable data" { const alloc = std.testing.allocator; var temp = std.testing.tmpDir(.{}); diff --git a/src/main.zig b/src/main.zig index 9092204f08..87b5ca0b4c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1280,7 +1280,7 @@ const App = struct { } projection.pending_diffs.clearRetainingCapacity(); self.next_diff_id = projection.next_diff_id; - projection.installRetained(&self.shell); + try projection.installRetained(&self.shell); } pub fn historicalToolActivityKind( diff --git a/src/ui/render_engine/frame_builder.zig b/src/ui/render_engine/frame_builder.zig index 687121864c..9f00c1b510 100644 --- a/src/ui/render_engine/frame_builder.zig +++ b/src/ui/render_engine/frame_builder.zig @@ -85,13 +85,22 @@ pub fn buildAndFlushFrame( committed_layout, &options, ); - const repaint_window = frameRepaintWindow(plan, options.transcript_body); + const sequential_document = switch (options.document_append.source) { + .sequential => true, + .none, .bytes => false, + }; + const repaint_window = if (sequential_document) + paint_plan.FrameRepaintWindow.fromPlan(plan, .{ .include_transcript = false }) + else + frameRepaintWindow(plan, options.transcript_body); logFrameBuildPlan(plan, options); + var movement_document = options.document_append; + if (sequential_document) movement_document.source = .none; var movement = try terminal_diff.prepareTerminalMovementForFrame( alloc, shadow, - options.document_append, + movement_document, terminal_scroll_rows, options.scroll_plan.preserved_release_rows, plan.layout.cols, @@ -122,7 +131,7 @@ pub fn buildAndFlushFrame( .scroll_plan = options.scroll_plan, .document_append = options.document_append, .terminal_transition = options.terminal_transition, - .prepared_movement = &movement, + .prepared_movement = if (sequential_document) null else &movement, .sink = shell.frameSink(), .metrics = metrics, }); @@ -437,6 +446,27 @@ const BuilderSink = struct { } }; +const BuilderSequentialSource = struct { + bytes: []const u8, + + fn source(self: *BuilderSequentialSource) frame_scroll_plan.SequentialDocumentSource { + return .{ + .ctx = self, + .committed_len = self.bytes.len, + .read_at = readAt, + }; + } + + fn readAt(ctx: *anyopaque, out: []u8, offset: u64) !usize { + const self: *BuilderSequentialSource = @ptrCast(@alignCast(ctx)); + const start = std.math.cast(usize, offset) orelse return error.InvalidOffset; + if (start >= self.bytes.len) return 0; + const len = @min(out.len, self.bytes.len - start); + @memcpy(out[0..len], self.bytes[start..][0..len]); + return len; + } +}; + fn testScrollPlan(rows: u16) frame_scroll_plan.FrameScrollPlan { return frame_scroll_plan.merge(4, 1, 0, rows); } @@ -570,6 +600,44 @@ test "frame preparation invalidations preserve owned band and clean flag semanti try std.testing.expect(empty_band_plan.invalidation.isEmpty()); } +test "buildAndFlushFrame forwards a sequential reset document to the terminal owner" { + var sink = BuilderSink{}; + defer sink.deinit(std.testing.allocator); + var shell = try TestShell.init(std.testing.allocator, sink.sink()); + shell.bind(); + defer shell.deinit(); + shell.shadow.autowrap = false; + + var source = BuilderSequentialSource{ .bytes = "history\r\n" }; + var body = PaintCtx{ .text = "body", .owner = .transcript, .row = 2 }; + var footer = PaintCtx{ .text = "footer", .owner = .footer, .row = 3 }; + var plan = testPlan(); + plan.reset_terminal = true; + var metrics: Metrics = .{}; + + const result = try buildAndFlushFrame(std.testing.allocator, &shell, &metrics, .{ + .plan = plan, + .scroll_plan = testScrollPlan(0), + .document_append = .{ + .source = .{ .sequential = source.source() }, + .start_row = 1, + .start_col = 1, + .reset_replay = true, + }, + .body_painter = .{ .ctx = &body, .paint = PaintCtx.painter }, + .footer_painter = .{ .ctx = &footer, .paint = PaintCtx.painter }, + }); + + try std.testing.expect(result.is_committed()); + try std.testing.expect(result.document_append_applied()); + const history = std.mem.find(u8, sink.bytes.items, source.bytes) orelse + return error.TestExpectedAppendBytes; + const painted_footer = std.mem.findPos(u8, sink.bytes.items, history, "footer") orelse + return error.TestExpectedFooter; + try std.testing.expect(history < painted_footer); + try std.testing.expect(std.mem.find(u8, sink.bytes.items, "body") == null); +} + test "buildAndFlushFrame runs body footer activity painters and one commit" { var sink = BuilderSink{}; defer sink.deinit(std.testing.allocator); diff --git a/src/ui/render_engine/frame_scroll_plan.zig b/src/ui/render_engine/frame_scroll_plan.zig index eb7ca415d8..182afcbd44 100644 --- a/src/ui/render_engine/frame_scroll_plan.zig +++ b/src/ui/render_engine/frame_scroll_plan.zig @@ -5,16 +5,56 @@ pub const FrameDocumentAppendClear = union(enum) { rows: u16, }; +pub const SequentialDocumentSource = struct { + ctx: *anyopaque, + committed_len: u64, + read_at: *const fn (ctx: *anyopaque, out: []u8, offset: u64) anyerror!usize, + + pub fn readAt(self: SequentialDocumentSource, out: []u8, offset: u64) !usize { + return self.read_at(self.ctx, out, offset); + } +}; + +pub const FrameDocumentSource = union(enum) { + none, + bytes: []const u8, + sequential: SequentialDocumentSource, + + pub fn declaredLen(self: FrameDocumentSource) u64 { + return switch (self) { + .none => 0, + .bytes => |bytes| bytes.len, + .sequential => |source| source.committed_len, + }; + } + + pub fn isEmpty(self: FrameDocumentSource) bool { + return self.declaredLen() == 0; + } + + pub fn inMemoryBytes(self: FrameDocumentSource) ![]const u8 { + return switch (self) { + .none => &.{}, + .bytes => |bytes| bytes, + .sequential => error.SequentialDocumentUnsupported, + }; + } +}; + pub const FrameDocumentAppend = struct { - /// Terminal-wire bytes; every LF must be preceded by CR. - bytes: []const u8 = &.{}, + /// Terminal-wire document source; byte-backed LF must be preceded by CR. + source: FrameDocumentSource = .none, start_row: u16 = 1, start_col: u16 = 1, clear: FrameDocumentAppendClear = .remainder, reset_replay: bool = false, pub fn isEmpty(self: FrameDocumentAppend) bool { - return self.bytes.len == 0; + return self.source.isEmpty(); + } + + pub fn inMemoryBytes(self: FrameDocumentAppend) ![]const u8 { + return self.source.inMemoryBytes(); } pub fn validatedStartCol(self: FrameDocumentAppend, target_cols: u16, target_rows: u16) !u16 { @@ -40,6 +80,42 @@ pub const FrameDocumentAppend = struct { } }; +test "frame document source represents exactly one authority" { + const Fixture = struct { + bytes: []const u8, + + fn readAt(ctx: *anyopaque, out: []u8, offset: u64) !usize { + const self: *@This() = @ptrCast(@alignCast(ctx)); + const start = std.math.cast(usize, offset) orelse return error.InvalidOffset; + if (start >= self.bytes.len) return 0; + const len = @min(out.len, self.bytes.len - start); + @memcpy(out[0..len], self.bytes[start..][0..len]); + return len; + } + + fn source(self: *@This()) SequentialDocumentSource { + return .{ + .ctx = self, + .committed_len = self.bytes.len, + .read_at = readAt, + }; + } + }; + + const none: FrameDocumentSource = .none; + try std.testing.expect(none.isEmpty()); + try std.testing.expectEqual(@as(u64, 0), none.declaredLen()); + + const bytes: FrameDocumentSource = .{ .bytes = "abc" }; + try std.testing.expect(!bytes.isEmpty()); + try std.testing.expectEqual(@as(u64, 3), bytes.declaredLen()); + + var fixture = Fixture{ .bytes = "abcdef" }; + const sequential: FrameDocumentSource = .{ .sequential = fixture.source() }; + try std.testing.expect(!sequential.isEmpty()); + try std.testing.expectEqual(@as(u64, 6), sequential.declaredLen()); +} + pub const AcceptedScrollRows = struct { preserved_release_rows: u16, inline_advance_rows: u32, @@ -299,21 +375,21 @@ test "merge does not clamp document movement to terminal height" { test "document append validates target geometry and clamps start column" { const append = FrameDocumentAppend{ - .bytes = "row\n", + .source = .{ .bytes = "row\n" }, .start_row = 4, .start_col = 99, }; try std.testing.expectEqual(@as(u16, 8), try append.validatedStartCol(8, 4)); try std.testing.expectEqual(@as(u16, 1), try (FrameDocumentAppend{ - .bytes = "row\n", + .source = .{ .bytes = "row\n" }, .start_row = 4, .start_col = 0, }).validatedStartCol(8, 4)); try std.testing.expectError( error.InvalidFrameScrollPlan, (FrameDocumentAppend{ - .bytes = "row\n", + .source = .{ .bytes = "row\n" }, .start_row = 5, .start_col = 1, }).validatedStartCol(8, 4), diff --git a/src/ui/render_engine/terminal_diff.zig b/src/ui/render_engine/terminal_diff.zig index 012a4c82ea..bf2305bb2c 100644 --- a/src/ui/render_engine/terminal_diff.zig +++ b/src/ui/render_engine/terminal_diff.zig @@ -1,5 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); +const session_transcript = @import("../../core/session/session_transcript.zig"); const debug_trace = @import("../../core/shared/debug_trace.zig"); const types = @import("../../core/shared/types.zig"); const ui_terminal = @import("../terminal/terminal.zig"); @@ -347,6 +348,15 @@ pub fn flushFrame(input: FrameCommitInput) !FrameCommitResult { if (input.document_append.reset_replay and !input.surface.plan.reset_terminal) { return error.InvalidFrameScrollPlan; } + switch (input.document_append.source) { + .sequential => |source| return flushSequentialResetFrame( + input, + source, + &target, + repaint_window, + ), + .none, .bytes => {}, + } var local_movement: ?PreparedTerminalMovement = null; defer if (local_movement) |*movement| movement.deinit(alloc); const movement = if (input.prepared_movement) |prepared| @@ -383,7 +393,7 @@ pub fn flushFrame(input: FrameCommitInput) !FrameCommitResult { terminal_scroll_rows, movement.scroll_rows.materialized_scroll_rows, movement.scroll_rows.alignment_scroll_rows, - input.document_append.bytes.len, + input.document_append.source.declaredLen(), movement.document_append_end -| movement.document_append_start, }, ); @@ -586,6 +596,24 @@ fn composeWireFrame( full_repaint: bool, terminal_scroll_bytes: []const u8, ) !usize { + try composeFramePrefix(wire_frame, input); + const prefix_len = wire_frame.written().len; + try wire_frame.writer.writeAll(terminal_scroll_bytes); + try composeFrameSuffix( + wire_frame, + input, + previous_for_diff, + target, + repaint_window, + full_repaint, + ); + return prefix_len; +} + +fn composeFramePrefix( + wire_frame: *std.Io.Writer.Allocating, + input: FrameCommitInput, +) !void { if (input.surface.plan.reset_terminal and input.history_reset_uses_ris) { // RIS clears modes that fx enables at startup, so restore them before repainting. try wire_frame.writer.writeAll("\x1bc"); @@ -596,9 +624,16 @@ fn composeWireFrame( if (input.surface.plan.reset_terminal) { try wire_frame.writer.writeAll("\x1b[0m\x1b[2J\x1b[3J\x1b[H"); } - const prefix_len = wire_frame.written().len; - try wire_frame.writer.writeAll(terminal_scroll_bytes); +} +fn composeFrameSuffix( + wire_frame: *std.Io.Writer.Allocating, + input: FrameCommitInput, + previous_for_diff: *const vt_emulator.Grid, + target: *const vt_emulator.Grid, + repaint_window: paint_plan.FrameRepaintWindow, + full_repaint: bool, +) !void { if (!repaint_window.isEmpty()) { if (full_repaint) { var clear_baseline = try cloneAuthoritativeShadow( @@ -626,7 +661,302 @@ fn composeWireFrame( try writeCursorPosition(&wire_frame.writer, target.*); if (input.synchronized_update) try wire_frame.writer.writeAll("\x1b[?2026l"); if (target.cursor_visible) try wire_frame.writer.writeAll("\x1b[?25h"); - return prefix_len; +} + +const sequential_frame_read_bytes: usize = 64 * 1024; + +fn flushSequentialResetFrame( + input: FrameCommitInput, + source: frame_scroll_plan.SequentialDocumentSource, + target: *const vt_emulator.Grid, + repaint_window: paint_plan.FrameRepaintWindow, +) !FrameCommitResult { + if (!input.surface.plan.reset_terminal or + !input.document_append.reset_replay or + input.prepared_movement != null or + input.scroll_plan.terminal_scroll_rows != 0 or + source.committed_len == 0) + { + return error.InvalidFrameScrollPlan; + } + + const alloc = input.surface.alloc; + var physical_shadow = try cloneAuthoritativeShadow( + alloc, + input.previous, + target.cols, + target.rows, + ); + var physical_shadow_owned = true; + defer if (physical_shadow_owned) physical_shadow.deinit(); + var logical_shadow = try cloneAuthoritativeShadow( + alloc, + input.previous, + target.cols, + target.rows, + ); + defer logical_shadow.deinit(); + + var prefix: std.Io.Writer.Allocating = .init(alloc); + defer prefix.deinit(); + try composeFramePrefix(&prefix, input); + try prefix.writer.writeAll(input.terminal_transition.sequence()); + try writeDocumentAppendPrelude( + &prefix.writer, + input.document_append, + target.cols, + target.rows, + ); + + var logical_prefix: std.Io.Writer.Allocating = .init(alloc); + defer logical_prefix.deinit(); + var logical_input = input; + logical_input.synchronized_update = false; + try composeFramePrefix(&logical_prefix, logical_input); + try logical_prefix.writer.writeAll(input.terminal_transition.sequence()); + try writeDocumentAppendPrelude( + &logical_prefix.writer, + input.document_append, + target.cols, + target.rows, + ); + + var bytes_written: usize = 0; + const prefix_stats = try writeSequentialFrameSegment( + input, + &physical_shadow, + &logical_shadow, + prefix.written(), + logical_prefix.written(), + ); + if (prefix_stats.scroll_rows != 0) { + try recoverSequentialFrame(input); + return error.InvalidFrameScrollPlan; + } + bytes_written = try checkedFrameByteTotal(bytes_written, prefix.written().len); + + const autowrap_was_enabled = logical_shadow.autowrap; + if (!autowrap_was_enabled) { + const enable_stats = try writeSequentialFrameSegment( + input, + &physical_shadow, + &logical_shadow, + "\x1b[?7h", + "\x1b[?7h", + ); + if (enable_stats.scroll_rows != 0) { + try recoverSequentialFrame(input); + return error.InvalidFrameScrollPlan; + } + bytes_written = try checkedFrameByteTotal(bytes_written, "\x1b[?7h".len); + } + + var storage: [sequential_frame_read_bytes + session_transcript.max_control_sequence_bytes]u8 = undefined; + var carry_len: usize = 0; + var read_offset: u64 = 0; + var emitted_bytes: u64 = 0; + var document_scroll_rows: u16 = 0; + while (read_offset < source.committed_len) { + const remaining = source.committed_len - read_offset; + const read_capacity = @min( + sequential_frame_read_bytes, + std.math.cast(usize, remaining) orelse sequential_frame_read_bytes, + ); + const read = source.readAt( + storage[carry_len .. carry_len + read_capacity], + read_offset, + ) catch |err| { + try recoverSequentialFrame(input); + return err; + }; + if (read == 0 or read > read_capacity) { + try recoverSequentialFrame(input); + return error.InvalidSequentialDocumentRead; + } + read_offset = std.math.add(u64, read_offset, @intCast(read)) catch { + try recoverSequentialFrame(input); + return error.TranscriptTooLarge; + }; + const total = carry_len + read; + const eof = read_offset == source.committed_len; + const safe_len = session_transcript.validatedPrefix(storage[0..total], eof) catch |err| { + try recoverSequentialFrame(input); + return err; + }; + if (safe_len > 0) { + const document_stats = try writeSequentialFrameSegment( + input, + &physical_shadow, + &logical_shadow, + storage[0..safe_len], + storage[0..safe_len], + ); + document_scroll_rows +|= document_stats.scroll_rows; + bytes_written = try checkedFrameByteTotal(bytes_written, safe_len); + emitted_bytes = std.math.add(u64, emitted_bytes, @intCast(safe_len)) catch { + try recoverSequentialFrame(input); + return error.TranscriptTooLarge; + }; + } + const remainder = total - safe_len; + if (remainder > session_transcript.max_control_sequence_bytes) { + try recoverSequentialFrame(input); + return error.InvalidTranscriptGrammar; + } + if (remainder > 0) { + std.mem.copyForwards(u8, storage[0..remainder], storage[safe_len..total]); + } + carry_len = remainder; + } + if (carry_len != 0 or emitted_bytes != source.committed_len) { + try recoverSequentialFrame(input); + return error.InvalidSequentialDocumentRead; + } + + var padding_rows = target.rows -| input.surface.plan.transcript_band.bottom; + while (padding_rows > 0) : (padding_rows -= 1) { + const padding_stats = try writeSequentialFrameSegment( + input, + &physical_shadow, + &logical_shadow, + "\r\n", + "\r\n", + ); + document_scroll_rows +|= padding_stats.scroll_rows; + bytes_written = try checkedFrameByteTotal(bytes_written, "\r\n".len); + } + + if (!autowrap_was_enabled) { + const disable_stats = try writeSequentialFrameSegment( + input, + &physical_shadow, + &logical_shadow, + "\x1b[?7l", + "\x1b[?7l", + ); + if (disable_stats.scroll_rows != 0) { + try recoverSequentialFrame(input); + return error.InvalidFrameScrollPlan; + } + bytes_written = try checkedFrameByteTotal(bytes_written, "\x1b[?7l".len); + } + + const changed_cells = countChangedCells(logical_shadow, target.*, repaint_window); + var suffix: std.Io.Writer.Allocating = .init(alloc); + defer suffix.deinit(); + composeFrameSuffix( + &suffix, + input, + &logical_shadow, + target, + repaint_window, + true, + ) catch |err| { + try recoverSequentialFrame(input); + return err; + }; + const suffix_stats = try writeSequentialFrameSegment( + input, + &physical_shadow, + &logical_shadow, + suffix.written(), + suffix.written(), + ); + if (suffix_stats.scroll_rows != 0) { + try recoverSequentialFrame(input); + return error.InvalidFrameScrollPlan; + } + bytes_written = try checkedFrameByteTotal(bytes_written, suffix.written().len); + + if (!authoritativeShadowHasSteadyState( + physical_shadow, + input.previous.autowrap, + target.cursor_visible, + ) or + !targetMatches(physical_shadow, target.*, repaint_window)) + { + return error.ShadowCrossCheckFailed; + } + + input.previous.deinit(); + input.previous.* = physical_shadow; + physical_shadow_owned = false; + + const expected_scroll_rows = @max( + input.scroll_plan.terminal_scroll_rows, + document_scroll_rows, + ); + const scroll_commit = input.scroll_plan.commitWithExpectedRows( + document_scroll_rows, + expected_scroll_rows, + ); + const scroll_attribution: FrameScrollAttribution = .{ + .document_append_scroll_rows = document_scroll_rows, + }; + debug_trace.logf( + "frame_commit", + "sequential_reset bytes={d} document_bytes={d} applied_scroll_rows={d}", + .{ bytes_written, emitted_bytes, document_scroll_rows }, + ); + return committed_result( + bytes_written, + changed_cells, + true, + scroll_commit, + scroll_attribution, + true, + target, + ); +} + +fn writeSequentialFrameSegment( + input: FrameCommitInput, + physical_shadow: *vt_emulator.Grid, + logical_shadow: *vt_emulator.Grid, + physical_bytes: []const u8, + logical_bytes: []const u8, +) !vt_emulator.FeedStats { + if (physical_bytes.len == 0 and logical_bytes.len == 0) return .{}; + const sink_result = input.sink.write_frame( + input.sink.ctx, + input.metrics, + physical_bytes, + ); + _ = acceptedBytesForSinkResult(physical_bytes, sink_result) catch |err| { + try recoverSequentialFrame(input); + return err; + }; + switch (sink_result) { + .complete => {}, + .partial => |partial| { + try recoverSequentialFrame(input); + return partial.err; + }, + } + physical_shadow.feed(physical_bytes) catch |err| { + try recoverSequentialFrame(input); + return err; + }; + var stats: vt_emulator.FeedStats = .{}; + logical_shadow.feedWithStats(logical_bytes, &stats) catch |err| { + try recoverSequentialFrame(input); + return err; + }; + return stats; +} + +fn checkedFrameByteTotal(total: usize, next: usize) !usize { + return std.math.add(usize, total, next) catch error.FrameTooLarge; +} + +fn recoverSequentialFrame(input: FrameCommitInput) !void { + const cleanup = recoveryBytes(input.synchronized_update, input.previous.autowrap); + const result = input.sink.write_frame(input.sink.ctx, input.metrics, cleanup); + _ = try acceptedBytesForSinkResult(cleanup, result); + switch (result) { + .complete => {}, + .partial => return error.TerminalSyncRecoveryFailed, + } } fn recoverPartialFrame( @@ -1020,7 +1350,7 @@ test "normal-screen transition precedes document movement in the prepared shadow try previous.feed("alternate"); const document_append: frame_scroll_plan.FrameDocumentAppend = .{ - .bytes = "doc", + .source = .{ .bytes = "doc" }, .start_row = 2, .start_col = 1, }; @@ -1353,14 +1683,38 @@ fn appendDocumentMovement( target_rows: u16, ) !DocumentAppendMovement { if (document_append.isEmpty()) return .{}; - for (document_append.bytes, 0..) |byte, index| { - if (byte == '\n' and (index == 0 or document_append.bytes[index - 1] != '\r')) + const document_bytes = try document_append.inMemoryBytes(); + for (document_bytes, 0..) |byte, index| { + if (byte == '\n' and (index == 0 or document_bytes[index - 1] != '\r')) return error.InvalidFrameScrollPlan; } const append_start = bytes.written().len; + try writeDocumentAppendPrelude( + &bytes.writer, + document_append, + target_cols, + target_rows, + ); + const autowrap_was_enabled = post_movement.autowrap; + if (!autowrap_was_enabled) try bytes.writer.writeAll("\x1b[?7h"); + try bytes.writer.writeAll(document_bytes); + if (!autowrap_was_enabled) try bytes.writer.writeAll("\x1b[?7l"); + + const append_segment = try feedMovementSegment(bytes, post_movement, append_start); + return .{ + .segment = append_segment, + }; +} + +fn writeDocumentAppendPrelude( + out: *std.Io.Writer, + document_append: frame_scroll_plan.FrameDocumentAppend, + target_cols: u16, + target_rows: u16, +) !void { const start_col = try document_append.validatedStartCol(target_cols, target_rows); - try bytes.writer.print( + try out.print( "\x1b[{d};{d}H", .{ document_append.start_row, start_col }, ); @@ -1369,7 +1723,7 @@ fn appendDocumentMovement( // Ordinary appends replace everything after the transcript endpoint. // Clear it before append rows can promote footer cells into scrollback. try writeDocumentAppendClear( - &bytes.writer, + out, document_append.start_row, start_col, target_cols, @@ -1378,22 +1732,13 @@ fn appendDocumentMovement( }, .rows => { const clear_bottom = (try document_append.validatedClearBottom(target_rows)).?; - try writeBandClears(&bytes.writer, document_append.start_row, clear_bottom); - try bytes.writer.print( + try writeBandClears(out, document_append.start_row, clear_bottom); + try out.print( "\x1b[{d};{d}H", .{ document_append.start_row, start_col }, ); }, } - const autowrap_was_enabled = post_movement.autowrap; - if (!autowrap_was_enabled) try bytes.writer.writeAll("\x1b[?7h"); - try bytes.writer.writeAll(document_append.bytes); - if (!autowrap_was_enabled) try bytes.writer.writeAll("\x1b[?7l"); - - const append_segment = try feedMovementSegment(bytes, post_movement, append_start); - return .{ - .segment = append_segment, - }; } fn appendAlignmentScroll( @@ -1599,6 +1944,34 @@ const TestSink = struct { } }; +const SequentialSourceFixture = struct { + bytes: []const u8, + max_read: usize, + fail_at_offset: ?u64 = null, + read_calls: usize = 0, + + fn source(self: *SequentialSourceFixture) frame_scroll_plan.SequentialDocumentSource { + return .{ + .ctx = self, + .committed_len = self.bytes.len, + .read_at = readAt, + }; + } + + fn readAt(ctx: *anyopaque, out: []u8, offset: u64) !usize { + const self: *SequentialSourceFixture = @ptrCast(@alignCast(ctx)); + self.read_calls += 1; + if (self.fail_at_offset) |fail_at| { + if (offset >= fail_at) return error.TestSequentialReadFailure; + } + const start = std.math.cast(usize, offset) orelse return error.InvalidOffset; + if (start >= self.bytes.len) return 0; + const len = @min(@min(out.len, self.max_read), self.bytes.len - start); + @memcpy(out[0..len], self.bytes[start..][0..len]); + return len; + } +}; + fn testScrollPlan(rows: u16) frame_scroll_plan.FrameScrollPlan { return frame_scroll_plan.merge(4, 1, 0, rows); } @@ -1612,6 +1985,248 @@ fn makeSurface(alloc: Allocator, previous: vt_emulator.Grid, plan: paint_plan.Pa return surface; } +test "flushFrame streams a sequential reset document inside one synchronized transaction" { + var previous = try vt_emulator.Grid.init(std.testing.allocator, 8, 4); + defer previous.deinit(); + previous.autowrap = false; + + var plan = testPlan(); + plan.reset_terminal = true; + var surface = try makeSurface(std.testing.allocator, previous, plan); + defer surface.deinit(); + + var fixture = SequentialSourceFixture{ + .bytes = "one\r\ntwo\r\n", + .max_read = 3, + }; + var sink = TestSink{}; + defer sink.deinit(std.testing.allocator); + var metrics: Metrics = .{}; + + const result = try flushFrame(.{ + .previous = &previous, + .surface = &surface, + .repaint_window = paint_plan.FrameRepaintWindow.fromPlan( + surface.plan, + .{ .include_transcript = true }, + ), + .synchronized_update = true, + .scroll_plan = frame_scroll_plan.merge(4, 1, 0, 0), + .document_append = .{ + .source = .{ .sequential = fixture.source() }, + .start_row = 1, + .start_col = 1, + .reset_replay = true, + }, + .sink = sink.frameSink(), + .metrics = &metrics, + }); + + try std.testing.expect(result.is_committed()); + try std.testing.expect(result.document_append_applied()); + try std.testing.expect(fixture.read_calls > 1); + + const bytes = sink.bytes.items; + const sync_begin = std.mem.find(u8, bytes, "\x1b[?2026h") orelse return error.TestExpectedSyncBegin; + const cursor_hide = std.mem.find(u8, bytes, "\x1b[?25l") orelse return error.TestExpectedCursorHide; + const clear = std.mem.find(u8, bytes, "\x1b[0m\x1b[2J\x1b[3J\x1b[H") orelse return error.TestExpectedClear; + const autowrap_enable = std.mem.find(u8, bytes, "\x1b[?7h") orelse return error.TestExpectedAutowrapEnable; + const document = std.mem.find(u8, bytes, fixture.bytes) orelse return error.TestExpectedAppendBytes; + const autowrap_disable = std.mem.findPos(u8, bytes, document + fixture.bytes.len, "\x1b[?7l") orelse return error.TestExpectedAutowrapDisable; + const footer = std.mem.findPos(u8, bytes, autowrap_disable, ">") orelse return error.TestExpectedFooter; + const sync_end = std.mem.findPos(u8, bytes, footer, "\x1b[?2026l") orelse return error.TestExpectedSyncEnd; + const cursor_show = std.mem.findPos(u8, bytes, sync_end, "\x1b[?25h") orelse return error.TestExpectedCursorShow; + try std.testing.expect(sync_begin < cursor_hide); + try std.testing.expect(cursor_hide < clear); + try std.testing.expect(clear < autowrap_enable); + try std.testing.expect(autowrap_enable < document); + try std.testing.expect(document < autowrap_disable); + try std.testing.expect(autowrap_disable < footer); + try std.testing.expect(footer < sync_end); + try std.testing.expect(sync_end < cursor_show); + try std.testing.expect(!previous.autowrap); + try std.testing.expect(!previous.sync_active); +} + +test "flushFrame stops a sequential reset before the footer when the reader fails" { + var previous = try vt_emulator.Grid.init(std.testing.allocator, 8, 4); + defer previous.deinit(); + previous.autowrap = false; + + var plan = testPlan(); + plan.reset_terminal = true; + var surface = try makeSurface(std.testing.allocator, previous, plan); + defer surface.deinit(); + + var fixture = SequentialSourceFixture{ + .bytes = "abcde-rest\r\n", + .max_read = 5, + .fail_at_offset = 5, + }; + var sink = TestSink{}; + defer sink.deinit(std.testing.allocator); + var metrics: Metrics = .{}; + + try std.testing.expectError(error.TestSequentialReadFailure, flushFrame(.{ + .previous = &previous, + .surface = &surface, + .repaint_window = paint_plan.FrameRepaintWindow.fromPlan( + surface.plan, + .{ .include_transcript = true }, + ), + .synchronized_update = true, + .scroll_plan = frame_scroll_plan.merge(4, 1, 0, 0), + .document_append = .{ + .source = .{ .sequential = fixture.source() }, + .start_row = 1, + .start_col = 1, + .reset_replay = true, + }, + .sink = sink.frameSink(), + .metrics = &metrics, + })); + + try std.testing.expect(std.mem.find(u8, sink.bytes.items, "abcde") != null); + try std.testing.expect(std.mem.find(u8, sink.bytes.items, "rest") == null); + try std.testing.expect(std.mem.find(u8, sink.bytes.items, ">") == null); +} + +test "sequential reset output is invariant across UTF-8 SGR and OSC 8 chunk boundaries" { + const transcript = "plain \xf0\x9f\x99\x82 " ++ + "\x1b[31mred\x1b[0m " ++ + "\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\\r\n"; + const read_sizes = [_]usize{ 1, 3, 1024, sequential_frame_read_bytes }; + var expected: ?[]u8 = null; + defer if (expected) |bytes| std.testing.allocator.free(bytes); + + for (read_sizes) |read_size| { + var previous = try vt_emulator.Grid.init(std.testing.allocator, 16, 4); + defer previous.deinit(); + previous.autowrap = false; + var plan = testPlan(); + plan.layout.cols = 16; + plan.reset_terminal = true; + var surface = try makeSurface(std.testing.allocator, previous, plan); + defer surface.deinit(); + var fixture = SequentialSourceFixture{ + .bytes = transcript, + .max_read = read_size, + }; + var sink = TestSink{}; + defer sink.deinit(std.testing.allocator); + var metrics: Metrics = .{}; + + const result = try flushFrame(.{ + .previous = &previous, + .surface = &surface, + .repaint_window = paint_plan.FrameRepaintWindow.fromPlan( + surface.plan, + .{ .include_transcript = true }, + ), + .synchronized_update = true, + .scroll_plan = frame_scroll_plan.merge(4, 1, 0, 0), + .document_append = .{ + .source = .{ .sequential = fixture.source() }, + .start_row = 1, + .start_col = 1, + .reset_replay = true, + }, + .sink = sink.frameSink(), + .metrics = &metrics, + }); + + try std.testing.expect(result.is_committed()); + const document = std.mem.find(u8, sink.bytes.items, transcript) orelse + return error.TestExpectedAppendBytes; + try std.testing.expect(std.mem.findPos( + u8, + sink.bytes.items, + document + transcript.len, + transcript, + ) == null); + if (expected) |bytes| { + try std.testing.expectEqualSlices(u8, bytes, sink.bytes.items); + } else { + expected = try std.testing.allocator.dupe(u8, sink.bytes.items); + } + } +} + +test "sequential reset rejects zero progress and never paints the suffix" { + var previous = try vt_emulator.Grid.init(std.testing.allocator, 8, 4); + defer previous.deinit(); + previous.autowrap = false; + var plan = testPlan(); + plan.reset_terminal = true; + var surface = try makeSurface(std.testing.allocator, previous, plan); + defer surface.deinit(); + var fixture = SequentialSourceFixture{ + .bytes = "history\r\n", + .max_read = 0, + }; + var sink = TestSink{}; + defer sink.deinit(std.testing.allocator); + var metrics: Metrics = .{}; + + try std.testing.expectError(error.InvalidSequentialDocumentRead, flushFrame(.{ + .previous = &previous, + .surface = &surface, + .repaint_window = paint_plan.FrameRepaintWindow.fromPlan( + surface.plan, + .{ .include_transcript = true }, + ), + .synchronized_update = true, + .scroll_plan = frame_scroll_plan.merge(4, 1, 0, 0), + .document_append = .{ + .source = .{ .sequential = fixture.source() }, + .start_row = 1, + .start_col = 1, + .reset_replay = true, + }, + .sink = sink.frameSink(), + .metrics = &metrics, + })); + try std.testing.expect(std.mem.find(u8, sink.bytes.items, ">") == null); +} + +test "sequential reset suffix write failure does not return a commit receipt" { + var previous = try vt_emulator.Grid.init(std.testing.allocator, 8, 4); + defer previous.deinit(); + previous.autowrap = false; + var plan = testPlan(); + plan.reset_terminal = true; + var surface = try makeSurface(std.testing.allocator, previous, plan); + defer surface.deinit(); + var fixture = SequentialSourceFixture{ + .bytes = "history\r\n", + .max_read = sequential_frame_read_bytes, + }; + var sink = TestSink{ .fail_on_call = 5 }; + defer sink.deinit(std.testing.allocator); + var metrics: Metrics = .{}; + + try std.testing.expectError(error.TestFrameWriteFailure, flushFrame(.{ + .previous = &previous, + .surface = &surface, + .repaint_window = paint_plan.FrameRepaintWindow.fromPlan( + surface.plan, + .{ .include_transcript = true }, + ), + .synchronized_update = true, + .scroll_plan = frame_scroll_plan.merge(4, 1, 0, 0), + .document_append = .{ + .source = .{ .sequential = fixture.source() }, + .start_row = 1, + .start_col = 1, + .reset_replay = true, + }, + .sink = sink.frameSink(), + .metrics = &metrics, + })); + try std.testing.expect(std.mem.find(u8, sink.bytes.items, "history\r\n") != null); + try std.testing.expect(std.mem.find(u8, sink.bytes.items, ">") == null); +} + const TestFrameCommitOptions = struct { plan: ?paint_plan.PaintPlan = null, surface_shadow: ?vt_emulator.Grid = null, @@ -1733,7 +2348,7 @@ test "normal-screen restore preserves saved modes through reset replay" { .restore_normal_screen = .{ .mouse_tracking_active = true }, }; const document_append: frame_scroll_plan.FrameDocumentAppend = .{ - .bytes = "result\r\n", + .source = .{ .bytes = "result\r\n" }, .start_row = 1, .start_col = 1, .reset_replay = true, @@ -2178,7 +2793,7 @@ test "flushFrame writes nonempty zero-scroll document append even when target al previous.cursor_col = 1; const append = frame_scroll_plan.FrameDocumentAppend{ - .bytes = "\x1b]8;;https://append.example\x1b\\\x1b]8;;\x1b\\", + .source = .{ .bytes = "\x1b]8;;https://append.example\x1b\\\x1b]8;;\x1b\\" }, .start_row = 3, .start_col = 1, }; @@ -2237,7 +2852,7 @@ test "terminal movement clears only bounded history rows before alignment" { try previous.feed("\x1b[1;1Hone\x1b[2;1Htwo\x1b[3;1Hthree\x1b[4;1Hfour"); const append = frame_scroll_plan.FrameDocumentAppend{ - .bytes = "\r\n", + .source = .{ .bytes = "\r\n" }, .start_row = 1, .start_col = 1, .clear = .{ .rows = 1 }, @@ -2420,7 +3035,7 @@ test "flushFrame attributes movement scroll and keeps repaint tail scroll neutra const scroll_plan = testScrollPlan(2); const append = frame_scroll_plan.FrameDocumentAppend{ - .bytes = "x\r\n", + .source = .{ .bytes = "x\r\n" }, .start_row = 4, .start_col = 1, }; @@ -3032,7 +3647,7 @@ test "terminal movement materializes appended document rows before alignment scr var movement = try prepareTerminalMovement( std.testing.allocator, &previous, - .{ .bytes = "new1\r\nnew2\r\nnew3\r\n", .start_row = 3, .start_col = 1 }, + .{ .source = .{ .bytes = "new1\r\nnew2\r\nnew3\r\n" }, .start_row = 3, .start_col = 1 }, 3, 8, 4, @@ -3057,7 +3672,7 @@ test "terminal movement records document append range relative to frame prefix" var movement = try prepareTerminalMovement( std.testing.allocator, &previous, - .{ .bytes = "next\r\n", .start_row = 4, .start_col = 1 }, + .{ .source = .{ .bytes = "next\r\n" }, .start_row = 4, .start_col = 1 }, 1, 8, 4, @@ -3084,7 +3699,7 @@ test "terminal movement rejects bare LF document bytes" { prepareTerminalMovement( std.testing.allocator, &previous, - .{ .bytes = "bad\n", .start_row = 4, .start_col = 1 }, + .{ .source = .{ .bytes = "bad\n" }, .start_row = 4, .start_col = 1 }, 1, 8, 4, @@ -3100,7 +3715,7 @@ test "terminal movement plans decomposed text by display width" { std.testing.allocator, &previous, .{ - .bytes = "e\u{0301}e\u{0301}e\u{0301}e\u{0301}e\u{0301}", + .source = .{ .bytes = "e\u{0301}e\u{0301}e\u{0301}e\u{0301}e\u{0301}" }, .start_row = 4, .start_col = 1, }, @@ -3123,7 +3738,7 @@ test "terminal movement brackets document append with temporary autowrap restora std.testing.allocator, &previous, .{ - .bytes = "wrapped\r\n", + .source = .{ .bytes = "wrapped\r\n" }, .start_row = 4, .start_col = 1, }, @@ -3151,7 +3766,7 @@ test "terminal movement clears stale rows after the exact append cursor" { var movement = try prepareTerminalMovement( std.testing.allocator, &previous, - .{ .bytes = "\r\nnext\r\n", .start_row = 3, .start_col = 7 }, + .{ .source = .{ .bytes = "\r\nnext\r\n" }, .start_row = 3, .start_col = 7 }, 1, 8, 4, @@ -3175,7 +3790,7 @@ test "terminal movement preserves an occupied exact-width append endpoint" { var movement = try prepareTerminalMovement( std.testing.allocator, &previous, - .{ .bytes = "\r\nnext\r\n", .start_row = 3, .start_col = 8 }, + .{ .source = .{ .bytes = "\r\nnext\r\n" }, .start_row = 3, .start_col = 8 }, 1, 8, 4, @@ -3201,7 +3816,7 @@ test "terminal movement rejects materialized rows beyond plan" { std.testing.allocator, &previous, .{ - .bytes = "one\r\ntwo\r\n", + .source = .{ .bytes = "one\r\ntwo\r\n" }, .start_row = 4, .start_col = 1, }, @@ -3260,7 +3875,7 @@ test "reset replay uses materialized append rows as the complete write expectati defer surface.deinit(); const document_append: frame_scroll_plan.FrameDocumentAppend = .{ - .bytes = "replay\r\n", + .source = .{ .bytes = "replay\r\n" }, .start_row = 4, .start_col = 1, .reset_replay = true, @@ -3348,7 +3963,7 @@ test "terminal movement rejects append outside target geometry" { prepareTerminalMovement( std.testing.allocator, &previous, - .{ .bytes = "row\r\n", .start_row = 5, .start_col = 1 }, + .{ .source = .{ .bytes = "row\r\n" }, .start_row = 5, .start_col = 1 }, 0, 8, 4, @@ -3369,9 +3984,9 @@ test "prepared movement keeps scrolled-out and visible append links before paint try previous.feed("\x1b]8;;https://old.example\x1b\\\x1b]8;;\x1b\\"); const append = frame_scroll_plan.FrameDocumentAppend{ - .bytes = "\x1b]8;;https://a.example\x1b\\a\x1b]8;;\x1b\\\r\n" ++ + .source = .{ .bytes = "\x1b]8;;https://a.example\x1b\\a\x1b]8;;\x1b\\\r\n" ++ "1\r\n2\r\n3\r\n4\r\n" ++ - "\x1b]8;;https://b.example\x1b\\b\x1b]8;;\x1b\\", + "\x1b]8;;https://b.example\x1b\\b\x1b]8;;\x1b\\" }, .start_row = 4, .start_col = 1, }; @@ -3459,7 +4074,7 @@ test "flushFrame rejects retry after interrupted document append" { .repaint_window = paint_plan.FrameRepaintWindow.fromPlan(surface.plan, .{ .include_transcript = true }), .synchronized_update = false, .scroll_plan = testScrollPlan(1), - .document_append = .{ .bytes = "x\r\n", .start_row = 4, .start_col = 1 }, + .document_append = .{ .source = .{ .bytes = "x\r\n" }, .start_row = 4, .start_col = 1 }, .sink = sink.frameSink(), .metrics = &metrics, }), @@ -3472,7 +4087,7 @@ test "flushFrame acknowledges complete document append before later repaint fail var surface = try makeSurface(std.testing.allocator, previous, testPlan()); defer surface.deinit(); const append = frame_scroll_plan.FrameDocumentAppend{ - .bytes = "x\r\n", + .source = .{ .bytes = "x\r\n" }, .start_row = 4, .start_col = 1, }; @@ -3510,7 +4125,7 @@ test "flushFrame acknowledges complete document append before later repaint fail test "flushFrame retry acknowledges document append and scroll exactly once" { const append = frame_scroll_plan.FrameDocumentAppend{ - .bytes = "x\r\n", + .source = .{ .bytes = "x\r\n" }, .start_row = 4, .start_col = 1, }; @@ -3602,7 +4217,7 @@ test "flushFrame retry acknowledges document append and scroll exactly once" { }, ); try std.testing.expect(retry.is_committed()); - try std.testing.expect(std.mem.find(u8, retry_sink.bytes.items, append.bytes) == null); + try std.testing.expect(std.mem.find(u8, retry_sink.bytes.items, try append.inMemoryBytes()) == null); try physical.feed(retry_sink.bytes.items); try expect_grid_and_cursor_equal(uninterrupted, authoritative); try expect_grid_and_cursor_equal(uninterrupted, physical); @@ -3689,7 +4304,7 @@ test "accepted frame write attributes partial scroll to the accepted movement se defer previous.deinit(); const append = frame_scroll_plan.FrameDocumentAppend{ - .bytes = "doc\r\n", + .source = .{ .bytes = "doc\r\n" }, .start_row = 4, .start_col = 1, }; diff --git a/src/ui/resize_tests.zig b/src/ui/resize_tests.zig index 4fe9963ef0..5fd5a9f4ee 100644 --- a/src/ui/resize_tests.zig +++ b/src/ui/resize_tests.zig @@ -719,7 +719,7 @@ fn commitTestFrame( .body_disposition = body_disposition, .body_paints = counters.body_paints, .retained_transcript_changed_cells = counters.retained_transcript_changed_cells, - .document_append_bytes = document_append.bytes.len, + .document_append_bytes = @intCast(document_append.source.declaredLen()), .document_append_clear_rows = switch (document_append.clear) { .remainder => null, .rows => |rows| rows, diff --git a/src/ui/transcript/presentation_record.zig b/src/ui/transcript/presentation_record.zig index 9b0abb5887..3ae26042ea 100644 --- a/src/ui/transcript/presentation_record.zig +++ b/src/ui/transcript/presentation_record.zig @@ -56,6 +56,7 @@ pub fn decide( if (span.entry_id <= after.entry_id) return .incompatible; } const first = spans[index]; + if (first.entry_id != after.entry_id +% 1) return .incompatible; prefix_newlines = transcript_blocks.blockSeparatorNewlineCount( after.kind, first.kind, @@ -87,7 +88,8 @@ pub fn decide( } } const span = endpoint orelse return .incompatible; - const next: Cursor = if (finalized_end == span.end) + const next: Cursor = if (finalized_end == span.end and + span.kind != .assistant_turn) .{ .after = .{ .entry_id = span.entry_id, .kind = span.kind, @@ -123,8 +125,8 @@ test "presentation record cursor advances finalized entry bytes exactly once" { try std.testing.expect(first == .append); try std.testing.expectEqual(@as(usize, 0), first.append.start); try std.testing.expectEqual(@as(usize, 4), first.append.end); - try std.testing.expect(first.append.next == .after); - try std.testing.expectEqual(@as(u32, 1), first.append.next.after.entry_id); + try std.testing.expect(first.append.next == .at); + try std.testing.expectEqual(@as(u32, 1), first.append.next.at.entry_id); const second = decide(first.append.next, 80, 10, 10, &spans); try std.testing.expect(second == .append); @@ -139,6 +141,23 @@ test "presentation record cursor advances finalized entry bytes exactly once" { } }, 40, 10, 10, &spans) == .incompatible); } +test "presentation record resumes when a finalized assistant entry grows" { + const initial_spans = [_]EntrySpan{ + .{ .entry_id = 1, .kind = .assistant_turn, .start = 0, .content_start = 0, .end = 4 }, + }; + const initial = decide(.start, 80, 4, 4, &initial_spans); + try std.testing.expect(initial == .append); + try std.testing.expect(initial.append.next == .at); + + const grown_spans = [_]EntrySpan{ + .{ .entry_id = 1, .kind = .assistant_turn, .start = 0, .content_start = 0, .end = 8 }, + }; + const grown = decide(initial.append.next, 80, 8, 8, &grown_spans); + try std.testing.expect(grown == .append); + try std.testing.expectEqual(@as(usize, 4), grown.append.start); + try std.testing.expectEqual(@as(usize, 8), grown.append.end); +} + test "presentation record resumes after a fully recorded cursor entry is pruned" { const spans = [_]EntrySpan{ .{ .entry_id = 3, .kind = .system_notice, .start = 0, .content_start = 0, .end = 5 }, @@ -153,3 +172,16 @@ test "presentation record resumes after a fully recorded cursor entry is pruned" try std.testing.expectEqual(@as(usize, 5), decision.append.start); try std.testing.expectEqual(@as(u16, 2), decision.append.prefix_newlines); } + +test "presentation record rejects an unrecorded pruned entry gap" { + const spans = [_]EntrySpan{ + .{ .entry_id = 3, .kind = .system_notice, .start = 0, .content_start = 0, .end = 5 }, + .{ .entry_id = 10, .kind = .user_turn, .start = 5, .content_start = 5, .end = 10 }, + }; + const decision = decide(.{ .after = .{ + .entry_id = 8, + .kind = .assistant_turn, + .ends_with_newline = false, + } }, 80, 10, 10, &spans); + try std.testing.expect(decision == .incompatible); +} diff --git a/src/ui/transcript/resume_projection.zig b/src/ui/transcript/resume_projection.zig index 4aca3d9a8a..e3d6c4303e 100644 --- a/src/ui/transcript/resume_projection.zig +++ b/src/ui/transcript/resume_projection.zig @@ -402,7 +402,7 @@ pub const ResumeProjection = struct { /// Installs structured continuation state after historical bytes were /// already written directly to the terminal. No pending source is armed. - pub fn installRetained(self: *ResumeProjection, target: *TranscriptRuntime) void { + pub fn installRetained(self: *ResumeProjection, target: *TranscriptRuntime) !void { std.debug.assert(self.finalized); std.debug.assert(!self.consumed); std.debug.assert(target.pending_resume_source == null); @@ -429,9 +429,14 @@ pub const ResumeProjection = struct { target.replaceable_last_line = self.runtime.replaceable_last_line; target.replaceable_row = self.runtime.replaceable_row; target.replaceable_start = self.runtime.replaceable_start; - target.recomputeCursorFromTranscript(); + if (target.streamedSessionHistoryActive()) { + target.adoptStreamedSessionHistory(); + try target.establishStreamedRetainedAnchor(self.alloc); + } else { + target.recomputeCursorFromTranscript(); + } target.reconcileDetachedInstallSource(self.publication_source.?.bytes); - target.markTranscriptDirty(); + if (!target.streamedSessionHistoryActive()) target.markTranscriptDirty(); var publication = self.publication_source.?; self.publication_source = null; @@ -551,16 +556,28 @@ test "resume projection can install retained continuation without publication" { var target: TranscriptRuntime = .{}; target.layout = source.layout; defer target.deinit(alloc); + try target.enableShadowVt(alloc); + target.shadow_vt.?.cursor_row = 7; + target.shadow_vt.?.cursor_col = 5; + target.adoptStreamedSessionHistory(); var projection = try ResumeProjection.initEmpty(alloc, &source, 42, 1); defer projection.deinit(); _ = try projection.appendRawClassified("retained marker\n", .unknown_raw); try projection.finalize(); - projection.installRetained(&target); + try projection.installRetained(&target); try std.testing.expectEqual(@as(usize, 0), target.pendingResumeFlow().len); try std.testing.expectEqual(@as(usize, 1), target.entries.items.len); try std.testing.expect(std.mem.find(u8, target.transcript.items, "retained marker") != null); + try std.testing.expectEqual(@as(u16, 7), target.cursor_row); + try std.testing.expectEqual(@as(u16, 5), target.cursor_col); + var installed_source = try target.prepareTranscriptSource(alloc, null); + defer installed_source.deinit(alloc); + const stable = target.stableTranscriptProjectionForFlow(installed_source.bytes) orelse + return error.MissingStreamedTranscriptAnchor; + try std.testing.expectEqual(@as(u16, 7), stable.cursor_row); + try std.testing.expectEqual(@as(u16, 5), stable.cursor_col); } test "resume projection transfers a complete standalone runtime" { diff --git a/src/ui/transcript/runtime.zig b/src/ui/transcript/runtime.zig index 282cbcf24a..7d5321061d 100644 --- a/src/ui/transcript/runtime.zig +++ b/src/ui/transcript/runtime.zig @@ -325,7 +325,7 @@ pub const TranscriptTransition = struct { /// not release; consuming the transition requests a follow-up frame so /// the debt settles promptly instead of waiting for outside activity. history_settle_pending: bool = false, - /// Owns `document_append.bytes` when append preparation allocates wire bytes. + /// Owns the byte-backed document source when append preparation allocates wire bytes. document_append_bytes: []u8 = &.{}, resize_reflow_rows: u16 = 0, semantic_rows: u32 = 0, @@ -1174,6 +1174,57 @@ test "command output admission invalidates cached prefixes when reservation trim ); } +test "presentation record protects uncommitted entries only until cursor advancement" { + const alloc = std.testing.allocator; + var runtime = cacheTestRuntime(.full, 40); + defer runtime.deinit(alloc); + runtime.max_retained_transcript_bytes = 8; + runtime.beginPresentationRecord(); + + const first_id = try runtime.appendRawTranscriptEntryClassified( + alloc, + "first", + .unknown_raw, + ); + const second_id = try runtime.appendRawTranscriptEntryClassified( + alloc, + "second", + .unknown_raw, + ); + + try std.testing.expectEqual(@as(usize, 2), runtime.entries.items.len); + try std.testing.expectEqual(first_id, runtime.entries.items[0].id()); + try std.testing.expectEqual(second_id, runtime.entries.items[1].id()); + + runtime.commitPresentationRecord(.{ .after = .{ + .entry_id = second_id, + .kind = .unknown_raw, + .ends_with_newline = false, + } }, 11); + try runtime.enforceStructuredRetention(alloc, null); + + try std.testing.expectEqual(@as(usize, 1), runtime.entries.items.len); + try std.testing.expectEqual(second_id, runtime.entries.items[0].id()); +} + +test "presentation record protection survives cloned assistant retention" { + const alloc = std.testing.allocator; + var runtime = cacheTestRuntime(.full, 40); + defer runtime.deinit(alloc); + runtime.max_retained_transcript_bytes = 8; + runtime.beginPresentationRecord(); + var metrics: Metrics = .{}; + + _ = try runtime.streamAssistantChunk(alloc, &metrics, "first"); + _ = try runtime.streamAssistantChunk(alloc, &metrics, "second"); + + try std.testing.expectEqual(@as(usize, 1), runtime.entries.items.len); + try std.testing.expectEqualStrings( + "firstsecond", + runtime.entries.items[0].assistant_turn.segments.text.items, + ); +} + test "command and lifecycle updates reuse measured prefix and publish atomically" { const alloc = std.testing.allocator; var runtime = cacheTestRuntime(.full, 40); @@ -4175,6 +4226,7 @@ pub const TranscriptRuntime = struct { /// Core-owned producer state and policy for native-history release. transcript_release: transcript_release.State = .{}, presentation_record: PresentationRecordState = .disabled, + presentation_record_frame_pending: bool = false, pending_presentation_record: ?PendingPresentationRecord = null, presentation_record_restamp_ready: bool = false, /// Sorted by entry id so exact lookup stays bounded as history grows. @@ -4216,6 +4268,7 @@ pub const TranscriptRuntime = struct { /// `viewport_top_row`. has_painted_transcript: bool = false, streamed_session_history_active: bool = false, + streamed_reset_requested: bool = false, footer_viewport: footer_viewport_runtime.FooterViewport = .{}, extra_input_rows: u16 = 0, footer_reserved_base_rows: u16 = 3, @@ -4287,10 +4340,126 @@ pub const TranscriptRuntime = struct { pub fn adoptStreamedSessionHistory(self: *TranscriptRuntime) void { self.streamed_session_history_active = true; + const shadow = self.shadow_vt orelse return; + self.cursor_row = shadow.cursor_row; + self.cursor_col = shadow.cursor_col; + self.has_painted_transcript = true; + } + + pub fn streamedSessionHistoryActive( + self: *const TranscriptRuntime, + ) bool { + return self.streamed_session_history_active; + } + + pub fn streamedResetRequested(self: *const TranscriptRuntime) bool { + return self.streamed_reset_requested; + } + + pub fn finishStreamedResetAttempt(self: *TranscriptRuntime) void { + self.streamed_reset_requested = false; + self.terminal_reset_pending = false; + } + + pub fn establishStreamedRetainedAnchor( + self: *TranscriptRuntime, + alloc: Allocator, + ) !void { + if (!self.streamed_session_history_active) return; + const shadow = self.shadow_vt orelse return error.ShadowTerminalUnavailable; + const top = if (self.owned_top_row == 0 or + self.owned_top_row > self.layout.content_bottom) + 1 + else + self.owned_top_row; + if (self.layout.content_bottom < top) return error.InvalidTranscriptTransition; + + var source = try self.prepareTranscriptSource(alloc, null); + defer source.deinit(alloc); + var metrics: Metrics = .{}; + var prepared = try self.prepareTranscriptSurfacePaintFromSourceForArea( + alloc, + &metrics, + &source, + .{ .top = top, .bottom = self.layout.content_bottom }, + ); + defer prepared.deinit(alloc); + + const flow = try alloc.dupe(u8, source.bytes); + errdefer alloc.free(flow); + const row_provenance = try alloc.dupe( + transcript_blocks.RowProvenance, + prepared.row_provenance.items, + ); + errdefer if (row_provenance.len > 0) alloc.free(row_provenance); + const visual_offset = visualOffsetForSelection( + prepared.sourceVisualRows(), + prepared.selection, + ); + const total_visual_rows = prepared.sourceTotalVisualRows(); + const footer_rows = self.layout.rows -| self.layout.content_bottom; + const solved_layout = render_engine.frame_layout.solve(.{ + .terminal = self.layout, + .owned_top = top, + .footer = .{ + .natural_rows = footer_rows, + .min_rows = footer_rows, + .max_rows = footer_rows, + }, + .transcript = .{ + .natural_visual_rows = @intCast(@min( + total_visual_rows, + @as(u32, std.math.maxInt(u16)), + )), + .cursor_row = shadow.cursor_row, + .cursor_col = shadow.cursor_col, + .tail_kind = prepared.selection.tail_kind, + }, + .prior = self.committed_frame_layout, + }); + const layout = render_engine.frame_layout.CommittedLayoutSnapshot.fromLayout( + solved_layout, + ); + + var previous = self.transcript_commit_state; + self.transcript_commit_state = .{ .stable = .{ + .selection = prepared.selection, + .visual_offset = visual_offset, + .history_visual_offset = visual_offset, + .total_visual_rows = total_visual_rows, + .flow = flow, + .cursor_row = shadow.cursor_row, + .cursor_col = shadow.cursor_col, + .occupied_last_row = prepared.selection.last_visible_row, + .occupied_last_row_blank = prepared.selection.last_visible_row_blank, + .layout_id = layout.layout_id, + .measured_history_origin = prepared.measured_history_origin, + .row_provenance = row_provenance, + } }; + previous.deinit(alloc); + + self.committed_frame_layout = layout; + self.cursor_row = shadow.cursor_row; + self.cursor_col = shadow.cursor_col; + self.last_rendered_cols = self.layout.cols; + self.last_visible_transcript_top_row = prepared.selection.top_row; + self.last_visible_transcript_start_line = prepared.selection.start_line; + self.last_visible_transcript_partial_skip_rows = prepared.selection.partial_skip_rows; + self.last_visible_transcript_split_active = prepared.selection.split_active; + self.last_visible_transcript_split_prefix_lines = prepared.selection.split_prefix_lines; + self.last_visible_transcript_split_suffix_start_line = prepared.selection.split_suffix_start_line; + self.last_visible_transcript_last_row = prepared.selection.last_visible_row; + self.last_visible_transcript_last_row_blank = prepared.selection.last_visible_row_blank; + self.last_visible_transcript_tail_kind = prepared.selection.tail_kind; + self.last_viewport_selection = prepared.selection; + self.has_painted_transcript = source.bytes.len > 0; + self.transcript_band_dirty = false; + self.render_requests.clearReason(.transcript); } pub fn beginPresentationRecord(self: *TranscriptRuntime) void { std.debug.assert(self.pending_presentation_record == null); + self.presentation_record_frame_pending = false; self.presentation_record = .{ .active = .{ .cursor = .start, .committed_len = 0, @@ -4298,12 +4467,44 @@ pub const TranscriptRuntime = struct { } }; } + pub fn presentationRecordActive(self: *const TranscriptRuntime) bool { + return self.presentation_record == .active; + } + + pub fn requestPresentationRecordFrame(self: *TranscriptRuntime) void { + if (self.presentationRecordActive()) { + self.presentation_record_frame_pending = true; + } + } + + pub fn presentationRecordFramePending( + self: *const TranscriptRuntime, + ) bool { + return self.presentationRecordActive() and + self.presentation_record_frame_pending; + } + + pub fn presentationRecordProtectsEntry( + self: *const TranscriptRuntime, + entry_id: u32, + ) bool { + return switch (self.presentation_record) { + .active => |state| switch (state.cursor) { + .start => true, + .after => |cursor| entry_id > cursor.entry_id, + .at => |cursor| entry_id >= cursor.entry_id, + }, + .disabled, .degraded => false, + }; + } + pub fn resumePresentationRecord( self: *TranscriptRuntime, committed_len: u64, ) void { std.debug.assert(committed_len > 0); std.debug.assert(self.pending_presentation_record == null); + self.presentation_record_frame_pending = false; const cursor: presentation_record.Cursor = if (self.entries.items.len == 0) .start else @@ -4332,6 +4533,7 @@ pub const TranscriptRuntime = struct { pub fn takePresentationRecordRestamp(self: *TranscriptRuntime) bool { const ready = self.presentation_record_restamp_ready; self.presentation_record_restamp_ready = false; + if (ready) self.presentation_record_frame_pending = false; return ready; } @@ -4365,12 +4567,14 @@ pub const TranscriptRuntime = struct { state.cursor = next_cursor; state.committed_len = committed_len; state.caught_up = true; + self.presentation_record_frame_pending = false; }, .disabled, .degraded => {}, } } pub fn degradePresentationRecord(self: *TranscriptRuntime) void { + self.presentation_record_frame_pending = false; self.presentation_record = .degraded; } @@ -4675,6 +4879,7 @@ pub const TranscriptRuntime = struct { pub fn clearTranscript(self: *TranscriptRuntime, alloc: Allocator) void { self.streamed_session_history_active = false; + self.streamed_reset_requested = false; const pending_resume_bytes = self.releasePendingResumeSource(alloc); if (pending_resume_bytes > 0) { debug_trace.logf( @@ -6375,7 +6580,24 @@ pub const TranscriptRuntime = struct { } pub fn requestTerminalReset(self: *TranscriptRuntime, metrics: *Metrics) !void { - const min_body_rows = self.min_visible_viewport_rows; + self.prepareTerminalResetState(); + try self.initViewportWithReservedRows(metrics, 1, self.min_visible_viewport_rows); + } + + pub fn armTerminalResetForCurrentFrame( + self: *TranscriptRuntime, + metrics: *Metrics, + ) !void { + self.prepareTerminalResetState(); + try transcript_viewport_runtime.initViewportForCurrentFrame( + self, + metrics, + 1, + self.min_visible_viewport_rows, + ); + } + + fn prepareTerminalResetState(self: *TranscriptRuntime) void { self.reflow_clear_guard_rows = 0; self.resize_history_row_delta = null; self.viewport_clear_pending = false; @@ -6388,7 +6610,6 @@ pub const TranscriptRuntime = struct { self.footer_viewport.has_frame = false; self.footer_viewport.clearExternalInvalidation(); self.terminal_reset_pending = true; - try self.initViewportWithReservedRows(metrics, 1, min_body_rows); } pub fn requestTerminalResetAfterResize( @@ -6397,11 +6618,13 @@ pub const TranscriptRuntime = struct { history_row_delta: ?i32, ) !void { if (self.streamed_session_history_active) { + self.streamed_reset_requested = true; self.reflow_clear_guard_rows = 0; self.resize_history_row_delta = null; self.pending_resize_observation = null; self.viewport_clear_pending = false; self.terminal_reset_pending = false; + self.render_requests.acknowledgeSettledResizeCommit(); self.footer_viewport.has_frame = false; self.footer_viewport.clearExternalInvalidation(); self.invalidateTranscriptAnchor("streamed_session_history_resize"); @@ -8271,14 +8494,15 @@ pub const TranscriptRuntime = struct { { switch (self.presentation_record) { .active => |record| { - const finalized_end = self.transcript_release.finality_floor( + const finality_floor = self.transcript_release.finality_floor( source.finality, self.finalizedToolTurnWatermark(), if (source.replaceable_last_line) source.replaceable_start else null, - ) orelse source.bytes.len; + ); + const finalized_end = finality_floor orelse source.bytes.len; switch (presentation_record.decide( record.cursor, source.cols, @@ -8286,7 +8510,7 @@ pub const TranscriptRuntime = struct { finalized_end, source.entry_spans, )) { - .hold => presentation_record_caught_up = true, + .hold => presentation_record_caught_up = finality_floor == null, .incompatible => { presentation_record_incompatible = true; switch (record.cursor) { @@ -8504,7 +8728,7 @@ pub const TranscriptRuntime = struct { replay_padding_rows, ); document_append = .{ - .bytes = document_append_bytes, + .source = .{ .bytes = document_append_bytes }, .start_row = 1, .start_col = 1, .reset_replay = true, @@ -8514,7 +8738,7 @@ pub const TranscriptRuntime = struct { "document_append_plan raw_bytes={d} wire_bytes={d} start={d},{d} source_visual_offset={d} target_visual_offset={d} planned_scroll_rows={d}", .{ target_flow.len, - document_append.bytes.len, + document_append.source.declaredLen(), document_append.start_row, document_append.start_col, 0, @@ -8582,10 +8806,10 @@ pub const TranscriptRuntime = struct { ); } document_append = .{ - .bytes = if (document_append_bytes.len > 0) + .source = .{ .bytes = if (document_append_bytes.len > 0) document_append_bytes else - raw_append, + raw_append }, .start_row = base.cursor_row, .start_col = base.cursor_col, .clear = if (base.history_replay) |replay| @@ -8598,7 +8822,7 @@ pub const TranscriptRuntime = struct { "document_append_plan raw_bytes={d} wire_bytes={d} start={d},{d} source_visual_offset={d} target_visual_offset={d} planned_scroll_rows={d}", .{ raw_append.len, - document_append.bytes.len, + document_append.source.declaredLen(), document_append.start_row, document_append.start_col, base.visual_offset, @@ -10590,7 +10814,7 @@ test "resume recovery selects a safe materialized endpoint for every outcome" { }; defer runtime.deinit(std.testing.allocator); const document_append: render_engine.frame_scroll_plan.FrameDocumentAppend = .{ - .bytes = "first\n", + .source = .{ .bytes = "first\n" }, .start_row = 2, .start_col = 3, }; @@ -11337,7 +11561,7 @@ test "terminal reset drops stale mid-scrollback footer clear before reanchor" { ); } -test "streamed session history resize invalidates only the retained tail" { +test "settled streamed session resize requests one file backed reset without clearing" { var metrics: Metrics = .{}; var runtime = TranscriptRuntime{ .layout = invalidationTestLayout(), @@ -11354,13 +11578,20 @@ test "streamed session history resize invalidates only the retained tail" { }; defer runtime.deinit(std.testing.allocator); runtime.adoptStreamedSessionHistory(); + runtime.render_requests.completeResizeGeometry(true); + try std.testing.expect(runtime.render_requests.pending_settled_width_reflow); try runtime.requestTerminalResetAfterResize(&metrics, 12); try std.testing.expect(!runtime.terminal_reset_pending); + try std.testing.expect(runtime.streamedResetRequested()); try std.testing.expectEqual(@as(?ResizeObservation, null), runtime.pending_resize_observation); try std.testing.expectEqual(@as(?i32, null), runtime.resize_history_row_delta); + try std.testing.expect(!runtime.render_requests.pending_settled_width_reflow); try std.testing.expect(runtime.render_requests.hasReason(.transcript)); + + runtime.finishStreamedResetAttempt(); + try std.testing.expect(!runtime.streamedResetRequested()); } test "marking transcript cache dirty also schedules transcript work" { @@ -11391,6 +11622,24 @@ test "frame invalidation overflow collapses to owned band with dominant reason" try std.testing.expectEqual(@as(u16, 24), pending.ranges()[0].bottom); } +test "current frame terminal reset arms no followup transcript invalidation" { + var metrics: Metrics = .{}; + var runtime = TranscriptRuntime{ + .layout = invalidationTestLayout(), + .owned_top_row = 7, + .viewport_top_row = 7, + }; + defer runtime.deinit(std.testing.allocator); + + try runtime.armTerminalResetForCurrentFrame(&metrics); + + try std.testing.expect(runtime.terminal_reset_pending); + try std.testing.expectEqual(@as(u16, 1), runtime.owned_top_row); + try std.testing.expectEqual(@as(u16, 1), runtime.viewport_top_row); + try std.testing.expect(runtime.render_requests.pendingInvalidations().isEmpty()); + try std.testing.expect(!runtime.render_requests.hasReason(.transcript)); +} + test "preserved row acknowledgment lowers ownership and viewport boundaries" { var runtime = TranscriptRuntime{ .layout = invalidationTestLayout(), diff --git a/src/ui/transcript/runtime_tests.zig b/src/ui/transcript/runtime_tests.zig index 91b9edc625..47c8354826 100644 --- a/src/ui/transcript/runtime_tests.zig +++ b/src/ui/transcript/runtime_tests.zig @@ -28,6 +28,11 @@ const TranscriptRuntime = transcript_runtime.TranscriptRuntime; const HardLineStarts = viewport_selection.HardLineStarts; const TranscriptBuffer = viewport_selection.TranscriptBuffer; const VisibleTranscriptLine = viewport_selection.VisibleTranscriptLine; + +fn documentAppendBytes(append: render_engine.frame_scroll_plan.FrameDocumentAppend) []const u8 { + return append.inMemoryBytes() catch unreachable; +} + fn renderEntriesToBytes(alloc: Allocator, entries: []const transcript_blocks.TranscriptEntry, cols: u16) ![]u8 { return transcript_runtime.renderEntriesToBytes(alloc, entries, cols, .{}); } @@ -1215,6 +1220,52 @@ test "stable zero-scroll frame yields the complete finalized presentation record try std.testing.expectEqualStrings("single visible line", pending.bytes); } +test "mutable presentation hold keeps the record frame pending" { + const alloc = std.testing.allocator; + var runtime = commandOutputTestRuntime(undefined); + defer runtime.deinit(alloc); + runtime.beginPresentationRecord(); + runtime.requestPresentationRecordFrame(); + var metrics: Metrics = .{}; + _ = try runtime.streamAssistantChunk(alloc, &metrics, "mutable assistant tail"); + + var source = try runtime.prepareTranscriptSource(alloc, null); + defer source.deinit(alloc); + var prepared = try prepareTestSourceForCurrentArea(&runtime, alloc, &source); + defer prepared.deinit(alloc); + var plan = testPaintPlan(&runtime, prepared.selection); + const scroll_plan = render_engine.frame_scroll_plan.FrameScrollPlan.none( + runtime.layout.rows, + runtime.owned_top_row, + ); + var transition = try resolveAndSealTranscriptTransitionForTest( + &runtime, + alloc, + &source, + &prepared, + render_engine.frame_layout.CommittedLayoutSnapshot.fromPaintPlan(plan), + &plan, + scroll_plan, + ); + defer transition.deinit(alloc); + + runtime.consumeTranscriptTransition(alloc, &transition, .{ + .bytes_written = 0, + .changed_cells = 0, + .full_repaint = false, + .terminal_scroll_rows_committed = 0, + .document_append_committed = false, + .committed_cursor_row = transition.cursor_row, + .committed_cursor_col = transition.cursor_col, + .shadow_state = .committed, + .next_invalidation = render_engine.paint_plan.FrameInvalidationSet.empty(), + }); + + try std.testing.expect(runtime.takePendingPresentationRecord() == null); + try std.testing.expect(!runtime.takePresentationRecordRestamp()); + try std.testing.expect(runtime.presentationRecordFramePending()); +} + fn cloneRecoveryTransitionForTest( alloc: Allocator, source: *const transcript_runtime.TranscriptTransition, @@ -1224,7 +1275,7 @@ fn cloneRecoveryTransitionForTest( std.debug.assert(source.document_append_bytes.len == 0); var clone = source.*; - clone.document_append.bytes = &.{}; + clone.document_append.source = .none; clone.document_append_bytes = &.{}; clone.target_flow = &.{}; clone.target_cache = .empty; @@ -1900,7 +1951,7 @@ fn expectRendererAppendRestored( try expectDocumentWireEqualsLogical( alloc, grown_flow[stable_flow.len..], - transition.document_append.bytes, + documentAppendBytes(transition.document_append), ); } @@ -2450,7 +2501,7 @@ test "oversized compatible growth materializes only each accepted projection" { try std.testing.expect(first_transition.materialized.flow_len.? < grown_flow.items.len); try materialized_append.appendSlice( alloc, - first_transition.document_append.bytes, + documentAppendBytes(first_transition.document_append), ); var first_painter = PreparedTranscriptFramePainter{ @@ -2545,7 +2596,7 @@ test "oversized compatible growth materializes only each accepted projection" { ); try materialized_append.appendSlice( alloc, - retry_transition.document_append.bytes, + documentAppendBytes(retry_transition.document_append), ); try expectDocumentWireEqualsLogical( alloc, @@ -3094,7 +3145,7 @@ test "folded inexact growth advances only materialized viewport rows" { try expectDocumentWireEqualsLogical( alloc, followup_append, - followup_transition.document_append.bytes, + documentAppendBytes(followup_transition.document_append), ); try std.testing.expectEqual( followup_flow.items.len, @@ -3321,13 +3372,13 @@ test "staged soft-wrapped presentation resumes across committed projections" { try std.testing.expect(first_raw_end < grown_flow.items.len); try std.testing.expect(std.mem.endsWith( u8, - first_transition.document_append.bytes, + documentAppendBytes(first_transition.document_append), steady_bytes, )); try expectDocumentWireEqualsLogical( alloc, first_raw_append, - first_transition.document_append.bytes[0 .. first_transition.document_append.bytes.len - steady_bytes.len], + documentAppendBytes(first_transition.document_append)[0 .. documentAppendBytes(first_transition.document_append).len - steady_bytes.len], ); try raw_append_bytes.appendSlice(alloc, first_raw_append); @@ -3415,13 +3466,13 @@ test "staged soft-wrapped presentation resumes across committed projections" { const retry_raw_append = grown_flow.items[first_raw_end..]; try std.testing.expect(std.mem.startsWith( u8, - retry_transition.document_append.bytes, + documentAppendBytes(retry_transition.document_append), resume_bytes, )); try expectDocumentWireEqualsLogical( alloc, retry_raw_append, - retry_transition.document_append.bytes[resume_bytes.len..], + documentAppendBytes(retry_transition.document_append)[resume_bytes.len..], ); try raw_append_bytes.appendSlice(alloc, retry_raw_append); try std.testing.expectEqualStrings( @@ -3540,7 +3591,7 @@ test "uncommitted stable-origin append retries from the materialized source endp try expectDocumentWireEqualsLogical( alloc, grown_flow[stable_flow.len..], - first_transition.document_append.bytes, + documentAppendBytes(first_transition.document_append), ); runtime.consumeTranscriptTransition(alloc, &first_transition, .{ @@ -3584,7 +3635,7 @@ test "uncommitted stable-origin append retries from the materialized source endp try expectDocumentWireEqualsLogical( alloc, grown_flow[stable_flow.len..], - retry_transition.document_append.bytes, + documentAppendBytes(retry_transition.document_append), ); } @@ -3892,7 +3943,7 @@ test "stable origin projection rebase narrow to wide survives repeated partial a try expectDocumentWireEqualsLogical( alloc, grown_flow[flow.len..], - append_transition.document_append.bytes, + documentAppendBytes(append_transition.document_append), ); } @@ -4041,7 +4092,7 @@ test "stable origin projection rebase wide to narrow retains debt until exhauste try expectDocumentWireEqualsLogical( alloc, grown_flow[flow.len..], - append_transition.document_append.bytes, + documentAppendBytes(append_transition.document_append), ); } @@ -5491,7 +5542,7 @@ test "finalized transcript transition owns append suffix and commits one anchor" ); defer transition.deinit(alloc); - try std.testing.expectEqualStrings("new\r\n", transition.document_append.bytes); + try std.testing.expectEqualStrings("new\r\n", documentAppendBytes(transition.document_append)); try std.testing.expectEqual(@as(u16, 3), transition.document_append.start_row); try std.testing.expectEqual(@as(u16, 1), transition.document_append.start_col); @@ -6420,7 +6471,7 @@ test "structured replacement preserves reflow debt and drops old semantic debt" try expectDocumentWireEqualsLogical( alloc, append_transition.target_flow[replacement_flow.len..], - append_transition.document_append.bytes, + documentAppendBytes(append_transition.document_append), ); } @@ -6677,7 +6728,7 @@ test "invalid origin partial recovery rebases until complete repaint then append try expectDocumentWireEqualsLogical( alloc, grown_flow[flow.len..], - append_transition.document_append.bytes, + documentAppendBytes(append_transition.document_append), ); try std.testing.expectEqual( stable.cursor_row, @@ -15738,7 +15789,7 @@ test "catch-up release materializes history before staging a larger finality hol try std.testing.expectEqualStrings( "row-00\r\nrow-01\r\nrow-02\r\n", - transition.document_append.bytes, + documentAppendBytes(transition.document_append), ); switch (transition.document_append.clear) { .rows => |rows| try std.testing.expectEqual(@as(u16, 3), rows), @@ -15816,7 +15867,7 @@ test "partial finality release bounds append before staging withheld viewport" { try std.testing.expectEqualStrings( "row-08\r\n", - transition.document_append.bytes, + documentAppendBytes(transition.document_append), ); try std.testing.expectEqual(@as(u32, 10), transition.visual_offset); try std.testing.expectEqual(@as(u32, 5), transition.history_visual_offset); diff --git a/src/ui/transcript/store.zig b/src/ui/transcript/store.zig index 007bb813e7..5b6e4b4c40 100644 --- a/src/ui/transcript/store.zig +++ b/src/ui/transcript/store.zig @@ -249,6 +249,7 @@ fn protectedPreviousUserTurnId(self: anytype, protected_id: u32) ?u32 { } fn isProtectedEntry(self: anytype, entry_id: u32, protected_id: ?u32) bool { + if (presentationRecordProtectsEntry(self, entry_id)) return true; if (isLifecyclePinned(self, entry_id)) return true; for (self.command_output_blocks.items) |block| { if (command_output_runtime.isPrunedRangeAnchor(block, entry_id)) return true; @@ -265,6 +266,14 @@ fn isProtectedEntry(self: anytype, entry_id: u32, protected_id: ?u32) bool { return false; } +fn presentationRecordProtectsEntry(self: anytype, entry_id: u32) bool { + const Runtime = @TypeOf(self.*); + if (comptime @hasDecl(Runtime, "presentationRecordProtectsEntry")) { + return self.presentationRecordProtectsEntry(entry_id); + } + return false; +} + pub fn isLifecyclePinned(self: anytype, entry_id: u32) bool { for (self.entries.items) |entry| { if (entry != .raw_bytes or entry.raw_bytes.id != entry_id) continue; @@ -327,6 +336,9 @@ fn buildProtectedEntryIdSet( errdefer protected_entry_ids.deinit(alloc); for (self.entries.items) |entry| { + if (presentationRecordProtectsEntry(self, entry.id())) { + try protected_entry_ids.put(alloc, entry.id(), {}); + } if (entry == .raw_bytes and entry.raw_bytes.lifecycle_pinned) { try protected_entry_ids.put(alloc, entry.raw_bytes.id, {}); } @@ -612,6 +624,7 @@ fn trimProtectedEntriesToBudget(self: anytype, alloc: Allocator, cap: usize, pro for (self.entries.items) |*entry| { const entry_id = entry.id(); if (!isProtectedEntry(self, entry_id, protected_id)) continue; + if (presentationRecordProtectsEntry(self, entry_id)) continue; if (isLifecyclePinned(self, entry_id)) continue; const before = entryRetainedBytes(entry.*); @@ -1788,6 +1801,8 @@ fn cloneMutationState(self: anytype, alloc: Allocator) !@TypeOf(self.*) { .transcript_cache_origin_untrimmed = self.transcript_cache_origin_untrimmed, .max_transcript_bytes = self.max_transcript_bytes, .max_retained_transcript_bytes = self.max_retained_transcript_bytes, + .presentation_record = self.presentation_record, + .presentation_record_frame_pending = self.presentation_record_frame_pending, .command_output_display = self.command_output_display, .command_output_render = self.command_output_render, .full_transcript = self.full_transcript, diff --git a/src/ui/transcript/viewport_runtime.zig b/src/ui/transcript/viewport_runtime.zig index ec0bfe47bc..0ff515b74c 100644 --- a/src/ui/transcript/viewport_runtime.zig +++ b/src/ui/transcript/viewport_runtime.zig @@ -45,6 +45,37 @@ pub fn initViewport(self: anytype, metrics: *Metrics, requested_start_row: u16) } pub fn initViewportWithReservedRows(self: anytype, metrics: *Metrics, requested_start_row: u16, min_body_rows: u16) !void { + return initViewportWithReservedRowsScheduling( + self, + metrics, + requested_start_row, + min_body_rows, + true, + ); +} + +pub fn initViewportForCurrentFrame( + self: anytype, + metrics: *Metrics, + requested_start_row: u16, + min_body_rows: u16, +) !void { + return initViewportWithReservedRowsScheduling( + self, + metrics, + requested_start_row, + min_body_rows, + false, + ); +} + +fn initViewportWithReservedRowsScheduling( + self: anytype, + metrics: *Metrics, + requested_start_row: u16, + min_body_rows: u16, + schedule_frame: bool, +) !void { _ = metrics; self.min_visible_viewport_rows = min_body_rows; @@ -69,8 +100,12 @@ pub fn initViewportWithReservedRows(self: anytype, metrics: *Metrics, requested_ self.owned_top_row = start_row; self.pending_scroll_compact = false; self.invalidateTranscriptAnchor("init_viewport_frame_reanchor"); - requestTranscriptPaint(self); - recordViewportInvalidation(self, .viewport_reanchor, start_row, self.layout.rows); + if (schedule_frame) { + requestTranscriptPaint(self); + recordViewportInvalidation(self, .viewport_reanchor, start_row, self.layout.rows); + } else { + self.transcript_band_dirty = true; + } debug_trace.logf("frame_plan", "init_viewport_frame_deferred start={d} rows={d}", .{ start_row, self.layout.rows }); debug_trace.logf("render", "initViewport exit viewport_top={d} cursor={d},{d} has_painted={s}", .{ self.viewport_top_row, self.cursor_row, self.cursor_col, if (self.has_painted_transcript) "true" else "false" }); } diff --git a/tests/e2e/tui-resume-brutal.test.ts b/tests/e2e/tui-resume-brutal.test.ts index a3608828a9..7613028c00 100644 --- a/tests/e2e/tui-resume-brutal.test.ts +++ b/tests/e2e/tui-resume-brutal.test.ts @@ -47,6 +47,7 @@ type Metrics = { scope: number[]; page: number[]; resize: number[]; + replayResize: number[]; close: number[]; resume: number[]; rssKib: number[]; @@ -102,6 +103,59 @@ function countScrollbackLines(path: string, marker: string): number { } } +function sessionMarkerCounts(path: string): { total: number; unique: number } { + const markers = readFileSync(path, "utf8").match(/RESUME_CHAT_[0-9]{6}/g) ?? []; + return { + total: markers.length, + unique: new Set(markers).size, + }; +} + +function countOccurrences(text: string, marker: string): number { + return text.split(marker).length - 1; +} + +async function waitForTraceCount( + path: string, + marker: string, + expected: number, + timeout: number, +): Promise { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (countOccurrences(readFileSync(path, "utf8"), marker) >= expected) return; + await Bun.sleep(25); + } + throw new Error(`Timed out waiting for trace marker ${marker} count ${expected}.`); +} + +async function waitForExactSessionScrollback( + session: TmuxSession, + path: string, + expectedSessionLines: number, + exactMarkers: readonly string[], + timeout: number, +): Promise { + const deadline = Date.now() + timeout; + let consecutive = 0; + while (Date.now() < deadline) { + await session.captureFullScrollbackToFile(path); + const counts = sessionMarkerCounts(path); + const text = readFileSync(path, "utf8"); + const exact = counts.total === expectedSessionLines && + counts.unique === expectedSessionLines && + exactMarkers.every((marker) => countOccurrences(text, marker) === 1); + if (exact) { + consecutive += 1; + if (consecutive === 2) return; + } else { + consecutive = 0; + } + await Bun.sleep(50); + } + throw new Error(`Timed out waiting for stable exact session scrollback at ${path}.`); +} + function percentile(values: number[], fraction: number): number { if (values.length === 0) return 0; const sorted = [...values].sort((a, b) => a - b); @@ -112,6 +166,7 @@ function summarize(values: number[]) { const result: Record = { count: values.length, p50Ms: Number(percentile(values, 0.5).toFixed(3)), + p90Ms: Number(percentile(values, 0.9).toFixed(3)), p95Ms: Number(percentile(values, 0.95).toFixed(3)), maxMs: Number(Math.max(0, ...values).toFixed(3)), }; @@ -445,6 +500,7 @@ async function runStress(config: Config): Promise { scope: [], page: [], resize: [], + replayResize: [], close: [], resume: [], rssKib: [], @@ -504,6 +560,11 @@ async function runStress(config: Config): Promise { const tailMarker = `RESUME_CHAT_${pad(config.chatBatches * config.chatLinesPerBatch - 1)}`; const restoredScrollbackPath = join(paths.root, "restored-scrollback.txt"); await session.captureFullScrollbackToFile(restoredScrollbackPath); + const expectedSessionLines = config.chatBatches * config.chatLinesPerBatch; + expect(sessionMarkerCounts(restoredScrollbackPath)).toEqual({ + total: expectedSessionLines, + unique: expectedSessionLines, + }); for (const marker of [headMarker, middleMarker, tailMarker, FINAL_MARKER]) { expect(countScrollbackLines(restoredScrollbackPath, marker)).toBe(1); } @@ -514,11 +575,58 @@ async function runStress(config: Config): Promise { await session.sendText("Continue after the complete transcript restore."); await session.waitForText(CONTINUATION_MARKER, TIMEOUT * 4); - await session.resizeWindow(90, 28); + await session.waitForComposer(TIMEOUT * 4); await session.captureFullScrollbackToFile(restoredScrollbackPath); - expect(countScrollbackLines(restoredScrollbackPath, headMarker)).toBe(1); + expect(sessionMarkerCounts(restoredScrollbackPath)).toEqual({ + total: expectedSessionLines, + unique: expectedSessionLines, + }); expect(countScrollbackLines(restoredScrollbackPath, tailMarker)).toBe(1); expect(countScrollbackLines(restoredScrollbackPath, CONTINUATION_MARKER)).toBe(1); + const resetCommitMarker = "event=session_transcript_reset outcome=committed"; + const resizeAndAssertExact = async (width: number, height: number): Promise => { + const resizedScrollbackPath = join( + paths.root, + `restored-${width}x${height}.txt`, + ); + const priorCommits = countOccurrences( + readFileSync(paths.trace, "utf8"), + resetCommitMarker, + ); + await time( + metrics.replayResize, + () => session!.resizeWindow(width, height), + () => waitForTraceCount( + paths.trace, + resetCommitMarker, + priorCommits + 1, + TIMEOUT * 20, + ), + ); + await waitForExactSessionScrollback( + session!, + resizedScrollbackPath, + expectedSessionLines, + [headMarker, middleMarker, tailMarker, CONTINUATION_MARKER], + TIMEOUT * 20, + ); + expect(sessionMarkerCounts(resizedScrollbackPath)).toEqual({ + total: expectedSessionLines, + unique: expectedSessionLines, + }); + expect(countScrollbackLines(resizedScrollbackPath, headMarker)).toBe(1); + expect(countScrollbackLines(resizedScrollbackPath, middleMarker)).toBe(1); + expect(countScrollbackLines(resizedScrollbackPath, tailMarker)).toBe(1); + expect(countScrollbackLines(resizedScrollbackPath, CONTINUATION_MARKER)).toBe(1); + }; + const resizeGeometries = [ + [140, 40], + [72, 24], + [120, 34], + ] as const; + for (const [width, height] of resizeGeometries) { + await resizeAndAssertExact(width, height); + } const postResumeTrace = readFileSync(paths.trace, "utf8"); const exactStreamIndex = postResumeTrace.lastIndexOf( "event=session_transcript_stream outcome=exact", @@ -541,6 +649,7 @@ async function runStress(config: Config): Promise { scope: summarize(metrics.scope), page: summarize(metrics.page), resize: summarize(metrics.resize), + replayResize: summarize(metrics.replayResize), close: summarize(metrics.close), resume: summarize(metrics.resume), }, diff --git a/tests/e2e/tui-resume.test.ts b/tests/e2e/tui-resume.test.ts index a2e2987d19..f4fa2b6fd6 100644 --- a/tests/e2e/tui-resume.test.ts +++ b/tests/e2e/tui-resume.test.ts @@ -6083,6 +6083,7 @@ test.skipIf(!tmuxAvailable())( const workspaceRoot = realpathSync(workspace); const earlyMarker = "SESSION_PICKER_SCROLLBACK_EARLY"; const lateMarker = "SESSION_PICKER_SCROLLBACK_LATE"; + const continuationMarker = "SESSION_PICKER_SCROLLBACK_CONTINUATION"; const lines = Array.from( { length: 80 }, (_, index) => `SESSION_PICKER_SCROLLBACK_LINE_${String(index).padStart(3, "0")} has enough text to wrap in the terminal viewport.`, @@ -6121,7 +6122,9 @@ test.skipIf(!tmuxAvailable())( active = null; const sessionId = sessionIdFromHome(home); - const resumedGateway = startFakeGateway([]); + const resumedGateway = startFakeGateway([ + fakeGatewayFinalText(continuationMarker), + ]); gateways.push(resumedGateway); writeFileSync(stderrPath, ""); active = await TmuxSession.create({ @@ -6146,6 +6149,21 @@ test.skipIf(!tmuxAvailable())( expect(active.isPaneAlive()).toBe(true); expect(readFileSync(stderrPath, "utf8")).toBe(""); + await active.sendText("Continue after restoring native scrollback."); + await active.waitForText(continuationMarker, TIMEOUT); + await active.waitForComposer(TIMEOUT); + await active.resizeWindow(72, 20); + const resized = await active.waitForStableScrollback( + (scrollback) => + countOccurrences(scrollback, earlyMarker) === 1 && + countOccurrences(scrollback, lateMarker) === 1 && + countOccurrences(scrollback, continuationMarker) === 1, + TIMEOUT, + ); + expect(countOccurrences(resized, earlyMarker)).toBe(1); + expect(countOccurrences(resized, lateMarker)).toBe(1); + expect(countOccurrences(resized, continuationMarker)).toBe(1); + await active.sendText("/quit"); expect(await active.waitForSessionEnd()).toBe(true); await active.kill(); From 37065d566ffb7d413b8d213c483d87f9f81c366f Mon Sep 17 00:00:00 2001 From: Pranit Date: Fri, 28 Aug 2026 13:47:23 -0400 Subject: [PATCH 03/12] Repair resume CI coverage Keep runtime tests below production source for the direct-write audit and align slash-menu geometry with the resumed-session notice. --- src/core/app/app_render_runtime.zig | 38 ++++++++++++++--------------- tests/e2e/tui-slash-menu.test.ts | 8 +++--- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/core/app/app_render_runtime.zig b/src/core/app/app_render_runtime.zig index f2e4c25d2f..f846a401d8 100644 --- a/src/core/app/app_render_runtime.zig +++ b/src/core/app/app_render_runtime.zig @@ -85,25 +85,6 @@ fn streamedResetRoute( return if (source_available) .sequential else .preserve_reflow; } -test "streamed reset routing changes only a requested main surface with a safe source" { - try std.testing.expectEqual( - StreamedResetRoute.none, - streamedResetRoute(false, true, true), - ); - try std.testing.expectEqual( - StreamedResetRoute.none, - streamedResetRoute(true, false, true), - ); - try std.testing.expectEqual( - StreamedResetRoute.preserve_reflow, - streamedResetRoute(true, true, false), - ); - try std.testing.expectEqual( - StreamedResetRoute.sequential, - streamedResetRoute(true, true, true), - ); -} - pub const VisualEpochResetTrigger = enum { native_clear_probe, ctrl_l, @@ -7029,3 +7010,22 @@ test "child approval arrival closes review and full transcript depths before ren "depth_transition from=full to=inline route=child trigger=approval_handoff", ) != null); } + +test "streamed reset routing changes only a requested main surface with a safe source" { + try std.testing.expectEqual( + StreamedResetRoute.none, + streamedResetRoute(false, true, true), + ); + try std.testing.expectEqual( + StreamedResetRoute.none, + streamedResetRoute(true, false, true), + ); + try std.testing.expectEqual( + StreamedResetRoute.preserve_reflow, + streamedResetRoute(true, true, false), + ); + try std.testing.expectEqual( + StreamedResetRoute.sequential, + streamedResetRoute(true, true, true), + ); +} diff --git a/tests/e2e/tui-slash-menu.test.ts b/tests/e2e/tui-slash-menu.test.ts index 2bd91cf893..ee55f68aa3 100644 --- a/tests/e2e/tui-slash-menu.test.ts +++ b/tests/e2e/tui-slash-menu.test.ts @@ -1003,12 +1003,12 @@ describe.skipIf(SKIP)("tui: slash menu", () => { expect( visibleTranscriptTailRow(afterResponse), `resumed baseline grid:\n${afterResponse.join("\n")}`, - ).toBe(71); + ).toBe(69); expect(closedComposerRow).toBe(73); await session.sendLiteralText("/"); await session.waitForText("Commands 36", 5_000); const afterSlash = await capture("after-slash"); - expect(visibleTranscriptTailRow(afterSlash)).toBe(62); + expect(visibleTranscriptTailRow(afterSlash)).toBe(60); expect(composerRow(afterSlash)).toBe(64); await session.sendLiteralText("f"); await session.waitForText("/feedback", 5_000); @@ -1028,7 +1028,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => { 5_000, ); const afterDismiss = await capture("after-dismiss"); - expect(visibleTranscriptTailRow(afterDismiss)).toBe(62); + expect(visibleTranscriptTailRow(afterDismiss)).toBe(60); expect(composerRow(afterDismiss)).toBe(64); expect(footerStatusRow(afterDismiss)).toBe(66); await session.sendLiteralText("x"); @@ -1039,7 +1039,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => { 5_000, ); const afterDismissEdit = await capture("after-dismiss-edit"); - expect(visibleTranscriptTailRow(afterDismissEdit)).toBe(62); + expect(visibleTranscriptTailRow(afterDismissEdit)).toBe(60); expect(composerRow(afterDismissEdit)).toBe(64); expect(footerStatusRow(afterDismissEdit)).toBe(66); From 60eca18f9cfdf109d17a411c784cec817af972a1 Mon Sep 17 00:00:00 2001 From: Pranit Date: Fri, 28 Aug 2026 14:10:05 -0400 Subject: [PATCH 04/12] Align Ctrl-O resume readiness check Wait for the stable text-first resume anchor before sending Ctrl-O in the brutal flow. --- tests/e2e/tui-full-transcript-brutal.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/tui-full-transcript-brutal.test.ts b/tests/e2e/tui-full-transcript-brutal.test.ts index 7a7586b086..eeb691b5a2 100644 --- a/tests/e2e/tui-full-transcript-brutal.test.ts +++ b/tests/e2e/tui-full-transcript-brutal.test.ts @@ -1091,7 +1091,7 @@ async function runStress(config: StressConfig): Promise { startupWaitMs: 0, }); await waitForTraceAfter(paths.resumedTracePath, 0, [ - "transcript_transition_commit state=recovering", + "transcript_transition_commit state=stable", ]); const resumeTraceStart = traceSize(paths.resumedTracePath); const resumeInputStarted = performance.now(); From 9986c0a6534e22298ac468645ecfface3067efec Mon Sep 17 00:00:00 2001 From: Pranit Date: Fri, 28 Aug 2026 14:33:49 -0400 Subject: [PATCH 05/12] Stabilize streaming status assertion Normalize the transient queue status independently of its current footer suffix. --- tests/e2e/tui-resume.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/tui-resume.test.ts b/tests/e2e/tui-resume.test.ts index f4fa2b6fd6..1467f1fc36 100644 --- a/tests/e2e/tui-resume.test.ts +++ b/tests/e2e/tui-resume.test.ts @@ -2562,7 +2562,7 @@ test.skipIf(!tmuxAvailable())( if (/^└ (?:Running|Ran) /.test(row)) return ""; if (/^│ \d+ output lines$/.test(row)) return ""; if (/^│ \d+ more lines · → to expand$/.test(row)) return ""; - if (row.includes("enter queue · ctrl+enter steer")) return ""; + if (row.includes("enter queue")) return ""; if (/^(?:auto · )?gpt-5$/.test(row)) return ""; return row; }); From dbc8daa04c5360d1975d5e431dd61d1ff3c6f101 Mon Sep 17 00:00:00 2001 From: Pranit Date: Fri, 28 Aug 2026 19:26:25 -0400 Subject: [PATCH 06/12] Harden session transcript recovery Keep interactive resume failures closed after teardown, preserve startup diagnostics as structured state, and only replay a complete caught-up transcript on resize. Cover terminal mode restoration, legacy wire backfill, active-tail resize, and complete large-session output. --- src/core/app/app_input_runtime.zig | 25 ++ src/core/app/app_render_runtime.zig | 56 +--- src/core/app/app_session_runtime.zig | 272 +++++++++++++++++-- src/ui/render_engine/transcript_blocks.zig | 5 +- src/ui/transcript/resume_projection.zig | 288 ++++++++++++++++++++- src/ui/transcript/runtime.zig | 93 ++++++- src/ui/transcript/runtime_tests.zig | 28 ++ src/ui/transcript/source_preparation.zig | 4 + tests/e2e/tui-resume-brutal.test.ts | 1 + tests/e2e/tui-resume.test.ts | 204 ++++++++++++++- 10 files changed, 890 insertions(+), 86 deletions(-) diff --git a/src/core/app/app_input_runtime.zig b/src/core/app/app_input_runtime.zig index 09c38e3075..b49dae2a1a 100644 --- a/src/core/app/app_input_runtime.zig +++ b/src/core/app/app_input_runtime.zig @@ -1803,6 +1803,7 @@ pub fn Runtime(comptime App: type) type { if (app.session_persistence.session_picker.selectedId() == null) return true; _ = app.resumeSelectedSession() catch |err| { + if (err == error.LiveSessionResumeFailedClosed) return err; const picker = &app.session_persistence.session_picker; debug_trace.logf( "session", @@ -5662,6 +5663,30 @@ test "app_input_runtime reports resume failure after picker transition" { try std.testing.expect(std.mem.find(u8, app.transcript.items, "SessionCommitBoundaryUnavailable") == null); } +test "app_input_runtime propagates a fail-closed live resume error" { + const alloc = std.testing.allocator; + var app = try RoutingFakeApp.init(alloc); + defer app.deinit(); + const picker = &app.session_persistence.session_picker; + picker.active = true; + picker.load_state = .ready; + try picker.summaries.append(alloc, .{ + .id = try alloc.dupe(u8, "fatal-session"), + .created_at_ms = 0, + .updated_at_ms = 0, + .conversation_language = session_runtime.ConversationLanguage.literal("en"), + .history_len = 1, + }); + app.resume_selected_closes_picker = true; + app.resume_selected_error = error.LiveSessionResumeFailedClosed; + + try std.testing.expectError( + error.LiveSessionResumeFailedClosed, + Runtime(RoutingFakeApp).handleByte(&app, '\r', 4096, 100), + ); + try std.testing.expectEqual(@as(usize, 0), app.notice_write_count); +} + test "app_input_runtime Enter loads the next session page from the catalog action" { const alloc = std.testing.allocator; var app = try RoutingFakeApp.init(alloc); diff --git a/src/core/app/app_render_runtime.zig b/src/core/app/app_render_runtime.zig index f846a401d8..95429f46bd 100644 --- a/src/core/app/app_render_runtime.zig +++ b/src/core/app/app_render_runtime.zig @@ -70,21 +70,6 @@ fn set_transcript_assistant_tail_writable( ); } -const StreamedResetRoute = enum { - none, - preserve_reflow, - sequential, -}; - -fn streamedResetRoute( - requested: bool, - main_surface: bool, - source_available: bool, -) StreamedResetRoute { - if (!requested or !main_surface) return .none; - return if (source_available) .sequential else .preserve_reflow; -} - pub const VisualEpochResetTrigger = enum { native_clear_probe, ctrl_l, @@ -1853,24 +1838,17 @@ pub fn Runtime(comptime App: type) type { !render_reconciliation.alternate_screen_owns_rendering; var reset_transcript_reader: ?app_session_runtime.ResetTranscriptReader = null; defer if (reset_transcript_reader) |*reader| reader.deinit(); - var finish_streamed_reset_attempt = false; - defer if (finish_streamed_reset_attempt) { - presentation_shell.finishStreamedResetAttempt(); - }; + var sequential_reset_armed = false; const streamed_reset_requested = presentation_shell.streamedResetRequested(); if (streamed_reset_requested and child_view == null and !render_reconciliation.alternate_screen_owns_rendering) { reset_transcript_reader = try app_session_runtime.Runtime(App).acquireResetTranscriptReader(app); - switch (streamedResetRoute( - true, - true, - reset_transcript_reader != null, - )) { - .none => unreachable, - .preserve_reflow => presentation_shell.finishStreamedResetAttempt(), - .sequential => try presentation_shell.armTerminalResetForCurrentFrame(&app.metrics), + if (reset_transcript_reader != null) { + try presentation_shell.armTerminalResetForCurrentFrame(&app.metrics); + } else { + presentation_shell.finishStreamedResetAttempt(); } } const active_committed_layout = if (render_reconciliation.alternate_screen_owns_rendering) @@ -2282,7 +2260,7 @@ pub fn Runtime(comptime App: type) type { .start_col = 1, .reset_replay = true, }; - finish_streamed_reset_attempt = true; + sequential_reset_armed = true; } else { presentation_shell.finishStreamedResetAttempt(); } @@ -2380,7 +2358,8 @@ pub fn Runtime(comptime App: type) type { }, }, ); - if (finish_streamed_reset_attempt and result.is_committed()) { + if (sequential_reset_armed and result.is_committed()) { + presentation_shell.finishStreamedResetAttempt(); debug_trace.logf( "session", "event=session_transcript_reset outcome=committed bytes={d}", @@ -7010,22 +6989,3 @@ test "child approval arrival closes review and full transcript depths before ren "depth_transition from=full to=inline route=child trigger=approval_handoff", ) != null); } - -test "streamed reset routing changes only a requested main surface with a safe source" { - try std.testing.expectEqual( - StreamedResetRoute.none, - streamedResetRoute(false, true, true), - ); - try std.testing.expectEqual( - StreamedResetRoute.none, - streamedResetRoute(true, false, true), - ); - try std.testing.expectEqual( - StreamedResetRoute.preserve_reflow, - streamedResetRoute(true, true, false), - ); - try std.testing.expectEqual( - StreamedResetRoute.sequential, - streamedResetRoute(true, true, true), - ); -} diff --git a/src/core/app/app_session_runtime.zig b/src/core/app/app_session_runtime.zig index 5c5bbd4c44..bb5a4da7dc 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -113,10 +113,23 @@ test "reset transcript reader lends a borrowed sequential frame source" { } fn streamSessionTranscript(app: anytype, reader: anytype) !u64 { - try app.shell.writeResumeStreamBytes( + app.shell.writeResumeStreamBytes( &app.metrics, resume_stream_autowrap_enable, - ); + ) catch |enable_err| { + app.shell.writeResumeStreamBytes( + &app.metrics, + resume_stream_autowrap_disable, + ) catch |restore_err| { + debug_trace.logf( + "session", + "event=session_transcript_stream outcome=failed stage=enable_restore enable_err={s} restore_err={s}", + .{ @errorName(enable_err), @errorName(restore_err) }, + ); + return error.TerminalModeRestoreFailed; + }; + return enable_err; + }; const payload_result = streamSessionTranscriptPayload(app, reader); if (payload_result) |payload_bytes| { scrollResumePayloadIntoHistory(app) catch |padding_err| { @@ -157,9 +170,8 @@ fn streamSessionTranscript(app: anytype, reader: anytype) !u64 { fn scrollResumePayloadIntoHistory(app: anytype) !void { var rows: u16 = 0; - const padding_rows: u16 = @intFromBool( - app.shell.layout.rows > app.shell.layout.content_bottom, - ); + const padding_rows = (app.shell.layout.rows -| + app.shell.layout.content_bottom) +| 1; while (rows < padding_rows) : (rows += 1) { try app.shell.writeResumeStreamBytes(&app.metrics, "\r\n"); } @@ -277,12 +289,64 @@ test "session transcript stream moves the payload clear of startup chrome" { try std.testing.expectEqual(@as(u64, payload.len), streamed); try std.testing.expectEqualStrings( resume_stream_autowrap_enable ++ payload ++ - "\r\n" ++ resume_stream_autowrap_disable, + "\r\n\r\n\r\n" ++ resume_stream_autowrap_disable, app.shell.output.items, ); try std.testing.expect(app.shell.adopted); } +test "session transcript stream restores autowrap when the enable write fails" { + const alloc = std.testing.allocator; + const FakeShell = struct { + alloc: Allocator, + layout: types.Layout = .{ + .rows = 1, + .cols = 20, + .content_bottom = 1, + .divider_top_row = 1, + .input_row = 1, + .divider_bottom_row = 1, + .hint_row = 1, + }, + output: std.ArrayList(u8) = .empty, + write_count: usize = 0, + + fn deinit(self: *@This()) void { + self.output.deinit(self.alloc); + } + + fn writeResumeStreamBytes( + self: *@This(), + _: *types.Metrics, + bytes: []const u8, + ) !void { + try self.output.appendSlice(self.alloc, bytes); + self.write_count += 1; + if (self.write_count == 1) return error.TestEnableWriteFailed; + } + + fn adoptStreamedSessionHistory(_: *@This()) void {} + }; + const FakeApp = struct { + shell: FakeShell, + metrics: types.Metrics = .{}, + }; + var app = FakeApp{ .shell = .{ .alloc = alloc } }; + defer app.shell.deinit(); + + try std.testing.expectError( + error.TestEnableWriteFailed, + streamSessionTranscript( + &app, + MemoryTranscriptReader{ .bytes = "payload\r\n" }, + ), + ); + try std.testing.expectEqualStrings( + resume_stream_autowrap_enable ++ resume_stream_autowrap_disable, + app.shell.output.items, + ); +} + const BackgroundSessionPolicy = enum { carry_forward, stop_forget, @@ -1348,6 +1412,22 @@ pub const Persistence = struct { pub fn Runtime(comptime App: type) type { return struct { pub fn acquireResetTranscriptReader(app: *App) !?ResetTranscriptReader { + if (!app.shell.streamedResetSourceReady()) { + debug_trace.logf( + "session", + "event=session_transcript_reset outcome=unavailable reason=record_not_caught_up", + .{}, + ); + return null; + } + const expected_committed_len = app.shell.presentationRecordCommittedLen() orelse { + debug_trace.logf( + "session", + "event=session_transcript_reset outcome=unavailable reason=record_degraded", + .{}, + ); + return null; + }; const writable = if (app.session_persistence.writable) |*value| value else { @@ -1360,7 +1440,17 @@ pub fn Runtime(comptime App: type) type { }; var admission = try writable.admitActiveTranscript(app.alloc); switch (admission) { - .exact => return .{ .admission = admission }, + .exact => |reader| if (reader.committed_len == expected_committed_len) { + return .{ .admission = admission }; + } else { + admission.deinit(); + debug_trace.logf( + "session", + "event=session_transcript_reset outcome=unavailable reason=record_length_mismatch expected={d} actual={d}", + .{ expected_committed_len, reader.committed_len }, + ); + return null; + }, .missing, .incomplete, .corrupt => { const reason = @tagName(std.meta.activeTag(admission)); admission.deinit(); @@ -1504,13 +1594,21 @@ pub fn Runtime(comptime App: type) type { if (retention_changed and app.shell.streamedSessionHistoryActive()) { + var anchor_reconciled = true; app.shell.establishStreamedRetainedAnchor(app.alloc) catch |err| { + anchor_reconciled = false; debug_trace.logf( "session", "event=session_transcript outcome=anchor_pending err={s}", .{@errorName(err)}, ); }; + if (anchor_reconciled) { + app.shell.commitPresentationRecord( + delta.next_cursor, + next_committed_len, + ); + } } } debug_trace.logf( @@ -2117,11 +2215,35 @@ pub fn Runtime(comptime App: type) type { } try app.prepareLiveSessionResume(log_options); + finishPreparedSelectedSessionResume( + app, + store, + &admission, + admitted_id, + log_options, + ) catch |err| { + debug_trace.logf( + "session", + "event=session_transcript_resume outcome=failed_closed stage=live_post_prepare err={s}", + .{@errorName(err)}, + ); + return error.LiveSessionResumeFailedClosed; + }; + return true; + } + + fn finishPreparedSelectedSessionResume( + app: *App, + store: session_store.Store, + admission: *session_store.TranscriptAdmission, + admitted_id: []const u8, + log_options: session_log.Options, + ) !void { try streamAdmittedTranscript(app, admission.transcript); var loaded = try subagent_resume_admission.resumeTranscriptAdmittedForExternalPrompt( store, app.alloc, - &admission, + admission, admitted_id, app.workspace_root, .{ @@ -2137,7 +2259,6 @@ pub fn Runtime(comptime App: type) type { requestSubagentBackgroundRecovery(app); startResumedSessionReconciliation(app); try app.finishLiveSessionResume(); - return true; } fn loadResumeTargetForWrite( @@ -2234,11 +2355,16 @@ pub fn Runtime(comptime App: type) type { try replayHistoryToSink(app, &sink, state.history); try writeRecoveryCheckpointToSink(app, &sink, state); const projection_finished_ns = io_mod.nanoTimestamp(); - try projection.finalize(); + const exact_transcript_streamed = + app.session_persistence.resume_transcript_streamed; + try projection.finalizeForResume(!exact_transcript_streamed); const finalization_finished_ns = io_mod.nanoTimestamp(); var committed_len = app.session_persistence.resume_transcript_committed_len; if (!app.session_persistence.resume_transcript_streamed) { - const transcript_bytes = projection.publicationBytes(); + const transcript_bytes = try projection.publicationWireBytes( + app.shell.layout.cols, + ); + defer app.alloc.free(transcript_bytes); const streamed = try streamSessionTranscript( app, MemoryTranscriptReader{ .bytes = transcript_bytes }, @@ -2270,14 +2396,15 @@ pub fn Runtime(comptime App: type) type { }; } } - try app.installResumeProjectionRetained(&projection); - var live_sink = LiveHistorySink(App){ .app = app }; - try writeResumeNotice(app, &live_sink, display_title, notice); + const record_cursor = projection.recordCursor(); if (committed_len) |len| { - app.shell.resumePresentationRecord(len); + app.shell.resumePresentationRecordAt(len, record_cursor); } else { app.shell.degradePresentationRecord(); } + try app.installResumeProjectionRetained(&projection); + var live_sink = LiveHistorySink(App){ .app = app }; + try writeResumeNotice(app, &live_sink, display_title, notice); app.session_persistence.resume_transcript_committed_len = null; app.session_persistence.resume_transcript_streamed = false; const install_finished_ns = io_mod.nanoTimestamp(); @@ -8019,6 +8146,56 @@ test "canceling a startup session picker starts a writable fresh session" { try std.testing.expect(app.session_persistence.writable != null); } +test "reset transcript reader requires a healthy caught-up presentation record" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const paths = try testPaths(alloc, &tmp); + defer { + alloc.free(paths.home); + alloc.free(paths.workspace); + } + const home = try TestHome.install(alloc, paths.home); + defer home.deinit(); + + var app = try TestApp.init(alloc, paths.workspace); + defer app.deinit(); + try configureTestPreferences(&app); + try Runtime(TestApp).initializePersistence(&app, true); + try Runtime(TestApp).beginFreshPersistedSession(&app); + + const transcript = "persisted history\r\n"; + try app.session_persistence.writable.?.publishTranscript(alloc, transcript); + app.shell.resumePresentationRecord(transcript.len); + app.shell.transcript_band_dirty = false; + app.shell.render_requests.clearReason(.transcript); + app.shell.adoptStreamedSessionHistory(); + try app.shell.requestTerminalResetAfterResize(&app.metrics, null); + + var exact = (try Runtime(TestApp).acquireResetTranscriptReader(&app)) orelse + return error.TestExpectedResetTranscriptReader; + exact.deinit(); + app.shell.finishStreamedResetAttempt(); + + try app.shell.writeTranscriptClassified( + alloc, + &app.metrics, + "unpersisted live tail\n", + true, + .unknown_raw, + ); + try app.shell.requestTerminalResetAfterResize(&app.metrics, null); + try std.testing.expect((try Runtime(TestApp).acquireResetTranscriptReader(&app)) == null); + app.shell.finishStreamedResetAttempt(); + + app.shell.degradePresentationRecord(); + app.shell.transcript_band_dirty = false; + app.shell.render_requests.clearReason(.transcript); + try app.shell.requestTerminalResetAfterResize(&app.metrics, null); + try std.testing.expect((try Runtime(TestApp).acquireResetTranscriptReader(&app)) == null); +} + test "interactive session resume uses the live transition and shared restore path" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); @@ -8081,6 +8258,71 @@ test "interactive session resume uses the live transition and shared restore pat ); } +test "interactive session resume marks post-prepare stream failures fail closed" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const paths = try testPaths(alloc, &tmp); + defer { + alloc.free(paths.home); + alloc.free(paths.workspace); + } + const home = try TestHome.install(alloc, paths.home); + defer home.deinit(); + + var app = try TestApp.init(alloc, paths.workspace); + defer app.deinit(); + try configureTestPreferences(&app); + try Runtime(TestApp).initializePersistence(&app, true); + const history = [_]types.HistoryTurn{.{ .assistant = .{ + .user = .{ .text = @constCast("saved prompt") }, + .assistant = @constCast("saved response"), + } }}; + try writeSessionFixture( + alloc, + app.session_persistence.store.?, + "invalid-transcript-session", + &history, + 0, + ); + { + var target = try app.session_persistence.store.?.resumeForWrite( + alloc, + "invalid-transcript-session", + ); + defer target.deinit(alloc); + try target.publishTranscript(alloc, "safe"); + var transcript_file = try target.log.dir.dir.openFile( + std.testing.io, + "transcript.ansi", + .{ .mode = .read_write }, + ); + defer transcript_file.close(std.testing.io); + try transcript_file.writePositionalAll(std.testing.io, "\x1b[2J", 0); + try transcript_file.sync(std.testing.io); + } + + try Runtime(TestApp).beginFreshPersistedSession(&app); + try Runtime(TestApp).openSessionPicker(&app); + try waitForSessionPickerLoad(&app); + var stdout_file = try tmp.dir.createFile( + std.testing.io, + "live-resume-failed-closed.stdout", + .{}, + ); + defer stdout_file.close(std.testing.io); + app.shell.stdout_file = stdout_file; + try app.shell.enableShadowVt(alloc); + + try std.testing.expectError( + error.LiveSessionResumeFailedClosed, + Runtime(TestApp).resumeSelectedSession(&app), + ); + try std.testing.expectEqual(@as(usize, 1), app.live_resume_prepare_count); + try std.testing.expectEqual(@as(usize, 0), app.live_resume_finish_count); +} + test "interactive session resume preserves the current writer when the target is unavailable" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); diff --git a/src/ui/render_engine/transcript_blocks.zig b/src/ui/render_engine/transcript_blocks.zig index 677f6e26f5..264751fff7 100644 --- a/src/ui/render_engine/transcript_blocks.zig +++ b/src/ui/render_engine/transcript_blocks.zig @@ -2298,6 +2298,7 @@ pub const TranscriptPreparationOptions = struct { entry_actions: []const EntryRenderAction = &.{}, folded_summary_entry_ids: []const ?u32 = &.{}, capture_provenance: bool = false, + capture_entry_spans: bool = false, entry_overrides: []const EntryRenderOverride = &.{}, }; @@ -2349,7 +2350,7 @@ pub fn renderEntriesForPreparationInterruptible( .summary_transcript_indices = summary_indices, .line_provenance = if (options.capture_provenance) &line_provenance else null, .entry_overrides = options.entry_overrides, - .capture_entry_spans = true, + .capture_entry_spans = options.capture_entry_spans, }, checkpoint, ); @@ -3579,7 +3580,7 @@ test "preparation records entry byte spans including their leading separators" { entries.items, 80, .{}, - .{}, + .{ .capture_entry_spans = true }, ); defer prepared.deinit(alloc); try std.testing.expectEqual(@as(usize, 2), prepared.entry_spans.len); diff --git a/src/ui/transcript/resume_projection.zig b/src/ui/transcript/resume_projection.zig index e3d6c4303e..50e97ff033 100644 --- a/src/ui/transcript/resume_projection.zig +++ b/src/ui/transcript/resume_projection.zig @@ -4,9 +4,12 @@ const diff = @import("../../core/output/diff.zig"); const types = @import("../../core/shared/types.zig"); const command_output_content = @import("../../core/tooling/command_output_content.zig"); const command_output_runtime = @import("command_output_runtime.zig"); +const transcript_painter = @import("painter.zig"); +const presentation_record = @import("presentation_record.zig"); const source_preparation = @import("source_preparation.zig"); const transcript_runtime = @import("runtime.zig"); const transcript_store = @import("store.zig"); +const transcript_blocks = @import("../render_engine/transcript_blocks.zig"); const Allocator = std.mem.Allocator; const TranscriptRuntime = transcript_runtime.TranscriptRuntime; @@ -18,6 +21,8 @@ pub const ResumeProjection = struct { alloc: Allocator, created_at_ms: i64, retention_cap: usize, + postlude_entries: std.ArrayList(transcript_runtime.TranscriptEntry) = .empty, + record_cursor: presentation_record.Cursor = .start, publication_source: ?transcript_runtime.TranscriptPreparationSource = null, pending_diffs: std.ArrayList(diff.DiffEntry) = .empty, next_diff_id: u32, @@ -29,15 +34,34 @@ pub const ResumeProjection = struct { source: *TranscriptRuntime, created_at_ms: i64, next_diff_id: u32, + ) !ResumeProjection { + return initDetached(alloc, source, created_at_ms, next_diff_id, true); + } + + fn initDetached( + alloc: Allocator, + source: *TranscriptRuntime, + created_at_ms: i64, + next_diff_id: u32, + capture_postlude: bool, ) !ResumeProjection { var detached = try transcript_store.cloneDetachedPresentationState(source, alloc); + errdefer detached.deinit(alloc); const retention_cap = detached.max_retained_transcript_bytes; + var postlude_entries: std.ArrayList(transcript_runtime.TranscriptEntry) = .empty; + if (capture_postlude and detached.entries.items.len > 0) { + postlude_entries = detached.entries; + detached.entries = .empty; + } + detached.clearTranscript(alloc); + detached.next_entry_id = 1; detached.max_retained_transcript_bytes = std.math.maxInt(usize); return .{ .runtime = detached, .alloc = alloc, .created_at_ms = created_at_ms, .retention_cap = retention_cap, + .postlude_entries = postlude_entries, .next_diff_id = next_diff_id, }; } @@ -50,14 +74,19 @@ pub const ResumeProjection = struct { created_at_ms: i64, next_diff_id: u32, ) !ResumeProjection { - var projection = try init(alloc, source, created_at_ms, next_diff_id); - projection.runtime.clearTranscript(alloc); - projection.runtime.next_entry_id = 1; - return projection; + return initDetached( + alloc, + source, + created_at_ms, + next_diff_id, + false, + ); } pub fn deinit(self: *ResumeProjection) void { if (!self.consumed) self.runtime.deinit(self.alloc); + for (self.postlude_entries.items) |*entry| entry.deinit(self.alloc); + self.postlude_entries.deinit(self.alloc); if (self.publication_source) |*source| source.deinit(self.alloc); for (self.pending_diffs.items) |*entry| entry.deinit(std.heap.c_allocator); self.pending_diffs.deinit(std.heap.c_allocator); @@ -324,6 +353,14 @@ pub const ResumeProjection = struct { return self.finalizeForPresentation(false); } + pub fn finalizeForResume( + self: *ResumeProjection, + postlude_persisted: bool, + ) !void { + if (postlude_persisted) try self.appendPostlude(); + return self.finalize(); + } + /// A live projection may end at a valid command-output prefix. Keep that /// block open so later output and its terminal event can continue it. pub fn finalizeLivePresentation(self: *ResumeProjection) !void { @@ -355,6 +392,7 @@ pub const ResumeProjection = struct { &self.runtime, self.alloc, ); + self.record_cursor = cursorAfterLastEntry(&self.runtime); var source = try source_preparation.prepareTranscriptSource( &self.runtime, self.alloc, @@ -400,6 +438,31 @@ pub const ResumeProjection = struct { return self.publication_source.?.bytes; } + /// Returns allocator-owned terminal-wire bytes for direct stream and + /// persistence. Logical LF separators become explicit CRLF. + pub fn publicationWireBytes( + self: *const ResumeProjection, + cols: u16, + ) ![]u8 { + const bytes = self.publicationBytes(); + const prepared = try transcript_painter.prepareTranscriptDocumentAppendBytes( + self.alloc, + bytes, + cols, + 0, + bytes.len, + true, + ); + if (prepared.len > 0) return prepared; + return self.alloc.dupe(u8, bytes); + } + + pub fn recordCursor(self: *const ResumeProjection) presentation_record.Cursor { + std.debug.assert(self.finalized); + std.debug.assert(!self.consumed); + return self.record_cursor; + } + /// Installs structured continuation state after historical bytes were /// already written directly to the terminal. No pending source is armed. pub fn installRetained(self: *ResumeProjection, target: *TranscriptRuntime) !void { @@ -436,7 +499,11 @@ pub const ResumeProjection = struct { target.recomputeCursorFromTranscript(); } target.reconcileDetachedInstallSource(self.publication_source.?.bytes); - if (!target.streamedSessionHistoryActive()) target.markTranscriptDirty(); + if (try self.appendPostludeTo(target)) { + target.markTranscriptStructureDirty(); + } else if (!target.streamedSessionHistoryActive()) { + target.markTranscriptDirty(); + } var publication = self.publication_source.?; self.publication_source = null; @@ -476,8 +543,59 @@ pub const ResumeProjection = struct { self.pending_diffs = .empty; return pending; } + + fn appendPostlude(self: *ResumeProjection) !void { + _ = try self.appendPostludeTo(&self.runtime); + } + + fn appendPostludeTo( + self: *ResumeProjection, + target: *TranscriptRuntime, + ) !bool { + if (self.postlude_entries.items.len == 0) return false; + try target.entries.ensureUnusedCapacity( + self.alloc, + self.postlude_entries.items.len, + ); + for (self.postlude_entries.items) |*entry| { + setEntryId(entry, target.next_entry_id); + target.entries.appendAssumeCapacity(entry.*); + target.next_entry_id +%= 1; + } + self.postlude_entries.clearRetainingCapacity(); + return true; + } }; +fn setEntryId(entry: *transcript_runtime.TranscriptEntry, id: u32) void { + switch (entry.*) { + .raw_bytes => |*value| value.id = id, + .semantic_notice => |*value| value.id = id, + .user_turn => |*value| value.id = id, + .assistant_turn => |*value| value.id = id, + .assistant_table => |*value| value.id = id, + .assistant_code_block => |*value| value.id = id, + .assistant_thematic_rule => |*value| value.id = id, + } +} + +fn cursorAfterLastEntry( + runtime: *const TranscriptRuntime, +) presentation_record.Cursor { + var index = runtime.entries.items.len; + while (index > 0) { + index -= 1; + const entry = runtime.entries.items[index]; + if (!transcript_blocks.isEntryVisibleInCompactPresentation(entry)) continue; + return .{ .after = .{ + .entry_id = entry.id(), + .kind = transcript_blocks.blockKindForEntry(entry), + .ends_with_newline = false, + } }; + } + return .start; +} + test "resume projection finalizes complete flow before one retained-tail pass" { const alloc = std.testing.allocator; var source: TranscriptRuntime = .{}; @@ -512,6 +630,166 @@ test "resume projection finalizes complete flow before one retained-tail pass" { try std.testing.expect(projection.runtime.entries.items.len < 13); } +test "resume projection appends current startup presentation after history" { + const alloc = std.testing.allocator; + var source: TranscriptRuntime = .{}; + source.layout = .{ + .rows = 24, + .cols = 80, + .content_bottom = 20, + .divider_top_row = 21, + .input_row = 22, + .divider_bottom_row = 23, + .hint_row = 24, + }; + defer source.deinit(alloc); + _ = try source.appendRawTranscriptEntryClassified( + alloc, + "STARTUP_WARNING_MARKER\n", + .unknown_raw, + ); + _ = try source.appendSemanticNotice(alloc, .{ + .topic = "system", + .tone = .information, + .body = "STARTUP_WARNING_FULL_DETAIL", + .visibility = .full_only, + }); + + var projection = try ResumeProjection.init(alloc, &source, 42, 1); + defer projection.deinit(); + _ = try projection.appendRawClassified( + "HISTORICAL_SESSION_MARKER\n", + .unknown_raw, + ); + try projection.finalizeForResume(true); + + const bytes = projection.publicationBytes(); + const history = std.mem.find(u8, bytes, "HISTORICAL_SESSION_MARKER") orelse + return error.MissingHistoricalSessionMarker; + const startup = std.mem.find(u8, bytes, "STARTUP_WARNING_MARKER") orelse + return error.MissingStartupWarningMarker; + try std.testing.expect(history < startup); + try std.testing.expectEqual( + @as(usize, 1), + std.mem.count(u8, bytes, "HISTORICAL_SESSION_MARKER"), + ); + try std.testing.expectEqual( + @as(usize, 1), + std.mem.count(u8, bytes, "STARTUP_WARNING_MARKER"), + ); + try std.testing.expect(std.mem.find( + u8, + bytes, + "STARTUP_WARNING_FULL_DETAIL", + ) == null); + try std.testing.expectEqual( + @as(u32, 2), + projection.recordCursor().after.entry_id, + ); +} + +test "exact resume retains structured startup presentation outside the historical anchor" { + const alloc = std.testing.allocator; + var source: TranscriptRuntime = .{}; + source.layout = .{ + .rows = 24, + .cols = 80, + .content_bottom = 20, + .divider_top_row = 21, + .input_row = 22, + .divider_bottom_row = 23, + .hint_row = 24, + }; + defer source.deinit(alloc); + _ = try source.appendRawTranscriptEntryClassified( + alloc, + "CURRENT_STARTUP_SUMMARY\n", + .unknown_raw, + ); + _ = try source.appendSemanticNotice(alloc, .{ + .topic = "system", + .tone = .information, + .body = "CURRENT_STARTUP_FULL_DETAIL", + .visibility = .full_only, + }); + + var projection = try ResumeProjection.init(alloc, &source, 42, 1); + defer projection.deinit(); + _ = try projection.appendRawClassified( + "EXACT_HISTORICAL_ENTRY\n", + .unknown_raw, + ); + try projection.finalizeForResume(false); + + try std.testing.expect(std.mem.find( + u8, + projection.publicationBytes(), + "CURRENT_STARTUP_SUMMARY", + ) == null); + try std.testing.expectEqual( + @as(u32, 1), + projection.recordCursor().after.entry_id, + ); + var target: TranscriptRuntime = .{}; + target.layout = source.layout; + defer target.deinit(alloc); + try target.enableShadowVt(alloc); + target.adoptStreamedSessionHistory(); + target.resumePresentationRecordAt(64, projection.recordCursor()); + try projection.installRetained(&target); + + try std.testing.expectEqual(@as(usize, 3), target.entries.items.len); + try std.testing.expectEqualStrings( + "CURRENT_STARTUP_SUMMARY\n", + target.entries.items[1].raw_bytes.bytes, + ); + try std.testing.expectEqual( + types.NoticeVisibility.full_only, + target.entries.items[2].semantic_notice.visibility, + ); + try std.testing.expectEqualStrings( + "CURRENT_STARTUP_FULL_DETAIL", + target.entries.items[2].semantic_notice.body, + ); + try std.testing.expectEqual(@as(u32, 2), target.entries.items[1].id()); + try std.testing.expectEqual(@as(u32, 3), target.entries.items[2].id()); + try std.testing.expect(!target.presentationRecordReadyForReset()); + + var compact = try target.prepareTranscriptSource(alloc, null); + defer compact.deinit(alloc); + try std.testing.expect(std.mem.find( + u8, + compact.bytes, + "CURRENT_STARTUP_SUMMARY", + ) != null); + try std.testing.expect(std.mem.find( + u8, + compact.bytes, + "CURRENT_STARTUP_FULL_DETAIL", + ) == null); +} + +test "resume projection converts complete publication to terminal wire bytes" { + const alloc = std.testing.allocator; + var source: TranscriptRuntime = .{}; + source.layout.cols = 80; + defer source.deinit(alloc); + + var projection = try ResumeProjection.initEmpty(alloc, &source, 42, 1); + defer projection.deinit(); + _ = try projection.appendRawClassified("one\ntwo\n", .unknown_raw); + try projection.finalize(); + + const wire_bytes = try projection.publicationWireBytes(80); + defer alloc.free(wire_bytes); + try std.testing.expectEqualStrings("one\r\ntwo", wire_bytes); + for (wire_bytes, 0..) |byte, index| { + if (byte == '\n') { + try std.testing.expect(index > 0 and wire_bytes[index - 1] == '\r'); + } + } +} + test "empty resume projection keeps layout without source conversation" { const alloc = std.testing.allocator; var source: TranscriptRuntime = .{}; diff --git a/src/ui/transcript/runtime.zig b/src/ui/transcript/runtime.zig index 7d5321061d..2c87c136e7 100644 --- a/src/ui/transcript/runtime.zig +++ b/src/ui/transcript/runtime.zig @@ -4269,6 +4269,7 @@ pub const TranscriptRuntime = struct { has_painted_transcript: bool = false, streamed_session_history_active: bool = false, streamed_reset_requested: bool = false, + streamed_reset_source_ready: bool = false, footer_viewport: footer_viewport_runtime.FooterViewport = .{}, extra_input_rows: u16 = 0, footer_reserved_base_rows: u16 = 3, @@ -4356,8 +4357,14 @@ pub const TranscriptRuntime = struct { return self.streamed_reset_requested; } + pub fn streamedResetSourceReady(self: *const TranscriptRuntime) bool { + return self.streamed_reset_requested and + self.streamed_reset_source_ready; + } + pub fn finishStreamedResetAttempt(self: *TranscriptRuntime) void { self.streamed_reset_requested = false; + self.streamed_reset_source_ready = false; self.terminal_reset_pending = false; } @@ -4515,6 +4522,17 @@ pub const TranscriptRuntime = struct { ), .ends_with_newline = false, } }; + self.resumePresentationRecordAt(committed_len, cursor); + } + + pub fn resumePresentationRecordAt( + self: *TranscriptRuntime, + committed_len: u64, + cursor: presentation_record.Cursor, + ) void { + std.debug.assert(committed_len > 0); + std.debug.assert(self.pending_presentation_record == null); + self.presentation_record_frame_pending = false; self.presentation_record = .{ .active = .{ .cursor = cursor, .committed_len = committed_len, @@ -4544,17 +4562,28 @@ pub const TranscriptRuntime = struct { }; } + fn presentationRecordCaughtUp(self: *const TranscriptRuntime) bool { + if (self.pending_presentation_record != null) return false; + return switch (self.presentation_record) { + .active => |state| state.caught_up, + .disabled, .degraded => false, + }; + } + pub fn presentationRecordCanRestamp(self: *const TranscriptRuntime) bool { - if (self.pending_presentation_record != null or + if (!self.presentationRecordCaughtUp() or self.transcript_band_dirty or self.render_requests.hasReason(.transcript)) { return false; } - return switch (self.presentation_record) { - .active => |state| state.caught_up, - .disabled, .degraded => false, - }; + return true; + } + + pub fn presentationRecordReadyForReset( + self: *const TranscriptRuntime, + ) bool { + return self.presentationRecordCaughtUp(); } pub fn commitPresentationRecord( @@ -4880,6 +4909,7 @@ pub const TranscriptRuntime = struct { pub fn clearTranscript(self: *TranscriptRuntime, alloc: Allocator) void { self.streamed_session_history_active = false; self.streamed_reset_requested = false; + self.streamed_reset_source_ready = false; const pending_resume_bytes = self.releasePendingResumeSource(alloc); if (pending_resume_bytes > 0) { debug_trace.logf( @@ -6618,6 +6648,10 @@ pub const TranscriptRuntime = struct { history_row_delta: ?i32, ) !void { if (self.streamed_session_history_active) { + const source_ready = if (self.streamed_reset_requested) + self.streamed_reset_source_ready + else + self.presentationRecordReadyForReset(); self.streamed_reset_requested = true; self.reflow_clear_guard_rows = 0; self.resize_history_row_delta = null; @@ -6629,6 +6663,7 @@ pub const TranscriptRuntime = struct { self.footer_viewport.clearExternalInvalidation(); self.invalidateTranscriptAnchor("streamed_session_history_resize"); self.markTranscriptDirty(); + self.streamed_reset_source_ready = source_ready; return; } try self.requestTerminalReset(metrics); @@ -10248,6 +10283,16 @@ pub const TranscriptRuntime = struct { self.render_requests.request(.transcript); } + fn markPresentationRecordBehind(self: *TranscriptRuntime) void { + switch (self.presentation_record) { + .active => |*state| state.caught_up = false, + .disabled, .degraded => {}, + } + if (self.streamed_reset_requested) { + self.streamed_reset_source_ready = false; + } + } + pub fn nativeHistoryActive(self: *const TranscriptRuntime) bool { return switch (self.transcript_commit_state) { .invalid => false, @@ -10257,10 +10302,7 @@ pub const TranscriptRuntime = struct { } pub fn markTranscriptContentDirty(self: *TranscriptRuntime) void { - switch (self.presentation_record) { - .active => |*state| state.caught_up = false, - .disabled, .degraded => {}, - } + self.markPresentationRecordBehind(); self.full_transcript_content_revision +%= 1; self.full_transcript_projection_cache.relationships.markDirtyAll(); self.full_transcript_projection_cache.full.markDirtyAll(); @@ -10281,6 +10323,7 @@ pub const TranscriptRuntime = struct { } pub fn markTranscriptStructureDirty(self: *TranscriptRuntime) void { + self.markPresentationRecordBehind(); self.full_transcript_content_revision +%= 1; self.full_transcript_review_revision +%= 1; self.full_transcript_projection_cache.relationships.markDirtyAll(); @@ -10311,6 +10354,7 @@ pub const TranscriptRuntime = struct { self.markTranscriptContentDirty(); return; }; + self.markPresentationRecordBehind(); self.full_transcript_content_revision +%= 1; self.full_transcript_projection_cache.relationships.markDirtyFrom(entry_id, entry_index); self.full_transcript_projection_cache.full.markDirtyFrom(entry_id, entry_index); @@ -10332,6 +10376,7 @@ pub const TranscriptRuntime = struct { } pub fn markTranscriptCommandOutputDirty(self: *TranscriptRuntime) void { + self.markPresentationRecordBehind(); self.full_transcript_content_revision +%= 1; self.full_transcript_projection_cache.relationships.markDirtyAll(); self.full_transcript_projection_cache.full.markDirtyAll(); @@ -10351,6 +10396,7 @@ pub const TranscriptRuntime = struct { self.markTranscriptCommandOutputDirty(); return; }; + self.markPresentationRecordBehind(); self.full_transcript_content_revision +%= 1; self.full_transcript_projection_cache.relationships.markDirtyFrom(entry_id, entry_index); self.full_transcript_projection_cache.full.markDirtyFrom(entry_id, entry_index); @@ -11594,6 +11640,35 @@ test "settled streamed session resize requests one file backed reset without cle try std.testing.expect(!runtime.streamedResetRequested()); } +test "armed streamed reset keeps its ready source until the attempt finishes" { + var metrics: Metrics = .{}; + var runtime = TranscriptRuntime{ + .layout = invalidationTestLayout(), + .owned_top_row = 1, + .viewport_top_row = 1, + }; + defer runtime.deinit(std.testing.allocator); + runtime.adoptStreamedSessionHistory(); + runtime.resumePresentationRecord(10); + runtime.transcript_band_dirty = false; + runtime.render_requests.clearReason(.transcript); + + try runtime.requestTerminalResetAfterResize(&metrics, null); + try std.testing.expect(runtime.streamedResetRequested()); + try std.testing.expect(runtime.streamedResetSourceReady()); + + try runtime.armTerminalResetForCurrentFrame(&metrics); + try std.testing.expect(runtime.streamedResetRequested()); + try std.testing.expect(runtime.streamedResetSourceReady()); + + try runtime.requestTerminalResetAfterResize(&metrics, null); + try std.testing.expect(runtime.streamedResetSourceReady()); + + runtime.finishStreamedResetAttempt(); + try std.testing.expect(!runtime.streamedResetRequested()); + try std.testing.expect(!runtime.streamedResetSourceReady()); +} + test "marking transcript cache dirty also schedules transcript work" { var runtime = TranscriptRuntime{}; defer runtime.deinit(std.testing.allocator); diff --git a/src/ui/transcript/runtime_tests.zig b/src/ui/transcript/runtime_tests.zig index 47c8354826..47df0b6d38 100644 --- a/src/ui/transcript/runtime_tests.zig +++ b/src/ui/transcript/runtime_tests.zig @@ -1220,6 +1220,34 @@ test "stable zero-scroll frame yields the complete finalized presentation record try std.testing.expectEqualStrings("single visible line", pending.bytes); } +test "transcript source captures entry spans only for an active presentation record" { + const alloc = std.testing.allocator; + var runtime = TranscriptRuntime{ + .layout = transcriptTestLayout(40, 8, 4), + .owned_top_row = 1, + }; + defer runtime.deinit(alloc); + _ = try runtime.appendRawTranscriptEntryClassified( + alloc, + "recorded entry", + .unknown_raw, + ); + + var disabled = try runtime.prepareTranscriptSource(alloc, null); + defer disabled.deinit(alloc); + try std.testing.expectEqual(@as(usize, 0), disabled.entry_spans.len); + + runtime.beginPresentationRecord(); + var active = try runtime.prepareTranscriptSource(alloc, null); + defer active.deinit(alloc); + try std.testing.expect(active.entry_spans.len > 0); + + runtime.degradePresentationRecord(); + var degraded = try runtime.prepareTranscriptSource(alloc, null); + defer degraded.deinit(alloc); + try std.testing.expectEqual(@as(usize, 0), degraded.entry_spans.len); +} + test "mutable presentation hold keeps the record frame pending" { const alloc = std.testing.allocator; var runtime = commandOutputTestRuntime(undefined); diff --git a/src/ui/transcript/source_preparation.zig b/src/ui/transcript/source_preparation.zig index 688c29872b..d3e154838f 100644 --- a/src/ui/transcript/source_preparation.zig +++ b/src/ui/transcript/source_preparation.zig @@ -557,6 +557,10 @@ fn prepareTranscriptSourceInternal( .omitted_entry_id = omitted_entry_id, .folded_summary_entry_ids = summary_entry_ids, .capture_provenance = capture_provenance, + .capture_entry_spans = if (comptime @hasDecl( + @TypeOf(self.*), + "presentationRecordActive", + )) self.presentationRecordActive() else false, .entry_actions = entry_actions, }, checkpoint, diff --git a/tests/e2e/tui-resume-brutal.test.ts b/tests/e2e/tui-resume-brutal.test.ts index 7613028c00..afc04e5efd 100644 --- a/tests/e2e/tui-resume-brutal.test.ts +++ b/tests/e2e/tui-resume-brutal.test.ts @@ -555,6 +555,7 @@ async function runStress(config: Config): Promise { () => session!.sendKeys("Enter"), () => session!.waitForText(FINAL_MARKER, TIMEOUT * 20), ); + await session.waitForComposer(TIMEOUT * 20); const headMarker = "RESUME_CHAT_000000"; const middleMarker = `RESUME_CHAT_${pad(Math.floor(config.chatBatches * config.chatLinesPerBatch / 2))}`; const tailMarker = `RESUME_CHAT_${pad(config.chatBatches * config.chatLinesPerBatch - 1)}`; diff --git a/tests/e2e/tui-resume.test.ts b/tests/e2e/tui-resume.test.ts index 1467f1fc36..903cbc5d26 100644 --- a/tests/e2e/tui-resume.test.ts +++ b/tests/e2e/tui-resume.test.ts @@ -170,7 +170,10 @@ type HoldState = { cancelled: boolean; }; -function heldGatewayResponse(state: HoldState): Response { +function heldGatewayResponse( + state: HoldState, + delta = "SESSION_PICKER_ACTIVE_STREAM", +): Response { const encoder = new TextEncoder(); let timer: ReturnType | undefined; return new Response( @@ -179,7 +182,7 @@ function heldGatewayResponse(state: HoldState): Response { state.started = true; controller.enqueue( encoder.encode( - `data: ${JSON.stringify({ type: "text-delta", id: "held", delta: "SESSION_PICKER_ACTIVE_STREAM" })}\n\n`, + `data: ${JSON.stringify({ type: "text-delta", id: "held", delta })}\n\n`, ), ); timer = setInterval(() => { @@ -4537,6 +4540,95 @@ test.skipIf(!tmuxAvailable())( TIMEOUT * 2, ); +test.skipIf(!tmuxAvailable())( + "legacy resume backfills terminal-wire bytes for the next exact resume", + async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-transcript-backfill-"))); + const home = join(root, "home"); + const workspace = join(root, "workspace"); + const stderrPath = join(root, "stderr.log"); + const marker = "LEGACY_TRANSCRIPT_BACKFILL_MARKER"; + mkdirSync(home); + mkdirSync(workspace); + const gateways: Array> = []; + let active: TmuxSession | null = null; + + try { + const seedGateway = startFakeGateway([ + fakeGatewayFinalText(`${marker}_HEAD\n${marker}_TAIL`), + ]); + gateways.push(seedGateway); + writeFileSync(stderrPath, ""); + active = await TmuxSession.create({ + cmd: FX_BIN, + cwd: workspace, + env: gatewayEnv(home, seedGateway), + stderrPath, + }); + await active.waitForComposer(TIMEOUT); + await active.sendText("Create a legacy backfill fixture."); + await active.waitForText(`${marker}_TAIL`, TIMEOUT); + await active.sendText("/quit"); + expect(await active.waitForSessionEnd()).toBe(true); + await active.kill(); + active = null; + + const sessionId = sessionIdFromHome(home); + const sessionRoot = join(home, ".fx", "sessions", sessionId); + const transcriptPath = join(sessionRoot, "transcript.ansi"); + const metadataPath = join(sessionRoot, "transcript.meta"); + rmSync(transcriptPath); + rmSync(metadataPath); + + const backfillGateway = startFakeGateway([]); + gateways.push(backfillGateway); + writeFileSync(stderrPath, ""); + active = await TmuxSession.create({ + cmd: `${FX_BIN} resume ${sessionId}`, + cwd: workspace, + env: gatewayEnv(home, backfillGateway), + stderrPath, + }); + await active.waitForComposer(TIMEOUT); + expect(await waitForScrollback(active, `${marker}_TAIL`)).toContain(`${marker}_HEAD`); + await active.sendText("/quit"); + expect(await active.waitForSessionEnd()).toBe(true); + await active.kill(); + active = null; + + const backfilled = readFileSync(transcriptPath); + expect(backfilled.includes(Buffer.from(`${marker}_HEAD`))).toBe(true); + expect(backfilled.includes(Buffer.from(`${marker}_TAIL`))).toBe(true); + for (let index = 0; index < backfilled.length; index += 1) { + if (backfilled[index] === 0x0a) expect(backfilled[index - 1]).toBe(0x0d); + } + + const exactGateway = startFakeGateway([]); + gateways.push(exactGateway); + writeFileSync(stderrPath, ""); + active = await TmuxSession.create({ + cmd: `${FX_BIN} resume ${sessionId}`, + cwd: workspace, + env: gatewayEnv(home, exactGateway), + stderrPath, + }); + await active.waitForComposer(TIMEOUT); + const exactScrollback = await waitForScrollback(active, `${marker}_TAIL`); + expect(countOccurrences(exactScrollback, `${marker}_HEAD`)).toBe(1); + expect(countOccurrences(exactScrollback, `${marker}_TAIL`)).toBe(1); + expect(readFileSync(stderrPath, "utf8")).toBe(""); + await active.sendText("/quit"); + expect(await active.waitForSessionEnd()).toBe(true); + active = null; + } finally { + if (active) await active.kill(); + for (const gateway of gateways) gateway.stop(); + rmSync(root, { recursive: true, force: true }); + } + }, + TIMEOUT * 3, +); + test.skipIf(!tmuxAvailable())( "interactive resume aliases restore history and return to a live composer", async () => { @@ -4679,12 +4771,10 @@ test.skipIf(!tmuxAvailable())( }); expect(replay.code).toBe(0); expect(replay.stderr).toBe(""); - const firstApplicationFrame = replay.stdout + const restoredFrame = replay.stdout .split(/(?=--- frame \d+)/) - .find((frame) => - /Recording:|Session:|Run \/help for commands|auto ·/.test(frame), - ); - expect(firstApplicationFrame).toContain(restoredMarker); + .find((frame) => frame.includes(restoredMarker)); + expect(restoredFrame).toBeDefined(); } const markdownHome = join(root, "markdown-home"); @@ -6078,6 +6168,7 @@ test.skipIf(!tmuxAvailable())( const stderrPath = join(root, "stderr.log"); const initialTapePath = join(root, "new-session-scroll.fxtape"); const tapePath = join(root, "direct-resume-scroll.fxtape"); + const tracePath = join(root, "resume-trace.log"); mkdirSync(home); mkdirSync(workspace); const workspaceRoot = realpathSync(workspace); @@ -6133,6 +6224,8 @@ test.skipIf(!tmuxAvailable())( env: { ...gatewayEnv(home, resumedGateway), FX_RECORD: tapePath, + FX_TRACE_LOG: tracePath, + FX_TRACE_SCOPES: "session,resize,frame_commit", }, stderrPath, width: 80, @@ -6142,6 +6235,7 @@ test.skipIf(!tmuxAvailable())( const resumed = await waitForScrollback(active, earlyMarker); expect(resumed).toContain(earlyMarker); + expect(countOccurrences(resumed, "direct-resume-scroll.fxtape")).toBe(1); const tape = readFileSync(tapePath); expect(tape.includes(Buffer.from("\x1b[?1002h"))).toBe(false); expect(tape.includes(Buffer.from("\x1b[?1006h"))).toBe(false); @@ -6163,11 +6257,107 @@ test.skipIf(!tmuxAvailable())( expect(countOccurrences(resized, earlyMarker)).toBe(1); expect(countOccurrences(resized, lateMarker)).toBe(1); expect(countOccurrences(resized, continuationMarker)).toBe(1); + expect(countOccurrences(resized, "Recording:")).toBe(2); + expect(readFileSync(tracePath, "utf8")).toContain( + "event=session_transcript_reset outcome=committed", + ); + + await active.sendText("/quit"); + expect(await active.waitForSessionEnd()).toBe(true); + await active.kill(); + active = null; + } finally { + if (active) await active.kill(); + for (const gateway of gateways) gateway.stop(); + rmSync(root, { recursive: true, force: true }); + } + }, + TIMEOUT * 3, +); + +test.skipIf(!tmuxAvailable())( + "resumed resize preserves an unpersisted active assistant tail", + async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-resume-active-resize-"))); + const home = join(root, "home"); + const workspace = join(root, "workspace"); + const stderrPath = join(root, "stderr.log"); + const tracePath = join(root, "trace.log"); + const earlyMarker = "ACTIVE_RESIZE_HISTORY_EARLY"; + const lateMarker = "ACTIVE_RESIZE_HISTORY_LATE"; + mkdirSync(home); + mkdirSync(workspace); + const gateways: Array> = []; + let active: TmuxSession | null = null; + try { + const seedGateway = startFakeGateway([ + fakeGatewayFinalText(`${earlyMarker}\n${"history row\n".repeat(40)}${lateMarker}`), + ]); + gateways.push(seedGateway); + writeFileSync(stderrPath, ""); + active = await TmuxSession.create({ + cmd: FX_BIN, + cwd: workspace, + env: gatewayEnv(home, seedGateway), + stderrPath, + width: 80, + height: 24, + }); + await active.waitForComposer(TIMEOUT); + await active.sendText("Save history for an active resize."); + await active.waitForText(lateMarker, TIMEOUT); await active.sendText("/quit"); expect(await active.waitForSessionEnd()).toBe(true); await active.kill(); active = null; + + const hold: HoldState = { started: false, cancelled: false }; + const heldGateway = startFakeGateway([ + () => heldGatewayResponse(hold, "SESSION_PICKER_ACTIVE_STREAM\n\n"), + ]); + gateways.push(heldGateway); + const sessionId = sessionIdFromHome(home); + writeFileSync(stderrPath, ""); + active = await TmuxSession.create({ + cmd: `${FX_BIN} resume ${sessionId}`, + cwd: workspace, + env: { + ...gatewayEnv(home, heldGateway), + FX_TRACE_LOG: tracePath, + FX_TRACE_SCOPES: "session,resize,frame_commit", + }, + stderrPath, + width: 80, + height: 24, + }); + await active.waitForComposer(TIMEOUT); + await waitForScrollback(active, earlyMarker); + await active.sendText("Keep an assistant tail active during resize."); + await waitForCondition(() => hold.started, "held active resize response"); + await active.waitForText("SESSION_PICKER_ACTIVE_STREAM", TIMEOUT); + + await active.resizeWindow(62, 18); + const resized = await active.waitForStableScrollback( + (scrollback) => + countOccurrences(scrollback, earlyMarker) === 1 && + countOccurrences(scrollback, lateMarker) === 1 && + countOccurrences(scrollback, "SESSION_PICKER_ACTIVE_STREAM") === 1, + TIMEOUT, + ); + expect(countOccurrences(resized, earlyMarker)).toBe(1); + expect(countOccurrences(resized, lateMarker)).toBe(1); + expect(countOccurrences(resized, "SESSION_PICKER_ACTIVE_STREAM")).toBe(1); + expect(readFileSync(tracePath, "utf8")).toContain( + "event=session_transcript_reset outcome=unavailable reason=record_not_caught_up", + ); + + await active.sendKeys("Escape"); + await waitForCondition(() => hold.cancelled, "cancel held active resize response"); + expect(readFileSync(stderrPath, "utf8")).toBe(""); + await active.sendText("/quit"); + expect(await active.waitForSessionEnd()).toBe(true); + active = null; } finally { if (active) await active.kill(); for (const gateway of gateways) gateway.stop(); From 91941023de118dd3e7eb24e84d57da790709e5cd Mon Sep 17 00:00:00 2001 From: Pranit Date: Fri, 28 Aug 2026 20:38:21 -0400 Subject: [PATCH 07/12] Preserve streamed resume tail before first frame Append startup postlude entries through the existing transcript contracts and protect the renderer's idle footer reservation during direct resume streaming. This keeps the complete historical tail intact before the first normal frame. --- src/core/app/app_session_runtime.zig | 10 +++- src/ui/footer/paint_plan.zig | 8 +++- src/ui/transcript/resume_projection.zig | 64 +++++++++++++++++++++++-- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/core/app/app_session_runtime.zig b/src/core/app/app_session_runtime.zig index bb5a4da7dc..95fbad00a5 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -52,6 +52,7 @@ const shell_runtime = @import("../../ui/shell_runtime.zig"); const transcript_runtime = @import("../../ui/transcript/runtime.zig"); const ui_input = @import("../../ui/input/runtime.zig"); const ui_render = @import("../../ui/render.zig"); +const footer_paint_plan = @import("../../ui/footer/paint_plan.zig"); const frame_scroll_plan = @import("../../ui/render_engine/frame_scroll_plan.zig"); const Allocator = std.mem.Allocator; @@ -171,7 +172,8 @@ fn streamSessionTranscript(app: anytype, reader: anytype) !u64 { fn scrollResumePayloadIntoHistory(app: anytype) !void { var rows: u16 = 0; const padding_rows = (app.shell.layout.rows -| - app.shell.layout.content_bottom) +| 1; + app.shell.layout.content_bottom) +| + footer_paint_plan.idle_footer_gap_reservation_rows +| 1; while (rows < padding_rows) : (rows += 1) { try app.shell.writeResumeStreamBytes(&app.metrics, "\r\n"); } @@ -289,7 +291,7 @@ test "session transcript stream moves the payload clear of startup chrome" { try std.testing.expectEqual(@as(u64, payload.len), streamed); try std.testing.expectEqualStrings( resume_stream_autowrap_enable ++ payload ++ - "\r\n\r\n\r\n" ++ resume_stream_autowrap_disable, + "\r\n\r\n\r\n\r\n\r\n" ++ resume_stream_autowrap_disable, app.shell.output.items, ); try std.testing.expect(app.shell.adopted); @@ -2403,6 +2405,10 @@ pub fn Runtime(comptime App: type) type { app.shell.degradePresentationRecord(); } try app.installResumeProjectionRetained(&projection); + try projection.installPostludeRetained( + &app.shell, + &app.metrics, + ); var live_sink = LiveHistorySink(App){ .app = app }; try writeResumeNotice(app, &live_sink, display_title, notice); app.session_persistence.resume_transcript_committed_len = null; diff --git a/src/ui/footer/paint_plan.zig b/src/ui/footer/paint_plan.zig index 72bdaa4b82..c8afad8f8a 100644 --- a/src/ui/footer/paint_plan.zig +++ b/src/ui/footer/paint_plan.zig @@ -33,6 +33,10 @@ const Allocator = std.mem.Allocator; const activity_overlay = render_engine.activity_overlay; const footer_layout = render_engine.footer_layout; const engine_paint_plan = render_engine.paint_plan; + +/// Maximum normal-buffer space reserved between an idle transcript tail and +/// the footer. Direct resume streaming must protect the same future rows. +pub const idle_footer_gap_reservation_rows: u16 = 2; const transcript_blocks = render_engine.transcript_blocks; const viewport_selection = render_engine.viewport_selection; const Layout = types.Layout; @@ -310,8 +314,8 @@ fn initialFooterReservation( .transient_row => false, }; const idle_reservation_rows: u16 = switch (banner_activity) { - .overlay_entry => if (input.footer_gap_active) 2 else 1, - .none => if (input.footer_gap_active) 2 else 1, + .overlay_entry => if (input.footer_gap_active) idle_footer_gap_reservation_rows else 1, + .none => if (input.footer_gap_active) idle_footer_gap_reservation_rows else 1, .transient_row => 0, }; const banner_idle_gap_offset: u16 = switch (banner_activity) { diff --git a/src/ui/transcript/resume_projection.zig b/src/ui/transcript/resume_projection.zig index 50e97ff033..ef57aa1533 100644 --- a/src/ui/transcript/resume_projection.zig +++ b/src/ui/transcript/resume_projection.zig @@ -499,11 +499,7 @@ pub const ResumeProjection = struct { target.recomputeCursorFromTranscript(); } target.reconcileDetachedInstallSource(self.publication_source.?.bytes); - if (try self.appendPostludeTo(target)) { - target.markTranscriptStructureDirty(); - } else if (!target.streamedSessionHistoryActive()) { - target.markTranscriptDirty(); - } + if (!target.streamedSessionHistoryActive()) target.markTranscriptDirty(); var publication = self.publication_source.?; self.publication_source = null; @@ -512,6 +508,62 @@ pub const ResumeProjection = struct { self.runtime.deinit(self.alloc); } + /// Replays current-startup entries through the ordinary append contracts + /// after the complete historical source has established its anchor. + pub fn installPostludeRetained( + self: *ResumeProjection, + target: *TranscriptRuntime, + metrics: *types.Metrics, + ) !void { + std.debug.assert(self.finalized); + std.debug.assert(self.consumed); + + var first_entry_id: ?u32 = null; + while (self.postlude_entries.items.len > 0) { + const entry = self.postlude_entries.items[0]; + const entry_id = switch (entry) { + .raw_bytes => |raw| blk: { + if (raw.lifecycle_pinned) return error.InvalidResumePostlude; + const next_id = target.next_entry_id; + try target.writeTranscriptClassified( + self.alloc, + metrics, + raw.bytes, + true, + raw.class, + ); + break :blk next_id; + }, + .semantic_notice => |notice| if (notice.pending_replacement) + try target.appendReplaceableSemanticNotice(self.alloc, .{ + .topic = notice.topic, + .tone = notice.tone, + .body = notice.body, + .visibility = notice.visibility, + }) + else + try target.appendSemanticNotice(self.alloc, .{ + .topic = notice.topic, + .tone = notice.tone, + .body = notice.body, + .visibility = notice.visibility, + }), + .user_turn, + .assistant_turn, + .assistant_table, + .assistant_code_block, + .assistant_thematic_rule, + => return error.InvalidResumePostlude, + }; + if (first_entry_id == null) first_entry_id = entry_id; + var removed = self.postlude_entries.orderedRemove(0); + removed.deinit(self.alloc); + } + if (first_entry_id) |entry_id| { + target.markTranscriptContentDirtyFrom(entry_id); + } + } + /// Transfers a finalized detached projection into a standalone transcript /// runtime. The caller owns the returned runtime and must deinitialize it /// with the same allocator. @@ -737,6 +789,8 @@ test "exact resume retains structured startup presentation outside the historica target.adoptStreamedSessionHistory(); target.resumePresentationRecordAt(64, projection.recordCursor()); try projection.installRetained(&target); + var metrics: types.Metrics = .{}; + try projection.installPostludeRetained(&target, &metrics); try std.testing.expectEqual(@as(usize, 3), target.entries.items.len); try std.testing.expectEqualStrings( From 60640190f4e2901967b40bd9b09fe21f7e0d1340 Mon Sep 17 00:00:00 2001 From: Pranit Date: Fri, 28 Aug 2026 22:56:27 -0400 Subject: [PATCH 08/12] Align resumed history before current notices Commit the streamed historical anchor before adding startup notices, preserve the renderer's footer-gap contract, and extend the physical guard only when structured retention caps a large session. --- src/core/app/app_session_runtime.zig | 35 +++++++++++++++++-------- src/ui/footer/paint_plan.zig | 8 ++++-- src/ui/transcript/resume_projection.zig | 9 +++++++ src/ui/transcript/runtime.zig | 8 ++++-- 4 files changed, 45 insertions(+), 15 deletions(-) diff --git a/src/core/app/app_session_runtime.zig b/src/core/app/app_session_runtime.zig index 95fbad00a5..fe59aaef82 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -133,19 +133,22 @@ fn streamSessionTranscript(app: anytype, reader: anytype) !u64 { }; const payload_result = streamSessionTranscriptPayload(app, reader); if (payload_result) |payload_bytes| { - scrollResumePayloadIntoHistory(app) catch |padding_err| { + app.shell.writeResumeStreamBytes( + &app.metrics, + "\r\n", + ) catch |guard_err| { app.shell.writeResumeStreamBytes( &app.metrics, resume_stream_autowrap_disable, ) catch |restore_err| { debug_trace.logf( "session", - "event=session_transcript_stream outcome=failed stage=padding_restore padding_err={s} restore_err={s}", - .{ @errorName(padding_err), @errorName(restore_err) }, + "event=session_transcript_stream outcome=failed stage=guard_restore guard_err={s} restore_err={s}", + .{ @errorName(guard_err), @errorName(restore_err) }, ); return error.TerminalModeRestoreFailed; }; - return padding_err; + return guard_err; }; try app.shell.writeResumeStreamBytes( &app.metrics, @@ -169,12 +172,12 @@ fn streamSessionTranscript(app: anytype, reader: anytype) !u64 { } } -fn scrollResumePayloadIntoHistory(app: anytype) !void { - var rows: u16 = 0; - const padding_rows = (app.shell.layout.rows -| +fn extendResumeStreamGuardForRetainedProjection(app: anytype) !void { + const desired_rows = (app.shell.layout.rows -| app.shell.layout.content_bottom) +| footer_paint_plan.idle_footer_gap_reservation_rows +| 1; - while (rows < padding_rows) : (rows += 1) { + var emitted_rows: u16 = 1; + while (emitted_rows < desired_rows) : (emitted_rows += 1) { try app.shell.writeResumeStreamBytes(&app.metrics, "\r\n"); } } @@ -243,7 +246,7 @@ const MemoryTranscriptReader = struct { } }; -test "session transcript stream moves the payload clear of startup chrome" { +test "session transcript stream writes the payload before adopting history" { const alloc = std.testing.allocator; const FakeShell = struct { alloc: Allocator, @@ -290,8 +293,7 @@ test "session transcript stream moves the payload clear of startup chrome" { try std.testing.expectEqual(@as(u64, payload.len), streamed); try std.testing.expectEqualStrings( - resume_stream_autowrap_enable ++ payload ++ - "\r\n\r\n\r\n\r\n\r\n" ++ resume_stream_autowrap_disable, + resume_stream_autowrap_enable ++ payload ++ "\r\n" ++ resume_stream_autowrap_disable, app.shell.output.items, ); try std.testing.expect(app.shell.adopted); @@ -2399,12 +2401,23 @@ pub fn Runtime(comptime App: type) type { } } const record_cursor = projection.recordCursor(); + if (projection.retentionChanged()) { + try extendResumeStreamGuardForRetainedProjection(app); + } if (committed_len) |len| { app.shell.resumePresentationRecordAt(len, record_cursor); } else { app.shell.degradePresentationRecord(); } + if (comptime @hasField( + @TypeOf(app.shell), + "footer_reserved_base_rows", + )) { + app.shell.footer_reserved_base_rows = + footer_paint_plan.composerReservedBaseRows(); + } try app.installResumeProjectionRetained(&projection); + try app.commitStartupResumeReplayAnchor(); try projection.installPostludeRetained( &app.shell, &app.metrics, diff --git a/src/ui/footer/paint_plan.zig b/src/ui/footer/paint_plan.zig index c8afad8f8a..de2b9543ce 100644 --- a/src/ui/footer/paint_plan.zig +++ b/src/ui/footer/paint_plan.zig @@ -34,8 +34,7 @@ const activity_overlay = render_engine.activity_overlay; const footer_layout = render_engine.footer_layout; const engine_paint_plan = render_engine.paint_plan; -/// Maximum normal-buffer space reserved between an idle transcript tail and -/// the footer. Direct resume streaming must protect the same future rows. +/// Maximum idle tail reservation shared with retained resume anchoring. pub const idle_footer_gap_reservation_rows: u16 = 2; const transcript_blocks = render_engine.transcript_blocks; const viewport_selection = render_engine.viewport_selection; @@ -175,8 +174,13 @@ pub noinline fn composerTopChromeRows() u16 { return 0; } +pub fn composerReservedBaseRows() u16 { + return footer_layout.reservedBaseRows(true, composerTopChromeRows()); +} + test "current composer suppresses top chrome" { try std.testing.expectEqual(@as(u16, 0), composerTopChromeRows()); + try std.testing.expectEqual(@as(u16, 2), composerReservedBaseRows()); } fn plannerFooterReservedBaseRows(input: FooterPlannerInput) u16 { diff --git a/src/ui/transcript/resume_projection.zig b/src/ui/transcript/resume_projection.zig index ef57aa1533..a8fd2ae42f 100644 --- a/src/ui/transcript/resume_projection.zig +++ b/src/ui/transcript/resume_projection.zig @@ -26,6 +26,7 @@ pub const ResumeProjection = struct { publication_source: ?transcript_runtime.TranscriptPreparationSource = null, pending_diffs: std.ArrayList(diff.DiffEntry) = .empty, next_diff_id: u32, + retention_changed: bool = false, finalized: bool = false, consumed: bool = false, @@ -412,6 +413,7 @@ pub const ResumeProjection = struct { self.alloc, null, ); + self.retention_changed = retention_changed; if (!retention_changed) { self.runtime.transcript.clearRetainingCapacity(); self.runtime.replaceable_last_line = false; @@ -463,6 +465,12 @@ pub const ResumeProjection = struct { return self.record_cursor; } + pub fn retentionChanged(self: *const ResumeProjection) bool { + std.debug.assert(self.finalized); + std.debug.assert(!self.consumed); + return self.retention_changed; + } + /// Installs structured continuation state after historical bytes were /// already written directly to the terminal. No pending source is armed. pub fn installRetained(self: *ResumeProjection, target: *TranscriptRuntime) !void { @@ -680,6 +688,7 @@ test "resume projection finalizes complete flow before one retained-tail pass" { try std.testing.expect(std.mem.find(u8, projection.publication_source.?.bytes, "historical marker 0") != null); try std.testing.expect(std.mem.find(u8, projection.publication_source.?.bytes, "historical marker 11") != null); try std.testing.expect(projection.runtime.entries.items.len < 13); + try std.testing.expect(projection.retentionChanged()); } test "resume projection appends current startup presentation after history" { diff --git a/src/ui/transcript/runtime.zig b/src/ui/transcript/runtime.zig index 2c87c136e7..b47ae4a5c8 100644 --- a/src/ui/transcript/runtime.zig +++ b/src/ui/transcript/runtime.zig @@ -4404,7 +4404,10 @@ pub const TranscriptRuntime = struct { prepared.selection, ); const total_visual_rows = prepared.sourceTotalVisualRows(); - const footer_rows = self.layout.rows -| self.layout.content_bottom; + const footer_rows = @min( + self.layout.rows, + self.footer_reserved_base_rows +| 1 +| self.extra_input_rows, + ); const solved_layout = render_engine.frame_layout.solve(.{ .terminal = self.layout, .owned_top = top, @@ -4418,6 +4421,8 @@ pub const TranscriptRuntime = struct { total_visual_rows, @as(u32, std.math.maxInt(u16)), )), + .trailing_boundary_blank_rows = source.preview.trailing_boundary_blank_rows, + .footer_boundary_gap_rows = source.preview.footer_boundary_gap_rows, .cursor_row = shadow.cursor_row, .cursor_col = shadow.cursor_col, .tail_kind = prepared.selection.tail_kind, @@ -4427,7 +4432,6 @@ pub const TranscriptRuntime = struct { const layout = render_engine.frame_layout.CommittedLayoutSnapshot.fromLayout( solved_layout, ); - var previous = self.transcript_commit_state; self.transcript_commit_state = .{ .stable = .{ .selection = prepared.selection, From 875f6a578e618fa8509c91da0d8bf1e427902187 Mon Sep 17 00:00:00 2001 From: Pranit Date: Fri, 28 Aug 2026 23:25:54 -0400 Subject: [PATCH 09/12] Stabilize resumed slash-menu geometry checks Assert the slash picker displaces transcript content relative to its resumed baseline while preserving the existing exact marker-copy and blank-gap checks across platform-specific path wrapping. --- tests/e2e/tui-slash-menu.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/e2e/tui-slash-menu.test.ts b/tests/e2e/tui-slash-menu.test.ts index ee55f68aa3..8cb38ae557 100644 --- a/tests/e2e/tui-slash-menu.test.ts +++ b/tests/e2e/tui-slash-menu.test.ts @@ -1000,15 +1000,17 @@ describe.skipIf(SKIP)("tui: slash menu", () => { const afterResponse = await capture("after-response"); const closedComposerRow = composerRow(afterResponse); + const closedTranscriptTailRow = visibleTranscriptTailRow(afterResponse); expect( - visibleTranscriptTailRow(afterResponse), + closedTranscriptTailRow, `resumed baseline grid:\n${afterResponse.join("\n")}`, - ).toBe(69); + ).toBeLessThan(closedComposerRow); expect(closedComposerRow).toBe(73); await session.sendLiteralText("/"); await session.waitForText("Commands 36", 5_000); const afterSlash = await capture("after-slash"); - expect(visibleTranscriptTailRow(afterSlash)).toBe(60); + const openTranscriptTailRow = visibleTranscriptTailRow(afterSlash); + expect(openTranscriptTailRow).toBeLessThan(closedTranscriptTailRow); expect(composerRow(afterSlash)).toBe(64); await session.sendLiteralText("f"); await session.waitForText("/feedback", 5_000); @@ -1028,7 +1030,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => { 5_000, ); const afterDismiss = await capture("after-dismiss"); - expect(visibleTranscriptTailRow(afterDismiss)).toBe(60); + expect(visibleTranscriptTailRow(afterDismiss)).toBe(openTranscriptTailRow); expect(composerRow(afterDismiss)).toBe(64); expect(footerStatusRow(afterDismiss)).toBe(66); await session.sendLiteralText("x"); @@ -1039,7 +1041,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => { 5_000, ); const afterDismissEdit = await capture("after-dismiss-edit"); - expect(visibleTranscriptTailRow(afterDismissEdit)).toBe(60); + expect(visibleTranscriptTailRow(afterDismissEdit)).toBe(openTranscriptTailRow); expect(composerRow(afterDismissEdit)).toBe(64); expect(footerStatusRow(afterDismissEdit)).toBe(66); From 14f8f927c472a1efdb031f0b44d0e6075b5c0297 Mon Sep 17 00:00:00 2001 From: Pranit Date: Mon, 31 Aug 2026 10:48:32 -0400 Subject: [PATCH 10/12] Avoid blank rows before session resume Position the resume stream at the existing terminal cursor without reserving welcome rows. Keep restored scrollback contiguous when fx starts near the bottom of a terminal. --- src/core/app/app_bootstrap_runtime.zig | 1 + src/core/app/app_lifecycle.zig | 79 +++++++++++++++++++++++++- tests/e2e/tui-resume.test.ts | 24 +++++++- 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/src/core/app/app_bootstrap_runtime.zig b/src/core/app/app_bootstrap_runtime.zig index 651db98a3e..0c46986b21 100644 --- a/src/core/app/app_bootstrap_runtime.zig +++ b/src/core/app/app_bootstrap_runtime.zig @@ -188,6 +188,7 @@ pub fn Runtime(comptime App: type) type { .terminal_title = deps.terminal_title, .footer_rows = footer_rows, .startup_min_body_rows = ui_render.welcome_message_reserved_rows, + .resume_stream_requested = app.requested_resume != null, .default_model = default_model, .default_agent_step_limit = default_agent_step_limit, .secret_store = if (comptime @hasDecl(App, "secretStore")) diff --git a/src/core/app/app_lifecycle.zig b/src/core/app/app_lifecycle.zig index 681c1478fa..8096dd50f4 100644 --- a/src/core/app/app_lifecycle.zig +++ b/src/core/app/app_lifecycle.zig @@ -247,6 +247,7 @@ pub const BootstrapConfig = struct { terminal_title: host.TerminalTitle, footer_rows: u16, startup_min_body_rows: u16 = 0, + resume_stream_requested: bool = false, default_model: []const u8, default_agent_step_limit: usize, secret_store: host.SecretStore, @@ -519,6 +520,7 @@ pub fn bootstrapInteractiveApp(cfg: BootstrapConfig) !StartupState { &launch_start_row, cfg.startup_min_body_rows, state.startup_scrollback, + cfg.resume_stream_requested, ); // Enable keyboard/paste protocol modes and disable autowrap for the // interactive session. Shutdown restores each mode. @@ -534,7 +536,19 @@ fn prepareStartupViewport( launch_start_row: *u16, startup_min_body_rows: u16, startup_scrollback: bool, + resume_stream_requested: bool, ) !u16 { + if (resume_stream_requested) { + var cursor_buf: [32]u8 = undefined; + const cursor = try ui_terminal.moveCursorSequence( + &cursor_buf, + launch_start_row.*, + 1, + ); + try writeLifecycleTerminalBytes(shell, metrics, cursor); + launch_start_row.* = 1; + return 0; + } if (startup_scrollback and launch_start_row.* > 1) { try pushLaunchRowsIntoScrollback(shell, metrics, launch_start_row.* - 1); launch_start_row.* = 1; @@ -1901,7 +1915,14 @@ test "prepare startup viewport uses scrollback setting for launch push only" { var metrics = Metrics{}; var enabled_launch_row: u16 = 6; - const enabled_reservation = try prepareStartupViewport(&enabled_shell, &metrics, &enabled_launch_row, 11, true); + const enabled_reservation = try prepareStartupViewport( + &enabled_shell, + &metrics, + &enabled_launch_row, + 11, + true, + false, + ); enabled_shell.stdout_file.close(io_mod.getIo()); var enabled_read = try tmp.dir.openFile(io_mod.getIo(), enabled_path, .{}); @@ -1922,7 +1943,14 @@ test "prepare startup viewport uses scrollback setting for launch push only" { defer disabled_shell.deinit(alloc); var disabled_launch_row: u16 = 6; - const disabled_reservation = try prepareStartupViewport(&disabled_shell, &metrics, &disabled_launch_row, 11, false); + const disabled_reservation = try prepareStartupViewport( + &disabled_shell, + &metrics, + &disabled_launch_row, + 11, + false, + false, + ); disabled_shell.stdout_file.close(io_mod.getIo()); var disabled_read = try tmp.dir.openFile(io_mod.getIo(), disabled_path, .{}); @@ -1935,6 +1963,53 @@ test "prepare startup viewport uses scrollback setting for launch push only" { try std.testing.expectEqualStrings("", disabled_bytes); } +test "prepare startup viewport positions streamed resume without blank launch rows" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const out_path = "resume.out"; + const out_file = try tmp.dir.createFile( + io_mod.getIo(), + out_path, + .{ .truncate = true }, + ); + var shell = TranscriptRuntime{ + .stdout_file = out_file, + .layout = .{ + .rows = 24, + .cols = 80, + .content_bottom = 20, + .divider_top_row = 21, + .input_row = 22, + .divider_bottom_row = 23, + .hint_row = 24, + }, + }; + defer shell.deinit(alloc); + + var metrics = Metrics{}; + var launch_row: u16 = 24; + const reservation = try prepareStartupViewport( + &shell, + &metrics, + &launch_row, + 11, + true, + true, + ); + shell.stdout_file.close(io_mod.getIo()); + + var read_file = try tmp.dir.openFile(io_mod.getIo(), out_path, .{}); + defer read_file.close(io_mod.getIo()); + const bytes = try io_mod.readFileToEnd(alloc, &read_file, 1024); + defer alloc.free(bytes); + + try std.testing.expectEqual(@as(u16, 1), launch_row); + try std.testing.expectEqual(@as(u16, 0), reservation); + try std.testing.expectEqualStrings("\x1b[24;1H", bytes); +} + test "shutdown cleanup erases from footer frame top after frame commit" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); diff --git a/tests/e2e/tui-resume.test.ts b/tests/e2e/tui-resume.test.ts index 903cbc5d26..e21e1e89df 100644 --- a/tests/e2e/tui-resume.test.ts +++ b/tests/e2e/tui-resume.test.ts @@ -6175,6 +6175,7 @@ test.skipIf(!tmuxAvailable())( const earlyMarker = "SESSION_PICKER_SCROLLBACK_EARLY"; const lateMarker = "SESSION_PICKER_SCROLLBACK_LATE"; const continuationMarker = "SESSION_PICKER_SCROLLBACK_CONTINUATION"; + const launchMarker = "SESSION_PICKER_SCROLLBACK_LAUNCH"; const lines = Array.from( { length: 80 }, (_, index) => `SESSION_PICKER_SCROLLBACK_LINE_${String(index).padStart(3, "0")} has enough text to wrap in the terminal viewport.`, @@ -6218,8 +6219,12 @@ test.skipIf(!tmuxAvailable())( ]); gateways.push(resumedGateway); writeFileSync(stderrPath, ""); + const resumeCommand = [ + `for i in {1..24}; do printf "${launchMarker}_%02d\\n" "$i"; done`, + `exec ${shellQuote(FX_BIN)} resume ${shellQuote(sessionId)}`, + ].join("; "); active = await TmuxSession.create({ - cmd: `${FX_BIN} resume ${sessionId}`, + cmd: `/bin/zsh -lc ${shellQuote(resumeCommand)}`, cwd: workspaceRoot, env: { ...gatewayEnv(home, resumedGateway), @@ -6235,6 +6240,23 @@ test.skipIf(!tmuxAvailable())( const resumed = await waitForScrollback(active, earlyMarker); expect(resumed).toContain(earlyMarker); + expect(countOccurrences(resumed, earlyMarker)).toBe(1); + expect(countOccurrences(resumed, lateMarker)).toBe(1); + expect(resumed).toContain(`${launchMarker}_24`); + const resumedRows = resumed.split("\n"); + const launchRow = resumedRows.findIndex((row) => + row.includes(`${launchMarker}_24`) + ); + const transcriptRow = resumedRows.findIndex((row) => + row.includes("Save a long transcript for resume.") + ); + expect(launchRow).toBeGreaterThanOrEqual(0); + expect(transcriptRow).toBeGreaterThan(launchRow); + expect( + resumedRows + .slice(launchRow + 1, transcriptRow) + .filter((row) => row.trim().length === 0).length, + ).toBeLessThanOrEqual(2); expect(countOccurrences(resumed, "direct-resume-scroll.fxtape")).toBe(1); const tape = readFileSync(tapePath); expect(tape.includes(Buffer.from("\x1b[?1002h"))).toBe(false); From 832d654dbb484b5beec3331ce9dbdbc8cda3f519 Mon Sep 17 00:00:00 2001 From: Pranit Date: Wed, 2 Sep 2026 00:50:36 -0400 Subject: [PATCH 11/12] Restore complete sessions without replaying history Persist executable and complete semantic presentation state in one resume snapshot. Stream saved terminal history first, then install retained interaction state without rebuilding the event log. Reflow session-owned history through the existing reset frame at each terminal width, and use hard resets where direct terminals do not clear native scrollback. --- src/core/app/app_render_runtime.zig | 6 + src/core/app/app_session_runtime.zig | 370 +++++++++++++++++-- src/core/session/session_log.zig | 299 ++++++++++++++- src/core/session/session_resume_snapshot.zig | 190 ++++++++++ src/core/session/session_usage.zig | 19 + src/core/session/snapshot_binary.zig | 297 +++++++++++++++ src/main.zig | 18 +- src/ui/shell_runtime.zig | 8 +- src/ui/transcript/resume_projection.zig | 289 ++++++++++++++- src/ui/transcript/resume_snapshot.zig | 232 ++++++++++++ src/ui/transcript/runtime.zig | 204 +++++++++- tests/e2e/tui-resume-brutal.test.ts | 8 +- tests/e2e/tui-resume.test.ts | 124 +++++-- 13 files changed, 1972 insertions(+), 92 deletions(-) create mode 100644 src/core/session/session_resume_snapshot.zig create mode 100644 src/core/session/snapshot_binary.zig create mode 100644 src/ui/transcript/resume_snapshot.zig diff --git a/src/core/app/app_render_runtime.zig b/src/core/app/app_render_runtime.zig index 95429f46bd..a5e37304f2 100644 --- a/src/core/app/app_render_runtime.zig +++ b/src/core/app/app_render_runtime.zig @@ -2359,6 +2359,12 @@ pub fn Runtime(comptime App: type) type { }, ); if (sequential_reset_armed and result.is_committed()) { + if (reset_transcript_reader) |*reader| { + app_session_runtime.Runtime(App).persistResetTranscriptAfterFrame( + app, + reader, + ); + } presentation_shell.finishStreamedResetAttempt(); debug_trace.logf( "session", diff --git a/src/core/app/app_session_runtime.zig b/src/core/app/app_session_runtime.zig index fe59aaef82..d914fd6a12 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -50,6 +50,8 @@ const session_permission_state = @import("../permissions/session_permission_stat const mcp_access = @import("../mcp/access_policy.zig"); const shell_runtime = @import("../../ui/shell_runtime.zig"); const transcript_runtime = @import("../../ui/transcript/runtime.zig"); +const transcript_painter = @import("../../ui/transcript/painter.zig"); +const resume_snapshot = @import("../../ui/transcript/resume_snapshot.zig"); const ui_input = @import("../../ui/input/runtime.zig"); const ui_render = @import("../../ui/render.zig"); const footer_paint_plan = @import("../../ui/footer/paint_plan.zig"); @@ -62,13 +64,20 @@ const resume_stream_autowrap_disable = "\x1b[?7l"; const resume_stream_read_bytes: usize = 64 * 1024; const resume_stream_first_read_bytes: usize = 1024; -pub const ResetTranscriptReader = struct { - admission: session_transcript.Admission, +pub const ResetTranscriptReader = union(enum) { + persisted: session_transcript.Admission, + memory: struct { + alloc: Allocator, + bytes: []u8, + }, pub fn frameSource(self: *ResetTranscriptReader) !frame_scroll_plan.SequentialDocumentSource { - const committed_len = switch (self.admission) { - .exact => |reader| reader.committed_len, - .missing, .incomplete, .corrupt => return error.TranscriptReaderUnavailable, + const committed_len: u64 = switch (self.*) { + .persisted => |admission| switch (admission) { + .exact => |reader| reader.committed_len, + .missing, .incomplete, .corrupt => return error.TranscriptReaderUnavailable, + }, + .memory => |source| @intCast(source.bytes.len), }; return .{ .ctx = self, @@ -78,15 +87,37 @@ pub const ResetTranscriptReader = struct { } pub fn deinit(self: *ResetTranscriptReader) void { - self.admission.deinit(); + switch (self.*) { + .persisted => |*admission| admission.deinit(), + .memory => |source| source.alloc.free(source.bytes), + } self.* = undefined; } + fn replacement( + self: *const ResetTranscriptReader, + ) ?[]const u8 { + return switch (self.*) { + .persisted => null, + .memory => |source| source.bytes, + }; + } + fn readAt(ctx: *anyopaque, out: []u8, offset: u64) !usize { const self: *ResetTranscriptReader = @ptrCast(@alignCast(ctx)); - return switch (self.admission) { - .exact => |reader| reader.readAt(out, offset), - .missing, .incomplete, .corrupt => error.TranscriptReaderUnavailable, + return switch (self.*) { + .persisted => |admission| switch (admission) { + .exact => |reader| reader.readAt(out, offset), + .missing, .incomplete, .corrupt => error.TranscriptReaderUnavailable, + }, + .memory => |source| blk: { + const start = std.math.cast(usize, offset) orelse + return error.InvalidTranscriptOffset; + if (start >= source.bytes.len) break :blk 0; + const len = @min(out.len, source.bytes.len - start); + @memcpy(out[0..len], source.bytes[start..][0..len]); + break :blk len; + }, }; } }; @@ -101,7 +132,7 @@ test "reset transcript reader lends a borrowed sequential frame source" { ); try file.writeStreamingAll(std.testing.io, "history\r\n"); - var reader = ResetTranscriptReader{ .admission = .{ .exact = .{ + var reader = ResetTranscriptReader{ .persisted = .{ .exact = .{ .file = file, .committed_len = "history\r\n".len, } } }; @@ -113,6 +144,21 @@ test "reset transcript reader lends a borrowed sequential frame source" { try std.testing.expectEqualStrings("history\r\n", buffer[0..read]); } +test "reset transcript reader owns a width-aware in-memory source" { + const bytes = try std.testing.allocator.dupe(u8, "reflowed\r\nhistory\r\n"); + var reader = ResetTranscriptReader{ .memory = .{ + .alloc = std.testing.allocator, + .bytes = bytes, + } }; + defer reader.deinit(); + + const source = try reader.frameSource(); + try std.testing.expectEqual(@as(u64, bytes.len), source.committed_len); + var buffer: [32]u8 = undefined; + const read = try source.readAt(&buffer, "reflowed\r\n".len); + try std.testing.expectEqualStrings("history\r\n", buffer[0..read]); +} + fn streamSessionTranscript(app: anytype, reader: anytype) !u64 { app.shell.writeResumeStreamBytes( &app.metrics, @@ -172,7 +218,10 @@ fn streamSessionTranscript(app: anytype, reader: anytype) !u64 { } } -fn extendResumeStreamGuardForRetainedProjection(app: anytype) !void { +fn extendResumeStreamGuardForRetainedProjection( + app: anytype, + live_session_resume: bool, +) !u16 { const desired_rows = (app.shell.layout.rows -| app.shell.layout.content_bottom) +| footer_paint_plan.idle_footer_gap_reservation_rows +| 1; @@ -180,6 +229,10 @@ fn extendResumeStreamGuardForRetainedProjection(app: anytype) !void { while (emitted_rows < desired_rows) : (emitted_rows += 1) { try app.shell.writeResumeStreamBytes(&app.metrics, "\r\n"); } + return if (live_session_resume) + footer_paint_plan.idle_footer_gap_reservation_rows + else + app.shell.layout.rows -| app.shell.layout.content_bottom; } fn streamSessionTranscriptPayload(app: anytype, reader: anytype) !u64 { @@ -1383,6 +1436,7 @@ pub const Persistence = struct { resume_transcript_streamed: bool = false, resume_handoff_intent: ResumeHandoffIntent = .none, pending_live_session_policy: ?BackgroundSessionPolicy = null, + live_session_resume: bool = false, pub fn deinit(self: *Persistence, alloc: Allocator) void { if (self.pending_live_session_policy) |policy| { @@ -1415,6 +1469,34 @@ pub const Persistence = struct { pub fn Runtime(comptime App: type) type { return struct { + fn acquireWidthAwareResetTranscriptReader( + app: *App, + ) !?ResetTranscriptReader { + var source = try app.shell.prepareTranscriptSource(app.alloc, null); + defer source.deinit(app.alloc); + const prepared = try transcript_painter.prepareTranscriptDocumentAppendBytes( + app.alloc, + source.bytes, + app.shell.layout.cols, + 0, + source.bytes.len, + true, + ); + const bytes = if (prepared.len > 0) + prepared + else + try app.alloc.dupe(u8, source.bytes); + debug_trace.logf( + "session", + "event=session_transcript_reset source=semantic_reflow cols={d} entries={d} bytes={d}", + .{ app.shell.layout.cols, source.entry_spans.len, bytes.len }, + ); + return .{ .memory = .{ + .alloc = app.alloc, + .bytes = bytes, + } }; + } + pub fn acquireResetTranscriptReader(app: *App) !?ResetTranscriptReader { if (!app.shell.streamedResetSourceReady()) { debug_trace.logf( @@ -1424,6 +1506,16 @@ pub fn Runtime(comptime App: type) type { ); return null; } + if (app.shell.streamedResetRequiresReflow()) { + return acquireWidthAwareResetTranscriptReader(app) catch |err| { + debug_trace.logf( + "session", + "event=session_transcript_reset outcome=unavailable reason=semantic_reflow_failed err={s}", + .{@errorName(err)}, + ); + return null; + }; + } const expected_committed_len = app.shell.presentationRecordCommittedLen() orelse { debug_trace.logf( "session", @@ -1445,7 +1537,7 @@ pub fn Runtime(comptime App: type) type { var admission = try writable.admitActiveTranscript(app.alloc); switch (admission) { .exact => |reader| if (reader.committed_len == expected_committed_len) { - return .{ .admission = admission }; + return .{ .persisted = admission }; } else { admission.deinit(); debug_trace.logf( @@ -1468,6 +1560,40 @@ pub fn Runtime(comptime App: type) type { } } + pub fn persistResetTranscriptAfterFrame( + app: *App, + reader: *const ResetTranscriptReader, + ) void { + const replacement = reader.replacement() orelse return; + app.session_persistence.write_mutex.lockUncancelable(io_mod.getIo()); + defer app.session_persistence.write_mutex.unlock(io_mod.getIo()); + const loaded = if (app.session_persistence.writable) |*value| value else { + app.shell.degradePresentationRecord(); + return; + }; + loaded.publishTranscript(app.alloc, replacement) catch |err| { + debug_trace.logf( + "session", + "event=session_transcript_reset persistence=degraded err={s}", + .{@errorName(err)}, + ); + app.shell.degradePresentationRecord(); + return; + }; + loaded.invalidateResumeSnapshotPresentation(); + if (app.session_persistence.resume_transcript_committed_len != null) { + app.session_persistence.resume_transcript_committed_len = + @intCast(replacement.len); + } + app.shell.commitStreamedSessionHistoryReflow(); + app.shell.degradePresentationRecord(); + debug_trace.logf( + "session", + "event=session_transcript_reset persistence=committed cols={d} bytes={d}", + .{ app.shell.layout.cols, replacement.len }, + ); + } + pub fn stageRequestedSessionTranscript(app: *App) !void { const requested = app.requested_resume orelse return; const store = app.session_persistence.store orelse return; @@ -1599,7 +1725,7 @@ pub fn Runtime(comptime App: type) type { app.shell.streamedSessionHistoryActive()) { var anchor_reconciled = true; - app.shell.establishStreamedRetainedAnchor(app.alloc) catch |err| { + app.shell.establishStreamedRetainedAnchor(app.alloc, 0) catch |err| { anchor_reconciled = false; debug_trace.logf( "session", @@ -1896,6 +2022,7 @@ pub fn Runtime(comptime App: type) type { log_options: session_log.Options, ) !void { try prepareLiveSessionTransition(app, .stop_forget, log_options); + app.session_persistence.live_session_resume = true; try app.shell.requestTerminalReset(&app.metrics); try app.commitStartupResumeReplayAnchor(); } @@ -2126,7 +2253,13 @@ pub fn Runtime(comptime App: type) type { return; }; defer display.deinit(app.alloc); - hydrateResumedSession(app, loaded.state, display.title, notice) catch |err| { + hydrateResumedSession( + app, + loaded.state, + display.title, + notice, + null, + ) catch |err| { traceJsHostRestoreFailure("hydrate", session_id, err); try continueWithFreshJsHostSession(app); return; @@ -2304,7 +2437,17 @@ pub fn Runtime(comptime App: type) type { app.session_persistence.writable = null; } const active = &app.session_persistence.writable.?; - try hydrateResumedSession(app, active.state, display.title, notice); + const resume_presentation = active.takeResumePresentation(); + defer if (resume_presentation) |bytes| { + if (bytes.len > 0) app.alloc.free(bytes); + }; + try hydrateResumedSession( + app, + active.state, + display.title, + notice, + resume_presentation, + ); enableSessionStores(app); refreshSubagentProjectionAfterSessionInstall(app); } @@ -2314,7 +2457,9 @@ pub fn Runtime(comptime App: type) type { state: session_codec.DurableSessionState, display_title: []const u8, notice: ResumeNotice, + resume_presentation: ?[]const u8, ) !void { + defer app.session_persistence.live_session_resume = false; if (comptime @hasField(App, "next_image_id")) { app.next_image_id = try nextImageIdForResumedHistory( app.alloc, @@ -2352,17 +2497,30 @@ pub fn Runtime(comptime App: type) type { const projection_started_ns = io_mod.nanoTimestamp(); var projection = try app.beginResumeProjection(); defer projection.deinit(); - var sink = DetachedHistorySink(@TypeOf(projection)){ - .app = app, - .projection = &projection, - }; - try replayHistoryToSink(app, &sink, state.history); - try writeRecoveryCheckpointToSink(app, &sink, state); - const projection_finished_ns = io_mod.nanoTimestamp(); const exact_transcript_streamed = app.session_persistence.resume_transcript_streamed; - try projection.finalizeForResume(!exact_transcript_streamed); - const finalization_finished_ns = io_mod.nanoTimestamp(); + var projection_finished_ns: i128 = undefined; + var finalization_finished_ns: i128 = undefined; + if (resume_presentation != null and exact_transcript_streamed) { + var retained = try resume_snapshot.decode( + app.alloc, + resume_presentation.?, + ); + defer retained.deinit(app.alloc); + try projection.adoptSnapshot(&retained); + projection_finished_ns = io_mod.nanoTimestamp(); + finalization_finished_ns = projection_finished_ns; + } else { + var sink = DetachedHistorySink(@TypeOf(projection)){ + .app = app, + .projection = &projection, + }; + try replayHistoryToSink(app, &sink, state.history); + try writeRecoveryCheckpointToSink(app, &sink, state); + projection_finished_ns = io_mod.nanoTimestamp(); + try projection.finalizeForResume(!exact_transcript_streamed); + finalization_finished_ns = io_mod.nanoTimestamp(); + } var committed_len = app.session_persistence.resume_transcript_committed_len; if (!app.session_persistence.resume_transcript_streamed) { const transcript_bytes = try projection.publicationWireBytes( @@ -2400,10 +2558,40 @@ pub fn Runtime(comptime App: type) type { }; } } - const record_cursor = projection.recordCursor(); - if (projection.retentionChanged()) { - try extendResumeStreamGuardForRetainedProjection(app); + if (app.session_persistence.writable) |*loaded| { + if (!loaded.resumeSnapshotReady()) { + const view = projection.snapshotView(); + const presentation = resume_snapshot.encode( + app.alloc, + view, + ) catch |err| blk: { + debug_trace.logf( + "session", + "event=resume_snapshot_publish outcome=degraded stage=legacy_encode err={s}", + .{@errorName(err)}, + ); + break :blk null; + }; + if (presentation) |bytes| { + defer app.alloc.free(bytes); + loaded.publishResumeSnapshot(app.alloc, bytes) catch |err| { + debug_trace.logf( + "session", + "event=resume_snapshot_publish outcome=degraded stage=legacy_replace err={s}", + .{@errorName(err)}, + ); + }; + } + } } + const record_cursor = projection.recordCursor(); + const streamed_history_guard_rows = if (projection.retentionChanged()) + try extendResumeStreamGuardForRetainedProjection( + app, + app.session_persistence.live_session_resume, + ) + else + 0; if (committed_len) |len| { app.shell.resumePresentationRecordAt(len, record_cursor); } else { @@ -2416,7 +2604,11 @@ pub fn Runtime(comptime App: type) type { app.shell.footer_reserved_base_rows = footer_paint_plan.composerReservedBaseRows(); } - try app.installResumeProjectionRetained(&projection); + try app.installResumeProjectionRetained( + &projection, + streamed_history_guard_rows, + ); + app.shell.requestStreamedSessionWidthReflow(); try app.commitStartupResumeReplayAnchor(); try projection.installPostludeRetained( &app.shell, @@ -2424,6 +2616,12 @@ pub fn Runtime(comptime App: type) type { ); var live_sink = LiveHistorySink(App){ .app = app }; try writeResumeNotice(app, &live_sink, display_title, notice); + if (app.session_persistence.resume_transcript_committed_len) |len| { + committed_len = len; + } + if (committed_len) |len| { + app.shell.resumePresentationRecord(len); + } app.session_persistence.resume_transcript_committed_len = null; app.session_persistence.resume_transcript_streamed = false; const install_finished_ns = io_mod.nanoTimestamp(); @@ -4824,6 +5022,114 @@ pub fn Runtime(comptime App: type) type { _ = closeWritableSessionWithResumeHandoff(app, log_options); } + fn publishCanonicalResumeSnapshot( + app: *App, + loaded: *session_store.LoadedWritableSession, + ) void { + if (comptime !@hasDecl(App, "beginResumeProjection")) return; + if (loaded.resumeSnapshotReady()) return; + + const started_ns = io_mod.nanoTimestamp(); + const live_complete = app.shell.retainsCompleteStreamedSessionHistory(); + var projection = (if (live_complete and + comptime @hasDecl(App, "beginCompleteResumeProjection")) + app.beginCompleteResumeProjection() + else + app.beginResumeProjection()) catch |err| { + debug_trace.logf( + "session", + "event=resume_snapshot_publish outcome=degraded stage=close_projection_init err={s}", + .{@errorName(err)}, + ); + return; + }; + defer projection.deinit(); + if (!live_complete) { + var sink = DetachedHistorySink(@TypeOf(projection)){ + .app = app, + .projection = &projection, + }; + replayHistoryToSink(app, &sink, loaded.state.history) catch |err| { + debug_trace.logf( + "session", + "event=resume_snapshot_publish outcome=degraded stage=close_history_projection err={s}", + .{@errorName(err)}, + ); + return; + }; + writeRecoveryCheckpointToSink(app, &sink, loaded.state) catch |err| { + debug_trace.logf( + "session", + "event=resume_snapshot_publish outcome=degraded stage=close_recovery_projection err={s}", + .{@errorName(err)}, + ); + return; + }; + } + projection.finalizeForResume(false) catch |err| { + debug_trace.logf( + "session", + "event=resume_snapshot_publish outcome=degraded stage=close_projection_finalize err={s}", + .{@errorName(err)}, + ); + return; + }; + if (live_complete) { + const transcript_bytes = projection.publicationWireBytes( + app.shell.layout.cols, + ) catch |err| blk: { + debug_trace.logf( + "session", + "event=session_transcript outcome=degraded stage=close_reflow err={s}", + .{@errorName(err)}, + ); + break :blk null; + }; + if (transcript_bytes) |bytes| { + defer app.alloc.free(bytes); + loaded.publishTranscript(app.alloc, bytes) catch |err| { + debug_trace.logf( + "session", + "event=session_transcript outcome=degraded stage=close_publish err={s}", + .{@errorName(err)}, + ); + }; + } + } + const presentation = resume_snapshot.encode( + app.alloc, + projection.snapshotView(), + ) catch |err| { + debug_trace.logf( + "session", + "event=resume_snapshot_publish outcome=degraded stage=close_projection_encode err={s}", + .{@errorName(err)}, + ); + return; + }; + defer app.alloc.free(presentation); + loaded.publishResumeSnapshot(app.alloc, presentation) catch |err| { + debug_trace.logf( + "session", + "event=resume_snapshot_publish outcome=degraded stage=close_durable_replace err={s}", + .{@errorName(err)}, + ); + return; + }; + debug_trace.logf( + "session", + "event=resume_snapshot_publish outcome=current source={s} bytes={d} elapsed_us={d}", + .{ + if (live_complete) "live_complete_close" else "canonical_close", + presentation.len, + @divTrunc( + io_mod.nanoTimestamp() - started_ns, + std.time.ns_per_us, + ), + }, + ); + } + fn closeWritableSessionWithResumeHandoff( app: *App, log_options: session_log.Options, @@ -4863,13 +5169,6 @@ pub fn Runtime(comptime App: type) type { ); }; } - loaded.writeCheckpointIfDue(app.alloc, true, log_options) catch |err| { - debug_trace.logf( - "session", - "final checkpoint failed session={s} err={s}", - .{ loaded.active_id, @errorName(err) }, - ); - }; if (app.shell.presentationRecordCanRestamp() and loaded.transcriptNeedsRestamp()) { @@ -4883,6 +5182,7 @@ pub fn Runtime(comptime App: type) type { }; } } + publishCanonicalResumeSnapshot(app, loaded); const should_create_handoff = resume_boundary_valid and shouldCreateResumeHandoff(.{ .intent = handoff_intent, diff --git a/src/core/session/session_log.zig b/src/core/session/session_log.zig index 6738654d84..15a13ec94b 100644 --- a/src/core/session/session_log.zig +++ b/src/core/session/session_log.zig @@ -13,10 +13,12 @@ const session_projection = @import("session_projection.zig"); const session_replay = @import("session_replay.zig"); const session_display_metadata = @import("session_display_metadata.zig"); const session_transcript = @import("session_transcript.zig"); +const session_resume_snapshot = @import("session_resume_snapshot.zig"); const session_usage = @import("session_usage.zig"); const session_usage_sidecar = @import("session_usage_sidecar.zig"); const Allocator = std.mem.Allocator; + const Identifier = session_event.Identifier; const private_dir_permissions = std.Io.File.Permissions.fromMode(0o700); const private_file_permissions = std.Io.File.Permissions.fromMode(0o600); @@ -31,6 +33,7 @@ const authority_intent_file = "authority.pending.json"; const publication_intent_file = "commit.pending.json"; const manifest_file = "session.json"; const checkpoint_file = "checkpoint.json"; +const resume_snapshot_file = "resume.snapshot"; const session_lock_file = "session.lock"; const commit_lock_file = "commit.lock"; @@ -140,6 +143,7 @@ pub const ProjectionStatus = enum { }; const ReplaySource = enum { + resume_snapshot, checkpoint, event_log, }; @@ -153,6 +157,9 @@ const OpenState = struct { projection_status: ProjectionStatus, source: ReplaySource, tail_bytes: u64, + resume_presentation: []u8 = &.{}, + resume_snapshot_position: ?CommitPosition = null, + resume_snapshot_has_presentation: bool = false, }; const CheckpointReplay = struct { @@ -548,6 +555,9 @@ pub const LoadedWritableSession = struct { migration_source_bytes: ?u64 = null, usage_sidecar_reseal_pending: bool = false, transcript_position: ?CommitPosition = null, + resume_presentation: []u8 = &.{}, + resume_snapshot_position: ?CommitPosition = null, + resume_snapshot_has_presentation: bool = false, /// Runtime-only provenance installed by subagent resume admission. These /// fields are never written into the session event log. external_prompt_origin: ExternalPromptOrigin = .root, @@ -565,6 +575,7 @@ pub const LoadedWritableSession = struct { if (self.external_root_user_messages.len > 0) { alloc.free(self.external_root_user_messages); } + if (self.resume_presentation.len > 0) alloc.free(self.resume_presentation); alloc.free(self.active_id); self.state.deinit(alloc); self.log.deinit(alloc); @@ -586,6 +597,55 @@ pub const LoadedWritableSession = struct { self.transcript_position = self.position; } + pub fn publishResumeSnapshot( + self: *LoadedWritableSession, + alloc: Allocator, + presentation: []const u8, + ) !void { + if (self.state_replacement_pending or + self.namespace_confirmation_required or + self.degraded_tail != null) + { + return error.SessionPersistenceDegraded; + } + const bytes = try session_resume_snapshot.encode( + alloc, + self.active_id, + self.position, + self.state, + presentation, + ); + defer alloc.free(bytes); + try durableReplace(alloc, &self.log.dir, resume_snapshot_file, bytes); + self.resume_snapshot_position = self.position; + self.resume_snapshot_has_presentation = presentation.len > 0; + } + + pub fn resumeSnapshotCurrent(self: *const LoadedWritableSession) bool { + const position = self.resume_snapshot_position orelse return false; + return positionsEqual(position, self.position); + } + + pub fn resumeSnapshotReady(self: *const LoadedWritableSession) bool { + return self.resume_snapshot_has_presentation and + self.resumeSnapshotCurrent(); + } + + pub fn invalidateResumeSnapshotPresentation( + self: *LoadedWritableSession, + ) void { + self.resume_snapshot_has_presentation = false; + } + + pub fn takeResumePresentation( + self: *LoadedWritableSession, + ) ?[]u8 { + if (!self.resumeSnapshotReady()) return null; + const bytes = self.resume_presentation; + self.resume_presentation = &.{}; + return bytes; + } + pub fn appendTranscript( self: *LoadedWritableSession, alloc: Allocator, @@ -727,6 +787,11 @@ pub const LoadedWritableSession = struct { self.state_replacement_pending = self.state_replacement_pending and replacement_was_pending; } + publishStateOnlyResumeSnapshotIfDueBestEffort( + self, + alloc, + options.checkpoint_interval, + ); return self.position; } @@ -784,6 +849,11 @@ pub const LoadedWritableSession = struct { return err; }; if (!lifecycle_published) self.state_replacement_pending = true; + publishStateOnlyResumeSnapshotIfDueBestEffort( + self, + alloc, + options.checkpoint_interval, + ); return self.position; } @@ -895,6 +965,18 @@ pub const LoadedWritableSession = struct { error.CheckpointTooLarge => return, else => return err, }; + try deleteAndSync( + &self.log.dir, + resume_snapshot_file, + options.test_controls, + null, + ); + if (self.resume_presentation.len > 0) { + alloc.free(self.resume_presentation); + } + self.resume_presentation = &.{}; + self.resume_snapshot_position = null; + self.resume_snapshot_has_presentation = false; try writeManifestProjection(alloc, self); self.projection_status = .current; } @@ -2412,7 +2494,12 @@ fn openWritableSession( error.OutOfMemory => return failLoadedWritableSession(error.SessionReplayResourceExhausted), else => return err, }; - errdefer open_state.state.deinit(alloc); + errdefer { + open_state.state.deinit(alloc); + if (open_state.resume_presentation.len > 0) { + alloc.free(open_state.resume_presentation); + } + } const usage_sidecar_reseal_pending = try restoreUsageSidecar( alloc, &writable.dir, @@ -2448,8 +2535,12 @@ fn openWritableSession( .checkpoint_sha256 = open_state.checkpoint_sha256, .projection_status = open_state.projection_status, .usage_sidecar_reseal_pending = usage_sidecar_reseal_pending, + .resume_presentation = open_state.resume_presentation, + .resume_snapshot_position = open_state.resume_snapshot_position, + .resume_snapshot_has_presentation = open_state.resume_snapshot_has_presentation, }; open_state.state = undefined; + open_state.resume_presentation = &.{}; if (result.projection_status == .stale) { writeManifestProjection(alloc, &result) catch { result.projection_status = .stale; @@ -2566,6 +2657,36 @@ fn loadOpenState( }; defer if (manifest) |*value| value.manifest.deinit(alloc); + var exact_snapshot = try loadExactResumeSnapshot( + alloc, + dir, + session_id, + position, + ); + defer if (exact_snapshot) |*snapshot| snapshot.deinit(alloc); + if (exact_snapshot != null and manifest != null and + manifest.?.event_file_matches) + { + var exact = exact_snapshot.?; + exact_snapshot = null; + const value = manifest.?.manifest; + alloc.free(exact.session_id); + exact.session_id = &.{}; + return .{ + .state = exact.state, + .generation_base_seq = value.generation_base_seq, + .generation_base_bytes = value.generation_base_bytes, + .checkpoint_seq = value.checkpoint_seq, + .checkpoint_sha256 = value.checkpoint_sha256, + .projection_status = .current, + .source = .resume_snapshot, + .tail_bytes = 0, + .resume_presentation = exact.presentation, + .resume_snapshot_position = exact.position, + .resume_snapshot_has_presentation = exact.presentation.len > 0, + }; + } + var checkpoint_failed = false; if (manifest) |candidate| { const value = candidate.manifest; @@ -2644,6 +2765,56 @@ fn loadOpenState( }; } +fn loadExactResumeSnapshot( + alloc: Allocator, + dir: *io_mod.VerifiedDir, + session_id: []const u8, + position: CommitPosition, +) !?session_resume_snapshot.Decoded { + if (!try entryExists(dir, resume_snapshot_file)) return null; + const bytes = readManagedFileAlloc( + alloc, + dir, + resume_snapshot_file, + session_resume_snapshot.max_file_bytes, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.CorruptResumeSnapshot, + }; + defer alloc.free(bytes); + var snapshot = session_resume_snapshot.decode(alloc, bytes) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.UnsupportedResumeSnapshotSchema => { + debug_trace.logf( + "session", + "event=resume_snapshot_admission outcome=incomplete reason=unsupported_schema", + .{}, + ); + return null; + }, + else => return error.CorruptResumeSnapshot, + }; + errdefer snapshot.deinit(alloc); + if (!std.mem.eql(u8, snapshot.session_id, session_id)) { + return error.CorruptResumeSnapshot; + } + if (!positionsEqual(snapshot.position, position)) { + debug_trace.logf( + "session", + "event=resume_snapshot_admission outcome=incomplete reason=stale", + .{}, + ); + snapshot.deinit(alloc); + return null; + } + debug_trace.logf( + "session", + "event=resume_snapshot_admission outcome=exact bytes={d}", + .{bytes.len}, + ); + return snapshot; +} + fn loadCurrentManifestForOpen( alloc: Allocator, dir: *io_mod.VerifiedDir, @@ -3955,18 +4126,44 @@ fn rollbackPublicationToPrior( fn refreshProjections( alloc: Allocator, loaded: *LoadedWritableSession, - options: Options, + _: Options, ) !void { - if (checkpointDue(loaded, options.checkpoint_interval)) { - writeCheckpointProjection(alloc, loaded) catch |err| switch (err) { - error.CheckpointTooLarge => {}, - else => return err, - }; - } try writeManifestProjection(alloc, loaded); loaded.projection_status = .current; } +fn publishStateOnlyResumeSnapshotIfDueBestEffort( + loaded: *LoadedWritableSession, + alloc: Allocator, + interval: u64, +) void { + if (!resumeSnapshotDue(loaded, interval)) return; + loaded.publishResumeSnapshot(alloc, &.{}) catch |err| { + debug_trace.logf( + "session", + "event=resume_snapshot_publish outcome=degraded stage=state_only err={s}", + .{@errorName(err)}, + ); + }; +} + +fn resumeSnapshotDue( + loaded: *const LoadedWritableSession, + interval: u64, +) bool { + if (interval == 0) return false; + const prior = loaded.resume_snapshot_position orelse { + const checkpoint_seq = loaded.checkpoint_seq orelse return true; + return loaded.position.through_seq > checkpoint_seq and + loaded.position.through_seq - checkpoint_seq >= interval; + }; + if (!std.mem.eql(u8, &prior.log_generation, &loaded.position.log_generation)) { + return true; + } + return loaded.position.through_seq > prior.through_seq and + loaded.position.through_seq - prior.through_seq >= interval; +} + fn checkpointDue(loaded: *LoadedWritableSession, interval: u64) bool { return interval > 0 and (loaded.checkpoint_seq == null or @@ -4732,6 +4929,85 @@ fn testState(alloc: Allocator, id: []const u8, updated_at_ms: i64) !session_code }; } +test "resume snapshot reloads exact state and opaque presentation" { + const alloc = std.testing.allocator; + var temp = try TempRoot.init(alloc); + defer temp.deinit(alloc); + var initial = try testState(alloc, "session-resume-snapshot-exact", 10); + defer initial.deinit(alloc); + + var loaded = try temp.root.startWritableSession(alloc, initial, .{}); + try loaded.publishResumeSnapshot(alloc, "retained-presentation"); + try std.testing.expect(loaded.resumeSnapshotCurrent()); + loaded.deinit(alloc); + + var resumed = try temp.root.resumeForWrite(alloc, initial.id, .{}); + defer resumed.deinit(alloc); + try std.testing.expect(resumed.resumeSnapshotCurrent()); + try std.testing.expect(resumed.resumeSnapshotReady()); + const presentation = resumed.takeResumePresentation() orelse + return error.MissingResumePresentation; + defer alloc.free(presentation); + try std.testing.expectEqualStrings("retained-presentation", presentation); + try std.testing.expectEqualStrings(initial.id, resumed.state.id); +} + +test "stale resume snapshot falls back to canonical events" { + const alloc = std.testing.allocator; + var temp = try TempRoot.init(alloc); + defer temp.deinit(alloc); + var initial = try testState(alloc, "session-resume-snapshot-stale", 10); + defer initial.deinit(alloc); + + var loaded = try temp.root.startWritableSession(alloc, initial, .{}); + try loaded.publishResumeSnapshot(alloc, "stale-presentation"); + _ = try loaded.appendEvent( + alloc, + .{ .preferences_changed = .{ .fast_mode = true } }, + 20, + .retry_expected_tail, + .{}, + ); + loaded.deinit(alloc); + + var resumed = try temp.root.resumeForWrite(alloc, initial.id, .{}); + defer resumed.deinit(alloc); + try std.testing.expect(!resumed.resumeSnapshotCurrent()); + try std.testing.expect(resumed.takeResumePresentation() == null); + try std.testing.expect(resumed.state.preferences.fast_mode); +} + +test "supported resume snapshot corruption fails closed" { + const alloc = std.testing.allocator; + var temp = try TempRoot.init(alloc); + defer temp.deinit(alloc); + var initial = try testState(alloc, "session-resume-snapshot-corrupt", 10); + defer initial.deinit(alloc); + + var loaded = try temp.root.startWritableSession(alloc, initial, .{}); + try loaded.publishResumeSnapshot(alloc, "retained-presentation"); + const bytes = try readManagedFileAlloc( + alloc, + &loaded.log.dir, + resume_snapshot_file, + session_resume_snapshot.max_file_bytes, + ); + defer alloc.free(bytes); + bytes[23] ^= 1; + try durableReplace( + alloc, + &loaded.log.dir, + resume_snapshot_file, + bytes, + ); + loaded.deinit(alloc); + + try std.testing.expectError( + error.CorruptResumeSnapshot, + temp.root.resumeForWrite(alloc, initial.id, .{}), + ); +} + fn stateWithTurn( alloc: Allocator, prior: session_codec.DurableSessionState, @@ -6372,7 +6648,7 @@ test "rollback required failure is excluded from generic degraded retry" { ); } -test "checkpoint scheduling publishes an exact committed checkpoint" { +test "checkpoint scheduling publishes an exact state-only resume snapshot" { const alloc = std.testing.allocator; var temp = try TempRoot.init(alloc); defer temp.deinit(alloc); @@ -6397,9 +6673,10 @@ test "checkpoint scheduling publishes an exact committed checkpoint" { try std.testing.expect(try temp.root.entryExistsForTest( alloc, initial.id, - "checkpoint.json", + resume_snapshot_file, )); - try loaded.validateCheckpointForTest(alloc); + try std.testing.expect(loaded.resumeSnapshotCurrent()); + try std.testing.expect(!loaded.resumeSnapshotReady()); } test "checkpoint clean shutdown publishes when missing" { diff --git a/src/core/session/session_resume_snapshot.zig b/src/core/session/session_resume_snapshot.zig new file mode 100644 index 0000000000..4e9d5a4312 --- /dev/null +++ b/src/core/session/session_resume_snapshot.zig @@ -0,0 +1,190 @@ +const std = @import("std"); +const session_codec = @import("session_codec.zig"); +const session_replay = @import("session_replay.zig"); +const session_usage = @import("session_usage.zig"); +const snapshot_binary = @import("snapshot_binary.zig"); + +const Allocator = std.mem.Allocator; +const magic = "FXRS"; +const schema_version: u8 = 2; +pub const max_file_bytes: usize = 128 * 1024 * 1024; + +pub const Decoded = struct { + session_id: []u8, + position: session_replay.CommitPosition, + state: session_codec.DurableSessionState, + presentation: []u8, + + pub fn deinit(self: *Decoded, alloc: Allocator) void { + alloc.free(self.session_id); + self.state.deinit(alloc); + alloc.free(self.presentation); + self.* = undefined; + } +}; + +fn writeInt(writer: *std.Io.Writer, comptime T: type, value: T) !void { + var bytes: [@sizeOf(T)]u8 = undefined; + std.mem.writeInt(T, &bytes, value, .little); + try writer.writeAll(&bytes); +} + +fn hashInt( + hash: *std.crypto.hash.sha2.Sha256, + comptime T: type, + value: T, +) void { + var bytes: [@sizeOf(T)]u8 = undefined; + std.mem.writeInt(T, &bytes, value, .little); + hash.update(&bytes); +} + +fn snapshotDigest( + session_id: []const u8, + position: session_replay.CommitPosition, + state_bytes: []const u8, + presentation: []const u8, +) [32]u8 { + var hash = std.crypto.hash.sha2.Sha256.init(.{}); + hash.update(magic); + hash.update(&.{schema_version}); + hashInt(&hash, u16, @intCast(session_id.len)); + hash.update(&position.log_generation); + hashInt(&hash, u64, position.through_seq); + hash.update(&position.through_event_id); + hashInt(&hash, u64, position.through_event_log_bytes); + hashInt(&hash, u64, @intCast(state_bytes.len)); + hashInt(&hash, u64, @intCast(presentation.len)); + hash.update(session_id); + hash.update(state_bytes); + hash.update(presentation); + var digest: [32]u8 = undefined; + hash.final(&digest); + return digest; +} + +const Cursor = struct { + bytes: []const u8, + pos: usize = 0, + + fn take(self: *Cursor, len: usize) ![]const u8 { + if (len > self.bytes.len - self.pos) return error.InvalidResumeSnapshot; + defer self.pos += len; + return self.bytes[self.pos..][0..len]; + } + + fn readInt(self: *Cursor, comptime T: type) !T { + const raw = try self.take(@sizeOf(T)); + return std.mem.readInt(T, raw[0..@sizeOf(T)], .little); + } +}; + +pub fn encode( + alloc: Allocator, + session_id: []const u8, + position: session_replay.CommitPosition, + state: session_codec.DurableSessionState, + presentation: []const u8, +) ![]u8 { + if (session_id.len == 0 or session_id.len > std.math.maxInt(u16)) { + return error.InvalidResumeSnapshot; + } + var canonical_state = try state.dupe(alloc); + defer canonical_state.deinit(alloc); + if (canonical_state.usage) |*usage| { + session_usage.stripSidecarOnlyFields(alloc, usage); + } + const state_bytes = try snapshot_binary.encode(alloc, canonical_state); + defer alloc.free(state_bytes); + const payload_len = std.math.add(usize, state_bytes.len, presentation.len) catch + return error.ResumeSnapshotTooLarge; + if (payload_len > max_file_bytes) return error.ResumeSnapshotTooLarge; + const digest = snapshotDigest( + session_id, + position, + state_bytes, + presentation, + ); + + var out: std.Io.Writer.Allocating = .init(alloc); + errdefer out.deinit(); + try out.writer.writeAll(magic); + try out.writer.writeByte(schema_version); + try writeInt(&out.writer, u16, @intCast(session_id.len)); + try out.writer.writeAll(&position.log_generation); + try writeInt(&out.writer, u64, position.through_seq); + try out.writer.writeAll(&position.through_event_id); + try writeInt(&out.writer, u64, position.through_event_log_bytes); + try writeInt(&out.writer, u64, @intCast(state_bytes.len)); + try writeInt(&out.writer, u64, @intCast(presentation.len)); + try out.writer.writeAll(&digest); + try out.writer.writeAll(session_id); + try out.writer.writeAll(state_bytes); + try out.writer.writeAll(presentation); + if (out.written().len > max_file_bytes) return error.ResumeSnapshotTooLarge; + return out.toOwnedSlice(); +} + +pub fn decode(alloc: Allocator, bytes: []const u8) !Decoded { + if (bytes.len == 0 or bytes.len > max_file_bytes) return error.InvalidResumeSnapshot; + var cursor = Cursor{ .bytes = bytes }; + if (!std.mem.eql(u8, try cursor.take(magic.len), magic)) { + return error.InvalidResumeSnapshot; + } + const version = (try cursor.take(1))[0]; + if (version != schema_version) return error.UnsupportedResumeSnapshotSchema; + const session_id_len = try cursor.readInt(u16); + const generation = try cursor.take(16); + const through_seq = try cursor.readInt(u64); + const event_id = try cursor.take(16); + const event_log_bytes = try cursor.readInt(u64); + const state_len = std.math.cast(usize, try cursor.readInt(u64)) orelse + return error.InvalidResumeSnapshot; + const presentation_len = std.math.cast(usize, try cursor.readInt(u64)) orelse + return error.InvalidResumeSnapshot; + const expected_digest = try cursor.take(32); + const session_id = try cursor.take(session_id_len); + const state_bytes = try cursor.take(state_len); + const presentation_bytes = try cursor.take(presentation_len); + if (cursor.pos != bytes.len or through_seq == 0 or event_log_bytes == 0) { + return error.InvalidResumeSnapshot; + } + const digest = snapshotDigest( + session_id, + .{ + .log_generation = generation[0..16].*, + .through_seq = through_seq, + .through_event_id = event_id[0..16].*, + .through_event_log_bytes = event_log_bytes, + }, + state_bytes, + presentation_bytes, + ); + if (!std.mem.eql(u8, expected_digest, &digest)) return error.InvalidResumeSnapshot; + + const owned_id = try alloc.dupe(u8, session_id); + errdefer alloc.free(owned_id); + var state = snapshot_binary.decode( + alloc, + session_codec.DurableSessionState, + state_bytes, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.InvalidResumeSnapshot, + }; + errdefer state.deinit(alloc); + session_codec.validateState(state) catch return error.InvalidResumeSnapshot; + if (!std.mem.eql(u8, state.id, owned_id)) return error.InvalidResumeSnapshot; + const presentation = try alloc.dupe(u8, presentation_bytes); + return .{ + .session_id = owned_id, + .position = .{ + .log_generation = generation[0..16].*, + .through_seq = through_seq, + .through_event_id = event_id[0..16].*, + .through_event_log_bytes = event_log_bytes, + }, + .state = state, + .presentation = presentation, + }; +} diff --git a/src/core/session/session_usage.zig b/src/core/session/session_usage.zig index 1d354e7465..394fa4d313 100644 --- a/src/core/session/session_usage.zig +++ b/src/core/session/session_usage.zig @@ -339,6 +339,25 @@ pub const Snapshot = struct { } }; +/// Removes fields owned exclusively by the validated usage sidecar from an +/// owned snapshot before it enters the canonical session materialization. +pub fn stripSidecarOnlyFields(alloc: Allocator, snapshot: *Snapshot) void { + snapshot.reasoning_tokens = null; + snapshot.request_count = null; + for (snapshot.models) |*model| { + model.reasoning_tokens = null; + model.request_count = null; + } + for (snapshot.pending) |*pending| pending.observed_at_ms = null; + for (snapshot.publication_backlog) |*fact| fact.deinit(alloc); + if (snapshot.publication_backlog.len > 0) { + alloc.free(snapshot.publication_backlog); + } + snapshot.publication_backlog = &.{}; + if (snapshot.incidents.len > 0) alloc.free(snapshot.incidents); + snapshot.incidents = &.{}; +} + /// True when a durable session can still contribute profile usage that is not /// proven present in the profile ledger. pub fn needsProfileRecovery(snapshot: Snapshot) bool { diff --git a/src/core/session/snapshot_binary.zig b/src/core/session/snapshot_binary.zig new file mode 100644 index 0000000000..4c91b9ae61 --- /dev/null +++ b/src/core/session/snapshot_binary.zig @@ -0,0 +1,297 @@ +const std = @import("std"); + +const Allocator = std.mem.Allocator; +const max_container_items: usize = 1024 * 1024; +const max_decoded_allocation_bytes: usize = 256 * 1024 * 1024; + +fn isArrayList(comptime T: type) bool { + return @typeInfo(T) == .@"struct" and + @hasField(T, "items") and + @hasField(T, "capacity") and + std.mem.find(u8, @typeName(T), "array_list") != null; +} + +fn writeInt(writer: *std.Io.Writer, comptime T: type, value: T) !void { + var bytes: [@sizeOf(T)]u8 = undefined; + std.mem.writeInt(T, &bytes, value, .little); + try writer.writeAll(&bytes); +} + +fn encodeValue(writer: *std.Io.Writer, comptime T: type, value: T) !void { + switch (@typeInfo(T)) { + .bool => try writer.writeByte(@intFromBool(value)), + .int => try writeInt(writer, T, value), + .float => { + const U = std.meta.Int(.unsigned, @bitSizeOf(T)); + try writeInt(writer, U, @bitCast(value)); + }, + .@"enum" => try writeInt(writer, u32, @intCast(@intFromEnum(value))), + .optional => |info| if (value) |present| { + try writer.writeByte(1); + try encodeValue(writer, info.child, present); + } else try writer.writeByte(0), + .array => |info| for (value) |item| try encodeValue(writer, info.child, item), + .pointer => |info| switch (info.size) { + .slice => { + if (info.child != u8 and value.len > max_container_items) { + return error.SnapshotContainerTooLarge; + } + try writeInt(writer, u64, @intCast(value.len)); + if (info.child == u8) { + try writer.writeAll(value); + } else { + for (value) |item| try encodeValue(writer, info.child, item); + } + }, + else => @compileError("unsupported snapshot pointer " ++ @typeName(T)), + }, + .@"struct" => |info| { + if (comptime isArrayList(T)) { + try encodeValue(writer, @TypeOf(value.items), value.items); + return; + } + inline for (info.fields) |field| { + if (!field.is_comptime) try encodeValue( + writer, + field.type, + @field(value, field.name), + ); + } + }, + .@"union" => |info| { + const Tag = info.tag_type orelse + @compileError("untagged snapshot union " ++ @typeName(T)); + const tag = std.meta.activeTag(value); + try encodeValue(writer, Tag, tag); + inline for (info.fields) |field| { + if (tag == @field(Tag, field.name)) { + try encodeValue(writer, field.type, @field(value, field.name)); + return; + } + } + unreachable; + }, + .void => {}, + else => @compileError("unsupported snapshot type " ++ @typeName(T)), + } +} + +pub fn encode(alloc: Allocator, value: anytype) ![]u8 { + var output: std.Io.Writer.Allocating = .init(alloc); + errdefer output.deinit(); + try encodeValue(&output.writer, @TypeOf(value), value); + return output.toOwnedSlice(); +} + +const Cursor = struct { + bytes: []const u8, + pos: usize = 0, + allocated_bytes: usize = 0, + + fn take(self: *Cursor, len: usize) ![]const u8 { + if (len > self.bytes.len - self.pos) return error.InvalidSnapshot; + defer self.pos += len; + return self.bytes[self.pos..][0..len]; + } + + fn readInt(self: *Cursor, comptime T: type) !T { + const bytes = try self.take(@sizeOf(T)); + return std.mem.readInt(T, bytes[0..@sizeOf(T)], .little); + } + + fn reserveAllocation( + self: *Cursor, + comptime T: type, + len: usize, + ) !void { + const bytes = std.math.mul(usize, @sizeOf(T), len) catch + return error.InvalidSnapshot; + self.allocated_bytes = std.math.add( + usize, + self.allocated_bytes, + bytes, + ) catch return error.InvalidSnapshot; + if (self.allocated_bytes > max_decoded_allocation_bytes) { + return error.InvalidSnapshot; + } + } +}; + +fn decodeValue(alloc: Allocator, cursor: *Cursor, comptime T: type) !T { + return switch (@typeInfo(T)) { + .bool => switch ((try cursor.take(1))[0]) { + 0 => false, + 1 => true, + else => error.InvalidSnapshot, + }, + .int => try cursor.readInt(T), + .float => blk: { + const U = std.meta.Int(.unsigned, @bitSizeOf(T)); + break :blk @bitCast(try cursor.readInt(U)); + }, + .@"enum" => std.enums.fromInt(T, try cursor.readInt(u32)) orelse + error.InvalidSnapshot, + .optional => |info| switch ((try cursor.take(1))[0]) { + 0 => null, + 1 => try decodeValue(alloc, cursor, info.child), + else => error.InvalidSnapshot, + }, + .array => |info| blk: { + var result: T = undefined; + for (&result) |*item| item.* = try decodeValue(alloc, cursor, info.child); + break :blk result; + }, + .pointer => |info| switch (info.size) { + .slice => blk: { + const len = std.math.cast(usize, try cursor.readInt(u64)) orelse + return error.InvalidSnapshot; + if (info.child != u8 and len > max_container_items) { + return error.InvalidSnapshot; + } + try cursor.reserveAllocation(info.child, len); + const result = try alloc.alloc(info.child, len); + errdefer alloc.free(result); + if (info.child == u8) { + @memcpy(result, try cursor.take(len)); + break :blk result; + } + var initialized: usize = 0; + errdefer { + for (result[0..initialized]) |*item| deinit(alloc, info.child, item); + } + for (result) |*item| { + item.* = try decodeValue(alloc, cursor, info.child); + initialized += 1; + } + break :blk result; + }, + else => @compileError("unsupported snapshot pointer " ++ @typeName(T)), + }, + .@"struct" => |info| blk: { + if (comptime isArrayList(T)) { + var result: T = .empty; + result.items = try decodeValue(alloc, cursor, @TypeOf(result.items)); + result.capacity = result.items.len; + break :blk result; + } + var result: T = undefined; + var initialized: usize = 0; + errdefer inline for (info.fields, 0..) |field, index| { + if (!field.is_comptime and index < initialized) { + deinit(alloc, field.type, &@field(result, field.name)); + } + }; + inline for (info.fields) |field| { + if (!field.is_comptime) { + @field(result, field.name) = try decodeValue(alloc, cursor, field.type); + initialized += 1; + } + } + break :blk result; + }, + .@"union" => |info| blk: { + const Tag = info.tag_type orelse + @compileError("untagged snapshot union " ++ @typeName(T)); + const tag = try decodeValue(alloc, cursor, Tag); + inline for (info.fields) |field| { + if (tag == @field(Tag, field.name)) { + break :blk @unionInit( + T, + field.name, + try decodeValue(alloc, cursor, field.type), + ); + } + } + return error.InvalidSnapshot; + }, + .void => {}, + else => @compileError("unsupported snapshot type " ++ @typeName(T)), + }; +} + +pub fn decode(alloc: Allocator, comptime T: type, bytes: []const u8) !T { + var cursor = Cursor{ .bytes = bytes }; + var value = try decodeValue(alloc, &cursor, T); + errdefer deinit(alloc, T, &value); + if (cursor.pos != bytes.len) return error.InvalidSnapshot; + return value; +} + +pub fn deinit(alloc: Allocator, comptime T: type, value: *T) void { + switch (@typeInfo(T)) { + .optional => |info| if (value.*) |*present| deinit(alloc, info.child, present), + .array => |info| for (value) |*item| deinit(alloc, info.child, item), + .pointer => |info| if (info.size == .slice) { + const mutable = @constCast(value.*); + if (info.child != u8) { + for (mutable) |*item| deinit(alloc, info.child, item); + } + alloc.free(mutable); + }, + .@"struct" => |info| { + if (comptime isArrayList(T)) { + const Item = @typeInfo(@TypeOf(value.items)).pointer.child; + for (value.items) |*item| deinit(alloc, Item, item); + value.deinit(alloc); + return; + } + inline for (info.fields) |field| { + if (!field.is_comptime) deinit( + alloc, + field.type, + &@field(value.*, field.name), + ); + } + }, + .@"union" => |info| { + const Tag = info.tag_type.?; + const tag = std.meta.activeTag(value.*); + inline for (info.fields) |field| { + if (tag == @field(Tag, field.name)) { + deinit(alloc, field.type, &@field(value.*, field.name)); + return; + } + } + }, + else => {}, + } + value.* = undefined; +} + +test "snapshot binary round trips nested owned values" { + const Value = struct { + name: []const u8, + values: []const u32, + selected: ?u32, + }; + const source = Value{ + .name = "session", + .values = &.{ 3, 5, 8 }, + .selected = 5, + }; + const bytes = try encode(std.testing.allocator, source); + defer std.testing.allocator.free(bytes); + var decoded = try decode(std.testing.allocator, Value, bytes); + defer deinit(std.testing.allocator, Value, &decoded); + try std.testing.expectEqualStrings(source.name, decoded.name); + try std.testing.expectEqualSlices(u32, source.values, decoded.values); + try std.testing.expectEqual(source.selected, decoded.selected); +} + +test "snapshot binary rejects truncation" { + const bytes = try encode(std.testing.allocator, @as([]const u8, "payload")); + defer std.testing.allocator.free(bytes); + try std.testing.expectError( + error.InvalidSnapshot, + decode(std.testing.allocator, []const u8, bytes[0 .. bytes.len - 1]), + ); +} + +test "snapshot binary rejects oversized container counts before allocation" { + var bytes: [8]u8 = undefined; + std.mem.writeInt(u64, &bytes, max_container_items + 1, .little); + try std.testing.expectError( + error.InvalidSnapshot, + decode(std.testing.allocator, []u64, &bytes), + ); +} diff --git a/src/main.zig b/src/main.zig index 87b5ca0b4c..d1942a57bf 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1266,9 +1266,22 @@ const App = struct { ); } + pub fn beginCompleteResumeProjection(self: *App) !ResumeProjection { + var projection = try ResumeProjection.initComplete( + self.alloc, + &self.shell, + io_mod.milliTimestamp(), + self.next_diff_id, + ); + errdefer projection.deinit(); + try projection.clonePendingDiffs(self.diff_entries.items); + return projection; + } + pub fn installResumeProjectionRetained( self: *App, projection: *ResumeProjection, + streamed_history_guard_rows: u16, ) !void { const c_alloc = std.heap.c_allocator; try self.diff_entries.ensureUnusedCapacity( @@ -1280,7 +1293,10 @@ const App = struct { } projection.pending_diffs.clearRetainingCapacity(); self.next_diff_id = projection.next_diff_id; - try projection.installRetained(&self.shell); + try projection.installRetained( + &self.shell, + streamed_history_guard_rows, + ); } pub fn historicalToolActivityKind( diff --git a/src/ui/shell_runtime.zig b/src/ui/shell_runtime.zig index b68a83267b..170389d60a 100644 --- a/src/ui/shell_runtime.zig +++ b/src/ui/shell_runtime.zig @@ -524,7 +524,8 @@ fn syncUpdatesEnabledForValues(override: ?[]const u8, term: ?[]const u8) bool { fn historyResetUsesRisForValues(term_program: ?[]const u8, tmux: ?[]const u8) bool { return tmux == null and term_program != null and - std.mem.eql(u8, term_program.?, "Apple_Terminal"); + (std.mem.eql(u8, term_program.?, "Apple_Terminal") or + std.ascii.eqlIgnoreCase(term_program.?, "ghostty")); } fn vminIndex() usize { @@ -551,10 +552,11 @@ test "sync updates override beats dumb term" { try std.testing.expect(!syncUpdatesEnabledForValues(null, "dumb")); } -test "direct Apple Terminal uses RIS for terminal history resets" { +test "direct terminals that need a hard history reset use RIS" { try std.testing.expect(historyResetUsesRisForValues("Apple_Terminal", null)); try std.testing.expect(!historyResetUsesRisForValues("Apple_Terminal", "/tmp/tmux-1/default,1,0")); - try std.testing.expect(!historyResetUsesRisForValues("Ghostty", null)); + try std.testing.expect(historyResetUsesRisForValues("ghostty", null)); + try std.testing.expect(!historyResetUsesRisForValues("ghostty", "/tmp/tmux-1/default,1,0")); } const TestPty = struct { diff --git a/src/ui/transcript/resume_projection.zig b/src/ui/transcript/resume_projection.zig index a8fd2ae42f..031e06a2b6 100644 --- a/src/ui/transcript/resume_projection.zig +++ b/src/ui/transcript/resume_projection.zig @@ -6,6 +6,7 @@ const command_output_content = @import("../../core/tooling/command_output_conten const command_output_runtime = @import("command_output_runtime.zig"); const transcript_painter = @import("painter.zig"); const presentation_record = @import("presentation_record.zig"); +const resume_snapshot = @import("resume_snapshot.zig"); const source_preparation = @import("source_preparation.zig"); const transcript_runtime = @import("runtime.zig"); const transcript_store = @import("store.zig"); @@ -48,7 +49,7 @@ pub const ResumeProjection = struct { ) !ResumeProjection { var detached = try transcript_store.cloneDetachedPresentationState(source, alloc); errdefer detached.deinit(alloc); - const retention_cap = detached.max_retained_transcript_bytes; + const retention_cap = source.configuredTranscriptRetentionCap(); var postlude_entries: std.ArrayList(transcript_runtime.TranscriptEntry) = .empty; if (capture_postlude and detached.entries.items.len > 0) { postlude_entries = detached.entries; @@ -84,6 +85,31 @@ pub const ResumeProjection = struct { ); } + pub fn initComplete( + alloc: Allocator, + source: *TranscriptRuntime, + created_at_ms: i64, + next_diff_id: u32, + ) !ResumeProjection { + var detached = try transcript_store.cloneDetachedPresentationState( + source, + alloc, + ); + errdefer detached.deinit(alloc); + const retention_cap = source.configuredTranscriptRetentionCap(); + detached.max_retained_transcript_bytes = std.math.maxInt(usize); + detached.restoreHistoricalToolTurnWatermark( + historicalToolTurnWatermark(detached.tool_details.items), + ); + return .{ + .runtime = detached, + .alloc = alloc, + .created_at_ms = created_at_ms, + .retention_cap = retention_cap, + .next_diff_id = next_diff_id, + }; + } + pub fn deinit(self: *ResumeProjection) void { if (!self.consumed) self.runtime.deinit(self.alloc); for (self.postlude_entries.items) |*entry| entry.deinit(self.alloc); @@ -351,7 +377,7 @@ pub const ResumeProjection = struct { } pub fn finalize(self: *ResumeProjection) !void { - return self.finalizeForPresentation(false); + return self.finalizeForPresentation(false, false); } pub fn finalizeForResume( @@ -359,20 +385,24 @@ pub const ResumeProjection = struct { postlude_persisted: bool, ) !void { if (postlude_persisted) try self.appendPostlude(); - return self.finalize(); + return self.finalizeForPresentation(false, true); } /// A live projection may end at a valid command-output prefix. Keep that /// block open so later output and its terminal event can continue it. pub fn finalizeLivePresentation(self: *ResumeProjection) !void { - return self.finalizeForPresentation(true); + return self.finalizeForPresentation(true, false); } fn finalizeForPresentation( self: *ResumeProjection, allow_open_command_block: bool, + preserve_complete_semantics: bool, ) !void { - return self.finalizeInner(allow_open_command_block) catch |err| switch (err) { + return self.finalizeInner( + allow_open_command_block, + preserve_complete_semantics, + ) catch |err| switch (err) { error.WriteFailed => error.OutOfMemory, else => err, }; @@ -381,6 +411,7 @@ pub const ResumeProjection = struct { fn finalizeInner( self: *ResumeProjection, allow_open_command_block: bool, + preserve_complete_semantics: bool, ) !void { std.debug.assert(!self.finalized); std.debug.assert(!self.consumed); @@ -409,12 +440,15 @@ pub const ResumeProjection = struct { ); self.runtime.max_retained_transcript_bytes = self.retention_cap; - const retention_changed = try self.runtime.enforceStructuredRetentionAndReport( - self.alloc, - null, - ); + const retention_changed = if (preserve_complete_semantics) + transcript_store.retainedStructuredBytes(&self.runtime) > self.retention_cap + else + try self.runtime.enforceStructuredRetentionAndReport( + self.alloc, + null, + ); self.retention_changed = retention_changed; - if (!retention_changed) { + if (!retention_changed or preserve_complete_semantics) { self.runtime.transcript.clearRetainingCapacity(); self.runtime.replaceable_last_line = false; self.runtime.replaceable_start = 0; @@ -471,9 +505,104 @@ pub const ResumeProjection = struct { return self.retention_changed; } + pub fn snapshotView(self: *const ResumeProjection) resume_snapshot.View { + std.debug.assert(self.finalized); + return .{ + .entries = self.runtime.entries.items, + .tool_details = self.runtime.tool_details.items, + .folded_command_blocks = self.runtime.folded_command_blocks.items, + .command_output_blocks = self.runtime.command_output_blocks.items, + .command_output_display = self.runtime.command_output_display, + .transcript = self.runtime.transcript.items, + .diffs = self.pending_diffs.items, + .record_cursor = self.record_cursor, + .next_entry_id = self.runtime.next_entry_id, + .next_diff_id = self.next_diff_id, + .last_rendered_cols = self.runtime.last_rendered_cols, + .transcript_cache_origin_untrimmed = self.runtime.transcript_cache_origin_untrimmed, + .replaceable_last_line = self.runtime.replaceable_last_line, + .replaceable_row = self.runtime.replaceable_row, + .replaceable_start = self.runtime.replaceable_start, + .retention_cap = self.retention_cap, + .retention_changed = self.retention_changed, + }; + } + + pub fn adoptSnapshot( + self: *ResumeProjection, + snapshot: *resume_snapshot.Owned, + ) !void { + std.debug.assert(!self.finalized); + std.debug.assert(!self.consumed); + std.debug.assert(self.runtime.entries.items.len == 0); + std.debug.assert(self.runtime.tool_details.items.len == 0); + std.debug.assert(self.runtime.folded_command_blocks.items.len == 0); + std.debug.assert(self.runtime.command_output_blocks.items.len == 0); + std.debug.assert(self.runtime.transcript.items.len == 0); + std.debug.assert(self.pending_diffs.items.len == 0); + + self.runtime.entries = listFromOwnedSlice( + transcript_blocks.TranscriptEntry, + snapshot.entries, + ); + snapshot.entries = &.{}; + self.runtime.tool_details = listFromOwnedSlice( + transcript_blocks.ToolDetailRecord, + snapshot.tool_details, + ); + snapshot.tool_details = &.{}; + self.runtime.folded_command_blocks = listFromOwnedSlice( + command_output_runtime.FoldedCommandBlock, + snapshot.folded_command_blocks, + ); + snapshot.folded_command_blocks = &.{}; + self.runtime.command_output_blocks = listFromOwnedSlice( + command_output_runtime.CommandOutputBlock, + snapshot.command_output_blocks, + ); + snapshot.command_output_blocks = &.{}; + self.runtime.command_output_display = snapshot.command_output_display; + self.runtime.transcript = listFromOwnedSlice(u8, snapshot.transcript); + snapshot.transcript = &.{}; + const installed_diffs = try cloneDiffEntries( + std.heap.c_allocator, + snapshot.diffs, + ); + for (snapshot.diffs) |*entry| entry.deinit(self.alloc); + if (snapshot.diffs.len > 0) self.alloc.free(snapshot.diffs); + snapshot.diffs = &.{}; + self.pending_diffs = listFromOwnedSlice(diff.DiffEntry, installed_diffs); + + self.record_cursor = snapshot.record_cursor; + self.runtime.next_entry_id = snapshot.next_entry_id; + self.next_diff_id = snapshot.next_diff_id; + self.runtime.last_rendered_cols = snapshot.last_rendered_cols; + self.runtime.transcript_cache_origin_untrimmed = + snapshot.transcript_cache_origin_untrimmed; + self.runtime.replaceable_last_line = snapshot.replaceable_last_line; + self.runtime.replaceable_row = snapshot.replaceable_row; + self.runtime.replaceable_start = snapshot.replaceable_start; + self.retention_cap = snapshot.retention_cap; + self.runtime.max_retained_transcript_bytes = snapshot.retention_cap; + self.retention_changed = snapshot.retention_changed; + self.runtime.recomputeCursorFromTranscript(); + self.runtime.restoreHistoricalToolTurnWatermark( + historicalToolTurnWatermark(self.runtime.tool_details.items), + ); + self.publication_source = try self.runtime.prepareTranscriptSource( + self.alloc, + null, + ); + self.finalized = true; + } + /// Installs structured continuation state after historical bytes were /// already written directly to the terminal. No pending source is armed. - pub fn installRetained(self: *ResumeProjection, target: *TranscriptRuntime) !void { + pub fn installRetained( + self: *ResumeProjection, + target: *TranscriptRuntime, + streamed_history_guard_rows: u16, + ) !void { std.debug.assert(self.finalized); std.debug.assert(!self.consumed); std.debug.assert(target.pending_resume_source == null); @@ -497,12 +626,21 @@ pub const ResumeProjection = struct { self.runtime.transcript_cache_origin_untrimmed; target.command_output_display = self.runtime.command_output_display; target.command_output_render = self.runtime.command_output_render; + target.restoreHistoricalToolTurnWatermark( + historicalToolTurnWatermark(target.tool_details.items), + ); target.replaceable_last_line = self.runtime.replaceable_last_line; target.replaceable_row = self.runtime.replaceable_row; target.replaceable_start = self.runtime.replaceable_start; if (target.streamedSessionHistoryActive()) { - target.adoptStreamedSessionHistory(); - try target.establishStreamedRetainedAnchor(self.alloc); + target.adoptStreamedSessionHistoryAt( + self.runtime.last_rendered_cols, + ); + target.retainCompleteStreamedSessionHistory(); + try target.establishStreamedRetainedAnchor( + self.alloc, + streamed_history_guard_rows, + ); } else { target.recomputeCursorFromTranscript(); } @@ -604,6 +742,15 @@ pub const ResumeProjection = struct { return pending; } + pub fn clonePendingDiffs( + self: *ResumeProjection, + source: []const diff.DiffEntry, + ) !void { + std.debug.assert(self.pending_diffs.items.len == 0); + const cloned = try cloneDiffEntries(std.heap.c_allocator, source); + self.pending_diffs = listFromOwnedSlice(diff.DiffEntry, cloned); + } + fn appendPostlude(self: *ResumeProjection) !void { _ = try self.appendPostludeTo(&self.runtime); } @@ -627,6 +774,54 @@ pub const ResumeProjection = struct { } }; +fn listFromOwnedSlice(comptime T: type, items: []T) std.ArrayList(T) { + return .{ .items = items, .capacity = items.len }; +} + +fn historicalToolTurnWatermark( + details: []const transcript_blocks.ToolDetailRecord, +) u64 { + var watermark: u64 = 0; + for (details) |detail| { + if (detail.presentation_group_id) |group| { + watermark = @max(watermark, group.turn_id); + } + if (detail.lifecycle_id) |lifecycle| { + watermark = @max(watermark, lifecycle.turn_id); + } + } + return watermark; +} + +fn cloneDiffEntries( + alloc: Allocator, + source: []const diff.DiffEntry, +) ![]diff.DiffEntry { + const entries = try alloc.alloc(diff.DiffEntry, source.len); + var initialized: usize = 0; + errdefer { + for (entries[0..initialized]) |*entry| entry.deinit(alloc); + alloc.free(entries); + } + for (source, entries) |from, *to| { + to.* = .{ .id = from.id }; + if (from.full) |full| { + const content = try alloc.dupe(u8, full.content); + errdefer alloc.free(content); + const call_id = try alloc.dupe(u8, full.lifecycle_id.call_id); + to.full = .{ + .content = content, + .lifecycle_id = .{ + .turn_id = full.lifecycle_id.turn_id, + .call_id = call_id, + }, + }; + } + initialized += 1; + } + return entries; +} + fn setEntryId(entry: *transcript_runtime.TranscriptEntry, id: u32) void { switch (entry.*) { .raw_bytes => |*value| value.id = id, @@ -797,7 +992,7 @@ test "exact resume retains structured startup presentation outside the historica try target.enableShadowVt(alloc); target.adoptStreamedSessionHistory(); target.resumePresentationRecordAt(64, projection.recordCursor()); - try projection.installRetained(&target); + try projection.installRetained(&target, 0); var metrics: types.Metrics = .{}; try projection.installPostludeRetained(&target, &metrics); @@ -906,7 +1101,7 @@ test "resume projection can install retained continuation without publication" { defer projection.deinit(); _ = try projection.appendRawClassified("retained marker\n", .unknown_raw); try projection.finalize(); - try projection.installRetained(&target); + try projection.installRetained(&target, 0); try std.testing.expectEqual(@as(usize, 0), target.pendingResumeFlow().len); try std.testing.expectEqual(@as(usize, 1), target.entries.items.len); @@ -921,6 +1116,70 @@ test "resume projection can install retained continuation without publication" { try std.testing.expectEqual(@as(u16, 5), stable.cursor_col); } +test "binary resume projection installs retained continuation without replay" { + const alloc = std.testing.allocator; + var source: TranscriptRuntime = .{}; + source.layout = .{ + .rows = 24, + .cols = 72, + .content_bottom = 10, + .divider_top_row = 21, + .input_row = 22, + .divider_bottom_row = 23, + .hint_row = 24, + }; + defer source.deinit(alloc); + + var built = try ResumeProjection.initEmpty(alloc, &source, 42, 1); + defer built.deinit(); + for (0..30) |index| { + var line_buf: [64]u8 = undefined; + const line = try std.fmt.bufPrint( + &line_buf, + "binary retained marker {d}\n", + .{index}, + ); + _ = try built.appendRawClassified(line, .unknown_raw); + } + try built.finalize(); + const bytes = try resume_snapshot.encode(alloc, built.snapshotView()); + defer alloc.free(bytes); + var decoded = try resume_snapshot.decode(alloc, bytes); + defer decoded.deinit(alloc); + + var restored = try ResumeProjection.initEmpty(alloc, &source, 43, 1); + defer restored.deinit(); + try restored.adoptSnapshot(&decoded); + + var target: TranscriptRuntime = .{}; + target.layout = source.layout; + defer target.deinit(alloc); + try target.enableShadowVt(alloc); + target.adoptStreamedSessionHistory(); + target.resumePresentationRecordAt(64, restored.recordCursor()); + try restored.installRetained(&target, 6); + + try std.testing.expectEqual(@as(usize, 30), target.entries.items.len); + try std.testing.expect(std.mem.find( + u8, + target.transcript.items, + "binary retained marker 29", + ) != null); + try std.testing.expectEqual(@as(usize, 0), target.pendingResumeFlow().len); + var installed_source = try target.prepareTranscriptSource(alloc, null); + defer installed_source.deinit(alloc); + const stable = target.stableTranscriptProjectionForFlow( + installed_source.bytes, + ) orelse return error.MissingStreamedTranscriptAnchor; + try std.testing.expectEqual( + stable.visual_offset, + stable.history_visual_offset, + ); + try std.testing.expect( + stable.total_visual_rows - stable.visual_offset <= 4, + ); +} + test "resume projection transfers a complete standalone runtime" { const alloc = std.testing.allocator; var source: TranscriptRuntime = .{}; diff --git a/src/ui/transcript/resume_snapshot.zig b/src/ui/transcript/resume_snapshot.zig new file mode 100644 index 0000000000..c48f4a2c93 --- /dev/null +++ b/src/ui/transcript/resume_snapshot.zig @@ -0,0 +1,232 @@ +const std = @import("std"); +const diff = @import("../../core/output/diff.zig"); +const snapshot_binary = @import("../../core/session/snapshot_binary.zig"); +const transcript_blocks = @import("../render_engine/transcript_blocks.zig"); +const command_output_runtime = @import("command_output_runtime.zig"); +const presentation_record = @import("presentation_record.zig"); + +const Allocator = std.mem.Allocator; +const max_retention_cap: usize = 64 * 1024 * 1024; + +pub const View = struct { + entries: []const transcript_blocks.TranscriptEntry, + tool_details: []const transcript_blocks.ToolDetailRecord, + folded_command_blocks: []const command_output_runtime.FoldedCommandBlock, + command_output_blocks: []const command_output_runtime.CommandOutputBlock, + command_output_display: transcript_blocks.CommandOutputDisplayState, + transcript: []const u8, + diffs: []const diff.DiffEntry, + record_cursor: presentation_record.Cursor, + next_entry_id: u32, + next_diff_id: u32, + last_rendered_cols: u16, + transcript_cache_origin_untrimmed: bool, + replaceable_last_line: bool, + replaceable_row: u16, + replaceable_start: usize, + retention_cap: usize, + retention_changed: bool, +}; + +pub const Owned = struct { + entries: []transcript_blocks.TranscriptEntry, + tool_details: []transcript_blocks.ToolDetailRecord, + folded_command_blocks: []command_output_runtime.FoldedCommandBlock, + command_output_blocks: []command_output_runtime.CommandOutputBlock, + command_output_display: transcript_blocks.CommandOutputDisplayState, + transcript: []u8, + diffs: []diff.DiffEntry, + record_cursor: presentation_record.Cursor, + next_entry_id: u32, + next_diff_id: u32, + last_rendered_cols: u16, + transcript_cache_origin_untrimmed: bool, + replaceable_last_line: bool, + replaceable_row: u16, + replaceable_start: usize, + retention_cap: usize, + retention_changed: bool, + + pub fn deinit(self: *Owned, alloc: Allocator) void { + for (self.entries) |*entry| entry.deinit(alloc); + if (self.entries.len > 0) alloc.free(self.entries); + for (self.tool_details) |*detail| detail.deinit(alloc); + if (self.tool_details.len > 0) alloc.free(self.tool_details); + for (self.folded_command_blocks) |*block| block.deinit(alloc); + if (self.folded_command_blocks.len > 0) { + alloc.free(self.folded_command_blocks); + } + for (self.command_output_blocks) |*block| block.deinit(alloc); + if (self.command_output_blocks.len > 0) { + alloc.free(self.command_output_blocks); + } + if (self.transcript.len > 0) alloc.free(self.transcript); + for (self.diffs) |*entry| entry.deinit(alloc); + if (self.diffs.len > 0) alloc.free(self.diffs); + self.* = undefined; + } +}; + +pub fn encode(alloc: Allocator, snapshot: View) ![]u8 { + return snapshot_binary.encode(alloc, snapshot); +} + +pub fn decode(alloc: Allocator, bytes: []const u8) !Owned { + var snapshot = snapshot_binary.decode(alloc, Owned, bytes) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.InvalidResumeSnapshot, + }; + errdefer snapshot.deinit(alloc); + try validate(snapshot); + return snapshot; +} + +fn validate(snapshot: Owned) !void { + if (snapshot.next_entry_id == 0 or snapshot.next_diff_id == 0 or + snapshot.last_rendered_cols == 0 or + snapshot.retention_cap == 0 or + snapshot.retention_cap > max_retention_cap or + snapshot.replaceable_start > snapshot.transcript.len or + snapshot.replaceable_row == 0) + { + return error.InvalidResumeSnapshot; + } + var prior_entry_id: u32 = 0; + for (snapshot.entries) |entry| { + const entry_id = entry.id(); + if (entry_id == 0 or entry_id <= prior_entry_id or + entry_id >= snapshot.next_entry_id) + { + return error.InvalidResumeSnapshot; + } + prior_entry_id = entry_id; + } + var prior_detail_id: u32 = 0; + for (snapshot.tool_details) |detail| { + if (detail.entry_id == 0 or detail.entry_id <= prior_detail_id or + detail.entry_id >= snapshot.next_entry_id) + { + return error.InvalidResumeSnapshot; + } + if (detail.command_output_entry_id) |entry_id| { + if (entry_id == 0 or entry_id >= snapshot.next_entry_id) { + return error.InvalidResumeSnapshot; + } + } + prior_detail_id = detail.entry_id; + } + for (snapshot.folded_command_blocks) |block| { + if (block.summary_transcript_index > snapshot.transcript.len) { + return error.InvalidResumeSnapshot; + } + if (block.summary_entry_id) |entry_id| { + if (entry_id == 0 or entry_id >= snapshot.next_entry_id) { + return error.InvalidResumeSnapshot; + } + } + } + for (snapshot.command_output_blocks) |block| { + if (block.entry_id) |entry_id| { + if (entry_id == 0 or entry_id >= snapshot.next_entry_id) { + return error.InvalidResumeSnapshot; + } + } + for (block.live_entry_ids.items) |entry_id| { + if (entry_id == 0 or entry_id >= snapshot.next_entry_id) { + return error.InvalidResumeSnapshot; + } + } + for (block.source_entry_ids.items) |entry_id| { + if (entry_id == 0 or entry_id >= snapshot.next_entry_id) { + return error.InvalidResumeSnapshot; + } + } + } + if (snapshot.command_output_display.open_block) |index| { + if (index >= snapshot.folded_command_blocks.len) { + return error.InvalidResumeSnapshot; + } + } + if (snapshot.command_output_display.open_command_block) |index| { + if (index >= snapshot.command_output_blocks.len) { + return error.InvalidResumeSnapshot; + } + } + for (snapshot.diffs) |entry| { + if (entry.id == 0 or entry.id >= snapshot.next_diff_id) { + return error.InvalidResumeSnapshot; + } + } + switch (snapshot.record_cursor) { + .start => {}, + .after => |cursor| if (cursor.entry_id == 0 or + cursor.entry_id >= snapshot.next_entry_id) + { + return error.InvalidResumeSnapshot; + }, + .at => |cursor| if (cursor.entry_id == 0 or + cursor.entry_id >= snapshot.next_entry_id or + cursor.cols != snapshot.last_rendered_cols) + { + return error.InvalidResumeSnapshot; + }, + } +} + +test "retained snapshot rejects trailing bytes" { + const source = View{ + .entries = &.{}, + .tool_details = &.{}, + .folded_command_blocks = &.{}, + .command_output_blocks = &.{}, + .command_output_display = .{}, + .transcript = "tail", + .diffs = &.{}, + .record_cursor = .start, + .next_entry_id = 1, + .next_diff_id = 1, + .last_rendered_cols = 80, + .transcript_cache_origin_untrimmed = true, + .replaceable_last_line = false, + .replaceable_row = 1, + .replaceable_start = 0, + .retention_cap = 1024, + .retention_changed = false, + }; + const bytes = try encode(std.testing.allocator, source); + defer std.testing.allocator.free(bytes); + const invalid = try std.mem.concat(std.testing.allocator, u8, &.{ bytes, "x" }); + defer std.testing.allocator.free(invalid); + try std.testing.expectError( + error.InvalidResumeSnapshot, + decode(std.testing.allocator, invalid), + ); +} + +test "retained snapshot round trips an owned value" { + const source = View{ + .entries = &.{}, + .tool_details = &.{}, + .folded_command_blocks = &.{}, + .command_output_blocks = &.{}, + .command_output_display = .{}, + .transcript = "tail", + .diffs = &.{}, + .record_cursor = .start, + .next_entry_id = 1, + .next_diff_id = 1, + .last_rendered_cols = 80, + .transcript_cache_origin_untrimmed = true, + .replaceable_last_line = false, + .replaceable_row = 1, + .replaceable_start = 0, + .retention_cap = 1024, + .retention_changed = false, + }; + const bytes = try encode(std.testing.allocator, source); + defer std.testing.allocator.free(bytes); + var restored = try decode(std.testing.allocator, bytes); + defer restored.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("tail", restored.transcript); + try std.testing.expectEqual(@as(u16, 80), restored.last_rendered_cols); +} diff --git a/src/ui/transcript/runtime.zig b/src/ui/transcript/runtime.zig index b47ae4a5c8..d2cb1a216e 100644 --- a/src/ui/transcript/runtime.zig +++ b/src/ui/transcript/runtime.zig @@ -4268,8 +4268,11 @@ pub const TranscriptRuntime = struct { /// `viewport_top_row`. has_painted_transcript: bool = false, streamed_session_history_active: bool = false, + streamed_session_history_cols: u16 = 0, + streamed_session_retention_cap: ?usize = null, streamed_reset_requested: bool = false, streamed_reset_source_ready: bool = false, + streamed_reset_requires_reflow: bool = false, footer_viewport: footer_viewport_runtime.FooterViewport = .{}, extra_input_rows: u16 = 0, footer_reserved_base_rows: u16 = 3, @@ -4340,7 +4343,15 @@ pub const TranscriptRuntime = struct { } pub fn adoptStreamedSessionHistory(self: *TranscriptRuntime) void { + self.adoptStreamedSessionHistoryAt(self.layout.cols); + } + + pub fn adoptStreamedSessionHistoryAt( + self: *TranscriptRuntime, + cols: u16, + ) void { self.streamed_session_history_active = true; + self.streamed_session_history_cols = cols; const shadow = self.shadow_vt orelse return; self.cursor_row = shadow.cursor_row; self.cursor_col = shadow.cursor_col; @@ -4353,6 +4364,45 @@ pub const TranscriptRuntime = struct { return self.streamed_session_history_active; } + pub fn streamedSessionHistoryMatchesCurrentWidth( + self: *const TranscriptRuntime, + ) bool { + return self.streamed_session_history_active and + self.streamed_session_history_cols == self.layout.cols; + } + + pub fn configuredTranscriptRetentionCap( + self: *const TranscriptRuntime, + ) usize { + return self.streamed_session_retention_cap orelse + self.max_retained_transcript_bytes; + } + + pub fn retainsCompleteStreamedSessionHistory( + self: *const TranscriptRuntime, + ) bool { + return self.streamed_session_history_active and + self.streamed_session_retention_cap != null; + } + + pub fn retainCompleteStreamedSessionHistory( + self: *TranscriptRuntime, + ) void { + std.debug.assert(self.streamed_session_history_active); + if (self.streamed_session_retention_cap == null) { + self.streamed_session_retention_cap = + self.max_retained_transcript_bytes; + } + self.max_retained_transcript_bytes = std.math.maxInt(usize); + } + + pub fn commitStreamedSessionHistoryReflow( + self: *TranscriptRuntime, + ) void { + std.debug.assert(self.streamed_session_history_active); + self.streamed_session_history_cols = self.layout.cols; + } + pub fn streamedResetRequested(self: *const TranscriptRuntime) bool { return self.streamed_reset_requested; } @@ -4362,15 +4412,37 @@ pub const TranscriptRuntime = struct { self.streamed_reset_source_ready; } + pub fn streamedResetRequiresReflow(self: *const TranscriptRuntime) bool { + return self.streamed_reset_requested and + self.streamed_reset_requires_reflow; + } + + pub fn requestStreamedSessionWidthReflow( + self: *TranscriptRuntime, + ) void { + if (!self.streamed_session_history_active or + self.streamedSessionHistoryMatchesCurrentWidth()) + { + return; + } + self.streamed_reset_requested = true; + self.streamed_reset_source_ready = true; + self.streamed_reset_requires_reflow = true; + self.invalidateTranscriptAnchor("streamed_session_startup_width_reflow"); + self.markTranscriptDirty(); + } + pub fn finishStreamedResetAttempt(self: *TranscriptRuntime) void { self.streamed_reset_requested = false; self.streamed_reset_source_ready = false; + self.streamed_reset_requires_reflow = false; self.terminal_reset_pending = false; } pub fn establishStreamedRetainedAnchor( self: *TranscriptRuntime, alloc: Allocator, + streamed_history_guard_rows: u16, ) !void { if (!self.streamed_session_history_active) return; const shadow = self.shadow_vt orelse return error.ShadowTerminalUnavailable; @@ -4392,6 +4464,45 @@ pub const TranscriptRuntime = struct { ); defer prepared.deinit(alloc); + const total_visual_rows = prepared.sourceTotalVisualRows(); + var visual_offset = visualOffsetForSelection( + prepared.sourceVisualRows(), + prepared.selection, + ); + const guarded_visual_offset = @min( + total_visual_rows, + visual_offset +| streamed_history_guard_rows, + ); + debug_trace.logf( + "session", + "event=streamed_anchor_align cols={d} guard_rows={d} shadow={d},{d} top={d} content_bottom={d} base_offset={d} target_offset={d} total_rows={d} selection={d}..{d} selection_last={d} lines={d}", + .{ + self.layout.cols, + streamed_history_guard_rows, + shadow.cursor_row, + shadow.cursor_col, + top, + self.layout.content_bottom, + visual_offset, + guarded_visual_offset, + total_visual_rows, + prepared.selection.top_row, + prepared.selection.bottom_row, + prepared.selection.last_visible_row, + prepared.selection.line_count, + }, + ); + if (guarded_visual_offset > visual_offset) { + try transcript_painter.reprojectPreparedTranscriptForVisualOffset( + alloc, + self.layout, + &prepared, + .{ .top = top, .bottom = self.layout.content_bottom }, + guarded_visual_offset, + ); + visual_offset = guarded_visual_offset; + } + const flow = try alloc.dupe(u8, source.bytes); errdefer alloc.free(flow); const row_provenance = try alloc.dupe( @@ -4399,11 +4510,6 @@ pub const TranscriptRuntime = struct { prepared.row_provenance.items, ); errdefer if (row_provenance.len > 0) alloc.free(row_provenance); - const visual_offset = visualOffsetForSelection( - prepared.sourceVisualRows(), - prepared.selection, - ); - const total_visual_rows = prepared.sourceTotalVisualRows(); const footer_rows = @min( self.layout.rows, self.footer_reserved_base_rows +| 1 +| self.extra_input_rows, @@ -4910,10 +5016,25 @@ pub const TranscriptRuntime = struct { return self.lifecycle_state.finalized_turn_watermark; } + pub fn restoreHistoricalToolTurnWatermark( + self: *TranscriptRuntime, + watermark: u64, + ) void { + std.debug.assert(self.lifecycle_state.activeCount() == 0); + self.lifecycle_state.finalized_turn_watermark = watermark; + self.lifecycle_state.batch_finalized_turn_watermark = null; + } + pub fn clearTranscript(self: *TranscriptRuntime, alloc: Allocator) void { self.streamed_session_history_active = false; + self.streamed_session_history_cols = 0; + if (self.streamed_session_retention_cap) |cap| { + self.max_retained_transcript_bytes = cap; + self.streamed_session_retention_cap = null; + } self.streamed_reset_requested = false; self.streamed_reset_source_ready = false; + self.streamed_reset_requires_reflow = false; const pending_resume_bytes = self.releasePendingResumeSource(alloc); if (pending_resume_bytes > 0) { debug_trace.logf( @@ -6652,11 +6773,19 @@ pub const TranscriptRuntime = struct { history_row_delta: ?i32, ) !void { if (self.streamed_session_history_active) { + const requires_reflow = + self.streamed_session_history_cols != self.layout.cols; const source_ready = if (self.streamed_reset_requested) self.streamed_reset_source_ready + else if (requires_reflow and + self.retainsCompleteStreamedSessionHistory()) + true else self.presentationRecordReadyForReset(); self.streamed_reset_requested = true; + self.streamed_reset_requires_reflow = + self.streamed_reset_requires_reflow or + requires_reflow; self.reflow_clear_guard_rows = 0; self.resize_history_row_delta = null; self.pending_resize_observation = null; @@ -11639,11 +11768,76 @@ test "settled streamed session resize requests one file backed reset without cle try std.testing.expectEqual(@as(?i32, null), runtime.resize_history_row_delta); try std.testing.expect(!runtime.render_requests.pending_settled_width_reflow); try std.testing.expect(runtime.render_requests.hasReason(.transcript)); + try std.testing.expect(!runtime.streamedResetRequiresReflow()); runtime.finishStreamedResetAttempt(); try std.testing.expect(!runtime.streamedResetRequested()); } +test "settled streamed width change requests semantic reflow" { + var metrics: Metrics = .{}; + var runtime = TranscriptRuntime{ + .layout = invalidationTestLayout(), + .owned_top_row = 1, + .viewport_top_row = 1, + }; + defer runtime.deinit(std.testing.allocator); + runtime.adoptStreamedSessionHistory(); + runtime.resumePresentationRecord(10); + runtime.layout.cols = 60; + + try runtime.requestTerminalResetAfterResize(&metrics, null); + + try std.testing.expect(runtime.streamedResetRequested()); + try std.testing.expect(runtime.streamedResetSourceReady()); + try std.testing.expect(runtime.streamedResetRequiresReflow()); + runtime.finishStreamedResetAttempt(); + try std.testing.expect(!runtime.streamedResetRequiresReflow()); +} + +test "streamed session schedules semantic reset when startup width differs" { + var runtime = TranscriptRuntime{ .layout = invalidationTestLayout() }; + defer runtime.deinit(std.testing.allocator); + runtime.adoptStreamedSessionHistoryAt(runtime.layout.cols - 1); + runtime.retainCompleteStreamedSessionHistory(); + + runtime.requestStreamedSessionWidthReflow(); + + try std.testing.expect(runtime.streamedResetRequested()); + try std.testing.expect(runtime.streamedResetSourceReady()); + try std.testing.expect(runtime.streamedResetRequiresReflow()); + try std.testing.expect(runtime.render_requests.hasReason(.transcript)); +} + +test "streamed session keeps complete semantics and restores its retention cap" { + const alloc = std.testing.allocator; + var runtime = TranscriptRuntime{ .layout = invalidationTestLayout() }; + defer runtime.deinit(alloc); + const semantic = try alloc.dupe(u8, "semantic source\n"); + try runtime.entries.append(alloc, .{ .raw_bytes = .{ + .id = runtime.next_entry_id, + .bytes = semantic, + } }); + runtime.next_entry_id += 1; + try runtime.transcript.appendSlice(alloc, "cached tail\n"); + const configured_cap = runtime.max_retained_transcript_bytes; + runtime.adoptStreamedSessionHistory(); + runtime.retainCompleteStreamedSessionHistory(); + + var prepared = try runtime.prepareTranscriptSource(alloc, null); + defer prepared.deinit(alloc); + try std.testing.expect(std.mem.find(u8, prepared.bytes, "semantic source") != null); + try std.testing.expectEqual(std.math.maxInt(usize), runtime.max_retained_transcript_bytes); + + runtime.layout.cols -= 1; + var reflowed = try runtime.prepareTranscriptSource(alloc, null); + defer reflowed.deinit(alloc); + try std.testing.expect(std.mem.find(u8, reflowed.bytes, "semantic source") != null); + + runtime.clearTranscript(alloc); + try std.testing.expectEqual(configured_cap, runtime.max_retained_transcript_bytes); +} + test "armed streamed reset keeps its ready source until the attempt finishes" { var metrics: Metrics = .{}; var runtime = TranscriptRuntime{ diff --git a/tests/e2e/tui-resume-brutal.test.ts b/tests/e2e/tui-resume-brutal.test.ts index afc04e5efd..c69b073774 100644 --- a/tests/e2e/tui-resume-brutal.test.ts +++ b/tests/e2e/tui-resume-brutal.test.ts @@ -560,8 +560,14 @@ async function runStress(config: Config): Promise { const middleMarker = `RESUME_CHAT_${pad(Math.floor(config.chatBatches * config.chatLinesPerBatch / 2))}`; const tailMarker = `RESUME_CHAT_${pad(config.chatBatches * config.chatLinesPerBatch - 1)}`; const restoredScrollbackPath = join(paths.root, "restored-scrollback.txt"); - await session.captureFullScrollbackToFile(restoredScrollbackPath); const expectedSessionLines = config.chatBatches * config.chatLinesPerBatch; + await waitForExactSessionScrollback( + session, + restoredScrollbackPath, + expectedSessionLines, + [headMarker, middleMarker, tailMarker, FINAL_MARKER], + TIMEOUT * 20, + ); expect(sessionMarkerCounts(restoredScrollbackPath)).toEqual({ total: expectedSessionLines, unique: expectedSessionLines, diff --git a/tests/e2e/tui-resume.test.ts b/tests/e2e/tui-resume.test.ts index e21e1e89df..36ca5c5f7c 100644 --- a/tests/e2e/tui-resume.test.ts +++ b/tests/e2e/tui-resume.test.ts @@ -4577,6 +4577,8 @@ test.skipIf(!tmuxAvailable())( const sessionRoot = join(home, ".fx", "sessions", sessionId); const transcriptPath = join(sessionRoot, "transcript.ansi"); const metadataPath = join(sessionRoot, "transcript.meta"); + const snapshotPath = join(sessionRoot, "resume.snapshot"); + expect(existsSync(snapshotPath)).toBe(true); rmSync(transcriptPath); rmSync(metadataPath); @@ -4605,17 +4607,30 @@ test.skipIf(!tmuxAvailable())( const exactGateway = startFakeGateway([]); gateways.push(exactGateway); + const exactTracePath = join(root, "exact-resume-trace.log"); writeFileSync(stderrPath, ""); active = await TmuxSession.create({ cmd: `${FX_BIN} resume ${sessionId}`, cwd: workspace, - env: gatewayEnv(home, exactGateway), + env: { + ...gatewayEnv(home, exactGateway), + FX_TRACE_LOG: exactTracePath, + FX_TRACE_SCOPES: "session", + }, stderrPath, }); await active.waitForComposer(TIMEOUT); const exactScrollback = await waitForScrollback(active, `${marker}_TAIL`); expect(countOccurrences(exactScrollback, `${marker}_HEAD`)).toBe(1); expect(countOccurrences(exactScrollback, `${marker}_TAIL`)).toBe(1); + await waitForCondition( + () => existsSync(exactTracePath) && + readFileSync(exactTracePath, "utf8").includes( + "event=session_replay source=resume_snapshot", + ), + "the exact resume snapshot trace", + TIMEOUT, + ); expect(readFileSync(stderrPath, "utf8")).toBe(""); await active.sendText("/quit"); expect(await active.waitForSessionEnd()).toBe(true); @@ -5285,11 +5300,16 @@ test.skipIf(!tmuxAvailable())( const flagGateway = startFakeGateway([fakeGatewayFinalText(flagFollowUp)]); gateways.push(flagGateway); + const flagTracePath = join(root, "flag-resume-trace.log"); writeFileSync(stderrPath, ""); active = await TmuxSession.create({ cmd: `${FX_BIN} --resume-last`, cwd: workspaceRoot, - env: gatewayEnv(home, flagGateway), + env: { + ...gatewayEnv(home, flagGateway), + FX_TRACE_LOG: flagTracePath, + FX_TRACE_SCOPES: "session,resize", + }, stderrPath, width: 100, height: 32, @@ -5298,6 +5318,16 @@ test.skipIf(!tmuxAvailable())( const flagResumed = await waitForScrollback(active, completion); expectCompactQuestion(flagResumed); await expectQuestionDetail(active); + await active.resizeWindow(80, 30); + await waitForCondition( + () => existsSync(flagTracePath) && + readFileSync(flagTracePath, "utf8").includes( + "event=session_transcript_reset outcome=committed", + ), + "the exact resume transcript reset", + TIMEOUT, + ); + await active.waitForComposer(TIMEOUT); await active.sendText("Continue the resumed flag session."); await active.waitForText(flagFollowUp, TIMEOUT); expect(active.isPaneAlive()).toBe(true); @@ -6176,11 +6206,22 @@ test.skipIf(!tmuxAvailable())( const lateMarker = "SESSION_PICKER_SCROLLBACK_LATE"; const continuationMarker = "SESSION_PICKER_SCROLLBACK_CONTINUATION"; const launchMarker = "SESSION_PICKER_SCROLLBACK_LAUNCH"; + const reflowMarker = "WHOLE_RESIZE_TOKEN"; + const reflowLine = + `RESIZE_REFLOW semantic wrapping keeps this marker whole: ${reflowMarker}`; + const startupReflowLine = + "STARTUP_REFLOW semantic history expands this sentence cleanly at the wider width"; const lines = Array.from( { length: 80 }, (_, index) => `SESSION_PICKER_SCROLLBACK_LINE_${String(index).padStart(3, "0")} has enough text to wrap in the terminal viewport.`, ); lines.unshift(earlyMarker); + lines.splice( + 1, + 0, + reflowLine, + startupReflowLine, + ); lines.push(lateMarker); const transcript = lines.join("\n"); const gateways: Array> = []; @@ -6228,35 +6269,37 @@ test.skipIf(!tmuxAvailable())( cwd: workspaceRoot, env: { ...gatewayEnv(home, resumedGateway), + TMUX: undefined, + TMUX_PANE: undefined, + TERM_PROGRAM: "ghostty", + TERM: "xterm-ghostty", FX_RECORD: tapePath, FX_TRACE_LOG: tracePath, FX_TRACE_SCOPES: "session,resize,frame_commit", }, stderrPath, - width: 80, + width: 88, height: 24, }); await active.waitForComposer(TIMEOUT); + await waitForCondition( + () => existsSync(tracePath) && + readFileSync(tracePath, "utf8").includes( + "event=session_transcript_reset source=semantic_reflow cols=88", + ) && + readFileSync(tracePath, "utf8").includes( + "event=session_transcript_reset outcome=committed", + ), + "the startup width reflow", + TIMEOUT, + ); const resumed = await waitForScrollback(active, earlyMarker); expect(resumed).toContain(earlyMarker); expect(countOccurrences(resumed, earlyMarker)).toBe(1); expect(countOccurrences(resumed, lateMarker)).toBe(1); - expect(resumed).toContain(`${launchMarker}_24`); - const resumedRows = resumed.split("\n"); - const launchRow = resumedRows.findIndex((row) => - row.includes(`${launchMarker}_24`) - ); - const transcriptRow = resumedRows.findIndex((row) => - row.includes("Save a long transcript for resume.") - ); - expect(launchRow).toBeGreaterThanOrEqual(0); - expect(transcriptRow).toBeGreaterThan(launchRow); - expect( - resumedRows - .slice(launchRow + 1, transcriptRow) - .filter((row) => row.trim().length === 0).length, - ).toBeLessThanOrEqual(2); + expect(resumed).toContain(startupReflowLine); + expect(resumed).not.toContain(`${launchMarker}_24`); expect(countOccurrences(resumed, "direct-resume-scroll.fxtape")).toBe(1); const tape = readFileSync(tapePath); expect(tape.includes(Buffer.from("\x1b[?1002h"))).toBe(false); @@ -6268,6 +6311,12 @@ test.skipIf(!tmuxAvailable())( await active.sendText("Continue after restoring native scrollback."); await active.waitForText(continuationMarker, TIMEOUT); await active.waitForComposer(TIMEOUT); + const resizeCommitMarker = + "event=session_transcript_reset outcome=committed"; + const priorNarrowCommits = countOccurrences( + readFileSync(tracePath, "utf8"), + resizeCommitMarker, + ); await active.resizeWindow(72, 20); const resized = await active.waitForStableScrollback( (scrollback) => @@ -6279,10 +6328,40 @@ test.skipIf(!tmuxAvailable())( expect(countOccurrences(resized, earlyMarker)).toBe(1); expect(countOccurrences(resized, lateMarker)).toBe(1); expect(countOccurrences(resized, continuationMarker)).toBe(1); - expect(countOccurrences(resized, "Recording:")).toBe(2); + expect(countOccurrences(resized, reflowMarker)).toBe(1); expect(readFileSync(tracePath, "utf8")).toContain( - "event=session_transcript_reset outcome=committed", + resizeCommitMarker, + ); + + await waitForCondition( + () => countOccurrences( + readFileSync(tracePath, "utf8"), + resizeCommitMarker, + ) > priorNarrowCommits, + "the narrow exact resume transcript reset", + TIMEOUT, + ); + const priorWideCommits = countOccurrences( + readFileSync(tracePath, "utf8"), + resizeCommitMarker, + ); + await active.resizeWindow(80, 24); + await waitForCondition( + () => countOccurrences( + readFileSync(tracePath, "utf8"), + resizeCommitMarker, + ) > priorWideCommits, + "the wide exact resume transcript reset", + TIMEOUT, ); + const widened = await active.waitForStableScrollback( + (scrollback) => + countOccurrences(scrollback, earlyMarker) === 1 && + countOccurrences(scrollback, lateMarker) === 1 && + countOccurrences(scrollback, continuationMarker) === 1, + TIMEOUT, + ); + expect(widened).toContain(reflowLine); await active.sendText("/quit"); expect(await active.waitForSessionEnd()).toBe(true); @@ -6371,7 +6450,10 @@ test.skipIf(!tmuxAvailable())( expect(countOccurrences(resized, lateMarker)).toBe(1); expect(countOccurrences(resized, "SESSION_PICKER_ACTIVE_STREAM")).toBe(1); expect(readFileSync(tracePath, "utf8")).toContain( - "event=session_transcript_reset outcome=unavailable reason=record_not_caught_up", + "event=session_transcript_reset source=semantic_reflow", + ); + expect(readFileSync(tracePath, "utf8")).toContain( + "event=session_transcript_reset outcome=committed", ); await active.sendKeys("Escape"); From 3cde6e01d81daf67087ece4a8406a471595fda77 Mon Sep 17 00:00:00 2001 From: Pranit Date: Wed, 2 Sep 2026 01:12:48 -0400 Subject: [PATCH 12/12] Observe resume snapshots in lifecycle tests --- tests/e2e/tui-gateway-stream-lifecycle.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/tui-gateway-stream-lifecycle.test.ts b/tests/e2e/tui-gateway-stream-lifecycle.test.ts index ae865a80da..7c7f689668 100644 --- a/tests/e2e/tui-gateway-stream-lifecycle.test.ts +++ b/tests/e2e/tui-gateway-stream-lifecycle.test.ts @@ -2591,14 +2591,14 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { () => existsSync(sessionsRoot) && readdirSync(sessionsRoot).some((entry) => - existsSync(join(sessionsRoot, entry, "checkpoint.json")), + existsSync(join(sessionsRoot, entry, "resume.snapshot")), ), - "session checkpoint", + "session resume snapshot", ); const sessionId = readdirSync(sessionsRoot).find((entry) => - existsSync(join(sessionsRoot, entry, "checkpoint.json")), + existsSync(join(sessionsRoot, entry, "resume.snapshot")), ); - if (!sessionId) throw new Error("session checkpoint was not found"); + if (!sessionId) throw new Error("session resume snapshot was not found"); const readSavedSession = () => execFileSync(FX_BIN, ["session", "--id", sessionId, "--json"], {