From c4d7f70ff831cfcdad0f2a6224f9a14f86905de6 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 2 Jul 2026 10:29:53 -0600 Subject: Stage and apply event-field overrides at tool lifecycle boundaries Stage writable event-field overrides by tool call id so they can be applied at the correct point in the turn lifecycle. Capture input overrides from tool_call_complete, capture output overrides from tool_result, and clear staged overrides defensively at turn start and shutdown. Also honor user_message text overrides when starting a turn, add the helper that applies staged overrides to the agent at dispatch boundaries, and cover the behavior with tests for staged input/output overrides. --- src/tui_app.zig | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 2 deletions(-) (limited to 'src/tui_app.zig') diff --git a/src/tui_app.zig b/src/tui_app.zig index 88c2c66..d097225 100644 --- a/src/tui_app.zig +++ b/src/tui_app.zig @@ -245,6 +245,14 @@ const Entry = struct { } }; +/// A staged `tool_result` output override: the replacement text a handler +/// assigned to `ev.output`, keyed by the call it belongs to. Both slices +/// owned by the App's allocator. +const PendingOutputOverride = struct { + id: []u8, + text: []u8, +}; + // =========================================================================== // App // =========================================================================== @@ -279,6 +287,19 @@ pub const App = struct { /// tool output starts collapsed to its last few lines. tools_collapsed: bool = true, + /// Staged writable-event-field overrides (taken off the bus right after + /// the corresponding fire), waiting for the turn driver — which owns the + /// agent — to apply them to the conversation: + /// - input overrides: tool_use_id → replacement input JSON, staged at + /// `tool_call_complete` (mid-stream), applied at `tool_dispatch_start` + /// once the assistant message is committed but before dispatch; + /// - output overrides: staged at `tool_result`, applied right after + /// `tool_dispatch_complete` routing, before the next provider call. + /// Keys and values owned by `alloc`. Cleared as applied (and defensively + /// at turn start). + pending_input_overrides: std.StringHashMapUnmanaged([]u8) = .empty, + pending_output_overrides: std.ArrayListUnmanaged(PendingOutputOverride) = .empty, + /// Optional override-release hook. When a slot's `override` is REPLACED by /// a newer override (a second handler swap on the same slot), the old /// override is no longer referenced by panto and its OWNER must release it. @@ -340,6 +361,24 @@ pub const App = struct { self.transcript.deinit(self.alloc); self.router.deinit(); self.bus.deinit(); + self.clearPendingOverrides(); + self.pending_input_overrides.deinit(self.alloc); + self.pending_output_overrides.deinit(self.alloc); + } + + /// Drop all staged (unapplied) writable-field overrides. + fn clearPendingOverrides(self: *App) void { + var it = self.pending_input_overrides.iterator(); + while (it.next()) |entry| { + self.alloc.free(entry.key_ptr.*); + self.alloc.free(entry.value_ptr.*); + } + self.pending_input_overrides.clearRetainingCapacity(); + for (self.pending_output_overrides.items) |po| { + self.alloc.free(po.id); + self.alloc.free(po.text); + } + self.pending_output_overrides.clearRetainingCapacity(); } /// Access the event bus so the embedder (and, later, the Lua bridge) can @@ -641,6 +680,42 @@ pub const App = struct { } } _ = try self.fireForEntry(entry, lc, name, enriched); + + // Writable-field overrides: take what a handler wrote during this + // fire and stage it by call id for the turn driver (which owns the + // agent) to apply at the effective boundary. An override without a + // resolved call id has nothing to attach to and is dropped. + const is_result = std.mem.eql(u8, name, "tool_result"); + const is_call_complete = std.mem.eql(u8, name, "tool_call_complete"); + if (!is_result and !is_call_complete) return; + const value = self.bus.takeOverride() orelse return; + const id = enriched.tool.id; + if (id.len == 0) { + self.alloc.free(value); + return; + } + if (is_result) { + const id_copy = self.alloc.dupe(u8, id) catch |e| { + self.alloc.free(value); + return e; + }; + try self.pending_output_overrides.append(self.alloc, .{ .id = id_copy, .text = value }); + } else { + const gop = self.pending_input_overrides.getOrPut(self.alloc, id) catch |e| { + self.alloc.free(value); + return e; + }; + if (gop.found_existing) { + self.alloc.free(gop.value_ptr.*); + } else { + gop.key_ptr.* = self.alloc.dupe(u8, id) catch |e| { + _ = self.pending_input_overrides.remove(id); + self.alloc.free(value); + return e; + }; + } + gop.value_ptr.* = value; + } } /// Fire a thinking/assistant lifecycle event for the entry backing a @@ -1029,6 +1104,7 @@ pub const App = struct { try self.fireToolLifecycle(box, .tool_result, "tool_result", .{ .tool = .{ .tool_name = if (box.name) |n| n.items else "", .id = tr.tool_use_id, + .input = box.input.items, .output = text.items, } }); any = true; @@ -1043,6 +1119,9 @@ pub const App = struct { /// the chat history); only the block-index map is cleared. pub fn beginTurn(self: *App) void { self.router.reset(); + // Defensive: an interrupted turn may have staged overrides it never + // applied; they must not leak into this turn's calls. + self.clearPendingOverrides(); } /// Surface a turn error as a dim status line in the transcript. @@ -1934,7 +2013,13 @@ fn handleSubmittedLine(app: *App, term: *Terminal, line: []const u8, opts: RunOp } // Model turn. Echo the user message, then pump the stream into components. - try app.spawnUser(line); + try app.spawnUser(line); // fires `user_message` + // A `user_message` handler may have overridden `ev.text`: the echo above + // keeps what the user typed; the override is what the turn sends. (The + // fire itself clears any stale override a replayed user_message left.) + const text_override = app.bus.takeOverride(); + defer if (text_override) |t| app.alloc.free(t); + const turn_text = text_override orelse line; app.beginTurn(); try app.renderNow(); @@ -1947,7 +2032,7 @@ fn handleSubmittedLine(app: *App, term: *Terminal, line: []const u8, opts: RunOp return; }; - driveTurn(app, term, opts, line) catch |err| { + driveTurn(app, term, opts, turn_text) catch |err| { try app.routeError(err); }; try app.renderNow(); @@ -2069,6 +2154,7 @@ fn driveTurn(app: *App, term: *Terminal, opts: RunOptions, message_text: []const }; const e = ev orelse break; try app.routeEvent(e); + try applyPendingOverrides(app, opts.agent, e); _ = try app.maybeRender(); if (try pumpTurnKeys(app, term, &turn_input_tail)) { interrupted = true; @@ -2084,6 +2170,36 @@ fn driveTurn(app: *App, term: *Terminal, opts: RunOptions, message_text: []const } } +/// Apply staged writable-event-field overrides at their effective stream +/// boundaries. Runs right after `routeEvent` (which is where the fires that +/// stage them happen), still before the next `stream.next()` — i.e. before +/// the agent advances — so the mutation is authoritative for dispatch, the +/// next provider request, and turn persistence: +/// - `tool_dispatch_start`: the assistant message is committed but tools +/// have not run — rewrite ToolUse inputs staged at `tool_call_complete`. +/// - `tool_dispatch_complete`: results are in the conversation tail — +/// rewrite ToolResult text staged at `tool_result`. +/// An override whose call id no longer matches (e.g. a stale stage after a +/// reopen) is dropped silently — the conversation stays untouched. +fn applyPendingOverrides(app: *App, agent: *panto.Agent, ev: Event) !void { + switch (ev) { + .tool_dispatch_start => { + defer app.clearPendingOverrides(); + var it = app.pending_input_overrides.iterator(); + while (it.next()) |entry| { + _ = try agent.overrideToolUseInput(entry.key_ptr.*, entry.value_ptr.*); + } + }, + .tool_dispatch_complete => { + defer app.clearPendingOverrides(); + for (app.pending_output_overrides.items) |po| { + _ = try agent.overrideToolResultOutput(po.id, po.text); + } + }, + else => {}, + } +} + /// Service app-level keybindings while a turn is in flight. Returns true when /// the user requested an interrupt (Escape). Text-editing keys are ignored: the /// input box is restored only after the interrupted turn is back in user-input @@ -2967,6 +3083,60 @@ test "event wiring: tool lifecycle events each fire EXACTLY ONCE at their bounda try testing.expectEqual(@as(usize, 1), counter.result); } +test "writable-field overrides are staged by call id at tool_call_complete and tool_result" { + const alloc = testing.allocator; + const h = try Harness.make(alloc); + defer h.teardown(alloc); + + // A native handler standing in for a Lua `ev.input = ...` / `ev.output + // = ...` assignment (the bridge routes those to the same bus slot). + const Writer = struct { + bus: *EventBus, + fn cb(ctx: *anyopaque, ev: *ui_event.Event) void { + const w: *@This() = @ptrCast(@alignCast(ctx)); + if (std.mem.eql(u8, ev.name, "tool_call_complete")) { + w.bus.setOverride("{\"a\":2}") catch {}; + } else if (std.mem.eql(u8, ev.name, "tool_result")) { + w.bus.setOverride("redacted") catch {}; + } + } + }; + var w = Writer{ .bus = &h.app.bus }; + try h.app.bus.on("tool_call_complete", .{ .ctx = &w, .callback = Writer.cb }); + try h.app.bus.on("tool_result", .{ .ctx = &w, .callback = Writer.cb }); + + try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } }); + try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "a", .name = "read" } }); + var tu = panto.ToolUseBlock{ + .id = try alloc.dupe(u8, "a"), + .name = try alloc.dupe(u8, "read"), + }; + defer tu.deinit(alloc); + try tu.input.appendSlice(alloc, "{\"a\":1}"); + try h.app.routeEvent(.{ .block_complete = .{ .index = 0, .block = .{ .ToolUse = tu } } }); + + // The input override is staged, keyed by the resolved call id. + try testing.expectEqual(@as(usize, 1), h.app.pending_input_overrides.count()); + try testing.expectEqualStrings("{\"a\":2}", h.app.pending_input_overrides.get("a").?); + + var msg: panto.Message = .{ .role = .user }; + defer msg.deinit(alloc); + var parts: std.ArrayList(panto.ResultPartStored) = .empty; + var text: panto.TextualBlock = .empty; + try text.appendSlice(alloc, "out"); + try parts.append(alloc, .{ .text = text }); + try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "a"), .parts = parts } }); + try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } }); + + // The output override is staged with the id it belongs to. + try testing.expectEqual(@as(usize, 1), h.app.pending_output_overrides.items.len); + try testing.expectEqualStrings("a", h.app.pending_output_overrides.items[0].id); + try testing.expectEqualStrings("redacted", h.app.pending_output_overrides.items[0].text); + + // Nothing left on the bus once staged. + try testing.expect(h.app.bus.takeOverride() == null); +} + test "event wiring: thinking lifecycle fires start + per-delta + complete" { const alloc = testing.allocator; const h = try Harness.make(alloc); -- cgit v1.3