You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(phase 3): structured tool-call persistence with interrupted-state pairing (#71)
Phase 3 of the 6-phase plan. Reference pattern: opencode's
`packages/opencode/src/session/message-v2.ts:310-320, 630-899`
where `ToolPart` carries a `state: pending|running|completed|error`
and is materialized into provider-format tool_use/tool_result
blocks on resume. Anthropic + OpenAI reject orphan tool_use
blocks, so opencode always emits a paired result — even
"[Tool execution was interrupted]" for unfinished calls. dirge
adopts the same shape.
## Problem
Before Phase 3, dirge's `SessionMessage` was (role + text). Tool
calls and results streamed to the UI but were never persisted.
On session resume, `convert_history` emitted assistants as
text-only — the LLM lost all structured knowledge of prior tool
work. It could re-attempt the same bash command, re-read the
same file, or hallucinate that "the file said X" without seeing
the actual prior tool_result.
## Fix
New `ToolCallEntry` + `ToolCallState` types in `src/session/mod.rs`:
```rust
pub struct ToolCallEntry {
pub id: String, // rig's ToolCall.id for correlation
pub name: String, // tool name
pub args: serde_json::Value, // unparsed args
pub state: ToolCallState,
}
pub enum ToolCallState {
Completed { result: String },
Interrupted,
Failed { error: String },
}
```
`SessionMessage` gains `#[serde(default)] tool_calls: Vec<ToolCallEntry>`
— back-compat with old session files (missing field → empty Vec).
`Session::add_message_with_tool_calls(role, content, tool_calls)`
is the new constructor; the existing `add_message` becomes a
thin wrapper that passes an empty Vec.
### Event shape
`AgentEvent::ToolCall` + `AgentEvent::ToolResult` now both carry
an `id: CompactString` (rig's `ToolCall.id` / `ToolResult.id`).
Empty when the provider didn't emit one — the UI falls back to
positional pairing in that case.
### UI capture (src/ui/mod.rs)
- New `tool_calls_buf: Vec<ToolCallEntry>` lives alongside
`response_buf` for the duration of an agent run.
- `AgentEvent::ToolCall` pushes a new entry with
`state: Interrupted` (defensive default — if the user aborts
before the result arrives, the saved state is already correct).
- `AgentEvent::ToolResult` finds the matching entry (by id, or
last-pending if id is empty) and flips state to
`Completed { result }`.
- `AgentEvent::Done` and `AgentEvent::Interjected` call
`add_message_with_tool_calls(Assistant, response,
std::mem::take(&mut tool_calls_buf))` — the run's tool calls
attach to the final assistant message.
- `capture_partial_on_abort` (Ctrl+C, Esc) also drains the
buffer onto the stashed message; any still-Interrupted entries
stay that way, completed ones keep their state. Empty buf
alone no longer counts as a no-op when there are pending tool
calls — the abort still stashes a message with just the
trailer + the tool_calls so the LLM sees the interrupted state.
### convert_history (src/agent/runner.rs)
When an assistant message has `tool_calls`, emit:
1. `Message::Assistant` with content = [text (if any), tool_call(...)...]
built via `OneOrMany::many(...)` / `OneOrMany::one(...)`.
2. `Message::tool_result(id, body)` per call, where:
- `Completed { result }` → body = result text verbatim
- `Interrupted` → "[Tool execution was interrupted]"
- `Failed { error }` → "[Tool error: <msg>]"
Bare assistant messages (no tool_calls) keep the prior simple
`Message::assistant(text)` shape — full backward compatibility
with existing session files.
## Tests
4 new session tests, written failing first:
- `session_message_tool_calls_default_when_field_missing`:
old session JSON without `tool_calls` field deserializes
with empty Vec — back-compat guard.
- `session_message_tool_calls_roundtrip`: write a message with
tool_calls, read back via serde, fields intact.
- `convert_history_emits_tool_use_and_tool_result_blocks`:
builds a session with one bash call → completed; asserts
history has [User, Assistant(text+tool_use), User(tool_result)]
with the id correlation intact.
- `convert_history_pairs_interrupted_tool_calls_with_error_marker`:
Interrupted entry → result body contains "interrupted".
1 new UI test:
- `capture_partial_on_abort_preserves_pending_tool_calls_as_interrupted`:
mix of Interrupted + Completed entries in the buffer →
saved assistant message has both with their states intact.
5 new tests total. 644 pass (was 639), 0 fail, all build
profiles clean.
## Test plan
- [x] `cargo test --features plugin` -> 644 pass.
- [x] `cargo build --all-features` -> compiles.
- [x] `cargo build --no-default-features` -> compiles.
## Up next: Phase 4 (optional, pi-style branch summaries)
Phase 2 drops sibling branches with a notification. If users
report this feels too lossy, Phase 4 would add pi's
`BranchSummaryMessage` pattern — generate per-branch LLM
summaries and persist them as a new message variant so forks
are preserved across compactions instead of discarded.
Co-authored-by: Yogthos <yogthos@gmail.com>
0 commit comments