feat: expose worker model and context metadata

This commit is contained in:
2026-10-02 15:29:45 +09:00
parent 43e3b42055
commit cf3cf4b766
25 changed files with 834 additions and 57 deletions
+83 -7
View File
@@ -766,6 +766,10 @@ pub struct InternalWorkerSnapshot {
pub worker: InternalWorkerRef, pub worker: InternalWorkerRef,
pub revision: u64, pub revision: u64,
pub session: SessionSnapshot, pub session: SessionSnapshot,
/// Public execution metadata for this child. Older snapshots and service
/// workers may omit it; clients must never substitute the parent's values.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub greeting: Option<Greeting>,
#[serde(default)] #[serde(default)]
pub status: WorkerStatus, pub status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1667,11 +1671,45 @@ impl InFlightToolCallState {
} }
} }
/// Worker self-description rendered by the TUI when a session starts empty. /// Public reasoning configuration resolved for the model client.
/// ///
/// Built once in the Worker controller from the resolved manifest and /// This deliberately exposes only the effective provider control, not the
/// transmitted alongside `Event::Snapshot` so clients don't need /// Worker manifest or provider credentials. The tagged shape prevents token
/// their own view of the manifest. /// budgets from being presented as effort labels.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ReasoningConfig {
Effort { effort: String },
BudgetTokens { budget_tokens: i32 },
}
/// Provenance of the current-context token count in a [`Greeting`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ContextTokenSource {
/// Provider-reported input occupancy from the most recent LLM request.
Measured,
/// Current history projected from the Worker's token accounting records.
Estimated,
}
/// Current prompt-context occupancy, distinct from cumulative run traffic.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct ContextUsage {
pub tokens: u64,
pub source: ContextTokenSource,
}
/// Worker self-description rendered by clients when a session starts empty.
///
/// Snapshot metadata contains only resolved public execution information so
/// clients do not need their own copy of the manifest.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
@@ -1680,14 +1718,22 @@ pub struct Greeting {
pub cwd: String, pub cwd: String,
pub provider: String, pub provider: String,
pub model: String, pub model: String,
/// Effective reasoning control. Absent means unspecified or unavailable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<ReasoningConfig>,
pub scope_summary: String, pub scope_summary: String,
pub tools: Vec<String>, pub tools: Vec<String>,
/// Model context window in tokens. Always filled by the Worker greeting. /// Resolved effective model context window in tokens. Legacy producers use
/// zero when this is unavailable; clients must treat that as unknown.
#[serde(default)] #[serde(default)]
pub context_window: u64, pub context_window: u64,
/// Estimated current session context tokens at connect time. /// Legacy numeric mirror retained for mixed-version clients.
#[serde(default)] #[serde(default)]
pub context_tokens: u64, pub context_tokens: u64,
/// Typed current-context value. Older snapshots omit it; clients may fall
/// back to `context_tokens` only when `context_window` is known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_usage: Option<ContextUsage>,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2354,6 +2400,11 @@ mod tests {
tools: vec!["Read".into()], tools: vec!["Read".into()],
context_window: 200_000, context_window: 200_000,
context_tokens: 42_000, context_tokens: 42_000,
reasoning: Some(ReasoningConfig::BudgetTokens { budget_tokens: -1 }),
context_usage: Some(ContextUsage {
tokens: 42_000,
source: ContextTokenSource::Measured,
}),
}, },
state: WorkerStatus::Paused.into(), state: WorkerStatus::Paused.into(),
in_flight: InFlightSnapshot::default(), in_flight: InFlightSnapshot::default(),
@@ -2372,6 +2423,19 @@ mod tests {
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read"); assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000); assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
assert_eq!(parsed["data"]["greeting"]["context_tokens"], 42_000); assert_eq!(parsed["data"]["greeting"]["context_tokens"], 42_000);
assert_eq!(
parsed["data"]["greeting"]["reasoning"]["kind"],
"budget_tokens"
);
assert_eq!(parsed["data"]["greeting"]["reasoning"]["budget_tokens"], -1);
assert_eq!(
parsed["data"]["greeting"]["context_usage"]["tokens"],
42_000
);
assert_eq!(
parsed["data"]["greeting"]["context_usage"]["source"],
"measured"
);
assert_eq!(parsed["data"]["state"]["state"]["kind"], "busy"); assert_eq!(parsed["data"]["state"]["state"]["kind"], "busy");
assert_eq!(parsed["data"]["state"]["state"]["state"]["state"], "paused"); assert_eq!(parsed["data"]["state"]["state"]["state"]["state"], "paused");
} }
@@ -2381,7 +2445,17 @@ mod tests {
let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"state":{"last_command_id":0,"state":{"kind":"busy","state":{"kind":"run","state":"running"}}}}}"#; let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"state":{"last_command_id":0,"state":{"kind":"busy","state":{"kind":"run","state":"running"}}}}}"#;
let decoded: Event = serde_json::from_str(inbound).unwrap(); let decoded: Event = serde_json::from_str(inbound).unwrap();
match decoded { match decoded {
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()), Event::Snapshot {
greeting,
in_flight,
..
} => {
assert!(in_flight.is_empty());
assert_eq!(greeting.reasoning, None);
assert_eq!(greeting.context_usage, None);
assert_eq!(greeting.context_window, 0);
assert_eq!(greeting.context_tokens, 0);
}
other => panic!("expected Snapshot, got {other:?}"), other => panic!("expected Snapshot, got {other:?}"),
} }
@@ -2399,6 +2473,8 @@ mod tests {
tools: Vec::new(), tools: Vec::new(),
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
reasoning: None,
context_usage: None,
}, },
state: WorkerStatus::Running.into(), state: WorkerStatus::Running.into(),
in_flight: InFlightSnapshot { in_flight: InFlightSnapshot {
+71 -16
View File
@@ -6,17 +6,18 @@ use ts_rs::{Config, TS};
use crate::{ use crate::{
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream, Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase, CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
CompactionTrigger, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, CompactionTrigger, CompletionEntry, CompletionKind, ContextTokenSource, ContextUsage,
InFlightCompaction, InFlightSnapshot, InFlightToolCallState, InternalWorkerKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightCompaction, InFlightSnapshot,
InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method, InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary, InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId, PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission,
RunFailureKind, RunResult, RunResumeSource, RunYieldReason, ScopeRule, Segment, ReasoningConfig, RewindSummary, RewindTarget, RewindTargetId, RunFailureKind, RunResult,
SessionContentPart, SessionConversationTurn, SessionEntryProvenance, RunResumeSource, RunYieldReason, ScopeRule, Segment, SessionContentPart,
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionConversationTurn, SessionEntryProvenance, SessionHistoryLineageBoundary,
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, SymlinkPolicy,
WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState,
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
WorkerStateSnapshot, WorkerStatus, WorkerStateSnapshot, WorkerStatus,
subscription::{ subscription::{
@@ -116,10 +117,13 @@ pub fn generated_protocol_types() -> String {
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output); push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
push_decl::<SubmissionDisposition>(&cfg, &mut output); push_decl::<SubmissionDisposition>(&cfg, &mut output);
push_decl::<SessionSnapshot>(&cfg, &mut output); push_decl::<SessionSnapshot>(&cfg, &mut output);
push_decl::<ReasoningConfig>(&cfg, &mut output);
push_decl::<ContextTokenSource>(&cfg, &mut output);
push_decl::<ContextUsage>(&cfg, &mut output);
push_decl::<Greeting>(&cfg, &mut output);
push_decl::<InternalWorkerKind>(&cfg, &mut output); push_decl::<InternalWorkerKind>(&cfg, &mut output);
push_decl::<InternalWorkerRef>(&cfg, &mut output); push_decl::<InternalWorkerRef>(&cfg, &mut output);
push_decl::<InternalWorkerSnapshot>(&cfg, &mut output); push_decl::<InternalWorkerSnapshot>(&cfg, &mut output);
push_decl::<Greeting>(&cfg, &mut output);
push_decl::<Alert>(&cfg, &mut output); push_decl::<Alert>(&cfg, &mut output);
push_decl::<MemoryWorkerEvent>(&cfg, &mut output); push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
push_decl::<PasteArtifactMediaType>(&cfg, &mut output); push_decl::<PasteArtifactMediaType>(&cfg, &mut output);
@@ -256,20 +260,71 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
)); ));
let pending = let pending =
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event { SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
subscription_id, subscription_id: subscription_id.clone(),
payload: SubscriptionEventPayload::WorkerProtocol { payload: SubscriptionEventPayload::WorkerProtocol {
worker_id, worker_id: worker_id.clone(),
event: Event::PendingSubmissionsChanged { event: Event::PendingSubmissionsChanged {
pending: PendingSubmissionsSnapshot::default(), pending: PendingSubmissionsSnapshot::default(),
}, },
}, },
})); }));
for fixture in [&subscribed, &pending] { let greeting = Greeting {
worker_name: "fixture-worker".into(),
cwd: "/fixture".into(),
provider: "openai_responses".into(),
model: "gpt-6-astra".into(),
reasoning: Some(ReasoningConfig::Effort {
effort: "high".into(),
}),
scope_summary: "fixture scope".into(),
tools: vec!["Read".into()],
context_window: 272_000,
context_tokens: 142_000,
context_usage: Some(ContextUsage {
tokens: 142_000,
source: ContextTokenSource::Measured,
}),
};
let snapshot =
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
subscription_id,
payload: SubscriptionEventPayload::WorkerProtocol {
worker_id,
event: Event::Snapshot {
session: SessionSnapshot {
pending_submissions: PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting: greeting.clone(),
state: WorkerStateSnapshot::initial(),
in_flight: InFlightSnapshot::default(),
internal_workers: vec![InternalWorkerSnapshot {
worker: InternalWorkerRef {
session_id: "fixture-child-session".into(),
name: "fixture-child".into(),
parent_session_id: Some("fixture-parent-session".into()),
kind: InternalWorkerKind::SubWorker,
},
revision: 1,
session: SessionSnapshot {
pending_submissions: PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting: Some(greeting),
status: WorkerStatus::Idle,
error: None,
in_flight: InFlightSnapshot::default(),
internal_workers: Vec::new(),
}],
},
},
}));
for fixture in [&subscribed, &pending, &snapshot] {
fixture fixture
.validate() .validate()
.expect("Rust-serialized Browser compatibility fixture must validate"); .expect("Rust-serialized Browser compatibility fixture must validate");
} }
vec![subscribed, pending] vec![subscribed, pending, snapshot]
} }
fn close_object_shapes(schema: &mut Value) { fn close_object_shapes(schema: &mut Value) {
+77 -8
View File
@@ -1403,7 +1403,9 @@ impl App {
output_tokens, output_tokens,
cache_read_input_tokens, cache_read_input_tokens,
} => { } => {
self.session_context_tokens = input_tokens.unwrap_or(0); if let Some(input_tokens) = input_tokens {
self.session_context_tokens = input_tokens;
}
// Subtract the cache-hit portion so a tool loop that // Subtract the cache-hit portion so a tool loop that
// re-sends the same prefix on every request doesn't // re-sends the same prefix on every request doesn't
// re-count it. cache_creation stays in (it is full // re-count it. cache_creation stays in (it is full
@@ -1475,7 +1477,6 @@ impl App {
return None; return None;
} }
self.active_compaction = None; self.active_compaction = None;
self.session_context_tokens = 0;
let new_segment_id = lifecycle let new_segment_id = lifecycle
.new_segment_id .new_segment_id
.as_deref() .as_deref()
@@ -2337,8 +2338,12 @@ impl App {
) -> InternalWorkerView { ) -> InternalWorkerView {
let mut app = App::new(snapshot.worker.name.clone()); let mut app = App::new(snapshot.worker.name.clone());
app.mode = mode; app.mode = mode;
if let Some(greeting) = snapshot.greeting {
app.restore_snapshot(&snapshot.session, greeting, snapshot.in_flight);
} else {
app.restore_session(&snapshot.session, None); app.restore_session(&snapshot.session, None);
app.apply_in_flight_snapshot(snapshot.in_flight); app.apply_in_flight_snapshot(snapshot.in_flight);
}
app.set_worker_status(snapshot.status); app.set_worker_status(snapshot.status);
if let Some(error) = snapshot.error { if let Some(error) = snapshot.error {
let _ = app.handle_worker_event(Event::Error { let _ = app.handle_worker_event(Event::Error {
@@ -2424,7 +2429,10 @@ impl App {
) { ) {
self.greeting = Some(greeting.clone()); self.greeting = Some(greeting.clone());
self.context_window = greeting.context_window; self.context_window = greeting.context_window;
self.session_context_tokens = greeting.context_tokens; self.session_context_tokens = greeting
.context_usage
.map(|usage| usage.tokens)
.unwrap_or(greeting.context_tokens);
self.restore_session(session, Some(greeting)); self.restore_session(session, Some(greeting));
self.apply_in_flight_snapshot(in_flight); self.apply_in_flight_snapshot(in_flight);
} }
@@ -2441,10 +2449,15 @@ impl App {
_ => None, _ => None,
}) })
}); });
let greeting = greeting.map(|mut greeting| {
greeting.context_tokens = 0;
greeting.context_usage = None;
greeting
});
if let Some(greeting) = greeting.clone() { if let Some(greeting) = greeting.clone() {
self.greeting = Some(greeting.clone()); self.greeting = Some(greeting.clone());
self.context_window = greeting.context_window; self.context_window = greeting.context_window;
self.session_context_tokens = greeting.context_tokens; self.session_context_tokens = 0;
} }
let missing_greeting = greeting.is_none(); let missing_greeting = greeting.is_none();
self.restore_session(session, greeting); self.restore_session(session, greeting);
@@ -2836,6 +2849,8 @@ mod rewind_refresh_tests {
assert!(!blocks_contain(&app, "old post-target output")); assert!(!blocks_contain(&app, "old post-target output"));
assert_eq!(composer_text(&app), "selected rewind input"); assert_eq!(composer_text(&app), "selected rewind input");
assert!(blocks_contain(&app, "Rewound session")); assert!(blocks_contain(&app, "Rewound session"));
assert_eq!(app.session_context_tokens, 0);
assert_eq!(app.greeting.as_ref().and_then(|g| g.context_usage), None);
} }
#[test] #[test]
@@ -2946,6 +2961,8 @@ mod rewind_refresh_tests {
tools: vec![], tools: vec![],
context_window: 100, context_window: 100,
context_tokens: 10, context_tokens: 10,
reasoning: None,
context_usage: None,
} }
} }
@@ -4021,6 +4038,7 @@ mod completion_flow_tests {
}, },
revision, revision,
status: WorkerStatus::Idle, status: WorkerStatus::Idle,
greeting: None,
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(), pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
@@ -4031,6 +4049,28 @@ mod completion_flow_tests {
} }
} }
#[test]
fn internal_worker_snapshot_uses_child_greeting_metadata() {
let mut app = App::new("parent".into());
let mut child = test_internal_worker_snapshot("child", "research", 1);
let mut greeting = test_greeting();
greeting.worker_name = "research".into();
greeting.model = "child-model".into();
greeting.context_window = 64_000;
greeting.context_usage = Some(protocol::ContextUsage {
tokens: 12_000,
source: protocol::ContextTokenSource::Measured,
});
child.greeting = Some(greeting);
app.replace_internal_worker_snapshots(vec![child]);
let child_app = &app.internal_workers[0].app;
assert_eq!(child_app.greeting.as_ref().unwrap().model, "child-model");
assert_eq!(child_app.context_window, 64_000);
assert_eq!(child_app.session_context_tokens, 12_000);
}
#[test] #[test]
fn worker_view_cycle_uses_stable_session_identity_and_wraps_to_main() { fn worker_view_cycle_uses_stable_session_identity_and_wraps_to_main() {
let mut app = App::new("parent".into()); let mut app = App::new("parent".into());
@@ -4309,6 +4349,7 @@ mod completion_flow_tests {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(), pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
greeting: None,
status: WorkerStatus::Running, status: WorkerStatus::Running,
error: None, error: None,
in_flight: Default::default(), in_flight: Default::default(),
@@ -4505,15 +4546,21 @@ mod completion_flow_tests {
tools: Vec::new(), tools: Vec::new(),
context_window: 200_000, context_window: 200_000,
context_tokens: 0, context_tokens: 0,
reasoning: None,
context_usage: None,
} }
} }
#[test] #[test]
fn snapshot_initializes_context_usage() { fn snapshot_prefers_typed_context_usage_over_legacy_mirror() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
let mut greeting = test_greeting(); let mut greeting = test_greeting();
greeting.context_window = 123_000; greeting.context_window = 123_000;
greeting.context_tokens = 45_000; greeting.context_tokens = 1;
greeting.context_usage = Some(protocol::ContextUsage {
tokens: 45_000,
source: protocol::ContextTokenSource::Measured,
});
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
@@ -4530,6 +4577,28 @@ mod completion_flow_tests {
assert_eq!(app.session_context_tokens, 45_000); assert_eq!(app.session_context_tokens, 45_000);
} }
#[test]
fn snapshot_falls_back_to_legacy_context_tokens() {
let mut app = App::new("test".into());
let mut greeting = test_greeting();
greeting.context_window = 100_000;
greeting.context_tokens = 25_000;
app.handle_worker_event(Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting,
state: test_worker_state(WorkerStatus::Idle),
in_flight: Default::default(),
internal_workers: Vec::new(),
});
assert_eq!(app.context_window, 100_000);
assert_eq!(app.session_context_tokens, 25_000);
}
#[test] #[test]
fn usage_updates_session_context_tokens_without_cache_discount() { fn usage_updates_session_context_tokens_without_cache_discount() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
@@ -4566,7 +4635,7 @@ mod completion_flow_tests {
} }
#[test] #[test]
fn compact_done_resets_session_context_tokens() { fn legacy_compact_done_does_not_fabricate_zero_context_usage() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.session_context_tokens = 42_000; app.session_context_tokens = 42_000;
@@ -4574,7 +4643,7 @@ mod completion_flow_tests {
lifecycle.new_segment_id = Some(uuid::Uuid::nil().to_string()); lifecycle.new_segment_id = Some(uuid::Uuid::nil().to_string());
app.handle_worker_event(Event::CompactDone { lifecycle }); app.handle_worker_event(Event::CompactDone { lifecycle });
assert_eq!(app.session_context_tokens, 0); assert_eq!(app.session_context_tokens, 42_000);
} }
#[test] #[test]
+6
View File
@@ -499,10 +499,12 @@ impl RetainedSnapshotSocket {
cwd: String::new(), cwd: String::new(),
provider: "retained session".to_string(), provider: "retained session".to_string(),
model: String::new(), model: String::new(),
reasoning: None,
scope_summary: "read-only retained session".to_string(), scope_summary: "read-only retained session".to_string(),
tools: Vec::new(), tools: Vec::new(),
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
context_usage: None,
}, },
state: protocol::WorkerStateSnapshot { state: protocol::WorkerStateSnapshot {
last_command_id: 0, last_command_id: 0,
@@ -654,10 +656,12 @@ async fn run_e2e_rewind_fixture(
cwd: workspace_root.display().to_string(), cwd: workspace_root.display().to_string(),
provider: "e2e-fixture".to_string(), provider: "e2e-fixture".to_string(),
model: "canned".to_string(), model: "canned".to_string(),
reasoning: None,
scope_summary: "isolated e2e rewind fixture".to_string(), scope_summary: "isolated e2e rewind fixture".to_string(),
tools: Vec::new(), tools: Vec::new(),
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
context_usage: None,
}, },
}); });
@@ -2784,10 +2788,12 @@ mod tests {
cwd: "/tmp".into(), cwd: "/tmp".into(),
provider: "test".into(), provider: "test".into(),
model: "test".into(), model: "test".into(),
reasoning: None,
scope_summary: "".into(), scope_summary: "".into(),
tools: vec![], tools: vec![],
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
context_usage: None,
} }
} }
+2
View File
@@ -3992,6 +3992,8 @@ mod ws_tests {
tools: Vec::new(), tools: Vec::new(),
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
reasoning: None,
context_usage: None,
}, },
state: protocol::WorkerStateSnapshot::initial(), state: protocol::WorkerStateSnapshot::initial(),
in_flight: protocol::InFlightSnapshot { in_flight: protocol::InFlightSnapshot {
+5
View File
@@ -5474,6 +5474,7 @@ mod tests {
worker, worker,
revision, revision,
status, status,
greeting: None,
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(), pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
@@ -5499,6 +5500,8 @@ mod tests {
tools: Vec::new(), tools: Vec::new(),
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
reasoning: None,
context_usage: None,
}, },
state: protocol::WorkerStatus::Idle.into(), state: protocol::WorkerStatus::Idle.into(),
in_flight: protocol::InFlightSnapshot::default(), in_flight: protocol::InFlightSnapshot::default(),
@@ -8111,6 +8114,8 @@ mod tests {
tools: Vec::new(), tools: Vec::new(),
context_window: 128, context_window: 128,
context_tokens: 64, context_tokens: 64,
reasoning: None,
context_usage: None,
}, },
state: protocol::WorkerStatus::Running.into(), state: protocol::WorkerStatus::Running.into(),
in_flight: protocol::InFlightSnapshot { in_flight: protocol::InFlightSnapshot {
@@ -4520,6 +4520,8 @@ mod tests {
tools: Vec::new(), tools: Vec::new(),
context_window: 1_000, context_window: 1_000,
context_tokens: 0, context_tokens: 0,
reasoning: None,
context_usage: None,
}, },
)); ));
hub.workers.lock().unwrap().insert( hub.workers.lock().unwrap().insert(
+75 -3
View File
@@ -151,7 +151,7 @@ impl WorkerHandle {
.snapshot(); .snapshot();
let event = Event::Snapshot { let event = Event::Snapshot {
session, session,
greeting: self.shared_state.greeting.clone(), greeting: self.shared_state.greeting(),
state: self.shared_state.snapshot(), state: self.shared_state.snapshot(),
in_flight, in_flight,
internal_workers: self.spawned_registry.internal_worker_snapshots(), internal_workers: self.spawned_registry.internal_worker_snapshots(),
@@ -303,6 +303,10 @@ async fn finish_controller_run<C, St>(
// the terminal run boundary so reconnect snapshots cannot append stale // the terminal run boundary so reconnect snapshots cannot append stale
// partial text/tool arguments after newer entries. // partial text/tool arguments after newer entries.
worker.clear_in_flight_events(); worker.clear_in_flight_events();
shared_state.update_context_usage(
Some(worker.total_tokens().tokens),
protocol::ContextTokenSource::Estimated,
);
set_controller_status(shared_state, runtime_dir, working_event_tx, new_status).await; set_controller_status(shared_state, runtime_dir, working_event_tx, new_status).await;
} }
@@ -792,6 +796,11 @@ impl WorkerController {
manifest_toml.clone(), manifest_toml.clone(),
greeting, greeting,
)); ));
let usage_state = shared_state.clone();
worker.engine_mut().on_usage(move |event| {
usage_state
.update_context_usage(event.input_tokens, protocol::ContextTokenSource::Measured);
});
if let Some(fs_for_view) = fs_for_view { if let Some(fs_for_view) = fs_for_view {
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view)); shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view));
} }
@@ -2509,6 +2518,10 @@ async fn controller_loop<C, St>(
} }
} }
}; };
shared_state.update_context_usage(
Some(worker.total_tokens().tokens),
protocol::ContextTokenSource::Estimated,
);
if !matches!( if !matches!(
result, result,
Err(WorkerError::Store(_)) Err(WorkerError::Store(_))
@@ -2576,6 +2589,10 @@ async fn controller_loop<C, St>(
.await .await
{ {
worker.clear_in_flight_events(); worker.clear_in_flight_events();
shared_state.update_context_usage(
Some(worker.total_tokens().tokens),
protocol::ContextTokenSource::Estimated,
);
let snapshot = shared_state.transition(WorkerState::Idle); let snapshot = shared_state.transition(WorkerState::Idle);
let _ = working_event_tx.send(Event::WorkerState { snapshot }); let _ = working_event_tx.send(Event::WorkerState { snapshot });
} }
@@ -3383,7 +3400,24 @@ fn model_supports_image_attachments(model: &manifest::ModelManifest) -> bool {
}) })
} }
fn build_greeting<C, St>(worker: &Worker<C, St>) -> protocol::Greeting fn public_reasoning_config(
reasoning: &agen::llm_client::capability::ReasoningControl,
) -> protocol::ReasoningConfig {
match reasoning {
agen::llm_client::capability::ReasoningControl::Effort(effort) => {
protocol::ReasoningConfig::Effort {
effort: effort.as_str().to_owned(),
}
}
agen::llm_client::capability::ReasoningControl::BudgetTokens(budget_tokens) => {
protocol::ReasoningConfig::BudgetTokens {
budget_tokens: *budget_tokens,
}
}
}
}
pub(crate) fn build_greeting<C, St>(worker: &Worker<C, St>) -> protocol::Greeting
where where
C: LlmClient + 'static, C: LlmClient + 'static,
St: Store, St: Store,
@@ -3426,6 +3460,7 @@ where
.into_iter() .into_iter()
.map(|def| def.name) .map(|def| def.name)
.collect(); .collect();
let context_tokens = worker.total_tokens().tokens;
protocol::Greeting { protocol::Greeting {
worker_name: manifest.worker.name.clone(), worker_name: manifest.worker.name.clone(),
cwd: worker cwd: worker
@@ -3434,10 +3469,19 @@ where
.unwrap_or_default(), .unwrap_or_default(),
provider: provider_name, provider: provider_name,
model: model_id, model: model_id,
reasoning: manifest
.engine
.reasoning
.as_ref()
.map(public_reasoning_config),
scope_summary: worker.scope_snapshot().summary(), scope_summary: worker.scope_snapshot().summary(),
tools: tool_names, tools: tool_names,
context_window, context_window,
context_tokens: worker.total_tokens().tokens, context_tokens,
context_usage: Some(protocol::ContextUsage {
tokens: context_tokens,
source: protocol::ContextTokenSource::Estimated,
}),
} }
} }
@@ -3500,6 +3544,28 @@ mod tests {
assert!(materialize < exposure); assert!(materialize < exposure);
} }
#[test]
fn public_reasoning_config_preserves_effort_and_signed_budget() {
let effort =
public_reasoning_config(&agen::llm_client::capability::ReasoningControl::Effort(
agen::llm_client::capability::ReasoningEffort::Other("provider-native".into()),
));
assert_eq!(
effort,
protocol::ReasoningConfig::Effort {
effort: "provider-native".into(),
}
);
let budget = public_reasoning_config(
&agen::llm_client::capability::ReasoningControl::BudgetTokens(-1),
);
assert_eq!(
budget,
protocol::ReasoningConfig::BudgetTokens { budget_tokens: -1 }
);
}
#[test] #[test]
fn automatic_memory_extraction_destinations_are_mutually_exclusive() { fn automatic_memory_extraction_destinations_are_mutually_exclusive() {
let mut memory = manifest::MemoryFeatureProfileConfig::default(); let mut memory = manifest::MemoryFeatureProfileConfig::default();
@@ -3658,6 +3724,8 @@ mod tests {
tools: Vec::new(), tools: Vec::new(),
context_window: 200_000, context_window: 200_000,
context_tokens: 0, context_tokens: 0,
reasoning: None,
context_usage: None,
}, },
)); ));
let notify_buffer = NotifyBuffer::new(); let notify_buffer = NotifyBuffer::new();
@@ -3707,6 +3775,8 @@ mod tests {
tools: Vec::new(), tools: Vec::new(),
context_window: 200_000, context_window: 200_000,
context_tokens: 0, context_tokens: 0,
reasoning: None,
context_usage: None,
}, },
state: WorkerStatus::Idle.into(), state: WorkerStatus::Idle.into(),
in_flight: Default::default(), in_flight: Default::default(),
@@ -4415,6 +4485,8 @@ mod tests {
tools: Vec::new(), tools: Vec::new(),
context_window: 1, context_window: 1,
context_tokens: 0, context_tokens: 0,
reasoning: None,
context_usage: None,
}, },
); );
assert!( assert!(
+12
View File
@@ -1507,6 +1507,8 @@ mod tests {
tools: Vec::new(), tools: Vec::new(),
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
reasoning: None,
context_usage: None,
}, },
state: WorkerStatus::Idle.into(), state: WorkerStatus::Idle.into(),
in_flight: Default::default(), in_flight: Default::default(),
@@ -1543,6 +1545,8 @@ mod tests {
tools: Vec::new(), tools: Vec::new(),
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
reasoning: None,
context_usage: None,
}, },
state: WorkerStatus::Idle.into(), state: WorkerStatus::Idle.into(),
in_flight: Default::default(), in_flight: Default::default(),
@@ -1634,6 +1638,8 @@ mod tests {
tools: Vec::new(), tools: Vec::new(),
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
reasoning: None,
context_usage: None,
}, },
state: WorkerStatus::Idle.into(), state: WorkerStatus::Idle.into(),
in_flight: Default::default(), in_flight: Default::default(),
@@ -1661,6 +1667,8 @@ mod tests {
tools: Vec::new(), tools: Vec::new(),
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
reasoning: None,
context_usage: None,
}, },
state: WorkerStatus::Idle.into(), state: WorkerStatus::Idle.into(),
in_flight: Default::default(), in_flight: Default::default(),
@@ -1765,6 +1773,8 @@ mod tests {
tools: Vec::new(), tools: Vec::new(),
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
reasoning: None,
context_usage: None,
}, },
state: WorkerStatus::Paused.into(), state: WorkerStatus::Paused.into(),
in_flight: Default::default(), in_flight: Default::default(),
@@ -1820,6 +1830,8 @@ mod tests {
tools: Vec::new(), tools: Vec::new(),
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
reasoning: None,
context_usage: None,
}, },
state: WorkerStatus::Idle.into(), state: WorkerStatus::Idle.into(),
in_flight: Default::default(), in_flight: Default::default(),
+26 -2
View File
@@ -7,7 +7,7 @@
//! need durable domain audit must keep using their domain authority (for example Memory audit). //! need durable domain audit must keep using their domain authority (for example Memory audit).
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex, RwLock};
use agen::timeline::event::UsageEvent; use agen::timeline::event::UsageEvent;
use agen::{Engine, EngineError, llm_client::LlmClient}; use agen::{Engine, EngineError, llm_client::LlmClient};
@@ -17,7 +17,9 @@ use session_store::{LogEntry, SegmentId, SessionId, Store, StoreError, TraceEntr
use tokio::sync::broadcast; use tokio::sync::broadcast;
use uuid::Uuid; use uuid::Uuid;
use crate::controller::{wire_event_bridges_on_engine, wire_workdir_command_events}; use crate::controller::{
build_greeting, wire_event_bridges_on_engine, wire_workdir_command_events,
};
use crate::feature::FeatureRegistryBuilder; use crate::feature::FeatureRegistryBuilder;
use crate::in_flight::{InFlightEvents, snapshot_from_guard}; use crate::in_flight::{InFlightEvents, snapshot_from_guard};
use crate::ipc::alerter::Alerter; use crate::ipc::alerter::Alerter;
@@ -409,6 +411,7 @@ enum InternalWorkerSessionCommand {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct InternalWorkerSessionSnapshot { pub(crate) struct InternalWorkerSessionSnapshot {
pub session: protocol::SessionSnapshot, pub session: protocol::SessionSnapshot,
pub greeting: Option<protocol::Greeting>,
pub status: WorkerStatus, pub status: WorkerStatus,
pub error: Option<String>, pub error: Option<String>,
pub in_flight: InFlightSnapshot, pub in_flight: InFlightSnapshot,
@@ -428,6 +431,7 @@ pub(crate) struct InternalWorkerSessionHandle {
visibility: InternalWorkerVisibility, visibility: InternalWorkerVisibility,
last_error: Arc<Mutex<Option<String>>>, last_error: Arc<Mutex<Option<String>>>,
last_outcome: Arc<Mutex<Option<InternalWorkerTurnOutcome>>>, last_outcome: Arc<Mutex<Option<InternalWorkerTurnOutcome>>>,
greeting: Option<Arc<RwLock<protocol::Greeting>>>,
child_registry: Option<Arc<SpawnedWorkerRegistry>>, child_registry: Option<Arc<SpawnedWorkerRegistry>>,
sink: SegmentLogSink, sink: SegmentLogSink,
#[cfg(test)] #[cfg(test)]
@@ -481,6 +485,12 @@ impl InternalWorkerSessionHandle {
}; };
InternalWorkerSessionSnapshot { InternalWorkerSessionSnapshot {
session: session_store::public_snapshot::project_current_session_snapshot(&entries), session: session_store::public_snapshot::project_current_session_snapshot(&entries),
greeting: self.greeting.as_ref().map(|greeting| {
greeting
.read()
.expect("internal Worker greeting lock poisoned")
.clone()
}),
status: match self.status() { status: match self.status() {
InternalWorkerSessionStatus::Running => WorkerStatus::Running, InternalWorkerSessionStatus::Running => WorkerStatus::Running,
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused, InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
@@ -851,7 +861,19 @@ pub(crate) async fn prepare_internal_worker_session(
worker.attach_alerter(alerter.clone()); worker.attach_alerter(alerter.clone());
worker.attach_working_event_tx(event_tx.clone()); worker.attach_working_event_tx(event_tx.clone());
worker.attach_in_flight_events(in_flight.clone()); worker.attach_in_flight_events(in_flight.clone());
let greeting = Arc::new(RwLock::new(build_greeting(&worker)));
wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight); wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight);
let usage_greeting = greeting.clone();
worker.engine_mut().on_usage(move |event| {
let mut greeting = usage_greeting
.write()
.expect("internal Worker greeting lock poisoned");
greeting.context_tokens = event.input_tokens.unwrap_or_default();
greeting.context_usage = event.input_tokens.map(|tokens| protocol::ContextUsage {
tokens,
source: protocol::ContextTokenSource::Measured,
});
});
let session_id = worker.session_id(); let session_id = worker.session_id();
let segment_id = worker.segment_id(); let segment_id = worker.segment_id();
@@ -874,6 +896,7 @@ pub(crate) async fn prepare_internal_worker_session(
visibility, visibility,
last_error: last_error.clone(), last_error: last_error.clone(),
last_outcome: last_outcome.clone(), last_outcome: last_outcome.clone(),
greeting: Some(greeting),
child_registry, child_registry,
sink, sink,
#[cfg(test)] #[cfg(test)]
@@ -1206,6 +1229,7 @@ pub(crate) fn test_internal_worker_session(
visibility, visibility,
last_error: Arc::new(Mutex::new(None)), last_error: Arc::new(Mutex::new(None)),
last_outcome: Arc::new(Mutex::new(None)), last_outcome: Arc::new(Mutex::new(None)),
greeting: None,
child_registry: None, child_registry: None,
sink, sink,
fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)), fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)),
+2
View File
@@ -212,6 +212,8 @@ mod tests {
tools: Vec::new(), tools: Vec::new(),
context_window: 200_000, context_window: 200_000,
context_tokens: 0, context_tokens: 0,
reasoning: None,
context_usage: None,
}, },
) )
} }
+41 -2
View File
@@ -39,7 +39,7 @@ pub struct WorkerSharedState {
pub worker_name: String, pub worker_name: String,
pub segment_id: SegmentId, pub segment_id: SegmentId,
pub manifest_toml: String, pub manifest_toml: String,
pub greeting: protocol::Greeting, greeting: RwLock<protocol::Greeting>,
state: RwLock<WorkerStateSnapshot>, state: RwLock<WorkerStateSnapshot>,
accepted_commands: RwLock<VecDeque<AcceptedWorkerCommand>>, accepted_commands: RwLock<VecDeque<AcceptedWorkerCommand>>,
/// Worker-from-the-inside view of the filesystem. Set once in /// Worker-from-the-inside view of the filesystem. Set once in
@@ -62,7 +62,7 @@ impl WorkerSharedState {
worker_name, worker_name,
segment_id, segment_id,
manifest_toml, manifest_toml,
greeting, greeting: RwLock::new(greeting),
state: RwLock::new(WorkerStateSnapshot::initial()), state: RwLock::new(WorkerStateSnapshot::initial()),
accepted_commands: RwLock::new(VecDeque::new()), accepted_commands: RwLock::new(VecDeque::new()),
fs_view: OnceLock::new(), fs_view: OnceLock::new(),
@@ -90,6 +90,22 @@ impl WorkerSharedState {
self.flow_transition_enabled.load(Ordering::Acquire) self.flow_transition_enabled.load(Ordering::Acquire)
} }
pub fn greeting(&self) -> protocol::Greeting {
self.greeting
.read()
.expect("worker greeting lock poisoned")
.clone()
}
pub fn update_context_usage(&self, tokens: Option<u64>, source: protocol::ContextTokenSource) {
let mut greeting = self
.greeting
.write()
.expect("worker greeting lock poisoned");
greeting.context_tokens = tokens.unwrap_or_default();
greeting.context_usage = tokens.map(|tokens| protocol::ContextUsage { tokens, source });
}
pub fn transition(&self, state: WorkerState) -> WorkerStateSnapshot { pub fn transition(&self, state: WorkerState) -> WorkerStateSnapshot {
let mut snapshot = self let mut snapshot = self
.state .state
@@ -245,9 +261,32 @@ mod tests {
tools: Vec::new(), tools: Vec::new(),
context_window: 200_000, context_window: 200_000,
context_tokens: 0, context_tokens: 0,
reasoning: None,
context_usage: None,
} }
} }
#[test]
fn current_context_usage_is_reflected_in_reconnect_greeting() {
let state = test_state();
state.update_context_usage(Some(42_000), protocol::ContextTokenSource::Measured);
let greeting = state.greeting();
assert_eq!(greeting.context_tokens, 42_000);
assert_eq!(
greeting.context_usage,
Some(protocol::ContextUsage {
tokens: 42_000,
source: protocol::ContextTokenSource::Measured,
})
);
state.update_context_usage(None, protocol::ContextTokenSource::Measured);
let greeting = state.greeting();
assert_eq!(greeting.context_tokens, 0);
assert_eq!(greeting.context_usage, None);
}
#[test] #[test]
fn initial_snapshot_is_idle() { fn initial_snapshot_is_idle() {
let state = test_state(); let state = test_state();
+2
View File
@@ -97,6 +97,8 @@ mod tests {
tools: Vec::new(), tools: Vec::new(),
context_window: 200_000, context_window: 200_000,
context_tokens: 0, context_tokens: 0,
reasoning: None,
context_usage: None,
}, },
state: WorkerStatus::Idle.into(), state: WorkerStatus::Idle.into(),
in_flight: Default::default(), in_flight: Default::default(),
+1
View File
@@ -995,6 +995,7 @@ fn internal_worker_snapshot(
worker, worker,
revision, revision,
session: snapshot.session, session: snapshot.session,
greeting: snapshot.greeting,
status: snapshot.status, status: snapshot.status,
error: snapshot.error, error: snapshot.error,
in_flight: snapshot.in_flight, in_flight: snapshot.in_flight,
File diff suppressed because one or more lines are too long
+30 -9
View File
@@ -200,21 +200,42 @@ export type SubmissionDisposition = "started" | "queued";
export type SessionSnapshot = { pending_submissions: PendingSubmissionsSnapshot, entries: Array<SessionSnapshotEntry>, }; export type SessionSnapshot = { pending_submissions: PendingSubmissionsSnapshot, entries: Array<SessionSnapshotEntry>, };
export type ReasoningConfig = { "kind": "effort", effort: string, } | { "kind": "budget_tokens", budget_tokens: number, };
export type ContextTokenSource = "measured" | "estimated";
export type ContextUsage = { tokens: number, source: ContextTokenSource, };
export type Greeting = { worker_name: string, cwd: string, provider: string, model: string,
/**
* Effective reasoning control. Absent means unspecified or unavailable.
*/
reasoning?: ReasoningConfig | null, scope_summary: string, tools: Array<string>,
/**
* Resolved effective model context window in tokens. Legacy producers use
* zero when this is unavailable; clients must treat that as unknown.
*/
context_window: number,
/**
* Legacy numeric mirror retained for mixed-version clients.
*/
context_tokens: number,
/**
* Typed current-context value. Older snapshots omit it; clients may fall
* back to `context_tokens` only when `context_window` is known.
*/
context_usage?: ContextUsage | null, };
export type InternalWorkerKind = "sub_worker" | { "service": { kind: string, } }; export type InternalWorkerKind = "sub_worker" | { "service": { kind: string, } };
export type InternalWorkerRef = { session_id: string, name: string, parent_session_id?: string | null, kind: InternalWorkerKind, }; export type InternalWorkerRef = { session_id: string, name: string, parent_session_id?: string | null, kind: InternalWorkerKind, };
export type InternalWorkerSnapshot = { worker: InternalWorkerRef, revision: number, session: SessionSnapshot, status: WorkerStatus, error?: string | null, in_flight?: InFlightSnapshot, internal_workers?: Array<InternalWorkerSnapshot>, }; export type InternalWorkerSnapshot = { worker: InternalWorkerRef, revision: number, session: SessionSnapshot,
export type Greeting = { worker_name: string, cwd: string, provider: string, model: string, scope_summary: string, tools: Array<string>,
/** /**
* Model context window in tokens. Always filled by the Worker greeting. * Public execution metadata for this child. Older snapshots and service
* workers may omit it; clients must never substitute the parent's values.
*/ */
context_window: number, greeting?: Greeting | null, status: WorkerStatus, error?: string | null, in_flight?: InFlightSnapshot, internal_workers?: Array<InternalWorkerSnapshot>, };
/**
* Estimated current session context tokens at connect time.
*/
context_tokens: number, };
export type Alert = { level: AlertLevel, source: AlertSource, message: string, export type Alert = { level: AlertLevel, source: AlertSource, message: string,
/** /**
@@ -0,0 +1,42 @@
// @vitest-environment happy-dom
import { cleanup, render } from "@testing-library/svelte";
import { afterEach, expect, test } from "vitest";
import WorkerContextStatus from "./WorkerContextStatus.svelte";
afterEach(cleanup);
test("renders resolved model, reasoning, and measured context accessibly", () => {
const view = render(WorkerContextStatus, {
metadata: {
model: "gpt-6-astra",
reasoning: { kind: "effort", effort: "high" },
contextWindow: 272_000,
contextTokens: 142_000,
contextSource: "measured",
},
});
const status = view.getByLabelText("Worker model and context");
expect(status.textContent).toContain("gpt-6-astra · high");
expect(status.textContent).toContain("Context 142.0k / 272.0k (52%)");
});
test("renders honest unavailable state instead of a fabricated zero percent", async () => {
const view = render(WorkerContextStatus, { metadata: null });
expect(view.getByText("Model unavailable")).not.toBeNull();
expect(view.getByText("Context unavailable")).not.toBeNull();
expect(view.container.textContent).not.toContain("0%");
await view.rerender({
metadata: {
model: "claude",
reasoning: { kind: "budget_tokens", budget_tokens: 8_192 },
contextWindow: 64_000,
contextTokens: 12_000,
contextSource: "estimated",
},
});
expect(view.container.textContent).toContain("claude · 8.2k token budget");
expect(view.container.textContent).toContain("Context ~12.0k / 64.0k (19%)");
});
@@ -0,0 +1,69 @@
<script lang="ts">
import type { ConsoleWorkerMetadata } from "./model";
import {
formatContextSummary,
formatModelSummary,
} from "./worker-metadata";
type Props = {
metadata: ConsoleWorkerMetadata | null;
};
let { metadata }: Props = $props();
const modelSummary = $derived(formatModelSummary(metadata));
const contextSummary = $derived(formatContextSummary(metadata));
</script>
<div class="worker-context-status" aria-label="Worker model and context">
<span class="worker-model" title={modelSummary}>{modelSummary}</span>
<span class="separator" aria-hidden="true">|</span>
<span class="worker-context" title={contextSummary}>{contextSummary}</span>
</div>
<style>
.worker-context-status {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
font-variant-numeric: tabular-nums;
}
.worker-model,
.worker-context {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.worker-model {
color: var(--text);
}
.separator {
flex: 0 0 auto;
color: var(--line-strong);
}
@media (max-width: 640px) {
.worker-context-status {
width: 100%;
flex-wrap: wrap;
row-gap: 0;
}
.separator {
display: none;
}
.worker-model,
.worker-context {
flex: 1 1 100%;
}
}
</style>
@@ -60,7 +60,7 @@
<span aria-hidden="true">・</span> <span aria-hidden="true">・</span>
<span>{requests} {requestLabel}</span> <span>{requests} {requestLabel}</span>
<span aria-hidden="true">|</span> <span aria-hidden="true">|</span>
<span>↑{formatRunTokens(uploadTokens)}/↓{formatRunTokens(outputTokens)}</span> <span>Run ↑{formatRunTokens(uploadTokens)}/↓{formatRunTokens(outputTokens)}</span>
</div> </div>
<style> <style>
@@ -210,6 +210,77 @@ function semanticOutput(lines: readonly ConsoleLine[]) {
})); }));
} }
Deno.test("snapshot and usage keep current context separate from run traffic", () => {
const snapshot = snapshotEvent("/repo") as SnapshotEvent;
snapshot.data.greeting.model = "gpt-6-astra";
snapshot.data.greeting.reasoning = { kind: "effort", effort: "high" };
snapshot.data.greeting.context_window = 272_000;
snapshot.data.greeting.context_tokens = 1;
snapshot.data.greeting.context_usage = { tokens: 142_000, source: "estimated" };
const projection = projectConsole([
{ eventId: "snapshot", event: snapshot, observedAtMs: 1 },
{ eventId: "usage", event: { event: "usage", data: {
input_tokens: 150_000,
cache_read_input_tokens: 140_000,
output_tokens: 2_000,
} }, observedAtMs: 2 },
]);
assertEquals(projection.workerMetadata, {
model: "gpt-6-astra",
reasoning: { kind: "effort", effort: "high" },
contextWindow: 272_000,
contextTokens: 150_000,
contextSource: "measured",
});
assertEquals(projection.runActivity.uploadTokens, 10_000);
assertEquals(projection.runActivity.outputTokens, 2_000);
});
Deno.test("snapshot metadata supports budget reasoning and legacy context fallback", () => {
const snapshot = snapshotEvent("/repo") as SnapshotEvent;
snapshot.data.greeting.reasoning = { kind: "budget_tokens", budget_tokens: 16_384 };
delete snapshot.data.greeting.context_usage;
const projection = projectConsole([{ eventId: "snapshot", event: snapshot }]);
assertEquals(projection.workerMetadata, {
model: "model",
reasoning: { kind: "budget_tokens", budget_tokens: 16_384 },
contextWindow: 100,
contextTokens: 20,
contextSource: "estimated",
});
});
Deno.test("unknown and destructive boundaries never fabricate context percentages", () => {
const retained = snapshotEvent("") as SnapshotEvent;
retained.data.greeting.model = "";
retained.data.greeting.context_window = 0;
retained.data.greeting.context_tokens = 0;
delete retained.data.greeting.context_usage;
let projection = projectConsole([{ eventId: "retained", event: retained }]);
assertEquals(projection.workerMetadata, {
model: null,
reasoning: null,
contextWindow: null,
contextTokens: null,
contextSource: null,
});
const live = snapshotEvent("/repo") as SnapshotEvent;
live.data.greeting.context_usage = { tokens: 20, source: "measured" };
projection = projectConsole([
{ eventId: "live", event: live },
{ eventId: "rewind", event: { event: "rewind_applied", data: {
session: canonicalSession([]),
input: [],
summary: { truncated_to_entries: 0, discarded_entries: 1, tool_side_effect_warning: false },
} } },
]);
assertEquals(projection.workerMetadata?.contextTokens, null);
assertEquals(projection.workerMetadata?.contextSource, null);
});
Deno.test("large paste segments project compact artifact metadata", () => { Deno.test("large paste segments project compact artifact metadata", () => {
const body = "secret pasted body"; const body = "secret pasted body";
const text = segmentsToText([{ const text = segmentsToText([{
@@ -2977,6 +3048,18 @@ Deno.test("Internal Worker snapshot output continues without entering the parent
worker, worker,
revision: 4, revision: 4,
session: canonicalSession([]), session: canonicalSession([]),
greeting: {
worker_name: "research",
cwd: "/child",
provider: "anthropic",
model: "child-model",
reasoning: { kind: "budget_tokens", budget_tokens: 8_192 },
scope_summary: "child scope",
tools: [],
context_window: 64_000,
context_tokens: 12_000,
context_usage: { tokens: 12_000, source: "measured" },
},
status: "running", status: "running",
in_flight: { in_flight: {
blocks: [{ kind: "text", text: "**chi", finished: false }], blocks: [{ kind: "text", text: "**chi", finished: false }],
@@ -2991,6 +3074,14 @@ Deno.test("Internal Worker snapshot output continues without entering the parent
projection.internalWorkers[0].console.lines.map((line) => line.body), projection.internalWorkers[0].console.lines.map((line) => line.body),
["**chi"], ["**chi"],
); );
assertEquals(projection.workerMetadata?.model, "model");
assertEquals(projection.internalWorkers[0].console.workerMetadata, {
model: "child-model",
reasoning: { kind: "budget_tokens", budget_tokens: 8_192 },
contextWindow: 64_000,
contextTokens: 12_000,
contextSource: "measured",
});
projection = projector.append([ projection = projector.append([
{ {
@@ -4,11 +4,13 @@ import type {
CommandSnapshot, CommandSnapshot,
CommandStreamSlice, CommandStreamSlice,
Event as ProtocolEvent, Event as ProtocolEvent,
Greeting,
InFlightBlock, InFlightBlock,
InFlightCompaction, InFlightCompaction,
InFlightToolCallState, InFlightToolCallState,
InternalWorkerRef, InternalWorkerRef,
InternalWorkerSnapshot, InternalWorkerSnapshot,
ReasoningConfig,
Segment, Segment,
SessionSnapshotEntry, SessionSnapshotEntry,
WorkerState, WorkerState,
@@ -164,6 +166,14 @@ export function resolveConsoleWorkerView(
return views.find((view) => view.sessionId === selectedSessionId) ?? views[0]; return views.find((view) => view.sessionId === selectedSessionId) ?? views[0];
} }
export type ConsoleWorkerMetadata = {
model: string | null;
reasoning: ReasoningConfig | null;
contextWindow: number | null;
contextTokens: number | null;
contextSource: "measured" | "estimated" | null;
};
export type ConsoleProjection = { export type ConsoleProjection = {
lines: ConsoleLine[]; lines: ConsoleLine[];
tasks: ConsoleTask[]; tasks: ConsoleTask[];
@@ -174,6 +184,7 @@ export type ConsoleProjection = {
usage: string | null; usage: string | null;
runActivity: RunActivityStats; runActivity: RunActivityStats;
cwd: string | null; cwd: string | null;
workerMetadata: ConsoleWorkerMetadata | null;
lastEventId: string | null; lastEventId: string | null;
internalWorkers: InternalWorkerProjection[]; internalWorkers: InternalWorkerProjection[];
/** Terminal child-session fences, reset only by an authoritative snapshot. */ /** Terminal child-session fences, reset only by an authoritative snapshot. */
@@ -204,6 +215,57 @@ function workerStatusFromState(snapshot: WorkerStateSnapshot): WorkerStatus {
return "running"; return "running";
} }
function safeTokenCount(value: unknown, allowZero = true): number | null {
return typeof value === "number" && Number.isSafeInteger(value) &&
(allowZero ? value >= 0 : value > 0)
? value
: null;
}
function metadataFromGreeting(greeting: Greeting): ConsoleWorkerMetadata {
const contextWindow = safeTokenCount(greeting.context_window, false);
const typedTokens = safeTokenCount(greeting.context_usage?.tokens);
const legacyTokens = contextWindow === null
? null
: safeTokenCount(greeting.context_tokens);
const model = greeting.model.trim();
return {
model: model.length > 0 ? model : null,
reasoning: greeting.reasoning ?? null,
contextWindow,
contextTokens: typedTokens ?? legacyTokens,
contextSource: typedTokens !== null
? greeting.context_usage?.source ?? null
: legacyTokens !== null
? "estimated"
: null,
};
}
function metadataWithUsage(
metadata: ConsoleWorkerMetadata | null,
inputTokens: number | null | undefined,
): ConsoleWorkerMetadata {
const tokens = safeTokenCount(inputTokens);
return {
model: metadata?.model ?? null,
reasoning: metadata?.reasoning ?? null,
contextWindow: metadata?.contextWindow ?? null,
contextTokens: tokens,
contextSource: tokens === null ? null : "measured",
};
}
function metadataWithoutContext(
metadata: ConsoleWorkerMetadata | null,
): ConsoleWorkerMetadata | null {
return metadata && {
...metadata,
contextTokens: null,
contextSource: null,
};
}
export function emptyConsoleProjection(): ConsoleProjection { export function emptyConsoleProjection(): ConsoleProjection {
return { return {
lines: [], lines: [],
@@ -215,6 +277,7 @@ export function emptyConsoleProjection(): ConsoleProjection {
usage: null, usage: null,
runActivity: emptyRunActivityStats(), runActivity: emptyRunActivityStats(),
cwd: null, cwd: null,
workerMetadata: null,
lastEventId: null, lastEventId: null,
internalWorkers: [], internalWorkers: [],
removedInternalWorkers: {}, removedInternalWorkers: {},
@@ -779,6 +842,9 @@ function projectInternalWorkerSnapshot(
cwd, cwd,
); );
console.status = snapshot.status; console.status = snapshot.status;
console.workerMetadata = snapshot.greeting
? metadataFromGreeting(snapshot.greeting)
: null;
appendSnapshotInFlightLines( appendSnapshotInFlightLines(
console, console,
snapshot.in_flight?.blocks ?? [], snapshot.in_flight?.blocks ?? [],
@@ -951,6 +1017,7 @@ export function applyProtocolEvent(
envelope.observedAtMs ?? 0, envelope.observedAtMs ?? 0,
), ),
cwd: projection.cwd, cwd: projection.cwd,
workerMetadata: projection.workerMetadata,
lastEventId: envelope.eventId, lastEventId: envelope.eventId,
internalWorkers: [...projection.internalWorkers], internalWorkers: [...projection.internalWorkers],
removedInternalWorkers: { ...projection.removedInternalWorkers }, removedInternalWorkers: { ...projection.removedInternalWorkers },
@@ -1055,6 +1122,10 @@ export function applyProtocolEvent(
break; break;
case "usage": case "usage":
next.usage = usageText(event.data); next.usage = usageText(event.data);
next.workerMetadata = metadataWithUsage(
next.workerMetadata,
event.data.input_tokens,
);
break; break;
case "error": case "error":
if (hasDurableRunFailure(next, event.data.message)) break; if (hasDurableRunFailure(next, event.data.message)) break;
@@ -1072,6 +1143,7 @@ export function applyProtocolEvent(
break; break;
case "snapshot": { case "snapshot": {
next.cwd = event.data.greeting.cwd; next.cwd = event.data.greeting.cwd;
next.workerMetadata = metadataFromGreeting(event.data.greeting);
const snapshot = snapshotProjectionFromSession( const snapshot = snapshotProjectionFromSession(
envelope.eventId, envelope.eventId,
event.data.session, event.data.session,
@@ -1171,6 +1243,7 @@ export function applyProtocolEvent(
next.lines = [...segment.lines, ...retainedErrors, ...retainedCompaction]; next.lines = [...segment.lines, ...retainedErrors, ...retainedCompaction];
next.tasks = segment.tasks; next.tasks = segment.tasks;
next.taskNextId = segment.taskNextId; next.taskNextId = segment.taskNextId;
next.workerMetadata = metadataWithoutContext(next.workerMetadata);
break; break;
} }
case "invoke_start": case "invoke_start":
@@ -1196,21 +1269,25 @@ export function applyProtocolEvent(
case "memory_worker": case "memory_worker":
case "completions": case "completions":
case "rewind_targets": case "rewind_targets":
case "rewind_applied":
case "workers_listed": case "workers_listed":
case "worker_restored": case "worker_restored":
case "peer_registered": case "peer_registered":
// These are protocol/status/control events. TUI Console does not append // These are protocol/status/control events. TUI Console does not append
// them to the conversation surface; browser Console should not either. // them to the conversation surface; browser Console should not either.
break; break;
case "rewind_applied":
next.workerMetadata = metadataWithoutContext(next.workerMetadata);
break;
case "compaction_progress": case "compaction_progress":
return applyInFlightCompaction(next, event.data.compaction ?? null); return applyInFlightCompaction(next, event.data.compaction ?? null);
case "compact_start": case "compact_start":
case "compact_done":
case "compact_failed": case "compact_failed":
// Durable lifecycle events are retained only as historical protocol // Durable lifecycle events are retained only as historical protocol
// compatibility. Runtime progress is the sole Console block authority. // compatibility. Runtime progress is the sole Console block authority.
break; break;
case "compact_done":
next.workerMetadata = metadataWithoutContext(next.workerMetadata);
break;
case "shutdown": case "shutdown":
next.status = "shutdown"; next.status = "shutdown";
break; break;
@@ -2182,6 +2259,7 @@ function snapshotProjectionFromSession(
usage: null, usage: null,
runActivity: emptyRunActivityStats(), runActivity: emptyRunActivityStats(),
cwd, cwd,
workerMetadata: null,
lastEventId: eventId, lastEventId: eventId,
internalWorkers: [], internalWorkers: [],
removedInternalWorkers: {}, removedInternalWorkers: {},
@@ -8,6 +8,11 @@ import {
resolveCompactionStatusPresentation, resolveCompactionStatusPresentation,
visibleCompactionProgress, visibleCompactionProgress,
} from "./run-status.ts"; } from "./run-status.ts";
import {
formatContextSummary,
formatModelSummary,
formatReasoning,
} from "./worker-metadata.ts";
function assertEquals(actual: unknown, expected: unknown): void { function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) { if (JSON.stringify(actual) !== JSON.stringify(expected)) {
@@ -17,6 +22,57 @@ function assertEquals(actual: unknown, expected: unknown): void {
} }
} }
Deno.test("worker metadata formats effort, budget, estimate, and unknown safely", () => {
assertEquals(
formatModelSummary({
model: "gpt-6-astra",
reasoning: { kind: "effort", effort: "high" },
contextWindow: 272_000,
contextTokens: 142_000,
contextSource: "measured",
}),
"gpt-6-astra · high",
);
assertEquals(
formatReasoning({ kind: "budget_tokens", budget_tokens: 16_384 }),
"16.4k token budget",
);
assertEquals(
formatContextSummary({
model: "model",
reasoning: null,
contextWindow: 272_000,
contextTokens: 142_000,
contextSource: "estimated",
}),
"Context ~142.0k / 272.0k (52%)",
);
assertEquals(formatModelSummary(null), "Model unavailable");
assertEquals(
formatReasoning({ kind: "effort", effort: "" }),
"reasoning unavailable",
);
assertEquals(
formatReasoning({ kind: "budget_tokens", budget_tokens: Number.NaN }),
"reasoning unavailable",
);
assertEquals(
formatReasoning({ kind: "budget_tokens", budget_tokens: -1 }),
"reasoning unavailable",
);
assertEquals(formatContextSummary(null), "Context unavailable");
assertEquals(
formatContextSummary({
model: null,
reasoning: null,
contextWindow: 0,
contextTokens: 0,
contextSource: null,
}),
"Context unavailable",
);
});
Deno.test("run activity follows TUI request and net-token accounting", () => { Deno.test("run activity follows TUI request and net-token accounting", () => {
let stats = applyRunActivityEvent( let stats = applyRunActivityEvent(
emptyRunActivityStats(), emptyRunActivityStats(),
@@ -720,7 +720,8 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps"
Deno.test("Console spacing and text metrics use existing design tokens", async () => { Deno.test("Console spacing and text metrics use existing design tokens", async () => {
const files = [ const files = [
"ComposerInput.svelte", "ConsoleLineItem.svelte", "RichMarkdown.svelte", "ComposerInput.svelte", "ConsoleLineItem.svelte", "RichMarkdown.svelte",
"ConsoleTasks.svelte", "WorkerRunStatus.svelte", "ConsoleDisplayState.svelte", "ConsoleTasks.svelte", "WorkerRunStatus.svelte", "WorkerContextStatus.svelte",
"ConsoleDisplayState.svelte",
"ConsoleTurnNavigation.svelte", "ConsoleTurnNavigation.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
]; ];
@@ -0,0 +1,43 @@
import type { ReasoningConfig } from "$lib/generated/protocol";
import type { ConsoleWorkerMetadata } from "./model";
import { formatRunTokens } from "./run-status";
export function formatReasoning(reasoning: ReasoningConfig | null): string {
if (!reasoning) return "reasoning unavailable";
if (reasoning.kind === "effort") {
const effort = reasoning.effort.trim();
return effort.length > 0 ? effort : "reasoning unavailable";
}
if (
!Number.isSafeInteger(reasoning.budget_tokens) ||
reasoning.budget_tokens <= 0
) {
return "reasoning unavailable";
}
return `${formatRunTokens(reasoning.budget_tokens)} token budget`;
}
export function formatModelSummary(
metadata: ConsoleWorkerMetadata | null,
): string {
if (!metadata?.model) return "Model unavailable";
return `${metadata.model} · ${formatReasoning(metadata.reasoning)}`;
}
export function formatContextSummary(
metadata: ConsoleWorkerMetadata | null,
): string {
const used = metadata?.contextTokens;
const limit = metadata?.contextWindow;
if (
used === null || used === undefined || limit === null ||
limit === undefined || !Number.isSafeInteger(used) ||
!Number.isSafeInteger(limit) || used < 0 || limit <= 0
) return "Context unavailable";
const percent = Math.round((used / limit) * 100);
if (!Number.isFinite(percent)) return "Context unavailable";
const estimate = metadata?.contextSource === "estimated" ? "~" : "";
return `Context ${estimate}${formatRunTokens(used)} / ${
formatRunTokens(limit)
} (${percent}%)`;
}
@@ -21,6 +21,7 @@
} from "$lib/workspace/console/composer-command"; } from "$lib/workspace/console/composer-command";
import { FileCompletions } from "$lib/workspace/console/file-completions"; import { FileCompletions } from "$lib/workspace/console/file-completions";
import WorkerRunStatus from "$lib/workspace/console/WorkerRunStatus.svelte"; import WorkerRunStatus from "$lib/workspace/console/WorkerRunStatus.svelte";
import WorkerContextStatus from "$lib/workspace/console/WorkerContextStatus.svelte";
import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts"; import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts";
import { import {
consoleWorkerViews, consoleWorkerViews,
@@ -1651,6 +1652,7 @@
<div class="console-shell worker-console-shell"> <div class="console-shell worker-console-shell">
<section class="console-header card" aria-label="Worker controls"> <section class="console-header card" aria-label="Worker controls">
<WorkerContextStatus metadata={selectedConsoleProjection.workerMetadata} />
<div class="console-header-actions"> <div class="console-header-actions">
<div <div
class="console-view-modes" class="console-view-modes"
@@ -2119,8 +2121,9 @@
.console-header { .console-header {
display: flex; display: flex;
align-items: flex-start; min-width: 0;
justify-content: flex-end; align-items: center;
justify-content: space-between;
gap: var(--space-4); gap: var(--space-4);
} }
@@ -2133,6 +2136,7 @@
.console-header-actions { .console-header-actions {
display: flex; display: flex;
flex: 0 0 auto;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
flex-wrap: wrap; flex-wrap: wrap;
@@ -2670,7 +2674,12 @@
} }
.console-header { .console-header {
align-items: stretch;
flex-direction: column; flex-direction: column;
} }
.console-header-actions {
align-self: flex-end;
}
} }
</style> </style>