feat: システムメッセージをTUIで表示させる
This commit is contained in:
parent
d2f2b7920d
commit
ac1a672973
|
|
@ -168,6 +168,10 @@ pub struct Worker<C: LlmClient, S: WorkerState = Mutable> {
|
|||
/// truncation have been applied — i.e. on the same data that
|
||||
/// enters history.
|
||||
tool_result_cbs: Vec<Box<dyn Fn(&ToolResult) + Send + Sync>>,
|
||||
/// History-append callbacks. Invoked for non-streamed items when they
|
||||
/// are appended to persistent worker history, so upper layers can
|
||||
/// broadcast those items using history itself as the source of truth.
|
||||
history_append_cbs: Vec<Box<dyn Fn(&Item) + Send + Sync>>,
|
||||
/// Request configuration (max_tokens, temperature, etc.)
|
||||
request_config: RequestConfig,
|
||||
/// Whether the previous run was interrupted
|
||||
|
|
@ -346,6 +350,25 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Register a callback invoked for items appended directly to worker
|
||||
/// history outside streaming timeline callbacks.
|
||||
pub fn on_history_append(&mut self, callback: impl Fn(&Item) + Send + Sync + 'static) {
|
||||
self.history_append_cbs.push(Box::new(callback));
|
||||
}
|
||||
|
||||
fn emit_history_append(&self, item: &Item) {
|
||||
for cb in &self.history_append_cbs {
|
||||
cb(item);
|
||||
}
|
||||
}
|
||||
|
||||
fn extend_history_with_callbacks(&mut self, items: impl IntoIterator<Item = Item>) {
|
||||
for item in items {
|
||||
self.emit_history_append(&item);
|
||||
self.history.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a turn-end callback (receives 0-based turn number).
|
||||
pub fn on_turn_end(&mut self, callback: impl Fn(usize) + Send + Sync + 'static) {
|
||||
self.turn_end_cbs.push(Box::new(callback));
|
||||
|
|
@ -863,7 +886,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
|||
// get persisted by the upper layer that owns history.json.
|
||||
let pending = self.interceptor.pending_history_appends().await;
|
||||
if !pending.is_empty() {
|
||||
self.history.extend(pending);
|
||||
self.extend_history_with_callbacks(pending);
|
||||
}
|
||||
|
||||
// Clone the history into a per-request context. Everything
|
||||
|
|
@ -962,7 +985,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
|||
return Ok(WorkerResult::Finished);
|
||||
}
|
||||
TurnEndAction::ContinueWithMessages(additional) => {
|
||||
self.history.extend(additional);
|
||||
self.extend_history_with_callbacks(additional);
|
||||
continue;
|
||||
}
|
||||
TurnEndAction::Pause => {
|
||||
|
|
@ -1118,6 +1141,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
|
|||
turn_end_cbs: Vec::new(),
|
||||
warning_cbs: Vec::new(),
|
||||
tool_result_cbs: Vec::new(),
|
||||
history_append_cbs: Vec::new(),
|
||||
request_config: RequestConfig::default(),
|
||||
last_run_interrupted: false,
|
||||
cancel_tx,
|
||||
|
|
@ -1375,6 +1399,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
|
|||
turn_end_cbs: self.turn_end_cbs,
|
||||
warning_cbs: self.warning_cbs,
|
||||
tool_result_cbs: self.tool_result_cbs,
|
||||
history_append_cbs: self.history_append_cbs,
|
||||
request_config: self.request_config,
|
||||
last_run_interrupted: self.last_run_interrupted,
|
||||
|
||||
|
|
@ -1415,7 +1440,7 @@ impl<C: LlmClient> Worker<C, Locked> {
|
|||
};
|
||||
self.history.push(user_item);
|
||||
if !extras.is_empty() {
|
||||
self.history.extend(extras);
|
||||
self.extend_history_with_callbacks(extras);
|
||||
}
|
||||
let result = self.run_turn_loop().await;
|
||||
self.finalize_interruption(result).await
|
||||
|
|
@ -1456,6 +1481,7 @@ impl<C: LlmClient> Worker<C, Locked> {
|
|||
turn_end_cbs: self.turn_end_cbs,
|
||||
warning_cbs: self.warning_cbs,
|
||||
tool_result_cbs: self.tool_result_cbs,
|
||||
history_append_cbs: self.history_append_cbs,
|
||||
request_config: self.request_config,
|
||||
last_run_interrupted: self.last_run_interrupted,
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use std::sync::Arc;
|
|||
|
||||
use llm_worker::WorkerError;
|
||||
use llm_worker::llm_client::client::LlmClient;
|
||||
use llm_worker::llm_client::types::{Item, Role};
|
||||
use session_store::Store;
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
|
||||
|
|
@ -19,6 +20,16 @@ use crate::spawn::registry::SpawnedPodRegistry;
|
|||
use crate::spawn::tool::spawn_pod_tool;
|
||||
use protocol::{AlertLevel, AlertSource, ErrorCode, Event, Method, RunResult, TurnResult};
|
||||
|
||||
fn is_system_message_item(item: &Item) -> bool {
|
||||
matches!(
|
||||
item,
|
||||
Item::Message {
|
||||
role: Role::System,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PodHandle — client-facing, Clone-able
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -245,6 +256,14 @@ impl PodController {
|
|||
alerter_for_worker.alert(AlertLevel::Warn, AlertSource::Worker, message.to_owned());
|
||||
});
|
||||
|
||||
let tx = event_tx.clone();
|
||||
worker.on_history_append(move |item| {
|
||||
if is_system_message_item(item) {
|
||||
let value = serde_json::to_value(item).expect("Item is Serialize");
|
||||
let _ = tx.send(Event::SystemMessage { item: value });
|
||||
}
|
||||
});
|
||||
|
||||
// Register the builtin file-manipulation tools (Read / Write /
|
||||
// Edit / Glob / Grep / Bash). `ScopedFs` carries the pod-
|
||||
// lifetime scope/pwd; `Tracker` is session-scoped — a fresh
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use llm_worker::Item;
|
|||
use llm_worker::llm_client::RequestConfig;
|
||||
use llm_worker::llm_client::client::LlmClient;
|
||||
use llm_worker::state::Mutable;
|
||||
use llm_worker::{ToolOutputLimits, UsageRecord, Worker, WorkerError, WorkerResult};
|
||||
use llm_worker::{Role, ToolOutputLimits, UsageRecord, Worker, WorkerError, WorkerResult};
|
||||
use session_store::{EntryHash, PodScopeSnapshot, SessionId, SessionStartState, Store, StoreError};
|
||||
use tracing::{info, warn};
|
||||
|
||||
|
|
@ -557,6 +557,20 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
}
|
||||
}
|
||||
|
||||
fn broadcast_system_message_item(&self, item: &Item) {
|
||||
if !matches!(
|
||||
item,
|
||||
Item::Message {
|
||||
role: Role::System,
|
||||
..
|
||||
}
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let value = serde_json::to_value(item).expect("Item is Serialize");
|
||||
self.send_event(Event::SystemMessage { item: value });
|
||||
}
|
||||
|
||||
/// Push a `Method::Notify` (or rendered `Method::PodEvent`) entry
|
||||
/// onto the pending buffer.
|
||||
///
|
||||
|
|
@ -1466,19 +1480,29 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
+ reference_message.is_some() as usize
|
||||
+ retained_items.len(),
|
||||
);
|
||||
new_history.push(Item::system_message(format!(
|
||||
"[Compacted context summary]\n\n{summary_text}"
|
||||
)));
|
||||
let mut compact_introduced_system_messages =
|
||||
Vec::with_capacity(2 + auto_read_messages.len() + reference_message.is_some() as usize);
|
||||
let summary_message =
|
||||
Item::system_message(format!("[Compacted context summary]\n\n{summary_text}"));
|
||||
compact_introduced_system_messages.push(summary_message.clone());
|
||||
compact_introduced_system_messages.extend(auto_read_messages.iter().cloned());
|
||||
if let Some(msg) = reference_message.as_ref() {
|
||||
compact_introduced_system_messages.push(msg.clone());
|
||||
}
|
||||
let task_snapshot_message = Item::system_message(format!(
|
||||
"[Session TaskStore snapshot]\n\n{task_snapshot_text}\n\n\
|
||||
This is the complete session task list preserved across compaction. \
|
||||
The following TaskList tool result presents the same state through the tool lane."
|
||||
));
|
||||
compact_introduced_system_messages.push(task_snapshot_message.clone());
|
||||
|
||||
new_history.push(summary_message);
|
||||
new_history.extend(auto_read_messages);
|
||||
if let Some(msg) = reference_message {
|
||||
new_history.push(msg);
|
||||
}
|
||||
new_history.extend(retained_items);
|
||||
new_history.push(Item::system_message(format!(
|
||||
"[Session TaskStore snapshot]\n\n{task_snapshot_text}\n\n\
|
||||
This is the complete session task list preserved across compaction. \
|
||||
The following TaskList tool result presents the same state through the tool lane."
|
||||
)));
|
||||
new_history.push(task_snapshot_message);
|
||||
new_history.push(Item::tool_call("compact-tasklist", "TaskList", "{}"));
|
||||
new_history.push(Item::tool_result_with_content(
|
||||
"compact-tasklist",
|
||||
|
|
@ -1531,8 +1555,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
self.user_segments.drain(..drop_n);
|
||||
}
|
||||
|
||||
self.worker.as_mut().unwrap().set_history(new_history);
|
||||
for item in &compact_introduced_system_messages {
|
||||
self.broadcast_system_message_item(item);
|
||||
}
|
||||
let worker = self.worker.as_mut().unwrap();
|
||||
worker.set_history(new_history);
|
||||
// Anchor the prompt cache at the summary item so that Anthropic
|
||||
// can place a durable `cache_control` breakpoint there — our
|
||||
// compact layout guarantees history[0] is the summary.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use async_trait::async_trait;
|
|||
use futures::Stream;
|
||||
use llm_worker::Worker;
|
||||
use llm_worker::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
|
||||
use llm_worker::llm_client::types::Item;
|
||||
use llm_worker::llm_client::{ClientError, LlmClient, Request};
|
||||
use protocol::Event;
|
||||
use session_store::FsStore;
|
||||
|
|
@ -176,6 +177,56 @@ fn drain(rx: &mut broadcast::Receiver<Event>) -> Vec<Event> {
|
|||
out
|
||||
}
|
||||
|
||||
fn system_event_text(event: &Event) -> Option<&str> {
|
||||
match event {
|
||||
Event::SystemMessage { item } => item["content"]
|
||||
.as_array()
|
||||
.and_then(|parts| parts.iter().filter_map(|p| p["text"].as_str()).next()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compact_broadcasts_only_new_system_messages_not_retained_ones() {
|
||||
let client = MockClient::new(vec![
|
||||
single_text_events("hi"),
|
||||
write_summary_tool_use_events("call-1", "summary"),
|
||||
single_text_events("done"),
|
||||
]);
|
||||
let mut pod = make_pod(client).await;
|
||||
|
||||
let (tx, mut rx) = broadcast::channel::<Event>(64);
|
||||
pod.attach_event_tx(tx);
|
||||
|
||||
pod.run_text("first").await.unwrap();
|
||||
let retained_message = Item::system_message("[Retained system]\nold");
|
||||
pod.worker_mut().push_item(retained_message);
|
||||
let _ = drain(&mut rx);
|
||||
|
||||
pod.compact(10_000).await.unwrap();
|
||||
|
||||
let events = drain(&mut rx);
|
||||
let system_texts: Vec<&str> = events.iter().filter_map(system_event_text).collect();
|
||||
assert!(
|
||||
system_texts
|
||||
.iter()
|
||||
.any(|text| text.starts_with("[Compacted context summary]")),
|
||||
"summary system message missing from {system_texts:?}"
|
||||
);
|
||||
assert!(
|
||||
system_texts
|
||||
.iter()
|
||||
.any(|text| text.starts_with("[Session TaskStore snapshot]")),
|
||||
"task snapshot system message missing from {system_texts:?}"
|
||||
);
|
||||
assert!(
|
||||
!system_texts
|
||||
.iter()
|
||||
.any(|text| text.starts_with("[Retained system]")),
|
||||
"retained system message should not be rebroadcast: {system_texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_run_compact_success_broadcasts_start_and_done() {
|
||||
// Responses: (1) first run returns short text, (2) compact worker
|
||||
|
|
|
|||
|
|
@ -223,6 +223,15 @@ pub enum Event {
|
|||
Notify {
|
||||
message: String,
|
||||
},
|
||||
/// Persisted `role:system` history item that should be rendered by
|
||||
/// clients through the same path used for `Event::History` replay.
|
||||
///
|
||||
/// The payload is the serialized history item, not an ad-hoc display
|
||||
/// DTO, so live subscribers and late subscribers have the same source
|
||||
/// of truth: worker history / history.json.
|
||||
SystemMessage {
|
||||
item: serde_json::Value,
|
||||
},
|
||||
/// Echo of `Method::PodEvent` received by this Pod. Same rationale
|
||||
/// as `Notify`: subscribers render the event as a log element,
|
||||
/// while a rendered summary is independently injected into the LLM
|
||||
|
|
|
|||
|
|
@ -300,6 +300,129 @@ impl App {
|
|||
});
|
||||
}
|
||||
|
||||
fn push_history_item(&mut self, item: &serde_json::Value) {
|
||||
let item_type = item["type"].as_str().unwrap_or("");
|
||||
match item_type {
|
||||
"message" => {
|
||||
let role = item["role"].as_str().unwrap_or("");
|
||||
let text = message_text(item);
|
||||
match role {
|
||||
"user" => {
|
||||
self.turn_index += 1;
|
||||
self.blocks.push(Block::TurnHeader {
|
||||
turn: self.turn_index,
|
||||
});
|
||||
// Pod attaches the original `Vec<Segment>` to user
|
||||
// messages from live submissions, so we can rebuild
|
||||
// typed atoms (paste chips, refs) here. Seed history
|
||||
// loaded post-compaction has no `segments` field —
|
||||
// fall back to a single text segment.
|
||||
let segments = item
|
||||
.get("segments")
|
||||
.and_then(|v| serde_json::from_value::<Vec<Segment>>(v.clone()).ok())
|
||||
.unwrap_or_else(|| {
|
||||
if text.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![Segment::text(text.clone())]
|
||||
}
|
||||
});
|
||||
if !segments.is_empty() {
|
||||
self.blocks.push(Block::UserMessage { segments });
|
||||
}
|
||||
}
|
||||
"assistant" if !text.is_empty() => {
|
||||
self.blocks.push(Block::AssistantText { text });
|
||||
}
|
||||
"system" if !text.is_empty() => {
|
||||
self.blocks.push(Block::SystemMessage { text });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
"tool_call" => {
|
||||
// `Item::ToolCall` serializes the linking key as
|
||||
// `call_id`; `id` is a separate optional item-level
|
||||
// identifier. Use `call_id` so this matches how
|
||||
// Event::ToolCallStart populates the block.
|
||||
let id = item["call_id"].as_str().unwrap_or("").to_owned();
|
||||
let name = item["name"].as_str().unwrap_or("?").to_owned();
|
||||
let arguments = item["arguments"].as_str().map(|s| s.to_owned());
|
||||
self.blocks.push(Block::ToolCall(ToolCallBlock {
|
||||
id,
|
||||
name,
|
||||
args_stream: arguments.clone().unwrap_or_default(),
|
||||
arguments,
|
||||
state: ToolCallState::Executing,
|
||||
edit_snapshot: None,
|
||||
}));
|
||||
}
|
||||
"reasoning" => {
|
||||
let text = item["text"].as_str().unwrap_or("").to_owned();
|
||||
let body = if text.is_empty() {
|
||||
item["summary"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
self.blocks.push(Block::Thinking(ThinkingBlock {
|
||||
text: body,
|
||||
state: ThinkingState::Finished { elapsed_secs: None },
|
||||
}));
|
||||
}
|
||||
"tool_result" => {
|
||||
let id = item["call_id"].as_str().unwrap_or("").to_owned();
|
||||
let summary = item["summary"].as_str().unwrap_or("").to_owned();
|
||||
let output = item["content"].as_str().map(|s| s.to_owned());
|
||||
let is_error = item["is_error"].as_bool().unwrap_or(false);
|
||||
let (name, args) = self
|
||||
.find_tool_call_mut(&id)
|
||||
.map(|b| (b.name.clone(), b.arguments.clone()))
|
||||
.unwrap_or_default();
|
||||
let edit_snapshot = if !is_error && name == "Edit" {
|
||||
args.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
||||
.and_then(|v| v["file_path"].as_str().map(|s| s.to_owned()))
|
||||
.and_then(|path| self.cache.get(&path).map(|s| s.to_owned()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(tc) = self.find_tool_call_mut(&id) {
|
||||
if edit_snapshot.is_some() {
|
||||
tc.edit_snapshot = edit_snapshot;
|
||||
}
|
||||
tc.state = if is_error {
|
||||
ToolCallState::Error {
|
||||
summary,
|
||||
output: output.clone(),
|
||||
}
|
||||
} else {
|
||||
ToolCallState::Done {
|
||||
summary,
|
||||
output: output.clone(),
|
||||
}
|
||||
};
|
||||
if !is_error {
|
||||
apply_cache_update(
|
||||
&mut self.cache,
|
||||
&name,
|
||||
args.as_deref(),
|
||||
output.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_pod_event(&mut self, event: Event) {
|
||||
match event {
|
||||
Event::UserMessage { segments } => {
|
||||
|
|
@ -318,6 +441,10 @@ impl App {
|
|||
self.blocks.push(Block::PodEvent { event });
|
||||
self.assistant_streaming = false;
|
||||
}
|
||||
Event::SystemMessage { item } => {
|
||||
self.push_history_item(&item);
|
||||
self.assistant_streaming = false;
|
||||
}
|
||||
Event::TurnStart { .. } => {
|
||||
self.running = true;
|
||||
self.paused = false;
|
||||
|
|
@ -658,130 +785,7 @@ impl App {
|
|||
self.assistant_streaming = false;
|
||||
|
||||
for item in items {
|
||||
let item_type = item["type"].as_str().unwrap_or("");
|
||||
match item_type {
|
||||
"message" => {
|
||||
let role = item["role"].as_str().unwrap_or("");
|
||||
let text = item["content"]
|
||||
.as_array()
|
||||
.and_then(|parts| parts.iter().filter_map(|p| p["text"].as_str()).next())
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
match role {
|
||||
"user" => {
|
||||
self.turn_index += 1;
|
||||
self.blocks.push(Block::TurnHeader {
|
||||
turn: self.turn_index,
|
||||
});
|
||||
// Pod attaches the original `Vec<Segment>` to
|
||||
// user messages from live submissions, so we
|
||||
// can rebuild typed atoms (paste chips, refs)
|
||||
// here. Seed history loaded post-compaction
|
||||
// has no `segments` field — fall back to a
|
||||
// single text segment.
|
||||
let segments = item
|
||||
.get("segments")
|
||||
.and_then(|v| {
|
||||
serde_json::from_value::<Vec<Segment>>(v.clone()).ok()
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
if text.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![Segment::text(text.clone())]
|
||||
}
|
||||
});
|
||||
if !segments.is_empty() {
|
||||
self.blocks.push(Block::UserMessage { segments });
|
||||
}
|
||||
}
|
||||
"assistant" if !text.is_empty() => {
|
||||
self.blocks.push(Block::AssistantText { text });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
"tool_call" => {
|
||||
// `Item::ToolCall` serializes the linking key as
|
||||
// `call_id`; `id` is a separate optional item-level
|
||||
// identifier. Use `call_id` so this matches how
|
||||
// Event::ToolCallStart populates the block.
|
||||
let id = item["call_id"].as_str().unwrap_or("").to_owned();
|
||||
let name = item["name"].as_str().unwrap_or("?").to_owned();
|
||||
let arguments = item["arguments"].as_str().map(|s| s.to_owned());
|
||||
self.blocks.push(Block::ToolCall(ToolCallBlock {
|
||||
id,
|
||||
name,
|
||||
args_stream: arguments.clone().unwrap_or_default(),
|
||||
arguments,
|
||||
state: ToolCallState::Executing,
|
||||
edit_snapshot: None,
|
||||
}));
|
||||
}
|
||||
"reasoning" => {
|
||||
let text = item["text"].as_str().unwrap_or("").to_owned();
|
||||
let body = if text.is_empty() {
|
||||
item["summary"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
self.blocks.push(Block::Thinking(ThinkingBlock {
|
||||
text: body,
|
||||
state: ThinkingState::Finished { elapsed_secs: None },
|
||||
}));
|
||||
}
|
||||
"tool_result" => {
|
||||
let id = item["call_id"].as_str().unwrap_or("").to_owned();
|
||||
let summary = item["summary"].as_str().unwrap_or("").to_owned();
|
||||
let output = item["content"].as_str().map(|s| s.to_owned());
|
||||
let is_error = item["is_error"].as_bool().unwrap_or(false);
|
||||
let (name, args) = self
|
||||
.find_tool_call_mut(&id)
|
||||
.map(|b| (b.name.clone(), b.arguments.clone()))
|
||||
.unwrap_or_default();
|
||||
let edit_snapshot = if !is_error && name == "Edit" {
|
||||
args.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
||||
.and_then(|v| v["file_path"].as_str().map(|s| s.to_owned()))
|
||||
.and_then(|path| self.cache.get(&path).map(|s| s.to_owned()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(tc) = self.find_tool_call_mut(&id) {
|
||||
if edit_snapshot.is_some() {
|
||||
tc.edit_snapshot = edit_snapshot;
|
||||
}
|
||||
tc.state = if is_error {
|
||||
ToolCallState::Error {
|
||||
summary,
|
||||
output: output.clone(),
|
||||
}
|
||||
} else {
|
||||
ToolCallState::Done {
|
||||
summary,
|
||||
output: output.clone(),
|
||||
}
|
||||
};
|
||||
if !is_error {
|
||||
apply_cache_update(
|
||||
&mut self.cache,
|
||||
&name,
|
||||
args.as_deref(),
|
||||
output.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.push_history_item(item);
|
||||
}
|
||||
|
||||
// Any tool_call entries that never got paired with a
|
||||
|
|
@ -811,6 +815,19 @@ pub fn fmt_tokens(n: u64) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
fn message_text(item: &serde_json::Value) -> String {
|
||||
item["content"]
|
||||
.as_array()
|
||||
.map(|parts| {
|
||||
parts
|
||||
.iter()
|
||||
.filter_map(|p| p["text"].as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Strip the `cat -n` line-number gutter that the Read tool prepends to
|
||||
/// its output (one `"{n:>6}\t{content}"` per line) and return the raw
|
||||
/// file body. Lines that don't match the pattern are kept verbatim, so
|
||||
|
|
@ -1173,6 +1190,58 @@ mod completion_flow_tests {
|
|||
});
|
||||
assert!(app.completion.as_ref().unwrap().entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_restore_renders_system_message_block() {
|
||||
let mut app = App::new("test".into());
|
||||
app.handle_pod_event(Event::History {
|
||||
greeting: test_greeting(),
|
||||
items: vec![serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "system",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "[File: src/main.rs]\nfn main() {}",
|
||||
}],
|
||||
})],
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
app.blocks.get(1),
|
||||
Some(Block::SystemMessage { text }) if text == "[File: src/main.rs]\nfn main() {}"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_system_message_event_uses_history_item_path() {
|
||||
let mut app = App::new("test".into());
|
||||
app.handle_pod_event(Event::SystemMessage {
|
||||
item: serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "system",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "[Workflow /build]\nRun the build",
|
||||
}],
|
||||
}),
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
app.blocks.as_slice(),
|
||||
[Block::SystemMessage { text }] if text == "[Workflow /build]\nRun the build"
|
||||
));
|
||||
}
|
||||
|
||||
fn test_greeting() -> protocol::Greeting {
|
||||
protocol::Greeting {
|
||||
pod_name: "test".into(),
|
||||
cwd: "/tmp".into(),
|
||||
provider: "test-provider".into(),
|
||||
model: "test-model".into(),
|
||||
scope_summary: String::new(),
|
||||
tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed / mutate the file-content cache based on a completed tool call.
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ pub enum Block {
|
|||
UserMessage {
|
||||
segments: Vec<Segment>,
|
||||
},
|
||||
/// Persisted `role:system` history item rendered as an ordinary log
|
||||
/// element. File refs, auto-read snippets, workflow bodies, and future
|
||||
/// system-message injections all share this lane.
|
||||
SystemMessage {
|
||||
text: String,
|
||||
},
|
||||
/// Echo of `Method::Notify` received by this Pod, surfaced as a log
|
||||
/// element so subscribers see the external input that drove any
|
||||
/// following auto-kicked turn.
|
||||
|
|
|
|||
|
|
@ -361,6 +361,7 @@ fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16,
|
|||
)));
|
||||
}
|
||||
Block::UserMessage { segments } => render_user_message(lines, segments, width, mode),
|
||||
Block::SystemMessage { text } => render_system_message(lines, text, width, mode),
|
||||
Block::Notify { message } => {
|
||||
let text = format!("[notify] {message}");
|
||||
match mode {
|
||||
|
|
@ -481,6 +482,77 @@ fn render_user_message(
|
|||
}
|
||||
}
|
||||
|
||||
fn render_system_message(lines: &mut Vec<Line<'static>>, text: &str, width: u16, mode: Mode) {
|
||||
let header_style = kind_style(MessageKind::System);
|
||||
let body_style = Style::default().fg(Color::DarkGray);
|
||||
let (header, body) = split_system_message(text);
|
||||
let overview_text = if body.is_empty() {
|
||||
header.to_owned()
|
||||
} else {
|
||||
format!("{header} {body}")
|
||||
};
|
||||
|
||||
match mode {
|
||||
Mode::Overview => push_overview_line(lines, &overview_text, width, MessageKind::System, ""),
|
||||
Mode::Detail => {
|
||||
lines.push(Line::from(Span::styled(header.to_owned(), header_style)));
|
||||
for raw in body.lines() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ", body_style),
|
||||
Span::styled(raw.to_owned(), body_style),
|
||||
]));
|
||||
}
|
||||
if body.is_empty() && header.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
}
|
||||
Mode::Normal => {
|
||||
lines.push(Line::from(Span::styled(header.to_owned(), header_style)));
|
||||
let preview = system_message_preview(body, 4);
|
||||
for line in preview.lines {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ", body_style),
|
||||
Span::styled(line, body_style),
|
||||
]));
|
||||
}
|
||||
if preview.omitted_lines > 0 {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ", body_style),
|
||||
Span::styled(
|
||||
format!("… ({} more lines)", preview.omitted_lines),
|
||||
body_style.add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn split_system_message(text: &str) -> (&str, &str) {
|
||||
match text.split_once('\n') {
|
||||
Some((header, body)) => (header, body.trim_start_matches('\n')),
|
||||
None => (text, ""),
|
||||
}
|
||||
}
|
||||
|
||||
struct SystemMessagePreview {
|
||||
lines: Vec<String>,
|
||||
omitted_lines: usize,
|
||||
}
|
||||
|
||||
fn system_message_preview(body: &str, max_lines: usize) -> SystemMessagePreview {
|
||||
let all: Vec<&str> = body.lines().collect();
|
||||
let lines = all
|
||||
.iter()
|
||||
.take(max_lines)
|
||||
.map(|line| (*line).to_owned())
|
||||
.collect();
|
||||
SystemMessagePreview {
|
||||
lines,
|
||||
omitted_lines: all.len().saturating_sub(max_lines),
|
||||
}
|
||||
}
|
||||
|
||||
/// Style + display text for a single chip-style `Segment`. `fallback`
|
||||
/// is used for `Segment::Text` (which the caller handles inline) and
|
||||
/// for `Segment::Unknown` so future variants degrade gracefully.
|
||||
|
|
@ -931,6 +1003,8 @@ pub enum MessageKind {
|
|||
/// Visually distinct from User / Assistant / Notice so it's clear
|
||||
/// the line came from another Pod or operator, not the local user.
|
||||
Notify,
|
||||
/// Persisted role:system history item preview.
|
||||
System,
|
||||
Assistant,
|
||||
Thinking,
|
||||
TurnStats,
|
||||
|
|
@ -943,6 +1017,7 @@ pub fn kind_style(kind: MessageKind) -> Style {
|
|||
MessageKind::TurnHeader => Style::default().fg(Color::DarkGray),
|
||||
MessageKind::User => Style::default().fg(Color::Green),
|
||||
MessageKind::Notify => Style::default().fg(Color::Yellow),
|
||||
MessageKind::System => Style::default().fg(Color::Cyan),
|
||||
MessageKind::Assistant => Style::default().fg(Color::White),
|
||||
MessageKind::Thinking => Style::default()
|
||||
.fg(Color::Magenta)
|
||||
|
|
@ -975,7 +1050,9 @@ fn format_pod_event(event: &PodEvent) -> String {
|
|||
format!("[pod_event] {pod_name} → shut_down")
|
||||
}
|
||||
PodEvent::ScopeSubDelegated {
|
||||
parent_pod, sub_pod, ..
|
||||
parent_pod,
|
||||
sub_pod,
|
||||
..
|
||||
} => {
|
||||
format!("[pod_event] {parent_pod} → scope_sub_delegated: {sub_pod}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,3 +45,8 @@ LLM のコンテキストには乗っているが TUI には何も出ない。TU
|
|||
- 別 client が後から subscribe して `Event::History` を受けた場合も、同じログ要素として描画される。
|
||||
- ライブ event と history 復元の表示が一致する(同じ Block バリアントを通る)。
|
||||
- 解決失敗時の従来経路(`Alert` / user-invocation エラー)は維持される。
|
||||
|
||||
## Review
|
||||
- 状態: Approve
|
||||
- レビュー詳細: [./tui-system-message-render.review.md](./tui-system-message-render.review.md)
|
||||
- 日付: 2026-05-04
|
||||
|
|
|
|||
48
tickets/tui-system-message-render.review.md
Normal file
48
tickets/tui-system-message-render.review.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Review: TUI で role:system の system message を表示する
|
||||
|
||||
## 前提・要件の確認
|
||||
|
||||
- **role:system Item を一律に表示経路に乗せる仕組み**: `Event::SystemMessage { item: serde_json::Value }` を新設し (crates/protocol/src/lib.rs:226–232)、TUI 側に `Block::SystemMessage { text }` を追加 (crates/tui/src/block.rs:22–25)。種別ごとの個別パッチではなく role:system を一律で扱える形になっている。✓
|
||||
- **ライブ submit の broadcast**: `Worker::on_history_append` 汎用フック (crates/llm-worker/src/worker.rs:353–369) を新設し、`extend_history_with_callbacks` 経由で `pending_history_appends` / `ContinueWithMessages` / `PromptAction::ContinueWith` の三箇所が共通配線になった (worker.rs:889, 988, 1443)。controller.rs:259–265 で role:system だけを `Event::SystemMessage` に乗せている。`@<path>` / `/<slug>` chip 解決後の `Item::system_message` は `pending_attachments` → `PromptAction::ContinueWith(extras)` 経路で必ずこのフックを通る。✓
|
||||
- **history.json を単一の情報源**: `Event::SystemMessage` の payload は `serde_json::to_value(&Item)` で `Event::History` と同じ shape。TUI は `App::push_history_item` を共通ヘルパとして抽出し (crates/tui/src/app.rs:303–423)、`handle_pod_event(Event::SystemMessage)` と `restore_history` の両方が同じ Block 生成経路に流れる。`restore_history` の `_ => {}` で握り潰していたバグも解消している (crates/tui/src/app.rs:660 周辺)。✓
|
||||
- **Auto-read 含む compact 経路**: compact が新規導入する 4 種 (`[Compacted context summary]`, `[Auto-read file: ...]`, `[Session reference]`, `[Session TaskStore snapshot]`) を `compact_introduced_system_messages` にまとめ、`set_history` 直後に `broadcast_system_message_item` で個別 broadcast (crates/pod/src/pod.rs:1483–1497, 1558–1561)。retain 済みの既存 system message は再 broadcast されないことを `compact_broadcasts_only_new_system_messages_not_retained_ones` テストで担保 (crates/pod/tests/compact_events_test.rs:188–227)。✓
|
||||
- **別 client が後から subscribe したケース**: `Event::History` 経路は今回 `push_history_item` に統一され、`role:"system"` を `Block::SystemMessage` として描画するブランチが追加されている (crates/tui/src/app.rs:336–339)。テスト `history_restore_renders_system_message_block` が回っている。✓
|
||||
- **解決失敗時の従来経路維持**: `Alert` / user-invocation エラー側のコードは触られていない。✓
|
||||
- **本文プレビュー**: `render_system_message` (crates/tui/src/ui.rs:485–550) で header 一行 + 本文 4 行 + `… (N more lines)` の素のプレビュー。Overview / Detail / Normal の 3 モードに対応。`MessageKind::System` の cyan を新設。✓
|
||||
|
||||
## アーキテクチャ・スコープ
|
||||
|
||||
- **CLAUDE.md「LLM コンテキスト加工原則」との整合**: 本実装は **history → broadcast** という許される方向のみで、context への割り込みは行わない (history を書き換えていない、新規 input を context だけに差し込んでいない)。`broadcast_system_message_item` は `set_history` 後の現在 history 内容を broadcast するだけで、worker.history と一致している。✓
|
||||
- **`Worker::on_history_append` の位置づけ**: 「streaming で生成された assistant items / tool result」とは別経路で history に append される非ストリーミング項目専用のフック。assistant items (worker.rs:979)、tool_result (worker.rs:1096–1103) は意図的に通らない。役割が「streaming bypass で history に積まれる項目を上層に観測させる」と明確で、汎用化しすぎていない。✓
|
||||
- **role:system フィルタの位置**: フックは Item 全種を流し、フィルタ (`is_system_message_item`) は controller 側のクロージャで掛けている。今回広がる用途 (notify_wrapper, system-reminder 系) はすべて role:system なので、フィルタ位置として妥当。✓
|
||||
- **Event payload に `serde_json::Value` を使う判断**: `Event::History.items: Vec<serde_json::Value>` と同じ流儀で、TUI は同じ deser 経路を共有できる。`Item` 型をそのまま expose しなかった点は protocol crate の依存最小化として一貫している。✓
|
||||
- **不要な抽象 / 歪み**: なし。新しい crate / モジュール追加もなく、既存の hook/event レーンに沿って最小拡張している。app.rs の `push_history_item` 抽出は重複排除としても妥当。
|
||||
|
||||
## 指摘事項
|
||||
|
||||
### Blocking
|
||||
|
||||
- なし。
|
||||
|
||||
### Non-blocking / Follow-up
|
||||
|
||||
- **Notify が二重表示になる可能性**: `Method::Notify` 受信時に `Event::Notify { message }` を即時送出 (crates/pod/src/controller.rs:480) しつつ、次ターン頭で `pending_history_appends` が `notify_wrapper` 形式の `Item::system_message` を返し、これが `Event::SystemMessage` として **追加で** broadcast される。TUI には `[notify] {message}` (Block::Notify) と `[Notification]…{message}…not a blocking request` (Block::SystemMessage) が両方並ぶ。本チケットの要件の範囲ではないが、`notify-history-persist` 系の表示設計と整合させる別チケットで整理が要る。
|
||||
- **compact の broadcast 経路が二系統あるための分かりにくさ**: 通常 append は `Worker::on_history_append` フック経由、compact は `Pod::broadcast_system_message_item` 直接呼び出し、という二層になっている。理由 (compact は `set_history` の wholesale replace で意味的に差分 broadcast したい) は正当だが、将来「他にも history を再構築する操作」が増えたとき、どちらの経路に乗せるかの判断軸が暗黙のままなので、`broadcast_system_message_item` 付近にコメントで「set_history 系の wholesale replace 後に意図的に新規導入 item だけを broadcast するためのレーン」と一行残しておくと将来の読み手が楽になる。
|
||||
- **`Event::SystemMessage.item` の型コメント**: protocol.rs:226–232 の docstring は良いが、payload が「Item の serialize 済み JSON (≒ history.json の 1 entry)」であることを 1 行明示すると、TUI 以外の client 実装者が deser 形式を迷わずに済む。
|
||||
|
||||
### Nits
|
||||
|
||||
- crates/tui/src/app.rs:303 `push_history_item` の `tool_call` ブランチで `name: item["name"].as_str().unwrap_or("?")` の `?` フォールバックは旧コードからの引き継ぎだが、本来 history JSON で name が欠ける状況は考えにくい。今回触ったついでに修正する性質ではないので現状維持で OK。
|
||||
- crates/tui/src/ui.rs:1053 `format_pod_event` の `ScopeSubDelegated { parent_pod, sub_pod, .. }` の改行は無関係なフォーマット差分。本筋に影響なし。
|
||||
- compact_events_test.rs の新テストは `worker_mut().push_item(...)` で retained system を仕込んでいるが、これは `on_history_append` を回避する経路。テストの意図 (retained は callback を通っていないので broadcast されない) を 1 行コメントで残してもよい。
|
||||
|
||||
## ビルド / テスト
|
||||
|
||||
- `cargo check --workspace --all-targets`: ✓ (既存 dead_code warning のみ、新規警告なし)
|
||||
- `cargo test -p pod`: ✓ (compact_events_test の 6 件含めすべて通過、新テスト `compact_broadcasts_only_new_system_messages_not_retained_ones` も通過)
|
||||
- `cargo test -p tui`: ✓ (新テスト `history_restore_renders_system_message_block`, `live_system_message_event_uses_history_item_path` 通過)
|
||||
- `cargo test -p llm-worker`: ✓
|
||||
|
||||
## 判断
|
||||
|
||||
**Approve** — チケットの要件 (一般的な role:system 表示経路、ライブ / 復元 / 後着 subscribe の三経路を同じ Block バリアントに統一、compact 後の auto-read を含む 4 種を同じレーンに乗せる、解決失敗時は従来経路維持) はすべて満たされている。アーキテクチャ的にも CLAUDE.md の「許される加工」原則に整合し、既存の hook/event レーンに自然に乗る最小拡張で、コードベースを歪めていない。Notify 二重表示は本チケット範囲外で、別 ticket で整理すれば足りる。
|
||||
Loading…
Reference in New Issue
Block a user