diff --git a/TUI/app.zig b/TUI/app.zig index d203e029..e56f3a1f 100644 --- a/TUI/app.zig +++ b/TUI/app.zig @@ -9,7 +9,7 @@ const theme_mod = @import("theme.zig"); pub const Screen = enum { welcome, agent }; pub const Focus = enum { prompt, scrollback }; -pub const Overlay = enum { none, palette, help, theme, model, effort, settings, rewind, slash, debug, image, file, jump }; +pub const Overlay = enum { none, palette, help, theme, model, effort, settings, rewind, slash, debug, image, file, jump, resume_pick }; /// One vocabulary with the engine (#551): the Model does not keep a private /// copy of the permission policy, it holds the engine's own enum and hands it /// straight to the turn backend. @@ -168,6 +168,7 @@ pub const Model = struct { hist_idx: ?usize = null, /// Newline-joined paths for the @-file picker, loaded once per session. files_cache: ?[]const u8 = null, + sessions_cache: ?[]const u8 = null, toast: []const u8 = "", toast_until_ms: u64 = 0, @@ -244,6 +245,7 @@ pub const Model = struct { } if (self.overlay_filter.len > 0) self.alloc.free(self.overlay_filter); if (self.files_cache) |f| self.alloc.free(f); + if (self.sessions_cache) |s| self.alloc.free(s); if (self.sel_text.len > 0) self.alloc.free(self.sel_text); if (self.osc_pending.len > 0) self.alloc.free(self.osc_pending); self.input.deinit(); diff --git a/TUI/catalog.zig b/TUI/catalog.zig index 71cde7b1..bee5d143 100644 --- a/TUI/catalog.zig +++ b/TUI/catalog.zig @@ -13,6 +13,7 @@ pub const items = [_]Item{ .{ .name = "/new", .desc = "Start a fresh session", .aliases = &.{"/clear"} }, .{ .name = "/home", .desc = "Return to the welcome screen", .aliases = &.{"/welcome"} }, .{ .name = "/compact", .desc = "Engine-compact model-visible history" }, + .{ .name = "/resume", .desc = "Resume a saved session", .aliases = &.{"/sessions"} }, .{ .name = "/context", .desc = "Show context-window use" }, .{ .name = "/session-info", .desc = "Session details", .aliases = &.{ "/status", "/info" } }, .{ .name = "/usage", .desc = "Token usage and cost", .aliases = &.{"/cost"} }, diff --git a/TUI/dispatch.zig b/TUI/dispatch.zig index 65ecbc58..4498da22 100644 --- a/TUI/dispatch.zig +++ b/TUI/dispatch.zig @@ -60,7 +60,7 @@ pub fn runCommand(self: *Model, line: []const u8) Effect { // #521: history-destroying commands must not run under a live job — the // steer guard in promptKey only covers plain text, and the slash menu, // palette, and steer drain all land here. - const destroys = std.mem.eql(u8, canon, "/new") or std.mem.eql(u8, canon, "/compact") or std.mem.eql(u8, canon, "/rewind"); + const destroys = std.mem.eql(u8, canon, "/new") or std.mem.eql(u8, canon, "/compact") or std.mem.eql(u8, canon, "/rewind") or std.mem.eql(u8, canon, "/resume"); if (self.pending != null and destroys) { self.push(.system, "a turn is still running — press Esc to cancel it first") catch {}; return .stay; @@ -83,6 +83,9 @@ pub fn runCommand(self: *Model, line: []const u8) Effect { self.focus = .prompt; } else if (std.mem.eql(u8, canon, "/rewind")) { rewind(self); + } else if (std.mem.eql(u8, canon, "/resume")) { + const res = @import("resume.zig"); + if (arg.len == 0) res.open(self) else res.resumeByName(self, arg); } else if (std.mem.eql(u8, canon, "/compact")) { compact(self); } else if (std.mem.eql(u8, canon, "/help")) { diff --git a/TUI/dispatch_tests.zig b/TUI/dispatch_tests.zig index 77de3cf5..6f64df91 100644 --- a/TUI/dispatch_tests.zig +++ b/TUI/dispatch_tests.zig @@ -63,7 +63,7 @@ test "a model turn cannot start while /compact is rewriting the history (#533)" try std.testing.expect(m.bg == null); } -test "/new /compact /rewind are blocked while a job is pending (#521)" { +test "/new /compact /rewind /resume are blocked while a job is pending (#521)" { var m: Model = undefined; m.setup(std.testing.allocator); defer m.deinit(); @@ -83,6 +83,7 @@ test "/new /compact /rewind are blocked while a job is pending (#521)" { try std.testing.expect(std.mem.indexOf(u8, m.history.items[1].text, "still running") != null); _ = dispatch.runCommand(&m, "/rewind"); _ = dispatch.runCommand(&m, "/compact"); + _ = dispatch.runCommand(&m, "/resume"); try std.testing.expectEqualStrings("hi", m.history.items[0].text); } diff --git a/TUI/engine.zig b/TUI/engine.zig index 3cf10a83..4ada6995 100644 --- a/TUI/engine.zig +++ b/TUI/engine.zig @@ -144,6 +144,16 @@ pub const CompactOut = struct { turns: []Turn = &.{}, }; pub const CompactFn = *const fn (turn_ctx: ?*anyopaque, gpa: std.mem.Allocator, history: []const Turn, out: *CompactOut) bool; +/// Newline-joined "base\ttitle\tage" rows of saved sessions, gpa-owned — +/// the /resume picker's list (same store the line REPL's /resume reads). +pub const SessionsFn = *const fn (turn_ctx: ?*anyopaque, gpa: std.mem.Allocator) ?[]const u8; +/// Fill `out` with a saved session's user/assistant turns and its saved +/// model name (all gpa-owned; caller frees). False when the load fails. +pub const ResumeOut = struct { + turns: []Turn = &.{}, + model: []const u8 = "", +}; +pub const ResumeFn = *const fn (turn_ctx: ?*anyopaque, gpa: std.mem.Allocator, base: []const u8, out: *ResumeOut) bool; /// The frontend just discarded part of its transcript. The engine owns the /// conversation the model actually sees (#551), so it has to be told: without @@ -234,6 +244,8 @@ pub const RunOpts = struct { copy_fn: ?CopyFn = null, compact_fn: ?CompactFn = null, history_fn: ?HistoryFn = null, + sessions_fn: ?SessionsFn = null, + resume_fn: ?ResumeFn = null, }; pub var g_turn_fn: ?TurnFn = null; @@ -247,6 +259,8 @@ pub var g_files_fn: ?FilesFn = null; pub var g_copy_fn: ?CopyFn = null; pub var g_compact_fn: ?CompactFn = null; pub var g_history_fn: ?HistoryFn = null; +pub var g_sessions_fn: ?SessionsFn = null; +pub var g_resume_fn: ?ResumeFn = null; /// Tell the engine the transcript was cut. Silent when nothing is wired /// (offline TUI, unit tests). diff --git a/TUI/overlaypane.zig b/TUI/overlaypane.zig index cda328b7..bd909188 100644 --- a/TUI/overlaypane.zig +++ b/TUI/overlaypane.zig @@ -82,6 +82,11 @@ fn spec(self: *const Model, a: std.mem.Allocator) !panel.Spec { const head = try files.head(self, a); break :blk .{ .title = head.title, .note = head.note, .footer = files.hint, .body = try files.render(self, a) }; }, + .resume_pick => blk: { + const pick = @import("resume.zig"); + const head = try pick.head(self, a); + break :blk .{ .title = head.title, .note = head.note, .footer = pick.hint, .body = try pick.render(self, a) }; + }, .settings => .{ .title = "Settings", .footer = "↑↓ move · click or Enter changes · Esc", @@ -351,6 +356,7 @@ test "every overlay is a bordered panel with its name in the frame" { .{ .o = .model, .name = "Model" }, .{ .o = .effort, .name = "Effort" }, .{ .o = .file, .name = "File" }, + .{ .o = .resume_pick, .name = "Resume" }, .{ .o = .settings, .name = "Settings" }, .{ .o = .jump, .name = "Jump to turn" }, }; diff --git a/TUI/overlays.zig b/TUI/overlays.zig index a06dc180..52a672be 100644 --- a/TUI/overlays.zig +++ b/TUI/overlays.zig @@ -57,7 +57,7 @@ pub fn key(self: *Model, k: Key) Effect { return .stay; } if (k == .enter) return activate(self); - if (self.overlay == .model or self.overlay == .effort or self.overlay == .file) { + if (self.overlay == .model or self.overlay == .effort or self.overlay == .file or self.overlay == .resume_pick) { switch (k) { .char => |c| self.typeOverlayFilter(c), .backspace => self.backspaceOverlayFilter(), @@ -169,6 +169,13 @@ pub fn rowSpan(self: *const Model) ?Span { if (n == 0) return null; return windowed(frame_rows, n, self.overlay_sel % n, files_mod.visible_rows); }, + .resume_pick => { + const pick = @import("resume.zig"); + var rows: [pick.max_rows]pick.Row = undefined; + const n = pick.filterRows(self.sessions_cache orelse "", self.overlay_filter, &rows); + if (n == 0) return null; + return windowed(frame_rows, n, self.overlay_sel % n, pick.visible_rows); + }, else => return null, } } @@ -277,6 +284,7 @@ pub fn activate(self: *Model) Effect { self.input.handle(.{ .char = ' ' }); self.focus = .prompt; }, + .resume_pick => @import("resume.zig").pick(self), .jump => { const total = self.userTurnCount(); const sel = if (total == 0) 0 else self.overlay_sel % total; diff --git a/TUI/resume.zig b/TUI/resume.zig new file mode 100644 index 00000000..de3ce04b --- /dev/null +++ b/TUI/resume.zig @@ -0,0 +1,205 @@ +//! /resume picker: the line REPL's saved sessions (.graff/sessions), listed +//! and loaded through the engine seam so the TUI stays engine-agnostic. +//! Cache rows are "base\ttitle\tage" lines from engine.g_sessions_fn. + +const std = @import("std"); + +const app = @import("app.zig"); +const engine = @import("engine.zig"); +const models = @import("models.zig"); +const panel = @import("panel.zig"); +const theme_mod = @import("theme.zig"); +const Model = app.Model; + +pub const max_rows = 256; +/// Rows the picker draws at once — overlays.rowSpan reads the same window. +pub const visible_rows = 14; + +pub const Row = struct { base: []const u8, title: []const u8, age: []const u8 }; + +fn parseRow(line: []const u8) Row { + var it = std.mem.splitScalar(u8, line, '\t'); + return .{ + .base = it.next() orelse "", + .title = it.next() orelse "", + .age = it.next() orelse "", + }; +} + +pub fn filterRows(cache: []const u8, query: []const u8, out: []Row) usize { + var n: usize = 0; + var it = std.mem.splitScalar(u8, cache, '\n'); + while (it.next()) |line| { + if (line.len == 0) continue; + const r = parseRow(line); + if (!(models.modelMatch(r.base, query) or models.modelMatch(r.title, query))) continue; + if (n >= out.len) break; + out[n] = r; + n += 1; + } + return n; +} + +/// Load the saved-session list once, then open the picker. +pub fn open(self: *Model) void { + if (self.sessions_cache == null) { + if (engine.g_sessions_fn) |f| self.sessions_cache = f(engine.g_turn_ctx, self.alloc); + } + if (self.sessions_cache == null or self.sessions_cache.?.len == 0) { + self.screen = .agent; // welcome hides system rows until a user turn + self.push(.system, "no saved sessions — /save one in the line REPL first") catch {}; + return; + } + self.openOverlay(.resume_pick); +} + +/// The panel's top-edge slots (overlaypane.zig places them). +pub fn head(self: *const Model, a: std.mem.Allocator) !panel.Head { + var rows: [max_rows]Row = undefined; + const cache = self.sessions_cache orelse ""; + const total = filterRows(cache, "", &rows); + const n = filterRows(cache, self.overlay_filter, &rows); + return .{ + .title = try std.fmt.allocPrint(a, "Resume › {s}\u{258B}", .{self.overlay_filter}), + .note = try std.fmt.allocPrint(a, "{d}/{d}", .{ n, total }), + }; +} + +pub const hint = "type to search · ↑↓ move · click or Enter resume · Esc"; + +/// The ROWS only — the frame carries the rest. +pub fn render(self: *const Model, a: std.mem.Allocator) ![]const u8 { + const th = self.theme(); + var rows: [max_rows]Row = undefined; + const n = filterRows(self.sessions_cache orelse "", self.overlay_filter, &rows); + var out = std.array_list.Managed(u8).init(a); + if (n == 0) { + try out.appendSlice(try theme_mod.paint(a, th.muted, " no matches — type to filter")); + try out.append('\n'); + return out.items; + } + const sel = self.overlay_sel % n; + const vis = @min(visible_rows, n); + const off = if (sel >= vis) sel - vis + 1 else 0; + var i = off; + while (i < n and i < off + vis) : (i += 1) { + const mark: []const u8 = if (i == sel) "› " else " "; + const line = if (rows[i].title.len > 0) + try std.fmt.allocPrint(a, "{s}{s} — {s} ({s})", .{ mark, rows[i].base, rows[i].title, rows[i].age }) + else + try std.fmt.allocPrint(a, "{s}{s} ({s})", .{ mark, rows[i].base, rows[i].age }); + try out.appendSlice(if (i == sel) try theme_mod.paint(a, th.accent, line) else try theme_mod.paint(a, th.muted, line)); + try out.append('\n'); + } + if (n > vis) try out.appendSlice(try panel.windowRow(a, th, off, vis, n)); + return out.items; +} + +/// Enter in the picker. +pub fn pick(self: *Model) void { + var rows: [max_rows]Row = undefined; + const n = filterRows(self.sessions_cache orelse "", self.overlay_filter, &rows); + const sel = if (n == 0) 0 else self.overlay_sel % n; + self.closeOverlay(); // keeps sessions_cache, so rows[sel].base stays valid + if (n == 0) return; + resumeByName(self, rows[sel].base); +} + +pub fn resumeByName(self: *Model, base: []const u8) void { + const f = engine.g_resume_fn orelse { + self.push(.system, "resume needs a live session") catch {}; + return; + }; + var out: engine.ResumeOut = .{}; + const ok = f(engine.g_turn_ctx, self.alloc, base, &out); + defer { + for (out.turns) |t| self.alloc.free(t.text); + if (out.turns.len > 0) self.alloc.free(out.turns); + if (out.model.len > 0) self.alloc.free(out.model); + } + if (!ok) { + self.screen = .agent; // welcome hides err rows until a user turn + self.pushFmt(.err, "couldn't resume '{s}' — see /sessions in the line REPL", .{base}) catch {}; + return; + } + self.clearHistory(); + // The engine owns the conversation (#551): drop it so the next turn's + // adopt() seeds from the resumed transcript instead of the old session. + engine.historyChanged(.reset); + self.screen = .agent; + for (out.turns) |t| { + self.push(switch (t.role) { + .user => .user, + .assistant => .assistant, + }, t.text) catch {}; + } + if (out.model.len > 0) { + if (engine.g_model_fn) |mf| { + if (mf(engine.g_turn_ctx, self.alloc, "", out.model)) |got| self.adoptModel(got); + } + } + self.pushFmt(.system, "resumed {s} · {d} turns", .{ base, out.turns.len }) catch {}; + self.scroll = 0; + self.follow = true; +} + +test "filterRows parses tab rows and fuzzy-matches base or title" { + const cache = "fix-login\tFix login bug\t2h ago\nspike\t\tjust now"; + var rows: [8]Row = undefined; + try std.testing.expectEqual(@as(usize, 2), filterRows(cache, "", &rows)); + try std.testing.expectEqual(@as(usize, 1), filterRows(cache, "login", &rows)); + try std.testing.expectEqualStrings("fix-login", rows[0].base); + try std.testing.expectEqualStrings("2h ago", rows[0].age); + try std.testing.expectEqual(@as(usize, 1), filterRows(cache, "spk", &rows)); +} + +test "resume picker loads turns into history and notes the session" { + var m: Model = undefined; + m.setup(std.testing.allocator); + defer m.deinit(); + m.sessions_cache = try std.testing.allocator.dupe(u8, "alpha\tFirst try\t1h ago"); + const Seen = struct { + var reset: bool = false; + fn hist(_: ?*anyopaque, op: engine.HistoryOp) void { + if (op == .reset) reset = true; + } + }; + Seen.reset = false; + engine.g_history_fn = Seen.hist; + defer engine.g_history_fn = null; + engine.g_resume_fn = struct { + fn f(_: ?*anyopaque, gpa: std.mem.Allocator, base: []const u8, out: *engine.ResumeOut) bool { + std.testing.expectEqualStrings("alpha", base) catch return false; + const turns = gpa.alloc(engine.Turn, 2) catch return false; + turns[0] = .{ .role = .user, .text = gpa.dupe(u8, "hi") catch return false }; + turns[1] = .{ .role = .assistant, .text = gpa.dupe(u8, "hello") catch return false }; + out.turns = turns; + return true; + } + }.f; + defer engine.g_resume_fn = null; + m.openOverlay(.resume_pick); + pick(&m); + try std.testing.expect(Seen.reset); + try std.testing.expectEqual(app.Overlay.none, m.overlay); + try std.testing.expectEqual(@as(usize, 3), m.history.items.len); // 2 turns + note + try std.testing.expectEqual(app.EntryKind.user, m.history.items[0].kind); + try std.testing.expectEqualStrings("hello", m.history.items[1].text); + try std.testing.expect(std.mem.indexOf(u8, m.history.items[2].text, "resumed alpha") != null); + try std.testing.expectEqual(app.Screen.agent, m.screen); +} + +test "resume with no saved sessions explains instead of opening an empty picker" { + var m: Model = undefined; + m.setup(std.testing.allocator); + defer m.deinit(); + engine.g_sessions_fn = struct { + fn f(_: ?*anyopaque, gpa: std.mem.Allocator) ?[]const u8 { + return gpa.dupe(u8, "") catch null; + } + }.f; + defer engine.g_sessions_fn = null; + open(&m); + try std.testing.expectEqual(app.Overlay.none, m.overlay); + try std.testing.expect(std.mem.indexOf(u8, m.history.items[0].text, "no saved sessions") != null); +} diff --git a/TUI/root.zig b/TUI/root.zig index fb5cbe62..982e374d 100644 --- a/TUI/root.zig +++ b/TUI/root.zig @@ -31,6 +31,9 @@ pub const CompactOut = engine.CompactOut; pub const CompactFn = engine.CompactFn; pub const HistoryOp = engine.HistoryOp; pub const HistoryFn = engine.HistoryFn; +pub const SessionsFn = engine.SessionsFn; +pub const ResumeOut = engine.ResumeOut; +pub const ResumeFn = engine.ResumeFn; pub const RunOpts = run_mod.RunOpts; pub const run = run_mod.run; pub const restore = @import("restore.zig"); @@ -93,6 +96,7 @@ test { _ = @import("tty.zig"); _ = @import("restore.zig"); _ = @import("files.zig"); + _ = @import("resume.zig"); _ = @import("overlays.zig"); _ = run_mod; _ = @import("paint.zig"); diff --git a/TUI/run.zig b/TUI/run.zig index 20aeee1a..8bee2008 100644 --- a/TUI/run.zig +++ b/TUI/run.zig @@ -55,6 +55,8 @@ pub fn run( engine.g_copy_fn = opts.copy_fn; engine.g_compact_fn = opts.compact_fn; engine.g_history_fn = opts.history_fn; + engine.g_sessions_fn = opts.sessions_fn; + engine.g_resume_fn = opts.resume_fn; engine.g_model_name = opts.model_name; engine.g_model_provider = opts.model_provider; engine.g_model_entries = opts.model_entries; diff --git a/src/test_hooks.zig b/src/test_hooks.zig index 710913e0..13486468 100644 --- a/src/test_hooks.zig +++ b/src/test_hooks.zig @@ -279,4 +279,5 @@ test { _ = sandbox_docker; _ = commands_sandbox; _ = sandbox_tests; + _ = @import("tui_resume.zig"); } diff --git a/src/tui_launch.zig b/src/tui_launch.zig index bd86e9a1..811281a8 100644 --- a/src/tui_launch.zig +++ b/src/tui_launch.zig @@ -116,6 +116,8 @@ pub fn run( .copy_fn = copyCb, .compact_fn = compactCb, .history_fn = historyCb, + .sessions_fn = @import("tui_resume.zig").sessionsCb, + .resume_fn = @import("tui_resume.zig").resumeCb, }); } diff --git a/src/tui_resume.zig b/src/tui_resume.zig new file mode 100644 index 00000000..96b1a2af --- /dev/null +++ b/src/tui_resume.zig @@ -0,0 +1,132 @@ +//! TUI /resume glue: list and load the line REPL's saved sessions +//! (.graff/sessions/*.session.json) for the fullscreen TUI's picker seam. +//! List rows are "base\ttitle\tage" lines, newest first — the same store +//! and metadata the line REPL's /resume and /sessions read. + +const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; +const Value = std.json.Value; + +const repl_glue = @import("repl_glue.zig"); +const ReplCtx = repl_glue.ReplCtx; +const session_index = @import("session_index.zig"); +const tui = @import("tui"); + +/// engine.SessionsFn. gpa-owned; null when the store is missing/empty. +pub fn sessionsCb(ctx_ptr: ?*anyopaque, gpa: Allocator) ?[]const u8 { + const c: *ReplCtx = @ptrCast(@alignCast(ctx_ptr orelse return null)); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const Item = struct { base: []const u8, title: []const u8, updated_ms: i64 }; + var items = std.array_list.Managed(Item).init(arena); + var dir = Io.Dir.cwd().openDir(c.io, session_index.sessions_dir, .{ .iterate = true }) catch return null; + defer dir.close(c.io); + var it = dir.iterate(); + while (it.next(c.io) catch null) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.name, session_index.session_ext)) continue; + const base = arena.dupe(u8, entry.name[0 .. entry.name.len - session_index.session_ext.len]) catch continue; + const path = session_index.sessionPath(arena, base) catch continue; + const data = Io.Dir.cwd().readFileAlloc(c.io, path, arena, .limited(8 * 1024 * 1024)) catch continue; + const meta = session_index.sessionMetaFromBytes(arena, data); + items.append(.{ .base = base, .title = meta.title orelse "", .updated_ms = meta.updated_ms }) catch {}; + } + if (items.items.len == 0) return null; + std.mem.sort(Item, items.items, {}, struct { + fn newerFirst(_: void, a: Item, b: Item) bool { + return a.updated_ms > b.updated_ms; + } + }.newerFirst); + + var out = std.array_list.Managed(u8).init(gpa); + for (items.items) |item| { + const age = session_index.sessionAge(arena, c.io, item.updated_ms); + out.appendSlice(item.base) catch break; + out.append('\t') catch break; + appendSanitized(&out, item.title); + out.append('\t') catch break; + out.appendSlice(age) catch break; + out.append('\n') catch break; + } + return out.toOwnedSlice() catch null; +} + +/// Row fields are tab/newline-delimited; titles may contain either. +fn appendSanitized(out: *std.array_list.Managed(u8), s: []const u8) void { + for (s) |ch| { + out.append(if (ch == '\t' or ch == '\n' or ch == '\r') ' ' else ch) catch return; + } +} + +/// engine.ResumeFn: user/assistant text turns + the saved model name. +/// Tool traffic and non-text blocks are skipped — the TUI's model-visible +/// history is text turns, exactly what its next startJob will resend. +pub fn resumeCb(ctx_ptr: ?*anyopaque, gpa: Allocator, base: []const u8, out: *tui.ResumeOut) bool { + const c: *ReplCtx = @ptrCast(@alignCast(ctx_ptr orelse return false)); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const path = session_index.sessionPath(arena, base) catch return false; + const data = Io.Dir.cwd().readFileAlloc(c.io, path, arena, .limited(32 * 1024 * 1024)) catch return false; + const parsed = std.json.parseFromSliceLeaky(Value, arena, data, .{}) catch return false; + if (parsed != .object) return false; + const msgs = parsed.object.get("messages") orelse return false; + if (msgs != .array) return false; + + var turns = std.array_list.Managed(tui.Turn).init(gpa); + for (msgs.array.items) |m| { + if (m != .object) continue; + const role_v = m.object.get("role") orelse continue; + if (role_v != .string) continue; + const role: tui.Turn.Role = if (std.mem.eql(u8, role_v.string, "user")) + .user + else if (std.mem.eql(u8, role_v.string, "assistant")) + .assistant + else + continue; + const text = textOf(arena, m.object.get("content") orelse continue) orelse continue; + const dup = gpa.dupe(u8, text) catch continue; + turns.append(.{ .role = role, .text = dup }) catch gpa.free(dup); + } + out.turns = turns.toOwnedSlice() catch &.{}; + if (parsed.object.get("model")) |mv| { + if (mv == .string and mv.string.len > 0) out.model = gpa.dupe(u8, mv.string) catch ""; + } + return true; +} + +/// Plain string content, or the joined text blocks of a structured message. +fn textOf(arena: Allocator, v: Value) ?[]const u8 { + if (v == .string) return if (v.string.len > 0) v.string else null; + if (v != .array) return null; + var buf = std.array_list.Managed(u8).init(arena); + for (v.array.items) |part| { + if (part != .object) continue; + const t = part.object.get("type") orelse continue; + if (t != .string or !std.mem.eql(u8, t.string, "text")) continue; + const s = part.object.get("text") orelse continue; + if (s != .string) continue; + if (buf.items.len > 0) buf.append('\n') catch {}; + buf.appendSlice(s.string) catch {}; + } + return if (buf.items.len == 0) null else buf.items; +} + +test "textOf: plain string, anthropic text blocks, tool-only content" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const plain = try std.json.parseFromSliceLeaky(Value, arena, "\"hello\"", .{}); + try std.testing.expectEqualStrings("hello", textOf(arena, plain).?); + const blocks = try std.json.parseFromSliceLeaky(Value, arena, + \\[{"type":"text","text":"a"},{"type":"tool_use","id":"x"},{"type":"text","text":"b"}] + , .{}); + try std.testing.expectEqualStrings("a\nb", textOf(arena, blocks).?); + const tool_only = try std.json.parseFromSliceLeaky(Value, arena, + \\[{"type":"tool_use","id":"x"}] + , .{}); + try std.testing.expect(textOf(arena, tool_only) == null); +}