feat: Invoke marker と LlmCall callback を導入し AgentTurn セマンティクスを明確化

- protocol: InvokeKind enum、Event::InvokeStart / LlmCallStart / LlmCallEnd 追加
- llm-worker: Worker.llm_call_count と on_llm_call_start/end callback、turn_count を AgentTurn 数として doc 更新
- session-store: LogEntry::Invoke { ts, trigger } 追加 (replay は marker のみで state 不変)
- pod: run/run_for_notification 開始時に Invoke marker commit、PendingRun::RunForNotification(InvokeKind) で kind を伝搬
- pod ipc: sink + server で Invoke エントリーを Event::InvokeStart として broadcast
- tui: 新 Event 3種を no-op で受理 (UI 設計はチケット範囲外)
This commit is contained in:
2026-05-15 07:04:26 +09:00
parent fd8526799b
commit 79b8336a14
9 changed files with 354 additions and 20 deletions
+82 -2
View File
@@ -10,7 +10,7 @@
use llm_worker::llm_client::types::{Item, RequestConfig};
use llm_worker::{UsageRecord, WorkerResult};
use protocol::{ScopeRule, Segment};
use protocol::{InvokeKind, ScopeRule, Segment};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@@ -91,8 +91,13 @@ pub struct HashedEntry {
///
/// Variants correspond to specific mutation points in `Worker`:
/// - `SessionStart` — always the first entry; captures initial state
/// - `Invoke` — IDLE → active marker (start of a new self-driving cycle)
/// - `UserInput` / `AssistantItems` / `ToolResults` / `HookInjectedItems` — history appends
/// - `TurnEnd` — turn boundary marker
/// - `TurnEnd` — AgentTurn boundary marker; carries the post-increment
/// `turn_count`. With retry unimplemented today this fires once per
/// `run()`/`resume()` (current callers persist a single TurnEnd at
/// run completion); the fork-point seq for `at_turn_index` is the
/// preceding `Invoke` entry, not the TurnEnd.
/// - `RunCompleted` / `RunErrored` — marks end of a `run()` or `resume()` call
/// - `ConfigChanged` — `RequestConfig` mutation
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -113,6 +118,23 @@ pub enum LogEntry {
compacted_from: Option<SessionOrigin>,
},
/// IDLE → active marker. Records the start of a new self-driving
/// cycle (Invoke range). The range extends implicitly until the
/// next `Invoke` entry; this entry carries the trigger only — the
/// actual payload (user text / notify message / pod event body) is
/// in the immediately following Turn entry (`UserInput` / `SystemItem`).
///
/// Used by `pod-session-fork` style operations: the fork-point seq
/// (`at_turn_index` in persistence-semantics) points at one of these
/// `Invoke` entries so "back to N-th send" maps cleanly to the
/// IDLE-break boundary the user sees.
///
/// Field name is `trigger` (not `kind`) because the LogEntry
/// serde tag already occupies `"kind"`.
///
/// Marker only — replay does not mutate `RestoredState`.
Invoke { ts: u64, trigger: InvokeKind },
/// User input accepted at submit time. Carries the original typed
/// `Vec<Segment>` so clients can re-render typed atoms (paste chips,
/// file/knowledge refs, workflow invocations) on session restore.
@@ -290,6 +312,11 @@ pub fn collect_state(entries: &[HashedEntry]) -> RestoredState {
state.config = config.clone();
state.history = history.iter().cloned().map(Item::from).collect();
}
LogEntry::Invoke { .. } => {
// Marker only; no state mutation. The trailing
// UserInput / SystemItem / TurnEnd entries carry all
// replay-relevant data.
}
LogEntry::UserInput { segments, .. } => {
let text = Segment::flatten_to_text(segments);
state.history.push(Item::user_message(text));
@@ -655,6 +682,59 @@ mod tests {
}
}
#[test]
fn invoke_entry_round_trip_via_json() {
let entry = LogEntry::Invoke {
ts: 12345,
trigger: InvokeKind::UserSend,
};
let json = serde_json::to_string(&entry).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["kind"], "invoke");
assert_eq!(parsed["trigger"], "user_send");
let decoded: LogEntry = serde_json::from_str(&json).unwrap();
match decoded {
LogEntry::Invoke { ts, trigger } => {
assert_eq!(ts, 12345);
assert_eq!(trigger, InvokeKind::UserSend);
}
other => panic!("expected Invoke, got {other:?}"),
}
}
#[test]
fn replay_invoke_marker_does_not_mutate_state() {
let entries = build_chain(&[
LogEntry::SessionStart {
ts: 0,
system_prompt: None,
config: RequestConfig::default(),
history: vec![],
forked_from: None,
compacted_from: None,
},
LogEntry::Invoke {
ts: 100,
trigger: InvokeKind::UserSend,
},
LogEntry::UserInput {
ts: 101,
segments: vec![Segment::text("hi")],
},
LogEntry::TurnEnd {
ts: 200,
turn_count: 1,
},
LogEntry::Invoke {
ts: 300,
trigger: InvokeKind::Notify,
},
]);
let state = collect_state(&entries);
assert_eq!(state.history.len(), 1);
assert_eq!(state.turn_count, 1);
}
#[test]
fn replay_extension_collects_domain_payload_pairs() {
let entries = build_chain(&[