Skip to content

plugin: typed session entries + custom renderers — P2 - #39

Closed
yogthos wants to merge 3 commits into
feat/plugin-streaming-hooksfrom
feat/plugin-message-renderers
Closed

plugin: typed session entries + custom renderers — P2#39
yogthos wants to merge 3 commits into
feat/plugin-streaming-hooksfrom
feat/plugin-message-renderers

Conversation

@yogthos

@yogthos yogthos commented May 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Plugins can record typed entries on the session timeline and register
custom renderers to display them. Persists across save/load via a new
`Session::extra_entries` field that defaults on deserialize, so
pre-P2 session files load unchanged.

New harness APIs

```janet
; Record a typed entry. data is opaque; the plugin chose its format.
(harness/append-entry "type" "data" &opt display)

; Register a renderer to format entries of a given type.
(harness/register-renderer "type" "fn-name")

; Inside the renderer, emit one or more colored lines.
(defn fn-name [data]
(harness/render "cyan" (string "★ " data))
(harness/render "white" "details below"))
```

When no renderer is registered for an entry's custom_type (e.g.
because the producing plugin is uninstalled), the host falls back
to a minimal `[entry: TYPE]` header + dim raw-data dump.
display=false entries persist but don't render — useful for
silent plugin state.

Implementation

  • New `PluginEntry` struct in session/mod.rs with serde defaults
    for backward-compatible session JSONL loading.
  • New PluginManager methods: `drain_entries`, `list_renderers`,
    `invoke_renderer`. Janet stores entries as a tab-delimited blob
    with embedded tab/newline/backslash escaping; host unescapes via
    a new `unescape_harness_field` helper.
  • Renderer invocation clears `harness-render-buf`, calls the
    Janet fn, then parses back `color\ttext\n` lines.
  • UI loop top drains entries each tick (same place notifications
    drain), calls `Session::append_plugin_entry`, then renders via
    the registered renderer or default fallback.
  • Color names mapped via `parse_plugin_color` — supports the full
    crossterm palette (black/red/green/yellow/blue/magenta/cyan/white
    • dark variants + grey/gray); unknown names fall back to dim grey.

Known limitation

Entries render at the end of the session view rather than interleaved
with messages by timestamp. Interleaving requires per-message
timestamps which the existing message format doesn't carry today —
deferred to a P2 follow-up.

Test plan

10 new unit tests in plugin::tests:

  • append-entry roundtrip + ordering + display flag
  • non-string args ignored
  • escape round-trip through tab/newline/backslash
  • register-renderer + list_renderers
  • invoke_renderer with multi-line output
  • silent handler returns empty
  • unknown handler returns empty
  • buffer reset between calls
  • `test_unescape_harness_field_roundtrips` (non-gated)
  • `cargo test --features plugin` — 526 pass, 0 fail (was 516).
  • `cargo test` (no plugin) — 461 + 12 pre-existing fails (was 460+12).
  • Clean build both flavors; no new clippy warnings.

`plugins/bookmark.janet` is included as a working example:
`/bookmark ` records a bookmark; a registered renderer
shows it as a cyan `★ ` line in the chat.

Stacked on

Builds on PR #38 (P3 streaming hooks). Targets
`feat/plugin-streaming-hooks` so the diff is just the new APIs

  • persistence, not P0/P3 plumbing.

Refs dirge-u49.

Yogthos added 3 commits May 20, 2026 11:53
Preparatory work for P3 (granular streaming hook events) and P4
(session tree branch-aware accounting). Rig's multi_turn stream is
a flat sequence of assistant content + tool results + a final response;
this commit introduces a pure state machine that detects per-turn
boundaries and emits them on the event channel.

A "turn" = one LLM call + the tool calls it dispatched + the tool
results returning. Pure-text response = 1 turn. Run with two cycles
of tool calls = 2 turns.

Implementation:

* `TurnTracker` (src/agent/runner.rs) is a 3-state state machine —
  Idle, InTurn, AwaitingNext — that consumes observation calls
  (`observe_assistant_content`, `observe_tool_result`,
  `observe_stream_end`) and returns `Vec<Boundary>` describing the
  TurnStart/TurnEnd events to emit.
* The InTurn→AwaitingNext→InTurn transition collapses N parallel
  tool results into a single turn boundary, no matter how many tool
  results arrive between assistant messages.
* Lazy emission: TurnEnd fires either when the next assistant
  content begins (closing the previous turn) or on stream end. This
  avoids needing lookahead to know whether more tool results are
  still coming.
* `run_stream` instantiates one TurnTracker and forwards Boundary
  events through the existing `event_tx` before each assistant-content
  event and at stream termination (Done, Error, Interjected, or
  unexpected stream close).

New events:
* `AgentEvent::TurnStart { index: u32 }` — fires before the first
  assistant content of each turn.
* `AgentEvent::TurnEnd { index: u32 }` — fires after the last event
  of each turn (either before the next TurnStart, or before Done /
  Error / Interjected).

The UI loop currently handles both with a no-op match arm; P3 will
wire them into plugin `on-turn-start` / `on-turn-end` hooks.

Tests: 8 new unit tests on `TurnTracker` in `mod turn_tracker_tests` —
pure-text, single tool call, multi-result collapsing, alternating
turns, empty stream, mid-dispatch end, lone tool result, restart
reuse. Total: 510 pass with plugin feature (was 502); 460 pass
without (was 452); 12 pre-existing plugin-test fails unchanged.

Refs dirge-e17.
Builds on P0's turn-boundary detection. Wires the new
AgentEvent::TurnStart / TurnEnd events into three new plugin hooks:

  on-turn-start     {:index N}
  on-message-update {:index N :partial "text-so-far"}
  on-turn-end       {:index N :message "full turn text"}

These give plugins per-turn observability that wasn't possible from
the previous `on-response` hook alone (which only fires once at the
end of the whole agent run). Typical uses:

* Per-turn cost / latency tracking.
* Token-level filtering or annotation.
* Cancel-on-condition based on streaming output.
* Streaming a live transcript to an external sink.

Implementation:

* `src/ui/streaming.rs` — new TokenBatcher type that collects tokens
  since the last flush and yields the accumulated text once the count
  threshold (DEFAULT_BATCH_TOKENS = 16) is crossed. Count-based
  rather than time-based so the tests are deterministic without
  mocking Instant. Module is cfg-gated to the plugin feature.
* `src/ui/mod.rs`:
  - New per-turn streaming state (token_batcher, current_turn_text,
    current_turn_index), cfg-gated.
  - AgentEvent::Token arm now pushes to the batcher; on flush,
    dispatches `on-message-update` with the accumulated text.
  - AgentEvent::TurnStart arm resets per-turn state and dispatches
    `on-turn-start`.
  - AgentEvent::TurnEnd arm flushes any trailing partial batch (as
    a final `on-message-update`), then dispatches `on-turn-end`
    with the full turn text.
* `src/main.rs` — adds the three hook names to auto-discovery.

`harness/replace-message` (mutating the persisted assistant text from
on-message-end) is deferred. It needs session-write position tracking
and interacts with usage accounting; will land in a follow-up phase.

Tests: 6 new unit tests on `TokenBatcher` — threshold yielding,
post-flush fresh batch, partial-batch draining, reset, zero-threshold
clamping, lossless content over a run. Total: 516 pass with plugin
(was 510); 460/12 baseline without plugin unchanged.

plugins/turn_timing.janet — example using on-turn-start / on-turn-end
to notify the user how long each turn took.

Refs dirge-87x.
Plugins can now record typed entries on the session timeline and
register custom renderers to display them. Persists across session
save/load via a `Session::extra_entries` field that defaults on
deserialize (pre-P2 session files load without migration).

New harness APIs:

  (harness/append-entry "type" "data" &opt display)
    Records a typed entry. `data` is opaque to the host — the
    plugin chose its own format (plain text, JSON, etc.) and any
    registered renderer for `type` formats it on display.
    `display` defaults to true; false-display entries persist
    silently (useful for plugin state like telemetry counters
    that shouldn't visually clutter the chat).

  (harness/register-renderer "type" "fn-name")
    Associates a custom_type with a Janet function. The host
    calls the function with the entry's data string when
    displaying entries of that type.

  (harness/render color text)
    Called from inside a registered renderer. Emits one line of
    output with the given color name. Multiple `harness/render`
    calls per renderer invocation produce multiple lines.

Default renderer: when no renderer is registered for an entry's
custom_type (e.g. the producing plugin is uninstalled), the host
falls back to a minimal "[entry: TYPE]" header + dim raw data
dump. Persisted entries from removed plugins still show up.

Color names accepted: black, red, green, yellow, blue, magenta,
cyan, white, plus dark* variants and grey/gray. Unrecognized names
fall back to dim grey.

Data flow:
* Plugin: `(harness/append-entry ...)` writes to a `\type\tdata\tdisplay\n`
  blob in Janet. Embedded tabs/newlines/backslashes are escaped via
  the harness's `-escape` helper.
* Host: `PluginManager::drain_entries()` reads the blob, unescapes
  via `unescape_harness_field`, clears, returns `Vec<(type, data, display)>`.
* UI loop top: drains each tick, calls `Session::append_plugin_entry`
  to record (with monotonic seq + timestamp), then renders via the
  registered renderer.

Persistence: `Session::extra_entries: Vec<PluginEntry>` with
`#[serde(default)]` so pre-P2 files round-trip unchanged.

Tests: 10 new in plugin::tests, plus `test_unescape_harness_field_roundtrips`
that's non-gated so the escape helper has coverage on both feature
flavors. Total: 526 pass with plugin (was 516); 461 + 12 pre-existing
without (was 460 + 12).

plugins/bookmark.janet — example combining `/bookmark <label>` slash
command + custom cyan-star rendering via append-entry +
register-renderer.

Known limitation (deferred to P2 follow-up): entries render at the
end of the session view rather than interleaved with messages by
timestamp. Interleaving requires per-message timestamps which messages
don't carry today.

Refs dirge-u49.
@yogthos
yogthos force-pushed the feat/plugin-streaming-hooks branch from ff08d5a to 6032415 Compare May 20, 2026 16:45
@yogthos
yogthos deleted the branch feat/plugin-streaming-hooks May 20, 2026 16:46
@yogthos yogthos closed this May 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant