fix: synchronize live worker context metadata
This commit is contained in:
@@ -1245,6 +1245,16 @@ pub enum Event {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
cache_read_input_tokens: Option<u64>,
|
||||
},
|
||||
/// Authoritative current-context occupancy after a non-request boundary.
|
||||
///
|
||||
/// Unlike [`Event::Usage`], this is not cumulative request traffic. It lets
|
||||
/// connected clients converge with reconnect snapshots after run completion,
|
||||
/// compaction, rewind, and other history rewrites. `None` explicitly means
|
||||
/// the current value is unavailable and stale occupancy must be cleared.
|
||||
ContextUsage {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
usage: Option<ContextUsage>,
|
||||
},
|
||||
RunEnd {
|
||||
result: RunResult,
|
||||
},
|
||||
@@ -2440,6 +2450,30 @@ mod tests {
|
||||
assert_eq!(parsed["data"]["state"]["state"]["state"]["state"], "paused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_usage_event_roundtrips_typed_value_and_unknown() {
|
||||
let event = Event::ContextUsage {
|
||||
usage: Some(ContextUsage {
|
||||
tokens: 25_000,
|
||||
source: ContextTokenSource::Estimated,
|
||||
}),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&json).unwrap(),
|
||||
serde_json::json!({
|
||||
"event": "context_usage",
|
||||
"data": {
|
||||
"usage": { "tokens": 25_000, "source": "estimated" }
|
||||
}
|
||||
})
|
||||
);
|
||||
assert!(matches!(
|
||||
serde_json::from_str::<Event>(r#"{"event":"context_usage","data":{}}"#).unwrap(),
|
||||
Event::ContextUsage { usage: None }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_snapshot_in_flight_roundtrip_and_default() {
|
||||
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"}}}}}"#;
|
||||
|
||||
+62
-4
@@ -247,6 +247,9 @@ pub struct App {
|
||||
/// Latest session context tokens reported by the Worker. This is the raw
|
||||
/// `input_tokens` value and is independent from per-run upload totals.
|
||||
pub session_context_tokens: u64,
|
||||
/// Availability and provenance for `session_context_tokens`. `None` means
|
||||
/// the current value is unknown and clients must not render a fabricated 0%.
|
||||
pub session_context_source: Option<protocol::ContextTokenSource>,
|
||||
pub context_window: u64,
|
||||
pub turn_index: usize,
|
||||
pub current_tool: Option<String>,
|
||||
@@ -352,6 +355,7 @@ impl App {
|
||||
run_upload_tokens: 0,
|
||||
run_output_tokens: 0,
|
||||
session_context_tokens: 0,
|
||||
session_context_source: None,
|
||||
context_window: 0,
|
||||
turn_index: 0,
|
||||
current_tool: None,
|
||||
@@ -1405,6 +1409,9 @@ impl App {
|
||||
} => {
|
||||
if let Some(input_tokens) = input_tokens {
|
||||
self.session_context_tokens = input_tokens;
|
||||
self.session_context_source = Some(protocol::ContextTokenSource::Measured);
|
||||
} else {
|
||||
self.session_context_source = None;
|
||||
}
|
||||
// Subtract the cache-hit portion so a tool loop that
|
||||
// re-sends the same prefix on every request doesn't
|
||||
@@ -1416,6 +1423,14 @@ impl App {
|
||||
self.run_upload_tokens += net_input;
|
||||
self.run_output_tokens += output_tokens.unwrap_or(0);
|
||||
}
|
||||
Event::ContextUsage { usage } => {
|
||||
if let Some(usage) = usage {
|
||||
self.session_context_tokens = usage.tokens;
|
||||
self.session_context_source = Some(usage.source);
|
||||
} else {
|
||||
self.session_context_source = None;
|
||||
}
|
||||
}
|
||||
Event::Error { code, message } => {
|
||||
self.handle_error(code, message);
|
||||
}
|
||||
@@ -2429,10 +2444,14 @@ impl App {
|
||||
) {
|
||||
self.greeting = Some(greeting.clone());
|
||||
self.context_window = greeting.context_window;
|
||||
self.session_context_tokens = greeting
|
||||
.context_usage
|
||||
.map(|usage| usage.tokens)
|
||||
.unwrap_or(greeting.context_tokens);
|
||||
let context_usage = greeting.context_usage.or_else(|| {
|
||||
(greeting.context_window > 0).then_some(protocol::ContextUsage {
|
||||
tokens: greeting.context_tokens,
|
||||
source: protocol::ContextTokenSource::Estimated,
|
||||
})
|
||||
});
|
||||
self.session_context_tokens = context_usage.map(|usage| usage.tokens).unwrap_or_default();
|
||||
self.session_context_source = context_usage.map(|usage| usage.source);
|
||||
self.restore_session(session, Some(greeting));
|
||||
self.apply_in_flight_snapshot(in_flight);
|
||||
}
|
||||
@@ -2458,6 +2477,7 @@ impl App {
|
||||
self.greeting = Some(greeting.clone());
|
||||
self.context_window = greeting.context_window;
|
||||
self.session_context_tokens = 0;
|
||||
self.session_context_source = None;
|
||||
}
|
||||
let missing_greeting = greeting.is_none();
|
||||
self.restore_session(session, greeting);
|
||||
@@ -2850,6 +2870,7 @@ mod rewind_refresh_tests {
|
||||
assert_eq!(composer_text(&app), "selected rewind input");
|
||||
assert!(blocks_contain(&app, "Rewound session"));
|
||||
assert_eq!(app.session_context_tokens, 0);
|
||||
assert_eq!(app.session_context_source, None);
|
||||
assert_eq!(app.greeting.as_ref().and_then(|g| g.context_usage), None);
|
||||
}
|
||||
|
||||
@@ -4575,6 +4596,10 @@ mod completion_flow_tests {
|
||||
|
||||
assert_eq!(app.context_window, 123_000);
|
||||
assert_eq!(app.session_context_tokens, 45_000);
|
||||
assert_eq!(
|
||||
app.session_context_source,
|
||||
Some(protocol::ContextTokenSource::Measured)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4597,6 +4622,10 @@ mod completion_flow_tests {
|
||||
|
||||
assert_eq!(app.context_window, 100_000);
|
||||
assert_eq!(app.session_context_tokens, 25_000);
|
||||
assert_eq!(
|
||||
app.session_context_source,
|
||||
Some(protocol::ContextTokenSource::Estimated)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4610,10 +4639,39 @@ mod completion_flow_tests {
|
||||
});
|
||||
|
||||
assert_eq!(app.session_context_tokens, 42_000);
|
||||
assert_eq!(
|
||||
app.session_context_source,
|
||||
Some(protocol::ContextTokenSource::Measured)
|
||||
);
|
||||
assert_eq!(app.run_upload_tokens, 2_000);
|
||||
assert_eq!(app.run_output_tokens, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_usage_event_converges_live_state_without_changing_run_traffic() {
|
||||
let mut app = App::new("test".into());
|
||||
app.run_upload_tokens = 2_000;
|
||||
app.run_output_tokens = 9;
|
||||
|
||||
app.handle_worker_event(Event::ContextUsage {
|
||||
usage: Some(protocol::ContextUsage {
|
||||
tokens: 45_000,
|
||||
source: protocol::ContextTokenSource::Estimated,
|
||||
}),
|
||||
});
|
||||
|
||||
assert_eq!(app.session_context_tokens, 45_000);
|
||||
assert_eq!(
|
||||
app.session_context_source,
|
||||
Some(protocol::ContextTokenSource::Estimated)
|
||||
);
|
||||
assert_eq!(app.run_upload_tokens, 2_000);
|
||||
assert_eq!(app.run_output_tokens, 9);
|
||||
|
||||
app.handle_worker_event(Event::ContextUsage { usage: None });
|
||||
assert_eq!(app.session_context_source, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_worker_event_updates_actionbar_state() {
|
||||
let mut app = App::new("test".into());
|
||||
|
||||
+29
-5
@@ -1830,13 +1830,20 @@ fn draw_separator(frame: &mut Frame, area: Rect) {
|
||||
}
|
||||
|
||||
fn context_usage_text(app: &App) -> String {
|
||||
let pct = if app.context_window == 0 {
|
||||
0
|
||||
} else {
|
||||
((app.session_context_tokens as f64 / app.context_window as f64) * 100.0).round() as u64
|
||||
let Some(source) = app.session_context_source else {
|
||||
return "Context unavailable".to_string();
|
||||
};
|
||||
if app.context_window == 0 {
|
||||
return "Context unavailable".to_string();
|
||||
}
|
||||
let pct =
|
||||
((app.session_context_tokens as f64 / app.context_window as f64) * 100.0).round() as u64;
|
||||
let estimate = matches!(source, protocol::ContextTokenSource::Estimated)
|
||||
.then_some("~")
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"{} / {} ({}%)",
|
||||
"{}{} / {} ({}%)",
|
||||
estimate,
|
||||
fmt_tokens(app.session_context_tokens),
|
||||
fmt_tokens(app.context_window),
|
||||
pct
|
||||
@@ -2194,6 +2201,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_usage_distinguishes_unknown_measured_and_estimated_values() {
|
||||
let mut app = App::new("worker".into());
|
||||
app.context_window = 100_000;
|
||||
app.session_context_tokens = 0;
|
||||
app.session_context_source = None;
|
||||
assert_eq!(context_usage_text(&app), "Context unavailable");
|
||||
|
||||
app.session_context_tokens = 25_000;
|
||||
app.session_context_source = Some(protocol::ContextTokenSource::Measured);
|
||||
assert_eq!(context_usage_text(&app), "25.0k / 100.0k (25%)");
|
||||
|
||||
app.session_context_tokens = 20_000;
|
||||
app.session_context_source = Some(protocol::ContextTokenSource::Estimated);
|
||||
assert_eq!(context_usage_text(&app), "~20.0k / 100.0k (20%)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_elapsed_uses_console_style_units() {
|
||||
assert_eq!(fmt_run_elapsed(9), "9s");
|
||||
|
||||
@@ -269,6 +269,17 @@ async fn set_controller_state(
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn publish_context_usage(
|
||||
shared_state: &Arc<WorkerSharedState>,
|
||||
working_event_tx: &broadcast::Sender<Event>,
|
||||
tokens: Option<u64>,
|
||||
source: protocol::ContextTokenSource,
|
||||
) {
|
||||
shared_state.update_context_usage(tokens, source);
|
||||
let usage = tokens.map(|tokens| protocol::ContextUsage { tokens, source });
|
||||
let _ = working_event_tx.send(Event::ContextUsage { usage });
|
||||
}
|
||||
|
||||
async fn set_controller_status(
|
||||
shared_state: &Arc<WorkerSharedState>,
|
||||
runtime_dir: &RuntimeDir,
|
||||
@@ -303,7 +314,9 @@ async fn finish_controller_run<C, St>(
|
||||
// the terminal run boundary so reconnect snapshots cannot append stale
|
||||
// partial text/tool arguments after newer entries.
|
||||
worker.clear_in_flight_events();
|
||||
shared_state.update_context_usage(
|
||||
publish_context_usage(
|
||||
shared_state,
|
||||
working_event_tx,
|
||||
Some(worker.total_tokens().tokens),
|
||||
protocol::ContextTokenSource::Estimated,
|
||||
);
|
||||
@@ -797,9 +810,14 @@ impl WorkerController {
|
||||
greeting,
|
||||
));
|
||||
let usage_state = shared_state.clone();
|
||||
let usage_events = working_event_tx.clone();
|
||||
worker.engine_mut().on_usage(move |event| {
|
||||
usage_state
|
||||
.update_context_usage(event.input_tokens, protocol::ContextTokenSource::Measured);
|
||||
publish_context_usage(
|
||||
&usage_state,
|
||||
&usage_events,
|
||||
event.input_tokens,
|
||||
protocol::ContextTokenSource::Measured,
|
||||
);
|
||||
});
|
||||
if let Some(fs_for_view) = fs_for_view {
|
||||
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view));
|
||||
@@ -2518,7 +2536,9 @@ async fn controller_loop<C, St>(
|
||||
}
|
||||
}
|
||||
};
|
||||
shared_state.update_context_usage(
|
||||
publish_context_usage(
|
||||
&shared_state,
|
||||
&working_event_tx,
|
||||
Some(worker.total_tokens().tokens),
|
||||
protocol::ContextTokenSource::Estimated,
|
||||
);
|
||||
@@ -2589,7 +2609,9 @@ async fn controller_loop<C, St>(
|
||||
.await
|
||||
{
|
||||
worker.clear_in_flight_events();
|
||||
shared_state.update_context_usage(
|
||||
publish_context_usage(
|
||||
&shared_state,
|
||||
&working_event_tx,
|
||||
Some(worker.total_tokens().tokens),
|
||||
protocol::ContextTokenSource::Estimated,
|
||||
);
|
||||
@@ -3566,6 +3588,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_usage_publication_updates_reconnect_state_and_live_clients() {
|
||||
let shared_state = Arc::new(WorkerSharedState::new(
|
||||
"worker".into(),
|
||||
session_store::new_segment_id(),
|
||||
String::new(),
|
||||
protocol::Greeting {
|
||||
worker_name: "worker".into(),
|
||||
cwd: "/tmp".into(),
|
||||
provider: "provider".into(),
|
||||
model: "model".into(),
|
||||
reasoning: None,
|
||||
scope_summary: String::new(),
|
||||
tools: Vec::new(),
|
||||
context_window: 100_000,
|
||||
context_tokens: 0,
|
||||
context_usage: None,
|
||||
},
|
||||
));
|
||||
let (events, mut receiver) = broadcast::channel(4);
|
||||
|
||||
publish_context_usage(
|
||||
&shared_state,
|
||||
&events,
|
||||
Some(25_000),
|
||||
protocol::ContextTokenSource::Estimated,
|
||||
);
|
||||
|
||||
let expected = protocol::ContextUsage {
|
||||
tokens: 25_000,
|
||||
source: protocol::ContextTokenSource::Estimated,
|
||||
};
|
||||
assert_eq!(shared_state.greeting().context_usage, Some(expected));
|
||||
assert!(matches!(
|
||||
receiver.try_recv(),
|
||||
Ok(Event::ContextUsage {
|
||||
usage: Some(usage)
|
||||
}) if usage == expected
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_memory_extraction_destinations_are_mutually_exclusive() {
|
||||
let mut memory = manifest::MemoryFeatureProfileConfig::default();
|
||||
|
||||
@@ -834,6 +834,23 @@ fn spawn_internal_log_event_bridge(sink: SegmentLogSink, event_tx: broadcast::Se
|
||||
});
|
||||
}
|
||||
|
||||
fn publish_internal_context_usage(
|
||||
greeting: &RwLock<protocol::Greeting>,
|
||||
event_tx: &broadcast::Sender<Event>,
|
||||
tokens: Option<u64>,
|
||||
source: protocol::ContextTokenSource,
|
||||
) {
|
||||
let usage = tokens.map(|tokens| protocol::ContextUsage { tokens, source });
|
||||
{
|
||||
let mut greeting = greeting
|
||||
.write()
|
||||
.expect("internal Worker greeting lock poisoned");
|
||||
greeting.context_tokens = tokens.unwrap_or_default();
|
||||
greeting.context_usage = usage;
|
||||
}
|
||||
let _ = event_tx.send(Event::ContextUsage { usage });
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare_internal_worker_session(
|
||||
mut worker: Worker<Box<dyn LlmClient>, EphemeralSessionStore>,
|
||||
store: EphemeralSessionStore,
|
||||
@@ -864,17 +881,17 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
let greeting = Arc::new(RwLock::new(build_greeting(&worker)));
|
||||
wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight);
|
||||
let usage_greeting = greeting.clone();
|
||||
let usage_events = event_tx.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,
|
||||
});
|
||||
publish_internal_context_usage(
|
||||
&usage_greeting,
|
||||
&usage_events,
|
||||
event.input_tokens,
|
||||
protocol::ContextTokenSource::Measured,
|
||||
);
|
||||
});
|
||||
|
||||
let actor_greeting = greeting.clone();
|
||||
let session_id = worker.session_id();
|
||||
let segment_id = worker.segment_id();
|
||||
let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(8);
|
||||
@@ -909,29 +926,13 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
match command {
|
||||
InternalWorkerSessionCommand::Run(input) => {
|
||||
actor_in_flight.clear();
|
||||
let (turn_status, error, outcome) = {
|
||||
let cancel_sender = worker.engine_mut().cancel_sender();
|
||||
let mut run = std::pin::pin!(worker.run_text(&input));
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = &mut run => {
|
||||
let (turn_status, error, outcome) =
|
||||
classify_internal_turn_result(result);
|
||||
actor_in_flight.clear();
|
||||
*last_outcome.lock().unwrap() = Some(outcome);
|
||||
status.store(turn_status.encode(), std::sync::atomic::Ordering::Release);
|
||||
if let Some(message) = error {
|
||||
*last_error.lock().unwrap() = Some(message.clone());
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: protocol::ErrorCode::Internal,
|
||||
message,
|
||||
});
|
||||
}
|
||||
send_internal_worker_state(&event_tx, turn_status);
|
||||
if let Some(callback) = &on_turn_end {
|
||||
callback(turn_status);
|
||||
}
|
||||
state_changed.notify_waiters();
|
||||
break;
|
||||
break classify_internal_turn_result(result);
|
||||
}
|
||||
command = command_rx.recv() => {
|
||||
match command {
|
||||
@@ -953,6 +954,28 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
publish_internal_context_usage(
|
||||
&actor_greeting,
|
||||
&event_tx,
|
||||
Some(worker.total_tokens().tokens),
|
||||
protocol::ContextTokenSource::Estimated,
|
||||
);
|
||||
actor_in_flight.clear();
|
||||
*last_outcome.lock().unwrap() = Some(outcome);
|
||||
status.store(turn_status.encode(), std::sync::atomic::Ordering::Release);
|
||||
if let Some(message) = error {
|
||||
*last_error.lock().unwrap() = Some(message.clone());
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: protocol::ErrorCode::Internal,
|
||||
message,
|
||||
});
|
||||
}
|
||||
send_internal_worker_state(&event_tx, turn_status);
|
||||
if let Some(callback) = &on_turn_end {
|
||||
callback(turn_status);
|
||||
}
|
||||
state_changed.notify_waiters();
|
||||
}
|
||||
InternalWorkerSessionCommand::Stop(done) => {
|
||||
stop_done = Some(done);
|
||||
@@ -1509,11 +1532,37 @@ permission = "write"
|
||||
assert_eq!(handle.protocol_snapshot().in_flight.blocks.len(), 1);
|
||||
let entries_after_first = handle.entries().len();
|
||||
assert!(entries_after_first >= 4);
|
||||
assert!(matches!(
|
||||
handle
|
||||
.protocol_snapshot()
|
||||
.greeting
|
||||
.and_then(|greeting| greeting.context_usage),
|
||||
Some(protocol::ContextUsage {
|
||||
source: protocol::ContextTokenSource::Estimated,
|
||||
..
|
||||
})
|
||||
));
|
||||
let mut events = handle.subscribe_events();
|
||||
handle.send("follow-up").await.expect("send follow-up turn");
|
||||
assert_eq!(
|
||||
handle.wait_until_idle().await,
|
||||
InternalWorkerSessionStatus::Idle
|
||||
);
|
||||
let mut saw_estimated_context = false;
|
||||
while let Ok(event) = events.try_recv() {
|
||||
if matches!(
|
||||
event,
|
||||
Event::ContextUsage {
|
||||
usage: Some(protocol::ContextUsage {
|
||||
source: protocol::ContextTokenSource::Estimated,
|
||||
..
|
||||
})
|
||||
}
|
||||
) {
|
||||
saw_estimated_context = true;
|
||||
}
|
||||
}
|
||||
assert!(saw_estimated_context);
|
||||
assert!(handle.protocol_snapshot().in_flight.blocks.is_empty());
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
assert!(handle.entries().len() > entries_after_first);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -381,7 +381,7 @@ summary: string,
|
||||
* Full tool output. Absent when the tool chose to return
|
||||
* summary-only, or when the result was pruned.
|
||||
*/
|
||||
output?: string | null, disposition?: ToolResultDisposition | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { session: SessionSnapshot, greeting: Greeting,
|
||||
output?: string | null, disposition?: ToolResultDisposition | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "context_usage", "data": { usage?: ContextUsage | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { session: SessionSnapshot, greeting: Greeting,
|
||||
/**
|
||||
* Full revisioned live execution state. `Stopped` remains Runtime
|
||||
* catalog authority and is deliberately not represented here.
|
||||
|
||||
@@ -39,4 +39,15 @@ test("renders honest unavailable state instead of a fabricated zero percent", as
|
||||
});
|
||||
expect(view.container.textContent).toContain("claude · 8.2k token budget");
|
||||
expect(view.container.textContent).toContain("Context ~12.0k / 64.0k (19%)");
|
||||
|
||||
await view.rerender({
|
||||
metadata: {
|
||||
model: "claude",
|
||||
reasoning: { kind: "budget_tokens", budget_tokens: -1 },
|
||||
contextWindow: 64_000,
|
||||
contextTokens: 12_000,
|
||||
contextSource: "estimated",
|
||||
},
|
||||
});
|
||||
expect(view.container.textContent).toContain("claude · dynamic token budget");
|
||||
});
|
||||
|
||||
@@ -224,14 +224,17 @@ Deno.test("snapshot and usage keep current context separate from run traffic", (
|
||||
cache_read_input_tokens: 140_000,
|
||||
output_tokens: 2_000,
|
||||
} }, observedAtMs: 2 },
|
||||
{ eventId: "context", event: { event: "context_usage", data: {
|
||||
usage: { tokens: 158_000, source: "estimated" },
|
||||
} }, observedAtMs: 3 },
|
||||
]);
|
||||
|
||||
assertEquals(projection.workerMetadata, {
|
||||
model: "gpt-6-astra",
|
||||
reasoning: { kind: "effort", effort: "high" },
|
||||
contextWindow: 272_000,
|
||||
contextTokens: 150_000,
|
||||
contextSource: "measured",
|
||||
contextTokens: 158_000,
|
||||
contextSource: "estimated",
|
||||
});
|
||||
assertEquals(projection.runActivity.uploadTokens, 10_000);
|
||||
assertEquals(projection.runActivity.outputTokens, 2_000);
|
||||
@@ -279,6 +282,20 @@ Deno.test("unknown and destructive boundaries never fabricate context percentage
|
||||
]);
|
||||
assertEquals(projection.workerMetadata?.contextTokens, null);
|
||||
assertEquals(projection.workerMetadata?.contextSource, null);
|
||||
|
||||
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 },
|
||||
} } },
|
||||
{ eventId: "post-rewind-context", event: { event: "context_usage", data: {
|
||||
usage: { tokens: 12, source: "estimated" },
|
||||
} } },
|
||||
]);
|
||||
assertEquals(projection.workerMetadata?.contextTokens, 12);
|
||||
assertEquals(projection.workerMetadata?.contextSource, "estimated");
|
||||
});
|
||||
|
||||
Deno.test("large paste segments project compact artifact metadata", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CommandEvent,
|
||||
CommandSnapshot,
|
||||
CommandStreamSlice,
|
||||
ContextUsage,
|
||||
Event as ProtocolEvent,
|
||||
Greeting,
|
||||
InFlightBlock,
|
||||
@@ -256,6 +257,20 @@ function metadataWithUsage(
|
||||
};
|
||||
}
|
||||
|
||||
function metadataWithContextUsage(
|
||||
metadata: ConsoleWorkerMetadata | null,
|
||||
usage: ContextUsage | null | undefined,
|
||||
): ConsoleWorkerMetadata {
|
||||
const tokens = safeTokenCount(usage?.tokens);
|
||||
return {
|
||||
model: metadata?.model ?? null,
|
||||
reasoning: metadata?.reasoning ?? null,
|
||||
contextWindow: metadata?.contextWindow ?? null,
|
||||
contextTokens: tokens,
|
||||
contextSource: tokens === null ? null : usage?.source ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function metadataWithoutContext(
|
||||
metadata: ConsoleWorkerMetadata | null,
|
||||
): ConsoleWorkerMetadata | null {
|
||||
@@ -1127,6 +1142,12 @@ export function applyProtocolEvent(
|
||||
event.data.input_tokens,
|
||||
);
|
||||
break;
|
||||
case "context_usage":
|
||||
next.workerMetadata = metadataWithContextUsage(
|
||||
next.workerMetadata,
|
||||
event.data.usage,
|
||||
);
|
||||
break;
|
||||
case "error":
|
||||
if (hasDurableRunFailure(next, event.data.message)) break;
|
||||
next.lines.push(
|
||||
|
||||
@@ -58,6 +58,10 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely"
|
||||
);
|
||||
assertEquals(
|
||||
formatReasoning({ kind: "budget_tokens", budget_tokens: -1 }),
|
||||
"dynamic token budget",
|
||||
);
|
||||
assertEquals(
|
||||
formatReasoning({ kind: "budget_tokens", budget_tokens: -2 }),
|
||||
"reasoning unavailable",
|
||||
);
|
||||
assertEquals(formatContextSummary(null), "Context unavailable");
|
||||
|
||||
@@ -8,10 +8,13 @@ export function formatReasoning(reasoning: ReasoningConfig | null): string {
|
||||
const effort = reasoning.effort.trim();
|
||||
return effort.length > 0 ? effort : "reasoning unavailable";
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(reasoning.budget_tokens) ||
|
||||
reasoning.budget_tokens <= 0
|
||||
) {
|
||||
if (!Number.isSafeInteger(reasoning.budget_tokens)) {
|
||||
return "reasoning unavailable";
|
||||
}
|
||||
if (reasoning.budget_tokens === -1) {
|
||||
return "dynamic token budget";
|
||||
}
|
||||
if (reasoning.budget_tokens <= 0) {
|
||||
return "reasoning unavailable";
|
||||
}
|
||||
return `${formatRunTokens(reasoning.budget_tokens)} token budget`;
|
||||
|
||||
Reference in New Issue
Block a user