Skip to content
Closed
50 changes: 15 additions & 35 deletions src/core/app/app_bootstrap_runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -195,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"))
Expand Down Expand Up @@ -317,10 +311,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,
Expand Down Expand Up @@ -466,10 +459,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);
}
};
Expand Down Expand Up @@ -643,8 +632,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 = .{
Expand Down Expand Up @@ -739,14 +727,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 {
Expand Down Expand Up @@ -987,13 +969,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;
Expand All @@ -1006,12 +987,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(),
);
Expand Down
25 changes: 25 additions & 0 deletions src/core/app/app_input_runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down
79 changes: 77 additions & 2 deletions src/core/app/app_lifecycle.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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, .{});
Expand All @@ -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, .{});
Expand All @@ -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(.{});
Expand Down
50 changes: 46 additions & 4 deletions src/core/app/app_render_runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}

Expand Down Expand Up @@ -1836,6 +1836,21 @@ 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 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);
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)
app.terminal.alternate_frame_layout
else
Expand Down Expand Up @@ -2233,10 +2248,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,
};
sequential_reset_armed = true;
} else {
presentation_shell.finishStreamedResetAttempt();
}
}
var transcript_body: render_engine.frame_builder.TranscriptBodyDisposition =
if (transcript_transition) |*transition| switch (transition.body_disposition) {
.paint => .paint,
Expand Down Expand Up @@ -2330,6 +2358,20 @@ 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",
"event=session_transcript_reset outcome=committed bytes={d}",
.{document_append.source.declaredLen()},
);
}
const scroll_commit = result.scrollCommit(scroll_plan);
debug_trace.logf(
"frame_diff",
Expand All @@ -2338,7 +2380,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,
Expand Down
Loading
Loading