diff --git a/run.json b/run.json index 961edb297..192724da0 100644 --- a/run.json +++ b/run.json @@ -494,7 +494,7 @@ "kind": "running" }, "status_updated_at": "2026-05-24T17:26:07.203998Z", - "last_event_at": "2026-05-24T18:01:18.824864Z", + "last_event_at": "2026-05-24T18:12:11.577756Z", "pending_control": null, "checkpoints": [ { @@ -742,9 +742,9 @@ } }, { - "seq": 0, + "seq": 1518, "checkpoint": { - "timestamp": "2026-05-24T18:01:18.863624Z", + "timestamp": "2026-05-24T18:01:22.931168Z", "current_node": "implement", "completed_nodes": [ "start", @@ -755,31 +755,154 @@ ], "node_retries": {}, "context_values": { + "internal.retry_count.start": 0, + "failure_signature": "", + "internal.run_id": "01KSDGB3G5YAPQMCXKE23SF08C", + "outcome": "succeeded", + "thread.preflight_lint.current_node": "implement", + "current_node": "implement", + "internal.retry_count.preflight_lint": 0, + "internal.retry_count.implement": 0, + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "internal.work_dir": "/home/daytona/workspace/fabro", + "thread.preflight_compile.current_node": "preflight_lint", + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "thread.toolchain.current_node": "preflight_compile", + "last_response": "Implemented the plan end-to-end.\n\nSummary:\n- Removed standalone `agent.context_window.snapshot` event from Rust event contracts, workflow conversion, event names, store reducer, frontend invalidation,", + "graph.goal": "# Fold Context Window Into Agent Messages Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Remove the chatty `agent.context_window.snapshot` event and persist context-window data through existing `agent.message` events.\n\n**Architecture:** Compute the context-window breakdown locally while the agent still has the exact request, attach the final content-free projection to the next `agent.message`, and let the run projection reducer store that latest projection for `GET /runs/{id}/stages/{stageId}/context-window`. Do not call provider token-count APIs during normal execution.\n\n**Tech Stack:** Rust, Serde, Fabro agent/session events, Fabro workflow event conversion, Fabro store projections, OpenAPI/progenitor, generated TypeScript client, React/SWR.\n\n---\n\n## Scope And Decisions\n\n- Remove `agent.context_window.snapshot` completely from new code. This is a greenfield/no-prod app, so do not preserve backward-compatible deserialization or frontend handling for old snapshot events.\n- Keep `StageContextWindowProjection`, `StageContextWindow`, `StageProjection.context_window`, and the existing context-window GET endpoint.\n- Add optional `context_window` data to `agent.message` properties.\n- Normal execution uses only local estimates and token usage returned by normal LLM responses.\n- Do not call `Client::count_input_tokens` from `fabro-agent::Session`.\n- The GET endpoint remains projection-backed for this pass. It must not require an in-memory session and must not trigger provider token-count API calls.\n- Failed-before-response turns do not persist context-window data.\n- Live pre-response context-window updates are not required. The UI updates after `agent.message`.\n\n## File Map\n\n- Modify `lib/crates/fabro-types/src/run_event/agent.rs`\n - Add `context_window: Option` to `AgentMessageProps`.\n - Remove `AgentContextWindowSnapshotProps`.\n- Modify `lib/crates/fabro-types/src/run_event/mod.rs`\n - Remove the `AgentContextWindowSnapshot` event variant, name mapping, known-event entry, and serde tests.\n - Update `agent.message` serde tests to cover optional context-window data.\n- Modify `lib/crates/fabro-agent/src/types.rs`\n - Remove `AgentEvent::ContextWindowSnapshot`.\n - Add `context_window: Option` to `AgentEvent::AssistantMessage`.\n- Modify `lib/crates/fabro-agent/src/session.rs`\n - Remove async provider-count task and response-usage snapshot event emission.\n - Keep local context-window snapshot construction at request-build time.\n - Attach the scaled or local projection to `AssistantMessage`.\n- Keep `lib/crates/fabro-agent/src/context_window.rs`\n - Reuse `build_local_snapshot` and `scaled_snapshot`.\n - Remove only tests or helpers that exist solely for provider-count snapshot emission.\n- Modify `lib/crates/fabro-workflow/src/event/convert.rs`\n - Remove conversion for `AgentEvent::ContextWindowSnapshot`.\n - Copy `context_window` from `AgentEvent::AssistantMessage` into `AgentMessageProps`.\n- Modify `lib/crates/fabro-workflow/src/event/names.rs`\n - Remove the snapshot event name.\n- Modify `lib/crates/fabro-store/src/run_state.rs`\n - Remove reducer support for `EventBody::AgentContextWindowSnapshot`.\n - When reducing `EventBody::AgentMessage`, copy `props.context_window` into `stage.context_window` when present and stamp `event_seq`.\n- Modify `lib/crates/fabro-server/src/server/tests.rs`\n - Seed context-window endpoint tests with `agent.message` events that include context-window data.\n- Modify `docs/public/api-reference/fabro-api.yaml`\n - Add optional `context_window` to `AgentMessageProps`.\n - Remove the snapshot event schema/variant.\n- Regenerate/update `lib/packages/fabro-api-client/src`.\n- Modify `apps/fabro-web/app/lib/run-events.ts` and tests\n - Remove special handling for `agent.context_window.snapshot`.\n - Rely on existing `agent.message` invalidation for stage events and context-window data.\n\n## Implementation Steps\n\n### Task 1: Move The Event Contract Onto `agent.message`\n\n- [ ] Add optional `context_window` to Rust `AgentMessageProps`.\n- [ ] Remove `AgentContextWindowSnapshotProps` and the `agent.context_window.snapshot` `EventBody` variant.\n- [ ] Update run-event serde tests so `agent.message` round-trips with and without `context_window`.\n- [ ] Remove tests whose only assertion is that `agent.context_window.snapshot` is known or serializes.\n\n### Task 2: Stop Emitting Snapshot Events\n\n- [ ] Remove `AgentEvent::ContextWindowSnapshot` and its trace/debug handling.\n- [ ] Change `AgentEvent::AssistantMessage` to carry `context_window: Option`.\n- [ ] In `Session::run_single_input`, keep the local context-window snapshot returned from request construction.\n- [ ] Delete the spawned `count_input_tokens(... PreferProvider)` task and its fingerprint suppression state.\n- [ ] After the normal LLM response arrives, compute:\n - `ResponseUsageScaledBreakdown` when response input/cache usage is positive.\n - `LocalEstimate` when response usage has no usable input tokens.\n- [ ] Attach that projection to the emitted `AssistantMessage`.\n\n### Task 3: Update Workflow Conversion And Store Projection\n\n- [ ] Remove snapshot event name/conversion branches.\n- [ ] Include `context_window` when converting `AgentEvent::AssistantMessage` to `EventBody::AgentMessage`.\n- [ ] In the store reducer, update `stage.context_window` from `AgentMessageProps.context_window`.\n- [ ] Stamp the copied projection with the `agent.message` event sequence.\n- [ ] Replace store tests for snapshot replacement with message-carried context-window tests.\n\n### Task 4: Keep The GET Endpoint Projection-Backed\n\n- [ ] Keep the endpoint route and response type unchanged.\n- [ ] Keep existing `not_agent_stage`, `not_observed`, and terminal `stored` behavior.\n- [ ] Update endpoint tests to seed context-window data via `agent.message`.\n- [ ] Do not add endpoint-time provider token-count calls.\n\n### Task 5: Remove Frontend And API Trace\n\n- [ ] Remove frontend constants/tests for `agent.context_window.snapshot`.\n- [ ] Ensure `agent.message` still invalidates `stageContextWindow` through existing stage activity handling.\n- [ ] Update OpenAPI and regenerated TypeScript client so no snapshot event model remains.\n- [ ] Run a final search for `agent.context_window.snapshot`, `AgentContextWindowSnapshot`, and `ContextWindowSnapshot`; only historical docs or this plan may remain.\n\n## Test Plan\n\n- Run `cargo build -p fabro-api` after OpenAPI/Rust type changes.\n- Run targeted Rust tests:\n - `cargo nextest run -p fabro-agent`\n - `cargo nextest run -p fabro-workflow`\n - `cargo nextest run -p fabro-store`\n - `cargo nextest run -p fabro-server get_run_stage_context_window`\n - `cargo nextest run -p fabro-types agent_message`\n- Regenerate TypeScript client with `cd lib/packages/fabro-api-client && bun run generate`.\n- Run web checks:\n - `cd apps/fabro-web && bun test`\n - `cd apps/fabro-web && bun run typecheck`\n - `cd lib/packages/fabro-api-client && bun run typecheck`\n- Run final targeted searches:\n - `rg -n \"agent\\\\.context_window\\\\.snapshot|AgentContextWindowSnapshot|ContextWindowSnapshot\" lib/crates apps/fabro-web lib/packages docs/public/api-reference/fabro-api.yaml`\n - Expected: no implementation/API/frontend matches.\n\n## Acceptance Criteria\n\n- Normal agent runs do not emit `agent.context_window.snapshot`.\n- Normal agent runs do not call provider token-count endpoints for context-window reporting.\n- `agent.message` includes context-window data when the agent produced a response.\n- `GET /runs/{id}/stages/{stageId}/context-window` still returns the latest context-window projection.\n- The event log contains no standalone context-window snapshot events.\n- Public API/client/types no longer expose `agent.context_window.snapshot`.\n", + "internal.thread_id": "preflight_lint", + "thread.start.current_node": "toolchain", + "failure_class": "", + "internal.node_visit_count": 1, + "internal.fidelity": "compact", + "last_stage": "implement", + "response.implement": "Implemented the plan end-to-end.\n\nSummary:\n- Removed standalone `agent.context_window.snapshot` event from Rust event contracts, workflow conversion, event names, store reducer, frontend invalidation, OpenAPI/API client surface.\n- Added optional `context_window` to `agent.message`:\n - `fabro-types::AgentMessageProps`\n - `fabro-agent::AgentEvent::AssistantMessage`\n - workflow conversion into `EventBody::AgentMessage`\n- Updated `Session::run_single_input` so context-window projection is computed locally from the exact request and attached only after a successful assistant response.\n- Removed normal-execution provider token-count API calls from `fabro-agent::Session`.\n- Updated store projection to persist latest `AgentMessageProps.context_window` and stamp `event_seq`.\n- Kept the context-window GET endpoint projection-backed and updated endpoint tests to seed data via `agent.message`.\n- Regenerated the TS API client and added generated `AgentMessageProps`.\n- Removed frontend snapshot event handling/tests; `agent.message` continues to invalidate stage context-window queries via stage activity handling.\n- Used red/green TDD: first added a failing `fabro-agent` test proving standalone snapshot events were still emitted, then implemented until green.\n- Used subagent-driven development for read-only investigation of Rust/API/frontend surfaces.\n\nValidation run:\n- `cargo build -p fabro-api`\n- `cargo nextest run -p fabro-types agent_message`\n- `cargo nextest run -p fabro-agent`\n- `cargo nextest run -p fabro-workflow`\n- `cargo nextest run -p fabro-store`\n- `cargo nextest run -p fabro-server get_run_stage_context_window`\n- `cargo check -q --workspace`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings`\n- `cd lib/packages/fabro-api-client && bun run generate`\n- `cd lib/packages/fabro-api-client && bun run typecheck`\n- `cd apps/fabro-web && bun run typecheck`\n- `cd apps/fabro-web && bun run test`\n- Final search: `agent\\.context_window\\.snapshot|AgentContextWindowSnapshot|ContextWindowSnapshot` — no matches.\n\nNote:\n- I pinned the API client `generate` script to `@openapitools/openapi-generator-cli@2.20.2` because current `bunx` was resolving the unpinned command to an incompatible latest wrapper in this environment.", + "internal.retry_count.preflight_compile": 0, + "internal.retry_count.toolchain": 0, + "graph.rankdir": "LR" + }, + "node_outcomes": { + "preflight_compile": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo check -q --workspace 2>&1", + "usage": null + }, + "start": { + "status": "succeeded", + "usage": null + }, + "preflight_lint": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", + "usage": null + }, + "toolchain": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/fc14b2ba2d770e5cd3169df7a29525c962adfc4cfa3097b9098c63ebd61a748c" + }, + "notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", + "usage": null + }, + "implement": { + "status": "succeeded", + "context_updates": { + "response.implement": "Implemented the plan end-to-end.\n\nSummary:\n- Removed standalone `agent.context_window.snapshot` event from Rust event contracts, workflow conversion, event names, store reducer, frontend invalidation, OpenAPI/API client surface.\n- Added optional `context_window` to `agent.message`:\n - `fabro-types::AgentMessageProps`\n - `fabro-agent::AgentEvent::AssistantMessage`\n - workflow conversion into `EventBody::AgentMessage`\n- Updated `Session::run_single_input` so context-window projection is computed locally from the exact request and attached only after a successful assistant response.\n- Removed normal-execution provider token-count API calls from `fabro-agent::Session`.\n- Updated store projection to persist latest `AgentMessageProps.context_window` and stamp `event_seq`.\n- Kept the context-window GET endpoint projection-backed and updated endpoint tests to seed data via `agent.message`.\n- Regenerated the TS API client and added generated `AgentMessageProps`.\n- Removed frontend snapshot event handling/tests; `agent.message` continues to invalidate stage context-window queries via stage activity handling.\n- Used red/green TDD: first added a failing `fabro-agent` test proving standalone snapshot events were still emitted, then implemented until green.\n- Used subagent-driven development for read-only investigation of Rust/API/frontend surfaces.\n\nValidation run:\n- `cargo build -p fabro-api`\n- `cargo nextest run -p fabro-types agent_message`\n- `cargo nextest run -p fabro-agent`\n- `cargo nextest run -p fabro-workflow`\n- `cargo nextest run -p fabro-store`\n- `cargo nextest run -p fabro-server get_run_stage_context_window`\n- `cargo check -q --workspace`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings`\n- `cd lib/packages/fabro-api-client && bun run generate`\n- `cd lib/packages/fabro-api-client && bun run typecheck`\n- `cd apps/fabro-web && bun run typecheck`\n- `cd apps/fabro-web && bun run test`\n- Final search: `agent\\.context_window\\.snapshot|AgentContextWindowSnapshot|ContextWindowSnapshot` — no matches.\n\nNote:\n- I pinned the API client `generate` script to `@openapitools/openapi-generator-cli@2.20.2` because current `bunx` was resolving the unpinned command to an incompatible latest wrapper in this environment.", + "last_stage": "implement", + "last_response": "Implemented the plan end-to-end.\n\nSummary:\n- Removed standalone `agent.context_window.snapshot` event from Rust event contracts, workflow conversion, event names, store reducer, frontend invalidation," + }, + "notes": "Stage completed: implement", + "usage": { + "input": { + "usage": { + "model": { + "provider": "openai", + "model_id": "gpt-5.5" + }, + "tokens": { + "input_tokens": 6519068, + "output_tokens": 22077, + "reasoning_tokens": 11268, + "cache_read_tokens": 22441472, + "cache_write_tokens": 0 + } + }, + "facts": { + "algorithm": "openai" + } + }, + "total_usd_micros": 44816426 + } + } + }, + "next_node_id": "simplify_opus", + "git_commit_sha": "7b0c4676db807f1ffbc728ca9074c7354fbb930d", + "node_visits": { + "implement": 1, + "toolchain": 1, + "start": 1, + "preflight_compile": 1, + "preflight_lint": 1 + } + }, + "diff": { + "patch": "diff --git a/apps/fabro-web/app/lib/query-keys.test.ts b/apps/fabro-web/app/lib/query-keys.test.ts\nindex 914f3b11f..8ad4a4a3a 100644\n--- a/apps/fabro-web/app/lib/query-keys.test.ts\n+++ b/apps/fabro-web/app/lib/query-keys.test.ts\n@@ -92,16 +92,6 @@ describe(\"queryKeys\", () => {\n }\n });\n \n- test(\"context-window snapshot invalidates context window, run events, and stage events\", () => {\n- expect(\n- queryKeysForRunEvent(\"run-1\", \"agent.context_window.snapshot\", \"stage-1\"),\n- ).toEqual([\n- queryKeys.runs.events(\"run-1\", 1000),\n- queryKeys.runs.stageEvents(\"run-1\", \"stage-1\"),\n- queryKeys.runs.stageContextWindow(\"run-1\", \"stage-1\"),\n- ]);\n- });\n-\n test(\"agent activity events without a node_id invalidate nothing\", () => {\n expect(queryKeysForRunEvent(\"run-1\", \"agent.message\")).toEqual([]);\n });\ndiff --git a/apps/fabro-web/app/lib/run-events.test.tsx b/apps/fabro-web/app/lib/run-events.test.tsx\nindex 0a33cc3d5..b73d272f1 100644\n--- a/apps/fabro-web/app/lib/run-events.test.tsx\n+++ b/apps/fabro-web/app/lib/run-events.test.tsx\n@@ -92,16 +92,6 @@ describe(\"queryKeysForRunEvent\", () => {\n ]);\n });\n \n- test(\"context-window snapshots invalidate context window, run events, and stage events\", () => {\n- expect(\n- queryKeysForRunEvent(\"run-1\", \"agent.context_window.snapshot\", \"agent@1\"),\n- ).toEqual([\n- queryKeys.runs.events(\"run-1\", 1000),\n- queryKeys.runs.stageEvents(\"run-1\", \"agent@1\"),\n- queryKeys.runs.stageContextWindow(\"run-1\", \"agent@1\"),\n- ]);\n- });\n-\n test(\"todo events invalidate run state and run events\", () => {\n for (const event of [\"todo.created\", \"todo.updated\", \"todo.deleted\"]) {\n expect(queryKeysForRunEvent(\"run-1\", event)).toEqual([\ndiff --git a/apps/fabro-web/app/lib/run-events.ts b/apps/fabro-web/app/lib/run-events.ts\nindex c88c76341..8efd40547 100644\n--- a/apps/fabro-web/app/lib/run-events.ts\n+++ b/apps/fabro-web/app/lib/run-events.ts\n@@ -107,7 +107,6 @@ const TODO_EVENTS = new Set([\n \"todo.updated\",\n \"todo.deleted\",\n ]);\n-const CONTEXT_WINDOW_SNAPSHOT_EVENT = \"agent.context_window.snapshot\";\n \n export function queryKeysForRunEvent(\n runId: string,\n@@ -169,15 +168,6 @@ export function queryKeysForRunEvent(\n return keys;\n }\n \n- if (event === CONTEXT_WINDOW_SNAPSHOT_EVENT) {\n- const keys: Key[] = [queryKeys.runs.events(runId, 1000)];\n- if (stageId) {\n- keys.push(queryKeys.runs.stageEvents(runId, stageId));\n- keys.push(queryKeys.runs.stageContextWindow(runId, stageId));\n- }\n- return keys;\n- }\n-\n if (STAGE_ACTIVITY_EVENTS.has(event)) {\n return stageId\n ? [\ndiff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml\nindex 58a1e02e9..1693d5f29 100644\n--- a/docs/public/api-reference/fabro-api.yaml\n+++ b/docs/public/api-reference/fabro-api.yaml\n@@ -7971,6 +7971,38 @@ components:\n type: integer\n minimum: 1\n \n+ AgentMessageProps:\n+ description: Properties for the `agent.message` event.\n+ type: object\n+ required:\n+ - text\n+ - model\n+ - billing\n+ - tool_call_count\n+ - visit\n+ properties:\n+ text:\n+ type: string\n+ model:\n+ $ref: \"#/components/schemas/BillingModelRef\"\n+ billing:\n+ $ref: \"#/components/schemas/BilledTokenCounts\"\n+ tool_call_count:\n+ type: integer\n+ minimum: 0\n+ visit:\n+ type: integer\n+ minimum: 1\n+ message:\n+ type: [\"object\", \"null\"]\n+ additionalProperties: true\n+ description: Canonical replay-authoritative transcript message, when present.\n+ context_window:\n+ oneOf:\n+ - $ref: \"#/components/schemas/StageContextWindowProjection\"\n+ - type: \"null\"\n+ description: Latest content-free context-window projection for this agent stage.\n+\n RunSupersededByProps:\n description: Properties for the `run.superseded_by` audit event emitted on a rewound source run after archive succeeds.\n type: object\ndiff --git a/lib/crates/fabro-agent/src/context_window.rs b/lib/crates/fabro-agent/src/context_window.rs\nindex da8da9d72..6d6923dae 100644\n--- a/lib/crates/fabro-agent/src/context_window.rs\n+++ b/lib/crates/fabro-agent/src/context_window.rs\n@@ -16,7 +16,7 @@ use crate::skills::{Skill, format_skills_prompt_section};\n use crate::tool_registry::{ToolDefinitionWithSource, ToolSource};\n \n #[derive(Clone, Copy)]\n-pub(crate) struct ContextWindowSnapshotInput<'a> {\n+pub(crate) struct ContextWindowInput<'a> {\n pub request: &'a Request,\n pub tools: &'a [ToolDefinitionWithSource],\n pub system_prompt: &'a str,\n@@ -29,9 +29,7 @@ pub(crate) struct ContextWindowSnapshotInput<'a> {\n }\n \n #[must_use]\n-pub(crate) fn build_local_snapshot(\n- input: ContextWindowSnapshotInput<'_>,\n-) -> StageContextWindowProjection {\n+pub(crate) fn build_local_snapshot(input: ContextWindowInput<'_>) -> StageContextWindowProjection {\n let mut builder = BreakdownBuilder::default();\n let mut warnings = Vec::new();\n \n@@ -131,18 +129,10 @@ pub(crate) fn warnings_from_llm(warnings: &[LlmWarning]) -> Vec StageContextWindowWarning {\n- StageContextWindowWarning {\n- code: code.to_string(),\n- message: message.to_string(),\n- }\n-}\n-\n fn add_message_breakdown(\n builder: &mut BreakdownBuilder,\n warnings: &mut Vec,\n- input: &ContextWindowSnapshotInput<'_>,\n+ input: &ContextWindowInput<'_>,\n ) {\n let memory_text = memory_prompt_suffix(input.memory);\n let skills_text = skills_prompt_suffix(input.skills);\n@@ -394,7 +384,7 @@ mod tests {\n tools.iter().map(|tool| tool.definition.clone()).collect(),\n );\n \n- let snapshot = build_local_snapshot(ContextWindowSnapshotInput {\n+ let snapshot = build_local_snapshot(ContextWindowInput {\n request: &req,\n tools: &tools,\n system_prompt: &system_prompt,\ndiff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs\nindex 11c43fffa..e7fd25566 100644\n--- a/lib/crates/fabro-agent/src/session.rs\n+++ b/lib/crates/fabro-agent/src/session.rs\n@@ -1,5 +1,4 @@\n-use std::collections::{HashMap, HashSet, VecDeque};\n-use std::hash::{Hash, Hasher};\n+use std::collections::{HashMap, VecDeque};\n use std::sync::{Arc, Mutex, RwLock};\n use std::time::SystemTime;\n \n@@ -8,7 +7,6 @@ use fabro_llm::client::Client;\n use fabro_llm::error::ProviderErrorKind;\n use fabro_llm::generate::StreamAccumulator;\n use fabro_llm::provider::StreamEventStream;\n-use fabro_llm::token_count::{InputTokenCountMethod, InputTokenCountPreference};\n use fabro_llm::types::{\n ContentPart, Message as LlmMessage, ReasoningEffort, Request, RetryPolicy, StreamEvent,\n TokenCounts, ToolChoice,\n@@ -18,8 +16,8 @@ use fabro_mcp::config::{McpServerSettings, McpTransport};\n use fabro_mcp::connection_manager::McpConnectionManager;\n use fabro_model::{AgentProfileKind, Catalog, ModelRef, Speed};\n use fabro_types::{\n- PermissionLevel, Principal, SessionMessage, SessionRecord, StageContextWindowProjection,\n- SteeringMessage,\n+ PermissionLevel, Principal, SessionMessage, SessionRecord, StageContextWindowCountMethod,\n+ StageContextWindowProjection, SteeringMessage,\n };\n use futures::StreamExt;\n use tokio::sync::{Mutex as AsyncMutex, Notify, broadcast};\n@@ -30,9 +28,7 @@ use tracing::{debug, info, warn};\n use crate::agent_profile::AgentProfile;\n use crate::compaction::{check_context_usage, compact_context};\n use crate::config::SessionOptions;\n-use crate::context_window::{\n- ContextWindowSnapshotInput, build_local_snapshot, scaled_snapshot, warning, warnings_from_llm,\n-};\n+use crate::context_window::{ContextWindowInput, build_local_snapshot, scaled_snapshot};\n use crate::error::{Error, InterruptReason};\n use crate::event::Emitter;\n use crate::file_tracker::FileTracker;\n@@ -47,7 +43,6 @@ use crate::skills::{\n };\n use crate::subagent::{SubAgentCallbackEvent, SubAgentEventCallback, SubAgentManager};\n use crate::tool_execution::execute_tool_calls;\n-use crate::tool_registry::ToolDefinitionWithSource;\n use crate::types::{\n AgentEvent, McpToolSummary, MemoryFileSummary, Message, SessionEvent, SessionState,\n SkillActivationSource, SkillSummary,\n@@ -305,13 +300,8 @@ impl ToolEnvProvider for StaticEnvProvider {\n }\n \n struct BuiltRequest {\n- request: Request,\n- tools: Vec,\n-}\n-\n-struct EmittedContextWindowSnapshot {\n- local_snapshot: StageContextWindowProjection,\n- fingerprint: Option,\n+ request: Request,\n+ context_window: StageContextWindowProjection,\n }\n \n pub struct Session {\n@@ -332,7 +322,6 @@ pub struct Session {\n control_notify: Arc,\n followup_queue: Arc>>,\n cancel_token: CancellationToken,\n- close_token: CancellationToken,\n round_token: Arc>,\n interrupt_reason: Arc>>,\n memory: Vec,\n@@ -340,8 +329,6 @@ pub struct Session {\n skills: Vec,\n system_prompt: String,\n activated_skill_context_observed: bool,\n- context_window_counted_fingerprints: HashSet,\n- context_window_response_usage_fingerprints: Arc>>,\n file_tracker: FileTracker,\n tool_env_provider: Option>,\n subagent_manager: Option>>,\n@@ -372,7 +359,6 @@ impl Session {\n control_notify: Arc::new(Notify::new()),\n followup_queue: Arc::new(Mutex::new(VecDeque::new())),\n cancel_token: CancellationToken::new(),\n- close_token: CancellationToken::new(),\n round_token: Arc::new(RwLock::new(CancellationToken::new())),\n interrupt_reason: Arc::new(Mutex::new(None)),\n memory: Vec::new(),\n@@ -380,8 +366,6 @@ impl Session {\n skills: Vec::new(),\n system_prompt: String::new(),\n activated_skill_context_observed: false,\n- context_window_counted_fingerprints: HashSet::new(),\n- context_window_response_usage_fingerprints: Arc::new(Mutex::new(HashSet::new())),\n file_tracker: FileTracker::default(),\n tool_env_provider: None,\n subagent_manager,\n@@ -1135,7 +1119,6 @@ impl Session {\n \n pub fn close(&mut self) -> bool {\n let was_open = self.state != SessionState::Closed;\n- self.close_token.cancel();\n self.transition(SessionState::Closed);\n was_open\n }\n@@ -1335,7 +1318,7 @@ impl Session {\n \n // Build request\n let built_request = self.build_request();\n- let context_window_snapshot = self.emit_context_window_snapshots(&built_request);\n+ let local_context_window = built_request.context_window.clone();\n let request = built_request.request;\n \n // Emit AssistantTextStart before LLM call\n@@ -1606,7 +1589,10 @@ impl Session {\n .cloned()\n .collect();\n let usage = response.usage.clone();\n- self.emit_response_usage_context_window_snapshot(&context_window_snapshot, &usage);\n+ let context_window = Some(context_window_from_response_usage(\n+ &local_context_window,\n+ &usage,\n+ ));\n \n self.history.push(Message::Assistant {\n content: text.clone(),\n@@ -1633,6 +1619,7 @@ impl Session {\n model,\n usage: response.usage.clone(),\n tool_call_count: tool_calls.len(),\n+ context_window,\n });\n \n // Post-response compaction: trim context after appending assistant turn\n@@ -1737,134 +1724,6 @@ impl Session {\n Ok(())\n }\n \n- fn emit_context_window_snapshots(\n- &mut self,\n- built_request: &BuiltRequest,\n- ) -> EmittedContextWindowSnapshot {\n- let provider = self.provider_profile.provider_id().to_string();\n- let model = self.provider_profile.model().to_string();\n- let local_snapshot = build_local_snapshot(ContextWindowSnapshotInput {\n- request: &built_request.request,\n- tools: &built_request.tools,\n- system_prompt: &self.system_prompt,\n- memory: &self.memory,\n- skills: &self.skills,\n- activated_skill_context_observed: self.activated_skill_context_observed,\n- provider: &provider,\n- model: &model,\n- context_window_tokens: self.provider_profile.context_window_size(),\n- });\n- self.event_emitter.emit(\n- self.id.clone(),\n- AgentEvent::ContextWindowSnapshot(local_snapshot.clone()),\n- );\n-\n- let Some(fingerprint) = request_fingerprint(&built_request.request) else {\n- return EmittedContextWindowSnapshot {\n- local_snapshot,\n- fingerprint: None,\n- };\n- };\n- if !self.context_window_counted_fingerprints.insert(fingerprint) {\n- return EmittedContextWindowSnapshot {\n- local_snapshot,\n- fingerprint: Some(fingerprint),\n- };\n- }\n-\n- let client = self.llm_client.clone();\n- let request = built_request.request.clone();\n- let session_id = self.id.clone();\n- let emitter = self.event_emitter.clone();\n- let local_for_count = local_snapshot.clone();\n- let close_token = self.close_token.clone();\n- let response_usage_fingerprints =\n- Arc::clone(&self.context_window_response_usage_fingerprints);\n- tokio::spawn(async move {\n- let count_result = tokio::select! {\n- biased;\n- () = close_token.cancelled() => return,\n- result = client.count_input_tokens(&request, InputTokenCountPreference::PreferProvider) => result,\n- };\n- if close_token.is_cancelled() {\n- return;\n- }\n- if response_usage_fingerprints\n- .lock()\n- .expect(\"context window response-usage fingerprint lock poisoned\")\n- .contains(&fingerprint)\n- {\n- return;\n- }\n- let snapshot = match count_result {\n- Ok(count) if count.method == InputTokenCountMethod::ProviderApi => {\n- let input_tokens = u64::try_from(count.input_tokens.max(0)).unwrap_or(u64::MAX);\n- scaled_snapshot(\n- &local_for_count,\n- input_tokens,\n- fabro_types::StageContextWindowCountMethod::ProviderApiScaledBreakdown,\n- warnings_from_llm(&count.warnings),\n- )\n- }\n- Ok(count) => {\n- let mut warnings = local_for_count.warnings.clone();\n- warnings.extend(warnings_from_llm(&count.warnings));\n- let input_tokens = u64::try_from(count.input_tokens.max(0)).unwrap_or(u64::MAX);\n- scaled_snapshot(\n- &local_for_count,\n- input_tokens,\n- fabro_types::StageContextWindowCountMethod::LocalEstimate,\n- warnings,\n- )\n- }\n- Err(_) => {\n- let mut warnings = local_for_count.warnings.clone();\n- warnings.push(warning(\n- \"provider_token_count_unavailable\",\n- \"provider input token counting was unavailable; retained local estimate\",\n- ));\n- let mut snapshot = local_for_count.clone();\n- snapshot.warnings = warnings;\n- snapshot\n- }\n- };\n- emitter.emit(session_id, AgentEvent::ContextWindowSnapshot(snapshot));\n- });\n-\n- EmittedContextWindowSnapshot {\n- local_snapshot,\n- fingerprint: Some(fingerprint),\n- }\n- }\n-\n- fn emit_response_usage_context_window_snapshot(\n- &self,\n- context_window_snapshot: &EmittedContextWindowSnapshot,\n- usage: &TokenCounts,\n- ) {\n- let input_tokens = usage\n- .input_tokens\n- .saturating_add(usage.cache_read_tokens)\n- .saturating_add(usage.cache_write_tokens);\n- if input_tokens <= 0 {\n- return;\n- }\n- if let Some(fingerprint) = context_window_snapshot.fingerprint {\n- self.context_window_response_usage_fingerprints\n- .lock()\n- .expect(\"context window response-usage fingerprint lock poisoned\")\n- .insert(fingerprint);\n- }\n- let snapshot = scaled_snapshot(\n- &context_window_snapshot.local_snapshot,\n- u64::try_from(input_tokens).unwrap_or(u64::MAX),\n- fabro_types::StageContextWindowCountMethod::ResponseUsageScaledBreakdown,\n- context_window_snapshot.local_snapshot.warnings.clone(),\n- );\n- self.event_emitter\n- .emit(self.id.clone(), AgentEvent::ContextWindowSnapshot(snapshot));\n- }\n-\n async fn compact_if_needed(&mut self) {\n let Some(estimate) = check_context_usage(\n &self.system_prompt,\n@@ -2002,9 +1861,22 @@ impl Session {\n metadata: None,\n provider_options: None,\n };\n+ let provider = self.provider_profile.provider_id().to_string();\n+ let model = self.provider_profile.model().to_string();\n+ let context_window = build_local_snapshot(ContextWindowInput {\n+ request: &request,\n+ tools: &tools_with_source,\n+ system_prompt: &self.system_prompt,\n+ memory: &self.memory,\n+ skills: &self.skills,\n+ activated_skill_context_observed: self.activated_skill_context_observed,\n+ provider: &provider,\n+ model: &model,\n+ context_window_tokens: self.provider_profile.context_window_size(),\n+ });\n BuiltRequest {\n request,\n- tools: tools_with_source,\n+ context_window,\n }\n }\n \n@@ -2026,6 +1898,25 @@ impl Session {\n }\n }\n \n+fn context_window_from_response_usage(\n+ local_snapshot: &StageContextWindowProjection,\n+ usage: &TokenCounts,\n+) -> StageContextWindowProjection {\n+ let input_tokens = usage\n+ .input_tokens\n+ .saturating_add(usage.cache_read_tokens)\n+ .saturating_add(usage.cache_write_tokens);\n+ if input_tokens <= 0 {\n+ return local_snapshot.clone();\n+ }\n+ scaled_snapshot(\n+ local_snapshot,\n+ u64::try_from(input_tokens).unwrap_or(u64::MAX),\n+ StageContextWindowCountMethod::ResponseUsageScaledBreakdown,\n+ local_snapshot.warnings.clone(),\n+ )\n+}\n+\n const fn is_auth_error(err: &LlmError) -> bool {\n matches!(\n err.provider_kind(),\n@@ -2033,13 +1924,6 @@ const fn is_auth_error(err: &LlmError) -> bool {\n )\n }\n \n-fn request_fingerprint(request: &Request) -> Option {\n- let bytes = serde_json::to_vec(request).ok()?;\n- let mut hasher = std::collections::hash_map::DefaultHasher::new();\n- bytes.hash(&mut hasher);\n- Some(hasher.finish())\n-}\n-\n /// Best-effort kill of a sandbox MCP server process group. Used when\n /// `start_sandbox_mcp_server` is cancelled after spawning a detached\n /// `setsid` child but before reporting readiness. Errors from the sandbox\n@@ -2574,10 +2458,15 @@ mod tests {\n .iter()\n .any(|e| matches!(e.event, AgentEvent::UserInput { .. }))\n );\n- assert!(\n- events\n- .iter()\n- .any(|e| matches!(e.event, AgentEvent::AssistantMessage { .. }))\n+ let assistant_context_window = events.iter().find_map(|e| match &e.event {\n+ AgentEvent::AssistantMessage { context_window, .. } => context_window.as_ref(),\n+ _ => None,\n+ });\n+ let context_window =\n+ assistant_context_window.expect(\"assistant message should carry context window data\");\n+ assert_eq!(\n+ context_window.count_method,\n+ StageContextWindowCountMethod::ResponseUsageScaledBreakdown\n );\n assert!(\n events\n@@ -2586,6 +2475,33 @@ mod tests {\n );\n }\n \n+ #[tokio::test]\n+ async fn assistant_message_context_window_uses_local_estimate_without_response_usage() {\n+ let mut session = make_session(vec![response_with_usage(\n+ text_response(\"Hello\"),\n+ TokenCounts::default(),\n+ )])\n+ .await;\n+ let mut rx = session.subscribe();\n+\n+ session.process_input(\"Hi\").await.unwrap();\n+\n+ let context_window = std::iter::from_fn(|| rx.try_recv().ok()).find_map(|event| {\n+ if let AgentEvent::AssistantMessage { context_window, .. } = event.event {\n+ context_window\n+ } else {\n+ None\n+ }\n+ });\n+\n+ let context_window = context_window.expect(\"assistant message should carry context window\");\n+ assert_eq!(\n+ context_window.count_method,\n+ StageContextWindowCountMethod::LocalEstimate\n+ );\n+ assert!(context_window.input_tokens > 0);\n+ }\n+\n #[tokio::test]\n async fn tool_call_end_has_untruncated_output() {\n let mut registry = ToolRegistry::new();\ndiff --git a/lib/crates/fabro-agent/src/types.rs b/lib/crates/fabro-agent/src/types.rs\nindex 9aa1a014c..d6907880f 100644\n--- a/lib/crates/fabro-agent/src/types.rs\n+++ b/lib/crates/fabro-agent/src/types.rs\n@@ -246,6 +246,8 @@ pub enum AgentEvent {\n model: ModelRef,\n usage: TokenCounts,\n tool_call_count: usize,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ context_window: Option,\n },\n TextDelta {\n delta: String,\n@@ -304,7 +306,6 @@ pub enum AgentEvent {\n delay_secs: f64,\n error: LlmError,\n },\n- ContextWindowSnapshot(StageContextWindowProjection),\n SubAgentSpawned {\n agent_id: String,\n depth: usize,\n@@ -504,17 +505,6 @@ impl AgentEvent {\n \"LLM request failed, retrying\"\n );\n }\n- Self::ContextWindowSnapshot(snapshot) => {\n- debug!(\n- session_id,\n- provider = snapshot.provider.as_str(),\n- model = snapshot.model.as_str(),\n- input_tokens = snapshot.input_tokens,\n- context_window_tokens = snapshot.context_window_tokens,\n- count_method = %snapshot.count_method,\n- \"Context window snapshot\"\n- );\n- }\n Self::SubAgentSpawned {\n agent_id,\n depth,\n@@ -891,6 +881,7 @@ mod tests {\n },\n usage: usage.clone(),\n tool_call_count: 2,\n+ context_window: None,\n };\n match &event {\n AgentEvent::AssistantMessage {\ndiff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs\nindex eee7c3789..b13f34dea 100644\n--- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs\n+++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs\n@@ -578,6 +578,7 @@ mod tests {\n },\n usage: TokenCounts::default(),\n tool_call_count: 0,\n+ context_window: None,\n })\n }\n \ndiff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs\nindex f13adafd5..e2c9b1885 100644\n--- a/lib/crates/fabro-server/src/demo/mod.rs\n+++ b/lib/crates/fabro-server/src/demo/mod.rs\n@@ -1430,6 +1430,7 @@ mod runs {\n tool_call_count: 0,\n visit: 1,\n message: None,\n+ context_window: None,\n }),\n ),\n make_envelope(\n@@ -1498,6 +1499,7 @@ mod runs {\n tool_call_count: 0,\n visit: 1,\n message: None,\n+ context_window: None,\n }),\n ),\n ]\ndiff --git a/lib/crates/fabro-server/src/server/handler/pair.rs b/lib/crates/fabro-server/src/server/handler/pair.rs\nindex f5170aa86..f60228376 100644\n--- a/lib/crates/fabro-server/src/server/handler/pair.rs\n+++ b/lib/crates/fabro-server/src/server/handler/pair.rs\n@@ -926,6 +926,7 @@ mod tests {\n tool_call_count: 0,\n visit: 1,\n message: None,\n+ context_window: None,\n }),\n ),\n )\n@@ -957,6 +958,7 @@ mod tests {\n tool_call_count: 0,\n visit: 1,\n message: None,\n+ context_window: None,\n }),\n ),\n )\ndiff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs\nindex 6488ab364..bd9759c43 100644\n--- a/lib/crates/fabro-server/src/server/tests.rs\n+++ b/lib/crates/fabro-server/src/server/tests.rs\n@@ -14,7 +14,7 @@ use fabro_config::bind::Bind;\n use fabro_interview::{\n AnswerValue, ControlInterviewer, Interviewer, Question, WorkerControlMessage,\n };\n-use fabro_llm::types::{Message as LlmMessage, Request as LlmRequest};\n+use fabro_llm::types::{Message as LlmMessage, Request as LlmRequest, TokenCounts};\n use fabro_model::catalog::LlmCatalogSettings;\n use fabro_model::{Catalog, ModelRef, ProviderId, ReasoningEffort, Speed};\n use fabro_types::settings::ServerAuthMethod;\n@@ -3023,12 +3023,22 @@ fn stage_completed_event(node_id: &str) -> workflow_event::Event {\n fn context_window_event(\n stage: &str,\n visit: u32,\n- snapshot: StageContextWindowProjection,\n+ context_window: StageContextWindowProjection,\n ) -> workflow_event::Event {\n workflow_event::Event::Agent {\n stage: stage.to_string(),\n visit,\n- event: fabro_agent::AgentEvent::ContextWindowSnapshot(snapshot),\n+ event: fabro_agent::AgentEvent::AssistantMessage {\n+ text: \"assistant response\".to_string(),\n+ model: ModelRef {\n+ provider: ProviderId::openai(),\n+ model_id: \"gpt-5.4\".to_string(),\n+ speed: None,\n+ },\n+ usage: TokenCounts::default(),\n+ tool_call_count: 0,\n+ context_window: Some(context_window),\n+ },\n session_id: Some(\"session-1\".to_string()),\n parent_session_id: None,\n tool_call_id: None,\n@@ -3045,7 +3055,7 @@ fn context_window_snapshot(\n context_window_tokens: 400_000,\n input_tokens,\n usage_percent: input_tokens as f64 * 100.0 / 400_000.0,\n- count_method: StageContextWindowCountMethod::ProviderApiScaledBreakdown,\n+ count_method: StageContextWindowCountMethod::ResponseUsageScaledBreakdown,\n staleness: StageContextWindowStaleness::Live,\n generated_at: Utc::now(),\n event_seq: None,\n@@ -6602,7 +6612,7 @@ async fn get_run_stage_context_window_returns_live_projected_snapshot() {\n assert_eq!(body[\"stage_id\"], \"agent_node@1\");\n assert_eq!(body[\"available\"], true);\n assert_eq!(body[\"provider\"], \"openai\");\n- assert_eq!(body[\"count_method\"], \"provider_api_scaled_breakdown\");\n+ assert_eq!(body[\"count_method\"], \"response_usage_scaled_breakdown\");\n assert_eq!(body[\"staleness\"], \"live\");\n assert_eq!(body[\"input_tokens\"], 123_456);\n assert_eq!(body[\"breakdown\"][0][\"category\"], \"conversation\");\ndiff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs\nindex d5854291e..e2ce31e07 100644\n--- a/lib/crates/fabro-store/src/run_state.rs\n+++ b/lib/crates/fabro-store/src/run_state.rs\n@@ -402,6 +402,11 @@ impl RunProjectionReducer for RunProjection {\n };\n stage.usage.add_counts(&props.billing);\n stage.model = Some(props.model.clone());\n+ if let Some(context_window) = &props.context_window {\n+ let mut context_window = context_window.clone();\n+ context_window.event_seq = Some(event.seq);\n+ stage.context_window = Some(context_window);\n+ }\n }\n EventBody::AgentSessionActivated(props) => {\n let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq)\n@@ -615,15 +620,6 @@ impl RunProjectionReducer for RunProjection {\n }\n }\n }\n- EventBody::AgentContextWindowSnapshot(props) => {\n- let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq)\n- else {\n- return Ok(());\n- };\n- let mut snapshot = props.snapshot.clone();\n- snapshot.event_seq = Some(event.seq);\n- stage.context_window = Some(snapshot);\n- }\n _ => {}\n }\n \n@@ -1227,14 +1223,14 @@ mod tests {\n use fabro_types::run_event::run::RunFailedProps;\n use fabro_types::run_event::{\n AgentAcpCancelledProps, AgentAcpCompletedProps, AgentAcpStartedProps,\n- AgentAcpTimedOutProps, AgentContextWindowSnapshotProps, AgentMcpFailedProps,\n- AgentMcpReadyProps, AgentMcpToolSummary, AgentMessageProps, AgentSessionActivatedProps,\n- AgentSessionEndedProps, AgentSessionStartedProps, AgentSkillActivatedProps,\n- AgentSkillActivationSource, AgentSkillSummary, AgentSkillsDiscoveredProps,\n- AgentSubClosedProps, AgentSubCompletedProps, AgentSubFailedProps, AgentSubSpawnedProps,\n- AgentToolStartedProps, CheckpointCompletedProps, InterviewCompletedProps, InterviewOption,\n- InterviewStartedProps, RunCompletedProps, RunControlEffectProps, StageCompletedProps,\n- StageFailedProps, StagePromptProps, StageRetryingProps, StageStartedProps,\n+ AgentAcpTimedOutProps, AgentMcpFailedProps, AgentMcpReadyProps, AgentMcpToolSummary,\n+ AgentMessageProps, AgentSessionActivatedProps, AgentSessionEndedProps,\n+ AgentSessionStartedProps, AgentSkillActivatedProps, AgentSkillActivationSource,\n+ AgentSkillSummary, AgentSkillsDiscoveredProps, AgentSubClosedProps, AgentSubCompletedProps,\n+ AgentSubFailedProps, AgentSubSpawnedProps, AgentToolStartedProps, CheckpointCompletedProps,\n+ InterviewCompletedProps, InterviewOption, InterviewStartedProps, RunCompletedProps,\n+ RunControlEffectProps, StageCompletedProps, StageFailedProps, StagePromptProps,\n+ StageRetryingProps, StageStartedProps,\n };\n use fabro_types::{\n AgentBackend, BilledModelUsage, BilledTokenCounts, BlockedReason, Checkpoint,\n@@ -3462,6 +3458,7 @@ mod tests {\n tool_call_count: 0,\n visit: 1,\n message: None,\n+ context_window: None,\n }\n }\n \n@@ -4816,7 +4813,7 @@ mod tests {\n }\n \n #[test]\n- fn context_window_snapshots_replace_latest_for_matching_stage() {\n+ fn agent_messages_replace_latest_context_window_for_matching_stage() {\n let mut state = initialized_projection();\n let stage_id = stage_id();\n let first = context_window_snapshot(10);\n@@ -4825,22 +4822,14 @@ mod tests {\n state\n .apply_event(&test_stage_event(\n 7,\n- EventBody::AgentContextWindowSnapshot(AgentContextWindowSnapshotProps {\n- stage_id: stage_id.clone(),\n- visit: 1,\n- snapshot: first,\n- }),\n+ EventBody::AgentMessage(agent_message_with_context_window(first)),\n stage_id.clone(),\n ))\n .unwrap();\n state\n .apply_event(&test_stage_event(\n 8,\n- EventBody::AgentContextWindowSnapshot(AgentContextWindowSnapshotProps {\n- stage_id: stage_id.clone(),\n- visit: 1,\n- snapshot: second,\n- }),\n+ EventBody::AgentMessage(agent_message_with_context_window(second)),\n stage_id.clone(),\n ))\n .unwrap();\n@@ -4852,25 +4841,44 @@ mod tests {\n }\n \n #[test]\n- fn context_window_snapshot_does_not_update_other_stage() {\n+ fn agent_message_without_context_window_preserves_existing_context_window() {\n let mut state = initialized_projection();\n- let target = stage_id();\n- let other = StageId::new(\"review\", 1);\n+ let stage_id = stage_id();\n \n state\n .apply_event(&test_stage_event(\n 7,\n- EventBody::AgentContextWindowSnapshot(AgentContextWindowSnapshotProps {\n- stage_id: target.clone(),\n- visit: 1,\n- snapshot: context_window_snapshot(10),\n- }),\n- target.clone(),\n+ EventBody::AgentMessage(agent_message_with_context_window(\n+ context_window_snapshot(10),\n+ )),\n+ stage_id.clone(),\n+ ))\n+ .unwrap();\n+ state\n+ .apply_event(&test_stage_event(\n+ 8,\n+ EventBody::AgentMessage(live_agent_message_props(live_counts(1, 1))),\n+ stage_id.clone(),\n ))\n .unwrap();\n \n- assert!(state.stage(&target).unwrap().context_window.is_some());\n- assert!(state.stage(&other).is_none());\n+ let snapshot = state\n+ .stage(&stage_id)\n+ .unwrap()\n+ .context_window\n+ .as_ref()\n+ .unwrap();\n+ assert_eq!(snapshot.input_tokens, 10);\n+ assert_eq!(snapshot.event_seq, Some(7));\n+ }\n+\n+ fn agent_message_with_context_window(\n+ context_window: StageContextWindowProjection,\n+ ) -> AgentMessageProps {\n+ AgentMessageProps {\n+ context_window: Some(context_window),\n+ ..live_agent_message_props(live_counts(1, 1))\n+ }\n }\n \n fn context_window_snapshot(input_tokens: u64) -> StageContextWindowProjection {\ndiff --git a/lib/crates/fabro-types/src/run_event/agent.rs b/lib/crates/fabro-types/src/run_event/agent.rs\nindex d172b2c9e..b1e567e6e 100644\n--- a/lib/crates/fabro-types/src/run_event/agent.rs\n+++ b/lib/crates/fabro-types/src/run_event/agent.rs\n@@ -6,7 +6,7 @@ use super::BilledTokenCounts;\n use crate::transcript::{ToolCall, ToolResult, TranscriptMessage};\n use crate::{\n MessageId, ModelRef, PairId, PairMessageId, PairSystemMessageKind, PermissionLevel,\n- StageContextWindowProjection, StageId, TurnId,\n+ StageContextWindowProjection, TurnId,\n };\n \n #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n@@ -77,6 +77,10 @@ pub struct AgentMessageProps {\n /// payloads so older events still deserialize.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub message: Option,\n+ /// Latest content-free context-window projection for this agent stage,\n+ /// computed from the request that produced this assistant response.\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub context_window: Option,\n }\n \n #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n@@ -218,14 +222,6 @@ pub struct AgentLlmRetryProps {\n pub visit: u32,\n }\n \n-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n-pub struct AgentContextWindowSnapshotProps {\n- pub stage_id: StageId,\n- pub visit: u32,\n- #[serde(flatten)]\n- pub snapshot: StageContextWindowProjection,\n-}\n-\n #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n pub struct AgentSubSpawnedProps {\n pub agent_id: String,\n@@ -357,6 +353,7 @@ mod tests {\n let props: AgentMessageProps = serde_json::from_value(v).unwrap();\n assert_eq!(props.text, \"hello\");\n assert!(props.message.is_none());\n+ assert!(props.context_window.is_none());\n }\n \n #[test]\n@@ -371,6 +368,7 @@ mod tests {\n tool_call_count: 0,\n visit: 1,\n message: Some(msg.clone()),\n+ context_window: None,\n };\n let v = serde_json::to_value(&props).unwrap();\n assert_eq!(v[\"message\"][\"kind\"], \"agent\");\ndiff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs\nindex 84a58ef16..571a3d860 100644\n--- a/lib/crates/fabro-types/src/run_event/mod.rs\n+++ b/lib/crates/fabro-types/src/run_event/mod.rs\n@@ -238,8 +238,6 @@ pub enum EventBody {\n AgentCompactionCompleted(AgentCompactionCompletedProps),\n #[serde(rename = \"agent.llm.retry\")]\n AgentLlmRetry(AgentLlmRetryProps),\n- #[serde(rename = \"agent.context_window.snapshot\")]\n- AgentContextWindowSnapshot(AgentContextWindowSnapshotProps),\n #[serde(rename = \"agent.sub.spawned\")]\n AgentSubSpawned(AgentSubSpawnedProps),\n #[serde(rename = \"agent.sub.completed\")]\n@@ -520,7 +518,6 @@ impl EventBody {\n Self::AgentCompactionStarted(_) => \"agent.compaction.started\",\n Self::AgentCompactionCompleted(_) => \"agent.compaction.completed\",\n Self::AgentLlmRetry(_) => \"agent.llm.retry\",\n- Self::AgentContextWindowSnapshot(_) => \"agent.context_window.snapshot\",\n Self::AgentSubSpawned(_) => \"agent.sub.spawned\",\n Self::AgentSubCompleted(_) => \"agent.sub.completed\",\n Self::AgentSubFailed(_) => \"agent.sub.failed\",\n@@ -702,7 +699,6 @@ fn is_known_event_name(event: &str) -> bool {\n | \"agent.compaction.started\"\n | \"agent.compaction.completed\"\n | \"agent.llm.retry\"\n- | \"agent.context_window.snapshot\"\n | \"agent.sub.spawned\"\n | \"agent.sub.completed\"\n | \"agent.sub.failed\"\n@@ -2156,43 +2152,95 @@ mod tests {\n }\n \n #[test]\n- fn agent_context_window_snapshot_serializes_with_canonical_name() {\n- let body = EventBody::AgentContextWindowSnapshot(AgentContextWindowSnapshotProps {\n- stage_id: crate::StageId::new(\"implement\", 1),\n- visit: 1,\n- snapshot: crate::StageContextWindowProjection {\n- provider: \"openai\".to_string(),\n- model: \"gpt-5.4\".to_string(),\n- context_window_tokens: 400_000,\n- input_tokens: 123_456,\n- usage_percent: 30.864,\n- count_method:\n- crate::StageContextWindowCountMethod::ProviderApiScaledBreakdown,\n- staleness: crate::StageContextWindowStaleness::Live,\n- generated_at: DateTime::parse_from_rfc3339(\"2026-05-23T12:34:56Z\")\n- .unwrap()\n- .with_timezone(&Utc),\n- event_seq: None,\n- breakdown: vec![crate::StageContextWindowBreakdownItem {\n- category: crate::StageContextWindowCategory::SystemPrompt,\n- tokens: 30_000,\n- usage_percent: 7.5,\n- }],\n- warnings: vec![crate::StageContextWindowWarning {\n- code: \"local_token_estimate\".to_string(),\n- message: \"input token count is a local estimate\".to_string(),\n- }],\n+ fn agent_message_omits_context_window_when_absent() {\n+ let body = EventBody::AgentMessage(AgentMessageProps {\n+ text: \"ok\".to_string(),\n+ model: crate::ModelRef {\n+ provider: fabro_model::ProviderId::openai(),\n+ model_id: \"gpt-5.4\".to_string(),\n+ speed: None,\n },\n+ billing: BilledTokenCounts::default(),\n+ tool_call_count: 0,\n+ visit: 1,\n+ message: None,\n+ context_window: None,\n });\n+\n let value = serde_json::to_value(&body).unwrap();\n- assert_eq!(value[\"event\"], \"agent.context_window.snapshot\");\n- assert_eq!(value[\"properties\"][\"stage_id\"], \"implement@1\");\n+ assert_eq!(value[\"event\"], \"agent.message\");\n+ assert!(\n+ value[\"properties\"]\n+ .as_object()\n+ .unwrap()\n+ .get(\"context_window\")\n+ .is_none()\n+ );\n+ let parsed: EventBody = serde_json::from_value(value).unwrap();\n+ assert_eq!(parsed.event_name(), \"agent.message\");\n+ }\n+\n+ #[test]\n+ fn agent_message_round_trips_optional_context_window() {\n+ let context_window = crate::StageContextWindowProjection {\n+ provider: \"openai\".to_string(),\n+ model: \"gpt-5.4\".to_string(),\n+ context_window_tokens: 400_000,\n+ input_tokens: 123_456,\n+ usage_percent: 30.864,\n+ count_method:\n+ crate::StageContextWindowCountMethod::ResponseUsageScaledBreakdown,\n+ staleness: crate::StageContextWindowStaleness::Live,\n+ generated_at: DateTime::parse_from_rfc3339(\"2026-05-23T12:34:56Z\")\n+ .unwrap()\n+ .with_timezone(&Utc),\n+ event_seq: None,\n+ breakdown: vec![crate::StageContextWindowBreakdownItem {\n+ category: crate::StageContextWindowCategory::SystemPrompt,\n+ tokens: 30_000,\n+ usage_percent: 7.5,\n+ }],\n+ warnings: vec![crate::StageContextWindowWarning {\n+ code: \"local_token_estimate\".to_string(),\n+ message: \"input token count is a local estimate\".to_string(),\n+ }],\n+ };\n+ let body = EventBody::AgentMessage(AgentMessageProps {\n+ text: \"ok\".to_string(),\n+ model: crate::ModelRef {\n+ provider: fabro_model::ProviderId::openai(),\n+ model_id: \"gpt-5.4\".to_string(),\n+ speed: None,\n+ },\n+ billing: BilledTokenCounts::default(),\n+ tool_call_count: 0,\n+ visit: 1,\n+ message: None,\n+ context_window: Some(context_window),\n+ });\n+\n+ let value = serde_json::to_value(&body).unwrap();\n+ assert_eq!(value[\"event\"], \"agent.message\");\n assert_eq!(\n- value[\"properties\"][\"breakdown\"][0][\"category\"],\n+ value[\"properties\"][\"context_window\"][\"breakdown\"][0][\"category\"],\n \"system_prompt\"\n );\n+ assert_eq!(\n+ value[\"properties\"][\"context_window\"][\"count_method\"],\n+ \"response_usage_scaled_breakdown\"\n+ );\n let parsed: EventBody = serde_json::from_value(value).unwrap();\n- assert_eq!(parsed.event_name(), \"agent.context_window.snapshot\");\n+ match parsed {\n+ EventBody::AgentMessage(props) => {\n+ let context_window = props.context_window.expect(\"context window present\");\n+ assert_eq!(context_window.input_tokens, 123_456);\n+ assert_eq!(\n+ context_window.count_method,\n+ crate::StageContextWindowCountMethod::ResponseUsageScaledBreakdown\n+ );\n+ }\n+ other => panic!(\"expected AgentMessage body, got {other:?}\"),\n+ }\n }\n \n #[test]\ndiff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs\nindex ff098e6f0..3f4b90098 100644\n--- a/lib/crates/fabro-workflow/src/event/convert.rs\n+++ b/lib/crates/fabro-workflow/src/event/convert.rs\n@@ -591,7 +591,7 @@ fn event_body_from_event(event: &Event) -> EventBody {\n billing: billing.clone(),\n }),\n Event::Agent {\n- stage,\n+ stage: _,\n visit,\n event,\n ..\n@@ -610,6 +610,7 @@ fn event_body_from_event(event: &Event) -> EventBody {\n model,\n usage,\n tool_call_count,\n+ context_window,\n } => {\n let billing = billed_token_counts_from_llm(usage);\n EventBody::AgentMessage(fabro_types::AgentMessageProps {\n@@ -619,6 +620,7 @@ fn event_body_from_event(event: &Event) -> EventBody {\n tool_call_count: *tool_call_count,\n visit: *visit,\n message: None,\n+ context_window: context_window.clone(),\n })\n }\n AgentEvent::ToolCallStarted {\n@@ -711,13 +713,6 @@ fn event_body_from_event(event: &Event) -> EventBody {\n error: serde_json::to_value(error).expect(\"serializable sdk error\"),\n visit: *visit,\n }),\n- AgentEvent::ContextWindowSnapshot(snapshot) => EventBody::AgentContextWindowSnapshot(\n- fabro_types::AgentContextWindowSnapshotProps {\n- stage_id: ::fabro_types::StageId::new(stage.clone(), *visit),\n- visit: *visit,\n- snapshot: snapshot.clone(),\n- },\n- ),\n AgentEvent::SubAgentSpawned {\n agent_id,\n depth,\n@@ -2172,6 +2167,7 @@ mod tests {\n },\n usage: LlmTokenCounts::default(),\n tool_call_count: 0,\n+ context_window: None,\n },\n session_id: Some(\"ses_agent\".to_string()),\n parent_session_id: None,\n@@ -2203,6 +2199,7 @@ mod tests {\n ..LlmTokenCounts::default()\n },\n tool_call_count: 0,\n+ context_window: None,\n },\n session_id: Some(\"ses_agent\".to_string()),\n parent_session_id: None,\n@@ -2219,6 +2216,55 @@ mod tests {\n assert_eq!(message.billing.total_usd_micros, None);\n }\n \n+ #[test]\n+ fn agent_assistant_message_copies_context_window_to_props() {\n+ let context_window = ::fabro_types::StageContextWindowProjection {\n+ provider: \"openai\".to_string(),\n+ model: \"gpt-5.4\".to_string(),\n+ context_window_tokens: 400_000,\n+ input_tokens: 123,\n+ usage_percent: 0.03075,\n+ count_method: ::fabro_types::StageContextWindowCountMethod::LocalEstimate,\n+ staleness: ::fabro_types::StageContextWindowStaleness::Live,\n+ generated_at: Utc::now(),\n+ event_seq: None,\n+ breakdown: vec![::fabro_types::StageContextWindowBreakdownItem {\n+ category: ::fabro_types::StageContextWindowCategory::Conversation,\n+ tokens: 123,\n+ usage_percent: 0.03075,\n+ }],\n+ warnings: Vec::new(),\n+ };\n+ let stored = to_run_event(&fixtures::RUN_1, &Event::Agent {\n+ stage: \"code\".to_string(),\n+ visit: 1,\n+ event: AgentEvent::AssistantMessage {\n+ text: \"ok\".to_string(),\n+ model: ModelRef {\n+ provider: ProviderId::openai(),\n+ model_id: \"gpt-5.4\".to_string(),\n+ speed: None,\n+ },\n+ usage: LlmTokenCounts::default(),\n+ tool_call_count: 0,\n+ context_window: Some(context_window),\n+ },\n+ session_id: Some(\"ses_agent\".to_string()),\n+ parent_session_id: None,\n+ tool_call_id: None,\n+ });\n+\n+ let EventBody::AgentMessage(message) = stored.body else {\n+ panic!(\"expected agent message body\");\n+ };\n+ let context_window = message.context_window.expect(\"context window copied\");\n+ assert_eq!(context_window.input_tokens, 123);\n+ assert_eq!(\n+ context_window.count_method,\n+ ::fabro_types::StageContextWindowCountMethod::LocalEstimate\n+ );\n+ }\n+\n #[test]\n fn agent_acp_events_map_to_event_bodies_with_stage_scope() {\n let scope = StageScope {\ndiff --git a/lib/crates/fabro-workflow/src/event/names.rs b/lib/crates/fabro-workflow/src/event/names.rs\nindex 958f8fdeb..c4d19d832 100644\n--- a/lib/crates/fabro-workflow/src/event/names.rs\n+++ b/lib/crates/fabro-workflow/src/event/names.rs\n@@ -86,7 +86,6 @@ pub fn event_name(event: &Event) -> &'static str {\n AgentEvent::CompactionStarted { .. } => \"agent.compaction.started\",\n AgentEvent::CompactionCompleted { .. } => \"agent.compaction.completed\",\n AgentEvent::LlmRetry { .. } => \"agent.llm.retry\",\n- AgentEvent::ContextWindowSnapshot(_) => \"agent.context_window.snapshot\",\n AgentEvent::SubAgentSpawned { .. } => \"agent.sub.spawned\",\n AgentEvent::SubAgentCompleted { .. } => \"agent.sub.completed\",\n AgentEvent::SubAgentFailed { .. } => \"agent.sub.failed\",\ndiff --git a/lib/packages/fabro-api-client/package.json b/lib/packages/fabro-api-client/package.json\nindex 7709ed33a..de8ac9883 100644\n--- a/lib/packages/fabro-api-client/package.json\n+++ b/lib/packages/fabro-api-client/package.json\n@@ -4,7 +4,7 @@\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n- \"generate\": \"bunx @openapitools/openapi-generator-cli generate -i ../../../docs/public/api-reference/fabro-api.yaml -g typescript-axios --additional-properties=supportsES6=true,typescriptThreePlus=true,withSeparateModelsAndApi=true,apiPackage=api,modelPackage=models,useTags=true,enumPropertyNaming=UPPERCASE -o src && bun run scripts/normalize-generated.ts\",\n+ \"generate\": \"bunx @openapitools/openapi-generator-cli@2.20.2 generate -i ../../../docs/public/api-reference/fabro-api.yaml -g typescript-axios --additional-properties=supportsES6=true,typescriptThreePlus=true,withSeparateModelsAndApi=true,apiPackage=api,modelPackage=models,useTags=true,enumPropertyNaming=UPPERCASE -o src && bun run scripts/normalize-generated.ts\",\n \"typecheck\": \"tsc\"\n },\n \"devDependencies\": {\ndiff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES\nindex f8d333e14..61f64e490 100644\n--- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES\n+++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES\n@@ -23,6 +23,7 @@ configuration.ts\n index.ts\n models/activated-skill.ts\n models/agent-mcp-tool-summary.ts\n+models/agent-message-props.ts\n models/agent-permissions.ts\n models/agent-session-activated-props.ts\n models/agent-skill-activation-source.ts\ndiff --git a/lib/packages/fabro-api-client/src/models/agent-message-props.ts b/lib/packages/fabro-api-client/src/models/agent-message-props.ts\nnew file mode 100644\nindex 000000000..3aa5c34b6\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/agent-message-props.ts\n@@ -0,0 +1,40 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { BilledTokenCounts } from './billed-token-counts';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { BillingModelRef } from './billing-model-ref';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { StageContextWindowProjection } from './stage-context-window-projection';\n+\n+/**\n+ * Properties for the `agent.message` event.\n+ */\n+export interface AgentMessageProps {\n+ 'text': string;\n+ 'model': BillingModelRef;\n+ 'billing': BilledTokenCounts;\n+ 'tool_call_count': number;\n+ 'visit': number;\n+ /**\n+ * Canonical replay-authoritative transcript message, when present.\n+ */\n+ 'message'?: { [key: string]: any; };\n+ 'context_window'?: StageContextWindowProjection | null;\n+}\ndiff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts\nindex 78d106d70..387306be1 100644\n--- a/lib/packages/fabro-api-client/src/models/index.ts\n+++ b/lib/packages/fabro-api-client/src/models/index.ts\n@@ -1,5 +1,6 @@\n export * from './activated-skill';\n export * from './agent-mcp-tool-summary';\n+export * from './agent-message-props';\n export * from './agent-permissions';\n export * from './agent-session-activated-props';\n export * from './agent-skill-activation-source';\n", + "summary": { + "files_changed": 20, + "additions": 373, + "deletions": 318 + } + } + }, + { + "seq": 0, + "checkpoint": { + "timestamp": "2026-05-24T18:12:11.658263Z", + "current_node": "simplify_opus", + "completed_nodes": [ + "start", + "toolchain", + "preflight_compile", + "preflight_lint", + "implement", + "simplify_opus" + ], + "node_retries": {}, + "context_values": { + "graph.rankdir": "LR", + "thread.preflight_lint.current_node": "implement", + "outcome": "succeeded", + "internal.retry_count.implement": 0, + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "internal.retry_count.toolchain": 0, + "thread.toolchain.current_node": "preflight_compile", + "thread.start.current_node": "toolchain", + "response.simplify_opus": "Cleanup complete. Here's what was fixed based on the parallel reviews:\n\n**Fixed (3 issues consolidated from reviews):**\n\n1. **Moved `context_window_from_response_usage` from `session.rs` to `context_window.rs`** — it was a free function that only operated on `StageContextWindowProjection` + `TokenCounts` and just delegated to `scaled_snapshot`. Co-located with its only collaborator. This also let `session.rs` drop its imports of `scaled_snapshot` and `StageContextWindowCountMethod`.\n\n2. **Eliminated avoidable `StageContextWindowProjection.clone()` in the per-LLM-turn hot path** — replaced `let local_context_window = built_request.context_window.clone(); let request = built_request.request;` with a destructuring let `BuiltRequest { request, context_window: local_context_window } = self.build_request();`.\n\n3. **Downgraded `warnings_from_llm` from `pub(crate)` to private `fn`** — its only outside caller was removed when the provider token-count path was deleted; it's now only used within `context_window.rs`.\n\n**Reviewed but skipped (false positives or below-the-bar):**\n\n- `BuiltRequest` 2-field struct could be a tuple, but the named-field destructure now reads cleanly and avoids the clone, so it earns its keep.\n- `AssistantMessage` 5-field variant — borderline but tightening it to a struct would just rename without compressing.\n- Reducer inline clone+stamp pattern — single use site, no helper warranted.\n- The producer-side `Option` always being `Some` in `Session::run_single_input` — kept as `Option` because tests and non-AssistantMessage emit sites need `None`, and the wire schema is optional anyway.\n\n**Verification:** `cargo check --workspace --all-targets`, `cargo nextest run -p fabro-agent -p fabro-workflow -p fabro-store -p fabro-types` (2089 tests pass), and the 7 `get_run_stage_context_window` server tests all pass. Pre-existing nightly clippy warning in `fabro-sandbox` (large_enum_variant) is unrelated to this change.", "internal.run_id": "01KSDGB3G5YAPQMCXKE23SF08C", "response.implement": "Implemented the plan end-to-end.\n\nSummary:\n- Removed standalone `agent.context_window.snapshot` event from Rust event contracts, workflow conversion, event names, store reducer, frontend invalidation, OpenAPI/API client surface.\n- Added optional `context_window` to `agent.message`:\n - `fabro-types::AgentMessageProps`\n - `fabro-agent::AgentEvent::AssistantMessage`\n - workflow conversion into `EventBody::AgentMessage`\n- Updated `Session::run_single_input` so context-window projection is computed locally from the exact request and attached only after a successful assistant response.\n- Removed normal-execution provider token-count API calls from `fabro-agent::Session`.\n- Updated store projection to persist latest `AgentMessageProps.context_window` and stamp `event_seq`.\n- Kept the context-window GET endpoint projection-backed and updated endpoint tests to seed data via `agent.message`.\n- Regenerated the TS API client and added generated `AgentMessageProps`.\n- Removed frontend snapshot event handling/tests; `agent.message` continues to invalidate stage context-window queries via stage activity handling.\n- Used red/green TDD: first added a failing `fabro-agent` test proving standalone snapshot events were still emitted, then implemented until green.\n- Used subagent-driven development for read-only investigation of Rust/API/frontend surfaces.\n\nValidation run:\n- `cargo build -p fabro-api`\n- `cargo nextest run -p fabro-types agent_message`\n- `cargo nextest run -p fabro-agent`\n- `cargo nextest run -p fabro-workflow`\n- `cargo nextest run -p fabro-store`\n- `cargo nextest run -p fabro-server get_run_stage_context_window`\n- `cargo check -q --workspace`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings`\n- `cd lib/packages/fabro-api-client && bun run generate`\n- `cd lib/packages/fabro-api-client && bun run typecheck`\n- `cd apps/fabro-web && bun run typecheck`\n- `cd apps/fabro-web && bun run test`\n- Final search: `agent\\.context_window\\.snapshot|AgentContextWindowSnapshot|ContextWindowSnapshot` — no matches.\n\nNote:\n- I pinned the API client `generate` script to `@openapitools/openapi-generator-cli@2.20.2` because current `bunx` was resolving the unpinned command to an incompatible latest wrapper in this environment.", "graph.goal": "# Fold Context Window Into Agent Messages Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Remove the chatty `agent.context_window.snapshot` event and persist context-window data through existing `agent.message` events.\n\n**Architecture:** Compute the context-window breakdown locally while the agent still has the exact request, attach the final content-free projection to the next `agent.message`, and let the run projection reducer store that latest projection for `GET /runs/{id}/stages/{stageId}/context-window`. Do not call provider token-count APIs during normal execution.\n\n**Tech Stack:** Rust, Serde, Fabro agent/session events, Fabro workflow event conversion, Fabro store projections, OpenAPI/progenitor, generated TypeScript client, React/SWR.\n\n---\n\n## Scope And Decisions\n\n- Remove `agent.context_window.snapshot` completely from new code. This is a greenfield/no-prod app, so do not preserve backward-compatible deserialization or frontend handling for old snapshot events.\n- Keep `StageContextWindowProjection`, `StageContextWindow`, `StageProjection.context_window`, and the existing context-window GET endpoint.\n- Add optional `context_window` data to `agent.message` properties.\n- Normal execution uses only local estimates and token usage returned by normal LLM responses.\n- Do not call `Client::count_input_tokens` from `fabro-agent::Session`.\n- The GET endpoint remains projection-backed for this pass. It must not require an in-memory session and must not trigger provider token-count API calls.\n- Failed-before-response turns do not persist context-window data.\n- Live pre-response context-window updates are not required. The UI updates after `agent.message`.\n\n## File Map\n\n- Modify `lib/crates/fabro-types/src/run_event/agent.rs`\n - Add `context_window: Option` to `AgentMessageProps`.\n - Remove `AgentContextWindowSnapshotProps`.\n- Modify `lib/crates/fabro-types/src/run_event/mod.rs`\n - Remove the `AgentContextWindowSnapshot` event variant, name mapping, known-event entry, and serde tests.\n - Update `agent.message` serde tests to cover optional context-window data.\n- Modify `lib/crates/fabro-agent/src/types.rs`\n - Remove `AgentEvent::ContextWindowSnapshot`.\n - Add `context_window: Option` to `AgentEvent::AssistantMessage`.\n- Modify `lib/crates/fabro-agent/src/session.rs`\n - Remove async provider-count task and response-usage snapshot event emission.\n - Keep local context-window snapshot construction at request-build time.\n - Attach the scaled or local projection to `AssistantMessage`.\n- Keep `lib/crates/fabro-agent/src/context_window.rs`\n - Reuse `build_local_snapshot` and `scaled_snapshot`.\n - Remove only tests or helpers that exist solely for provider-count snapshot emission.\n- Modify `lib/crates/fabro-workflow/src/event/convert.rs`\n - Remove conversion for `AgentEvent::ContextWindowSnapshot`.\n - Copy `context_window` from `AgentEvent::AssistantMessage` into `AgentMessageProps`.\n- Modify `lib/crates/fabro-workflow/src/event/names.rs`\n - Remove the snapshot event name.\n- Modify `lib/crates/fabro-store/src/run_state.rs`\n - Remove reducer support for `EventBody::AgentContextWindowSnapshot`.\n - When reducing `EventBody::AgentMessage`, copy `props.context_window` into `stage.context_window` when present and stamp `event_seq`.\n- Modify `lib/crates/fabro-server/src/server/tests.rs`\n - Seed context-window endpoint tests with `agent.message` events that include context-window data.\n- Modify `docs/public/api-reference/fabro-api.yaml`\n - Add optional `context_window` to `AgentMessageProps`.\n - Remove the snapshot event schema/variant.\n- Regenerate/update `lib/packages/fabro-api-client/src`.\n- Modify `apps/fabro-web/app/lib/run-events.ts` and tests\n - Remove special handling for `agent.context_window.snapshot`.\n - Rely on existing `agent.message` invalidation for stage events and context-window data.\n\n## Implementation Steps\n\n### Task 1: Move The Event Contract Onto `agent.message`\n\n- [ ] Add optional `context_window` to Rust `AgentMessageProps`.\n- [ ] Remove `AgentContextWindowSnapshotProps` and the `agent.context_window.snapshot` `EventBody` variant.\n- [ ] Update run-event serde tests so `agent.message` round-trips with and without `context_window`.\n- [ ] Remove tests whose only assertion is that `agent.context_window.snapshot` is known or serializes.\n\n### Task 2: Stop Emitting Snapshot Events\n\n- [ ] Remove `AgentEvent::ContextWindowSnapshot` and its trace/debug handling.\n- [ ] Change `AgentEvent::AssistantMessage` to carry `context_window: Option`.\n- [ ] In `Session::run_single_input`, keep the local context-window snapshot returned from request construction.\n- [ ] Delete the spawned `count_input_tokens(... PreferProvider)` task and its fingerprint suppression state.\n- [ ] After the normal LLM response arrives, compute:\n - `ResponseUsageScaledBreakdown` when response input/cache usage is positive.\n - `LocalEstimate` when response usage has no usable input tokens.\n- [ ] Attach that projection to the emitted `AssistantMessage`.\n\n### Task 3: Update Workflow Conversion And Store Projection\n\n- [ ] Remove snapshot event name/conversion branches.\n- [ ] Include `context_window` when converting `AgentEvent::AssistantMessage` to `EventBody::AgentMessage`.\n- [ ] In the store reducer, update `stage.context_window` from `AgentMessageProps.context_window`.\n- [ ] Stamp the copied projection with the `agent.message` event sequence.\n- [ ] Replace store tests for snapshot replacement with message-carried context-window tests.\n\n### Task 4: Keep The GET Endpoint Projection-Backed\n\n- [ ] Keep the endpoint route and response type unchanged.\n- [ ] Keep existing `not_agent_stage`, `not_observed`, and terminal `stored` behavior.\n- [ ] Update endpoint tests to seed context-window data via `agent.message`.\n- [ ] Do not add endpoint-time provider token-count calls.\n\n### Task 5: Remove Frontend And API Trace\n\n- [ ] Remove frontend constants/tests for `agent.context_window.snapshot`.\n- [ ] Ensure `agent.message` still invalidates `stageContextWindow` through existing stage activity handling.\n- [ ] Update OpenAPI and regenerated TypeScript client so no snapshot event model remains.\n- [ ] Run a final search for `agent.context_window.snapshot`, `AgentContextWindowSnapshot`, and `ContextWindowSnapshot`; only historical docs or this plan may remain.\n\n## Test Plan\n\n- Run `cargo build -p fabro-api` after OpenAPI/Rust type changes.\n- Run targeted Rust tests:\n - `cargo nextest run -p fabro-agent`\n - `cargo nextest run -p fabro-workflow`\n - `cargo nextest run -p fabro-store`\n - `cargo nextest run -p fabro-server get_run_stage_context_window`\n - `cargo nextest run -p fabro-types agent_message`\n- Regenerate TypeScript client with `cd lib/packages/fabro-api-client && bun run generate`.\n- Run web checks:\n - `cd apps/fabro-web && bun test`\n - `cd apps/fabro-web && bun run typecheck`\n - `cd lib/packages/fabro-api-client && bun run typecheck`\n- Run final targeted searches:\n - `rg -n \"agent\\\\.context_window\\\\.snapshot|AgentContextWindowSnapshot|ContextWindowSnapshot\" lib/crates apps/fabro-web lib/packages docs/public/api-reference/fabro-api.yaml`\n - Expected: no implementation/API/frontend matches.\n\n## Acceptance Criteria\n\n- Normal agent runs do not emit `agent.context_window.snapshot`.\n- Normal agent runs do not call provider token-count endpoints for context-window reporting.\n- `agent.message` includes context-window data when the agent produced a response.\n- `GET /runs/{id}/stages/{stageId}/context-window` still returns the latest context-window projection.\n- The event log contains no standalone context-window snapshot events.\n- Public API/client/types no longer expose `agent.context_window.snapshot`.\n", - "graph.rankdir": "LR", - "internal.thread_id": "preflight_lint", - "current_node": "implement", - "outcome": "succeeded", + "internal.thread_id": "implement", + "current_node": "simplify_opus", "thread.preflight_compile.current_node": "preflight_lint", "internal.work_dir": "/home/daytona/workspace/fabro", - "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", "failure_signature": "", - "thread.preflight_lint.current_node": "implement", + "last_response": "Cleanup complete. Here's what was fixed based on the parallel reviews:\n\n**Fixed (3 issues consolidated from reviews):**\n\n1. **Moved `context_window_from_response_usage` from `session.rs` to `context_w", "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", - "last_response": "Implemented the plan end-to-end.\n\nSummary:\n- Removed standalone `agent.context_window.snapshot` event from Rust event contracts, workflow conversion, event names, store reducer, frontend invalidation,", + "internal.retry_count.simplify_opus": 0, "failure_class": "", - "internal.retry_count.implement": 0, - "internal.retry_count.toolchain": 0, "internal.fidelity": "compact", - "thread.toolchain.current_node": "preflight_compile", "internal.node_visit_count": 1, "internal.retry_count.preflight_compile": 0, "internal.retry_count.preflight_lint": 0, "internal.retry_count.start": 0, - "thread.start.current_node": "toolchain", - "last_stage": "implement" + "last_stage": "simplify_opus", + "thread.implement.current_node": "simplify_opus" }, "node_outcomes": { "implement": { @@ -828,6 +951,42 @@ "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", "usage": null }, + "simplify_opus": { + "status": "succeeded", + "context_updates": { + "last_response": "Cleanup complete. Here's what was fixed based on the parallel reviews:\n\n**Fixed (3 issues consolidated from reviews):**\n\n1. **Moved `context_window_from_response_usage` from `session.rs` to `context_w", + "last_stage": "simplify_opus", + "response.simplify_opus": "Cleanup complete. Here's what was fixed based on the parallel reviews:\n\n**Fixed (3 issues consolidated from reviews):**\n\n1. **Moved `context_window_from_response_usage` from `session.rs` to `context_window.rs`** — it was a free function that only operated on `StageContextWindowProjection` + `TokenCounts` and just delegated to `scaled_snapshot`. Co-located with its only collaborator. This also let `session.rs` drop its imports of `scaled_snapshot` and `StageContextWindowCountMethod`.\n\n2. **Eliminated avoidable `StageContextWindowProjection.clone()` in the per-LLM-turn hot path** — replaced `let local_context_window = built_request.context_window.clone(); let request = built_request.request;` with a destructuring let `BuiltRequest { request, context_window: local_context_window } = self.build_request();`.\n\n3. **Downgraded `warnings_from_llm` from `pub(crate)` to private `fn`** — its only outside caller was removed when the provider token-count path was deleted; it's now only used within `context_window.rs`.\n\n**Reviewed but skipped (false positives or below-the-bar):**\n\n- `BuiltRequest` 2-field struct could be a tuple, but the named-field destructure now reads cleanly and avoids the clone, so it earns its keep.\n- `AssistantMessage` 5-field variant — borderline but tightening it to a struct would just rename without compressing.\n- Reducer inline clone+stamp pattern — single use site, no helper warranted.\n- The producer-side `Option` always being `Some` in `Session::run_single_input` — kept as `Option` because tests and non-AssistantMessage emit sites need `None`, and the wire schema is optional anyway.\n\n**Verification:** `cargo check --workspace --all-targets`, `cargo nextest run -p fabro-agent -p fabro-workflow -p fabro-store -p fabro-types` (2089 tests pass), and the 7 `get_run_stage_context_window` server tests all pass. Pre-existing nightly clippy warning in `fabro-sandbox` (large_enum_variant) is unrelated to this change." + }, + "notes": "Stage completed: simplify_opus", + "usage": { + "input": { + "usage": { + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, + "tokens": { + "input_tokens": 67811, + "output_tokens": 15684, + "reasoning_tokens": 0, + "cache_read_tokens": 2629856, + "cache_write_tokens": 318073 + } + }, + "facts": { + "algorithm": "anthropic", + "cache_write_5m_tokens": 318073, + "cache_write_1h_tokens": 0 + } + }, + "total_usd_micros": 4034039 + }, + "files_touched": [ + "/home/daytona/workspace/fabro/lib/crates/fabro-agent/src/context_window.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-agent/src/session.rs" + ] + }, "start": { "status": "succeeded", "usage": null @@ -841,10 +1000,11 @@ "usage": null } }, - "next_node_id": "simplify_opus", + "next_node_id": "simplify_gpt", "node_visits": { "implement": 1, "start": 1, + "simplify_opus": 1, "preflight_compile": 1, "toolchain": 1, "preflight_lint": 1 @@ -973,7 +1133,12 @@ "first_event_seq": 52, "prompt": null, "response": null, - "completion": null, + "completion": { + "outcome": "succeeded", + "notes": "Stage completed: implement", + "failure_reason": null, + "timestamp": "2026-05-24T18:01:18.862852Z" + }, "provider_used": { "mode": "agent", "provider": "openai", @@ -987,13 +1152,20 @@ "output": null, "started_at": "2026-05-24T17:30:39.152621Z", "handler": "agent", + "timing": { + "wall_time_ms": 1839697, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, "usage": { - "input_tokens": 6727226, - "output_tokens": 24101, - "total_tokens": 29240073, - "reasoning_tokens": 11946, - "cache_read_tokens": 22476800, - "cache_write_tokens": 0 + "input_tokens": 6519068, + "output_tokens": 22077, + "total_tokens": 28993885, + "reasoning_tokens": 11268, + "cache_read_tokens": 22441472, + "cache_write_tokens": 0, + "total_usd_micros": 44816426 }, "model": { "provider": "openai", @@ -1097,7 +1269,7 @@ ], "warnings": [] }, - "state": "running" + "state": "succeeded" }, "start@1": { "first_event_seq": 18, @@ -1133,6 +1305,110 @@ }, "state": "succeeded" }, + "simplify_opus@1": { + "first_event_seq": 1521, + "prompt": null, + "response": null, + "completion": null, + "provider_used": { + "mode": "agent", + "provider": "anthropic", + "model": "claude-opus-4-7" + }, + "diff": null, + "script_invocation": null, + "script_timing": null, + "parallel_results": null, + "output": null, + "started_at": "2026-05-24T18:01:22.933780Z", + "handler": "agent", + "usage": { + "input_tokens": 67811, + "output_tokens": 15684, + "total_tokens": 3031424, + "reasoning_tokens": 0, + "cache_read_tokens": 2629856, + "cache_write_tokens": 318073, + "total_usd_micros": 4034039 + }, + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, + "subagents": [ + { + "agent_id": "b574f89b", + "depth": 1, + "task": "You are a code reuse reviewer. Review the following git diff for opportunities to use existing utilities/helpers instead of newly written or hand-rolled code. The repo is at /home/daytona/workspace/fabro.\n\nUse grep/glob to search the codebase for existing utilities. Common locations: fabro-util, fabro-types, fabro-agent context_window.rs, scaled_snapshot helper.\n\nFocus areas:\n1. The new function `context_window_from_response_usage` in lib/crates/fabro-agent/src/session.rs — does it duplicate logic that existed before or that lives in context_window.rs? \n2. The renaming `ContextWindowSnapshotInput` -> `ContextWindowInput` — is the naming consistent with similar input structs in the crate?\n3. New struct `BuiltRequest` now only has 2 fields (request, context_window) — could it be replaced with a tuple? Was it previously needed?\n4. The local_estimate fall-through in `context_window_from_response_usage` returns `local_snapshot.clone()` when input_tokens<=0. Does this match existing behavior elsewhere? Could `scaled_snapshot` handle this case?\n5. The reducer in lib/crates/fabro-store/src/run_state.rs now copies context_window inline in AgentMessage handling — should this be a helper?\n\nRead these files for context:\n- lib/crates/fabro-agent/src/session.rs (look around the build_request and run_single_input regions)\n- lib/crates/fabro-agent/src/context_window.rs\n- lib/crates/fabro-store/src/run_state.rs\n\nHere is the diff summary (full diff is in git):\n\nThe change removes `agent.context_window.snapshot` events and folds the context-window projection into `agent.message` event props. Key changes:\n- `AgentEvent::AssistantMessage` now carries `context_window: Option`\n- `AgentEvent::ContextWindowSnapshot` removed\n- `Session::emit_context_window_snapshots` and `emit_response_usage_context_window_snapshot` removed\n- New free function `context_window_from_response_usage(local, usage) -> StageContextWindowProjection` in session.rs\n- `BuiltRequest` now has `request` + `context_window` (not `tools`)\n- `build_request` now computes context_window locally and embeds it in BuiltRequest\n- Store reducer copies `props.context_window` to `stage.context_window` in AgentMessage handler\n- Removed `warning()` helper from context_window.rs (was only used for provider-count warning)\n- Removed `request_fingerprint`, `close_token`, `context_window_counted_fingerprints`, `context_window_response_usage_fingerprints` from Session\n\nReport findings as a concise bulleted list. For each finding: file:line, what's duplicated/inline, and the recommended fix (specific existing function/utility to use). If everything looks fine, say so.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 56 + } + }, + { + "agent_id": "774b5919", + "depth": 1, + "task": "You are a code quality reviewer. Review the following git diff for hacky patterns. The repo is at /home/daytona/workspace/fabro.\n\nLook for:\n1. Redundant state — does `BuiltRequest` need to carry context_window or could it be returned separately/derived?\n2. Parameter sprawl — `AgentEvent::AssistantMessage` now has 5 fields (text, model, usage, tool_call_count, context_window). Is this getting unwieldy?\n3. Copy-paste with slight variation — compare the agent.message handler in lib/crates/fabro-store/src/run_state.rs reducer (copies context_window into stage) to other patterns that update `stage.context_window` (search for `stage.context_window =`).\n4. Leaky abstractions — does the diff expose `StageContextWindowProjection` or `ContextWindowInput` in places that shouldn't see them?\n5. Stringly-typed code — any places using raw strings where enums/constants exist (e.g. for count_method)?\n\nKey new code:\n- session.rs: new free function `context_window_from_response_usage`\n- session.rs: `build_request` now constructs context_window inline\n- session.rs: removed close_token / fingerprint-tracking state\n- types.rs: AgentEvent::AssistantMessage gains `context_window: Option` with `#[serde(default, skip_serializing_if = \"Option::is_none\")]`\n- convert.rs: extracts context_window from AgentEvent::AssistantMessage into AgentMessageProps\n- run_state.rs: reducer for AgentMessage now also copies context_window with event_seq stamp\n\nRead these files:\n- lib/crates/fabro-agent/src/session.rs\n- lib/crates/fabro-agent/src/types.rs\n- lib/crates/fabro-workflow/src/event/convert.rs\n- lib/crates/fabro-store/src/run_state.rs\n- lib/crates/fabro-agent/src/context_window.rs\n\nAlso search for `context_window.event_seq = Some` and `stage.context_window =` across the codebase to compare patterns.\n\nReport findings as a concise bulleted list with file:line. Skip anything that's a false positive. If everything looks fine, say so. This is greenfield — be aggressive about simplification opportunities.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 46 + } + }, + { + "agent_id": "d1c5de5a", + "depth": 1, + "task": "You are an efficiency reviewer. Review the following git diff for performance issues. The repo is at /home/daytona/workspace/fabro.\n\nKey changes:\n- `build_local_snapshot` (context_window computation) is now called UNCONDITIONALLY inside `Session::build_request` for every LLM call. Previously it was called from `emit_context_window_snapshots`.\n- After response arrives, `context_window_from_response_usage` is called which calls `scaled_snapshot` to produce a new projection from local snapshot + response usage tokens. Both `local_context_window` and the scaled snapshot exist briefly.\n- The local snapshot is `.clone()`d when set into `local_context_window`, then passed by ref to `context_window_from_response_usage`.\n\nFocus areas:\n1. Hot path: Is `build_local_snapshot` cheap? It iterates breakdown items and counts tokens for messages, tools, skills. Look at the implementation in lib/crates/fabro-agent/src/context_window.rs.\n2. Is `local_context_window.clone()` necessary? Could the value be moved?\n3. The store reducer in run_state.rs clones `context_window` and stamps event_seq — is this clone avoidable (the props are already owned at that point)?\n4. `context_window_from_response_usage` clones `local_snapshot.warnings.clone()` — necessary?\n5. Removed: spawned async task that called `count_input_tokens` (with provider API). This is a positive efficiency change. Good.\n6. Did we remove any caches/dedup (fingerprint sets) that were important?\n\nRead:\n- lib/crates/fabro-agent/src/session.rs (build_request, run_single_input loop, context_window_from_response_usage)\n- lib/crates/fabro-agent/src/context_window.rs (build_local_snapshot, scaled_snapshot)\n- lib/crates/fabro-store/src/run_state.rs (AgentMessage handler in apply_event reducer)\n\nReport findings as a concise bulleted list with file:line. If everything looks fine, say so. Skip false positives.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 33 + } + } + ], + "permission_level": "full", + "context_window": { + "provider": "anthropic", + "model": "claude-opus-4-7", + "context_window_tokens": 1000000, + "input_tokens": 80126, + "usage_percent": 8.0126, + "count_method": "response_usage_scaled_breakdown", + "staleness": "live", + "generated_at": "2026-05-24T18:12:11.577409Z", + "event_seq": 2247, + "breakdown": [ + { + "category": "system_prompt", + "tokens": 2483, + "usage_percent": 0.2483 + }, + { + "category": "tools", + "tokens": 2827, + "usage_percent": 0.2827 + }, + { + "category": "memory", + "tokens": 5905, + "usage_percent": 0.5905 + }, + { + "category": "conversation", + "tokens": 68904, + "usage_percent": 6.8904 + }, + { + "category": "other", + "tokens": 7, + "usage_percent": 0.0007 + } + ], + "warnings": [] + }, + "state": "running" + }, "toolchain@1": { "first_event_seq": 22, "prompt": null, diff --git a/stages/005-implement@1/diff.patch b/stages/005-implement@1/diff.patch new file mode 100644 index 000000000..5ffbb0307 --- /dev/null +++ b/stages/005-implement@1/diff.patch @@ -0,0 +1,1282 @@ +diff --git a/apps/fabro-web/app/lib/query-keys.test.ts b/apps/fabro-web/app/lib/query-keys.test.ts +index 914f3b11f..8ad4a4a3a 100644 +--- a/apps/fabro-web/app/lib/query-keys.test.ts ++++ b/apps/fabro-web/app/lib/query-keys.test.ts +@@ -92,16 +92,6 @@ describe("queryKeys", () => { + } + }); + +- test("context-window snapshot invalidates context window, run events, and stage events", () => { +- expect( +- queryKeysForRunEvent("run-1", "agent.context_window.snapshot", "stage-1"), +- ).toEqual([ +- queryKeys.runs.events("run-1", 1000), +- queryKeys.runs.stageEvents("run-1", "stage-1"), +- queryKeys.runs.stageContextWindow("run-1", "stage-1"), +- ]); +- }); +- + test("agent activity events without a node_id invalidate nothing", () => { + expect(queryKeysForRunEvent("run-1", "agent.message")).toEqual([]); + }); +diff --git a/apps/fabro-web/app/lib/run-events.test.tsx b/apps/fabro-web/app/lib/run-events.test.tsx +index 0a33cc3d5..b73d272f1 100644 +--- a/apps/fabro-web/app/lib/run-events.test.tsx ++++ b/apps/fabro-web/app/lib/run-events.test.tsx +@@ -92,16 +92,6 @@ describe("queryKeysForRunEvent", () => { + ]); + }); + +- test("context-window snapshots invalidate context window, run events, and stage events", () => { +- expect( +- queryKeysForRunEvent("run-1", "agent.context_window.snapshot", "agent@1"), +- ).toEqual([ +- queryKeys.runs.events("run-1", 1000), +- queryKeys.runs.stageEvents("run-1", "agent@1"), +- queryKeys.runs.stageContextWindow("run-1", "agent@1"), +- ]); +- }); +- + test("todo events invalidate run state and run events", () => { + for (const event of ["todo.created", "todo.updated", "todo.deleted"]) { + expect(queryKeysForRunEvent("run-1", event)).toEqual([ +diff --git a/apps/fabro-web/app/lib/run-events.ts b/apps/fabro-web/app/lib/run-events.ts +index c88c76341..8efd40547 100644 +--- a/apps/fabro-web/app/lib/run-events.ts ++++ b/apps/fabro-web/app/lib/run-events.ts +@@ -107,7 +107,6 @@ const TODO_EVENTS = new Set([ + "todo.updated", + "todo.deleted", + ]); +-const CONTEXT_WINDOW_SNAPSHOT_EVENT = "agent.context_window.snapshot"; + + export function queryKeysForRunEvent( + runId: string, +@@ -169,15 +168,6 @@ export function queryKeysForRunEvent( + return keys; + } + +- if (event === CONTEXT_WINDOW_SNAPSHOT_EVENT) { +- const keys: Key[] = [queryKeys.runs.events(runId, 1000)]; +- if (stageId) { +- keys.push(queryKeys.runs.stageEvents(runId, stageId)); +- keys.push(queryKeys.runs.stageContextWindow(runId, stageId)); +- } +- return keys; +- } +- + if (STAGE_ACTIVITY_EVENTS.has(event)) { + return stageId + ? [ +diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml +index 58a1e02e9..1693d5f29 100644 +--- a/docs/public/api-reference/fabro-api.yaml ++++ b/docs/public/api-reference/fabro-api.yaml +@@ -7971,6 +7971,38 @@ components: + type: integer + minimum: 1 + ++ AgentMessageProps: ++ description: Properties for the `agent.message` event. ++ type: object ++ required: ++ - text ++ - model ++ - billing ++ - tool_call_count ++ - visit ++ properties: ++ text: ++ type: string ++ model: ++ $ref: "#/components/schemas/BillingModelRef" ++ billing: ++ $ref: "#/components/schemas/BilledTokenCounts" ++ tool_call_count: ++ type: integer ++ minimum: 0 ++ visit: ++ type: integer ++ minimum: 1 ++ message: ++ type: ["object", "null"] ++ additionalProperties: true ++ description: Canonical replay-authoritative transcript message, when present. ++ context_window: ++ oneOf: ++ - $ref: "#/components/schemas/StageContextWindowProjection" ++ - type: "null" ++ description: Latest content-free context-window projection for this agent stage. ++ + RunSupersededByProps: + description: Properties for the `run.superseded_by` audit event emitted on a rewound source run after archive succeeds. + type: object +diff --git a/lib/crates/fabro-agent/src/context_window.rs b/lib/crates/fabro-agent/src/context_window.rs +index da8da9d72..6d6923dae 100644 +--- a/lib/crates/fabro-agent/src/context_window.rs ++++ b/lib/crates/fabro-agent/src/context_window.rs +@@ -16,7 +16,7 @@ use crate::skills::{Skill, format_skills_prompt_section}; + use crate::tool_registry::{ToolDefinitionWithSource, ToolSource}; + + #[derive(Clone, Copy)] +-pub(crate) struct ContextWindowSnapshotInput<'a> { ++pub(crate) struct ContextWindowInput<'a> { + pub request: &'a Request, + pub tools: &'a [ToolDefinitionWithSource], + pub system_prompt: &'a str, +@@ -29,9 +29,7 @@ pub(crate) struct ContextWindowSnapshotInput<'a> { + } + + #[must_use] +-pub(crate) fn build_local_snapshot( +- input: ContextWindowSnapshotInput<'_>, +-) -> StageContextWindowProjection { ++pub(crate) fn build_local_snapshot(input: ContextWindowInput<'_>) -> StageContextWindowProjection { + let mut builder = BreakdownBuilder::default(); + let mut warnings = Vec::new(); + +@@ -131,18 +129,10 @@ pub(crate) fn warnings_from_llm(warnings: &[LlmWarning]) -> Vec StageContextWindowWarning { +- StageContextWindowWarning { +- code: code.to_string(), +- message: message.to_string(), +- } +-} +- + fn add_message_breakdown( + builder: &mut BreakdownBuilder, + warnings: &mut Vec, +- input: &ContextWindowSnapshotInput<'_>, ++ input: &ContextWindowInput<'_>, + ) { + let memory_text = memory_prompt_suffix(input.memory); + let skills_text = skills_prompt_suffix(input.skills); +@@ -394,7 +384,7 @@ mod tests { + tools.iter().map(|tool| tool.definition.clone()).collect(), + ); + +- let snapshot = build_local_snapshot(ContextWindowSnapshotInput { ++ let snapshot = build_local_snapshot(ContextWindowInput { + request: &req, + tools: &tools, + system_prompt: &system_prompt, +diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs +index 11c43fffa..e7fd25566 100644 +--- a/lib/crates/fabro-agent/src/session.rs ++++ b/lib/crates/fabro-agent/src/session.rs +@@ -1,5 +1,4 @@ +-use std::collections::{HashMap, HashSet, VecDeque}; +-use std::hash::{Hash, Hasher}; ++use std::collections::{HashMap, VecDeque}; + use std::sync::{Arc, Mutex, RwLock}; + use std::time::SystemTime; + +@@ -8,7 +7,6 @@ use fabro_llm::client::Client; + use fabro_llm::error::ProviderErrorKind; + use fabro_llm::generate::StreamAccumulator; + use fabro_llm::provider::StreamEventStream; +-use fabro_llm::token_count::{InputTokenCountMethod, InputTokenCountPreference}; + use fabro_llm::types::{ + ContentPart, Message as LlmMessage, ReasoningEffort, Request, RetryPolicy, StreamEvent, + TokenCounts, ToolChoice, +@@ -18,8 +16,8 @@ use fabro_mcp::config::{McpServerSettings, McpTransport}; + use fabro_mcp::connection_manager::McpConnectionManager; + use fabro_model::{AgentProfileKind, Catalog, ModelRef, Speed}; + use fabro_types::{ +- PermissionLevel, Principal, SessionMessage, SessionRecord, StageContextWindowProjection, +- SteeringMessage, ++ PermissionLevel, Principal, SessionMessage, SessionRecord, StageContextWindowCountMethod, ++ StageContextWindowProjection, SteeringMessage, + }; + use futures::StreamExt; + use tokio::sync::{Mutex as AsyncMutex, Notify, broadcast}; +@@ -30,9 +28,7 @@ use tracing::{debug, info, warn}; + use crate::agent_profile::AgentProfile; + use crate::compaction::{check_context_usage, compact_context}; + use crate::config::SessionOptions; +-use crate::context_window::{ +- ContextWindowSnapshotInput, build_local_snapshot, scaled_snapshot, warning, warnings_from_llm, +-}; ++use crate::context_window::{ContextWindowInput, build_local_snapshot, scaled_snapshot}; + use crate::error::{Error, InterruptReason}; + use crate::event::Emitter; + use crate::file_tracker::FileTracker; +@@ -47,7 +43,6 @@ use crate::skills::{ + }; + use crate::subagent::{SubAgentCallbackEvent, SubAgentEventCallback, SubAgentManager}; + use crate::tool_execution::execute_tool_calls; +-use crate::tool_registry::ToolDefinitionWithSource; + use crate::types::{ + AgentEvent, McpToolSummary, MemoryFileSummary, Message, SessionEvent, SessionState, + SkillActivationSource, SkillSummary, +@@ -305,13 +300,8 @@ impl ToolEnvProvider for StaticEnvProvider { + } + + struct BuiltRequest { +- request: Request, +- tools: Vec, +-} +- +-struct EmittedContextWindowSnapshot { +- local_snapshot: StageContextWindowProjection, +- fingerprint: Option, ++ request: Request, ++ context_window: StageContextWindowProjection, + } + + pub struct Session { +@@ -332,7 +322,6 @@ pub struct Session { + control_notify: Arc, + followup_queue: Arc>>, + cancel_token: CancellationToken, +- close_token: CancellationToken, + round_token: Arc>, + interrupt_reason: Arc>>, + memory: Vec, +@@ -340,8 +329,6 @@ pub struct Session { + skills: Vec, + system_prompt: String, + activated_skill_context_observed: bool, +- context_window_counted_fingerprints: HashSet, +- context_window_response_usage_fingerprints: Arc>>, + file_tracker: FileTracker, + tool_env_provider: Option>, + subagent_manager: Option>>, +@@ -372,7 +359,6 @@ impl Session { + control_notify: Arc::new(Notify::new()), + followup_queue: Arc::new(Mutex::new(VecDeque::new())), + cancel_token: CancellationToken::new(), +- close_token: CancellationToken::new(), + round_token: Arc::new(RwLock::new(CancellationToken::new())), + interrupt_reason: Arc::new(Mutex::new(None)), + memory: Vec::new(), +@@ -380,8 +366,6 @@ impl Session { + skills: Vec::new(), + system_prompt: String::new(), + activated_skill_context_observed: false, +- context_window_counted_fingerprints: HashSet::new(), +- context_window_response_usage_fingerprints: Arc::new(Mutex::new(HashSet::new())), + file_tracker: FileTracker::default(), + tool_env_provider: None, + subagent_manager, +@@ -1135,7 +1119,6 @@ impl Session { + + pub fn close(&mut self) -> bool { + let was_open = self.state != SessionState::Closed; +- self.close_token.cancel(); + self.transition(SessionState::Closed); + was_open + } +@@ -1335,7 +1318,7 @@ impl Session { + + // Build request + let built_request = self.build_request(); +- let context_window_snapshot = self.emit_context_window_snapshots(&built_request); ++ let local_context_window = built_request.context_window.clone(); + let request = built_request.request; + + // Emit AssistantTextStart before LLM call +@@ -1606,7 +1589,10 @@ impl Session { + .cloned() + .collect(); + let usage = response.usage.clone(); +- self.emit_response_usage_context_window_snapshot(&context_window_snapshot, &usage); ++ let context_window = Some(context_window_from_response_usage( ++ &local_context_window, ++ &usage, ++ )); + + self.history.push(Message::Assistant { + content: text.clone(), +@@ -1633,6 +1619,7 @@ impl Session { + model, + usage: response.usage.clone(), + tool_call_count: tool_calls.len(), ++ context_window, + }); + + // Post-response compaction: trim context after appending assistant turn +@@ -1737,134 +1724,6 @@ impl Session { + Ok(()) + } + +- fn emit_context_window_snapshots( +- &mut self, +- built_request: &BuiltRequest, +- ) -> EmittedContextWindowSnapshot { +- let provider = self.provider_profile.provider_id().to_string(); +- let model = self.provider_profile.model().to_string(); +- let local_snapshot = build_local_snapshot(ContextWindowSnapshotInput { +- request: &built_request.request, +- tools: &built_request.tools, +- system_prompt: &self.system_prompt, +- memory: &self.memory, +- skills: &self.skills, +- activated_skill_context_observed: self.activated_skill_context_observed, +- provider: &provider, +- model: &model, +- context_window_tokens: self.provider_profile.context_window_size(), +- }); +- self.event_emitter.emit( +- self.id.clone(), +- AgentEvent::ContextWindowSnapshot(local_snapshot.clone()), +- ); +- +- let Some(fingerprint) = request_fingerprint(&built_request.request) else { +- return EmittedContextWindowSnapshot { +- local_snapshot, +- fingerprint: None, +- }; +- }; +- if !self.context_window_counted_fingerprints.insert(fingerprint) { +- return EmittedContextWindowSnapshot { +- local_snapshot, +- fingerprint: Some(fingerprint), +- }; +- } +- +- let client = self.llm_client.clone(); +- let request = built_request.request.clone(); +- let session_id = self.id.clone(); +- let emitter = self.event_emitter.clone(); +- let local_for_count = local_snapshot.clone(); +- let close_token = self.close_token.clone(); +- let response_usage_fingerprints = +- Arc::clone(&self.context_window_response_usage_fingerprints); +- tokio::spawn(async move { +- let count_result = tokio::select! { +- biased; +- () = close_token.cancelled() => return, +- result = client.count_input_tokens(&request, InputTokenCountPreference::PreferProvider) => result, +- }; +- if close_token.is_cancelled() { +- return; +- } +- if response_usage_fingerprints +- .lock() +- .expect("context window response-usage fingerprint lock poisoned") +- .contains(&fingerprint) +- { +- return; +- } +- let snapshot = match count_result { +- Ok(count) if count.method == InputTokenCountMethod::ProviderApi => { +- let input_tokens = u64::try_from(count.input_tokens.max(0)).unwrap_or(u64::MAX); +- scaled_snapshot( +- &local_for_count, +- input_tokens, +- fabro_types::StageContextWindowCountMethod::ProviderApiScaledBreakdown, +- warnings_from_llm(&count.warnings), +- ) +- } +- Ok(count) => { +- let mut warnings = local_for_count.warnings.clone(); +- warnings.extend(warnings_from_llm(&count.warnings)); +- let input_tokens = u64::try_from(count.input_tokens.max(0)).unwrap_or(u64::MAX); +- scaled_snapshot( +- &local_for_count, +- input_tokens, +- fabro_types::StageContextWindowCountMethod::LocalEstimate, +- warnings, +- ) +- } +- Err(_) => { +- let mut warnings = local_for_count.warnings.clone(); +- warnings.push(warning( +- "provider_token_count_unavailable", +- "provider input token counting was unavailable; retained local estimate", +- )); +- let mut snapshot = local_for_count.clone(); +- snapshot.warnings = warnings; +- snapshot +- } +- }; +- emitter.emit(session_id, AgentEvent::ContextWindowSnapshot(snapshot)); +- }); +- +- EmittedContextWindowSnapshot { +- local_snapshot, +- fingerprint: Some(fingerprint), +- } +- } +- +- fn emit_response_usage_context_window_snapshot( +- &self, +- context_window_snapshot: &EmittedContextWindowSnapshot, +- usage: &TokenCounts, +- ) { +- let input_tokens = usage +- .input_tokens +- .saturating_add(usage.cache_read_tokens) +- .saturating_add(usage.cache_write_tokens); +- if input_tokens <= 0 { +- return; +- } +- if let Some(fingerprint) = context_window_snapshot.fingerprint { +- self.context_window_response_usage_fingerprints +- .lock() +- .expect("context window response-usage fingerprint lock poisoned") +- .insert(fingerprint); +- } +- let snapshot = scaled_snapshot( +- &context_window_snapshot.local_snapshot, +- u64::try_from(input_tokens).unwrap_or(u64::MAX), +- fabro_types::StageContextWindowCountMethod::ResponseUsageScaledBreakdown, +- context_window_snapshot.local_snapshot.warnings.clone(), +- ); +- self.event_emitter +- .emit(self.id.clone(), AgentEvent::ContextWindowSnapshot(snapshot)); +- } +- + async fn compact_if_needed(&mut self) { + let Some(estimate) = check_context_usage( + &self.system_prompt, +@@ -2002,9 +1861,22 @@ impl Session { + metadata: None, + provider_options: None, + }; ++ let provider = self.provider_profile.provider_id().to_string(); ++ let model = self.provider_profile.model().to_string(); ++ let context_window = build_local_snapshot(ContextWindowInput { ++ request: &request, ++ tools: &tools_with_source, ++ system_prompt: &self.system_prompt, ++ memory: &self.memory, ++ skills: &self.skills, ++ activated_skill_context_observed: self.activated_skill_context_observed, ++ provider: &provider, ++ model: &model, ++ context_window_tokens: self.provider_profile.context_window_size(), ++ }); + BuiltRequest { + request, +- tools: tools_with_source, ++ context_window, + } + } + +@@ -2026,6 +1898,25 @@ impl Session { + } + } + ++fn context_window_from_response_usage( ++ local_snapshot: &StageContextWindowProjection, ++ usage: &TokenCounts, ++) -> StageContextWindowProjection { ++ let input_tokens = usage ++ .input_tokens ++ .saturating_add(usage.cache_read_tokens) ++ .saturating_add(usage.cache_write_tokens); ++ if input_tokens <= 0 { ++ return local_snapshot.clone(); ++ } ++ scaled_snapshot( ++ local_snapshot, ++ u64::try_from(input_tokens).unwrap_or(u64::MAX), ++ StageContextWindowCountMethod::ResponseUsageScaledBreakdown, ++ local_snapshot.warnings.clone(), ++ ) ++} ++ + const fn is_auth_error(err: &LlmError) -> bool { + matches!( + err.provider_kind(), +@@ -2033,13 +1924,6 @@ const fn is_auth_error(err: &LlmError) -> bool { + ) + } + +-fn request_fingerprint(request: &Request) -> Option { +- let bytes = serde_json::to_vec(request).ok()?; +- let mut hasher = std::collections::hash_map::DefaultHasher::new(); +- bytes.hash(&mut hasher); +- Some(hasher.finish()) +-} +- + /// Best-effort kill of a sandbox MCP server process group. Used when + /// `start_sandbox_mcp_server` is cancelled after spawning a detached + /// `setsid` child but before reporting readiness. Errors from the sandbox +@@ -2574,10 +2458,15 @@ mod tests { + .iter() + .any(|e| matches!(e.event, AgentEvent::UserInput { .. })) + ); +- assert!( +- events +- .iter() +- .any(|e| matches!(e.event, AgentEvent::AssistantMessage { .. })) ++ let assistant_context_window = events.iter().find_map(|e| match &e.event { ++ AgentEvent::AssistantMessage { context_window, .. } => context_window.as_ref(), ++ _ => None, ++ }); ++ let context_window = ++ assistant_context_window.expect("assistant message should carry context window data"); ++ assert_eq!( ++ context_window.count_method, ++ StageContextWindowCountMethod::ResponseUsageScaledBreakdown + ); + assert!( + events +@@ -2586,6 +2475,33 @@ mod tests { + ); + } + ++ #[tokio::test] ++ async fn assistant_message_context_window_uses_local_estimate_without_response_usage() { ++ let mut session = make_session(vec![response_with_usage( ++ text_response("Hello"), ++ TokenCounts::default(), ++ )]) ++ .await; ++ let mut rx = session.subscribe(); ++ ++ session.process_input("Hi").await.unwrap(); ++ ++ let context_window = std::iter::from_fn(|| rx.try_recv().ok()).find_map(|event| { ++ if let AgentEvent::AssistantMessage { context_window, .. } = event.event { ++ context_window ++ } else { ++ None ++ } ++ }); ++ ++ let context_window = context_window.expect("assistant message should carry context window"); ++ assert_eq!( ++ context_window.count_method, ++ StageContextWindowCountMethod::LocalEstimate ++ ); ++ assert!(context_window.input_tokens > 0); ++ } ++ + #[tokio::test] + async fn tool_call_end_has_untruncated_output() { + let mut registry = ToolRegistry::new(); +diff --git a/lib/crates/fabro-agent/src/types.rs b/lib/crates/fabro-agent/src/types.rs +index 9aa1a014c..d6907880f 100644 +--- a/lib/crates/fabro-agent/src/types.rs ++++ b/lib/crates/fabro-agent/src/types.rs +@@ -246,6 +246,8 @@ pub enum AgentEvent { + model: ModelRef, + usage: TokenCounts, + tool_call_count: usize, ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ context_window: Option, + }, + TextDelta { + delta: String, +@@ -304,7 +306,6 @@ pub enum AgentEvent { + delay_secs: f64, + error: LlmError, + }, +- ContextWindowSnapshot(StageContextWindowProjection), + SubAgentSpawned { + agent_id: String, + depth: usize, +@@ -504,17 +505,6 @@ impl AgentEvent { + "LLM request failed, retrying" + ); + } +- Self::ContextWindowSnapshot(snapshot) => { +- debug!( +- session_id, +- provider = snapshot.provider.as_str(), +- model = snapshot.model.as_str(), +- input_tokens = snapshot.input_tokens, +- context_window_tokens = snapshot.context_window_tokens, +- count_method = %snapshot.count_method, +- "Context window snapshot" +- ); +- } + Self::SubAgentSpawned { + agent_id, + depth, +@@ -891,6 +881,7 @@ mod tests { + }, + usage: usage.clone(), + tool_call_count: 2, ++ context_window: None, + }; + match &event { + AgentEvent::AssistantMessage { +diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +index eee7c3789..b13f34dea 100644 +--- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs ++++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +@@ -578,6 +578,7 @@ mod tests { + }, + usage: TokenCounts::default(), + tool_call_count: 0, ++ context_window: None, + }) + } + +diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs +index f13adafd5..e2c9b1885 100644 +--- a/lib/crates/fabro-server/src/demo/mod.rs ++++ b/lib/crates/fabro-server/src/demo/mod.rs +@@ -1430,6 +1430,7 @@ mod runs { + tool_call_count: 0, + visit: 1, + message: None, ++ context_window: None, + }), + ), + make_envelope( +@@ -1498,6 +1499,7 @@ mod runs { + tool_call_count: 0, + visit: 1, + message: None, ++ context_window: None, + }), + ), + ] +diff --git a/lib/crates/fabro-server/src/server/handler/pair.rs b/lib/crates/fabro-server/src/server/handler/pair.rs +index f5170aa86..f60228376 100644 +--- a/lib/crates/fabro-server/src/server/handler/pair.rs ++++ b/lib/crates/fabro-server/src/server/handler/pair.rs +@@ -926,6 +926,7 @@ mod tests { + tool_call_count: 0, + visit: 1, + message: None, ++ context_window: None, + }), + ), + ) +@@ -957,6 +958,7 @@ mod tests { + tool_call_count: 0, + visit: 1, + message: None, ++ context_window: None, + }), + ), + ) +diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs +index 6488ab364..bd9759c43 100644 +--- a/lib/crates/fabro-server/src/server/tests.rs ++++ b/lib/crates/fabro-server/src/server/tests.rs +@@ -14,7 +14,7 @@ use fabro_config::bind::Bind; + use fabro_interview::{ + AnswerValue, ControlInterviewer, Interviewer, Question, WorkerControlMessage, + }; +-use fabro_llm::types::{Message as LlmMessage, Request as LlmRequest}; ++use fabro_llm::types::{Message as LlmMessage, Request as LlmRequest, TokenCounts}; + use fabro_model::catalog::LlmCatalogSettings; + use fabro_model::{Catalog, ModelRef, ProviderId, ReasoningEffort, Speed}; + use fabro_types::settings::ServerAuthMethod; +@@ -3023,12 +3023,22 @@ fn stage_completed_event(node_id: &str) -> workflow_event::Event { + fn context_window_event( + stage: &str, + visit: u32, +- snapshot: StageContextWindowProjection, ++ context_window: StageContextWindowProjection, + ) -> workflow_event::Event { + workflow_event::Event::Agent { + stage: stage.to_string(), + visit, +- event: fabro_agent::AgentEvent::ContextWindowSnapshot(snapshot), ++ event: fabro_agent::AgentEvent::AssistantMessage { ++ text: "assistant response".to_string(), ++ model: ModelRef { ++ provider: ProviderId::openai(), ++ model_id: "gpt-5.4".to_string(), ++ speed: None, ++ }, ++ usage: TokenCounts::default(), ++ tool_call_count: 0, ++ context_window: Some(context_window), ++ }, + session_id: Some("session-1".to_string()), + parent_session_id: None, + tool_call_id: None, +@@ -3045,7 +3055,7 @@ fn context_window_snapshot( + context_window_tokens: 400_000, + input_tokens, + usage_percent: input_tokens as f64 * 100.0 / 400_000.0, +- count_method: StageContextWindowCountMethod::ProviderApiScaledBreakdown, ++ count_method: StageContextWindowCountMethod::ResponseUsageScaledBreakdown, + staleness: StageContextWindowStaleness::Live, + generated_at: Utc::now(), + event_seq: None, +@@ -6602,7 +6612,7 @@ async fn get_run_stage_context_window_returns_live_projected_snapshot() { + assert_eq!(body["stage_id"], "agent_node@1"); + assert_eq!(body["available"], true); + assert_eq!(body["provider"], "openai"); +- assert_eq!(body["count_method"], "provider_api_scaled_breakdown"); ++ assert_eq!(body["count_method"], "response_usage_scaled_breakdown"); + assert_eq!(body["staleness"], "live"); + assert_eq!(body["input_tokens"], 123_456); + assert_eq!(body["breakdown"][0]["category"], "conversation"); +diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs +index d5854291e..e2ce31e07 100644 +--- a/lib/crates/fabro-store/src/run_state.rs ++++ b/lib/crates/fabro-store/src/run_state.rs +@@ -402,6 +402,11 @@ impl RunProjectionReducer for RunProjection { + }; + stage.usage.add_counts(&props.billing); + stage.model = Some(props.model.clone()); ++ if let Some(context_window) = &props.context_window { ++ let mut context_window = context_window.clone(); ++ context_window.event_seq = Some(event.seq); ++ stage.context_window = Some(context_window); ++ } + } + EventBody::AgentSessionActivated(props) => { + let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq) +@@ -615,15 +620,6 @@ impl RunProjectionReducer for RunProjection { + } + } + } +- EventBody::AgentContextWindowSnapshot(props) => { +- let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq) +- else { +- return Ok(()); +- }; +- let mut snapshot = props.snapshot.clone(); +- snapshot.event_seq = Some(event.seq); +- stage.context_window = Some(snapshot); +- } + _ => {} + } + +@@ -1227,14 +1223,14 @@ mod tests { + use fabro_types::run_event::run::RunFailedProps; + use fabro_types::run_event::{ + AgentAcpCancelledProps, AgentAcpCompletedProps, AgentAcpStartedProps, +- AgentAcpTimedOutProps, AgentContextWindowSnapshotProps, AgentMcpFailedProps, +- AgentMcpReadyProps, AgentMcpToolSummary, AgentMessageProps, AgentSessionActivatedProps, +- AgentSessionEndedProps, AgentSessionStartedProps, AgentSkillActivatedProps, +- AgentSkillActivationSource, AgentSkillSummary, AgentSkillsDiscoveredProps, +- AgentSubClosedProps, AgentSubCompletedProps, AgentSubFailedProps, AgentSubSpawnedProps, +- AgentToolStartedProps, CheckpointCompletedProps, InterviewCompletedProps, InterviewOption, +- InterviewStartedProps, RunCompletedProps, RunControlEffectProps, StageCompletedProps, +- StageFailedProps, StagePromptProps, StageRetryingProps, StageStartedProps, ++ AgentAcpTimedOutProps, AgentMcpFailedProps, AgentMcpReadyProps, AgentMcpToolSummary, ++ AgentMessageProps, AgentSessionActivatedProps, AgentSessionEndedProps, ++ AgentSessionStartedProps, AgentSkillActivatedProps, AgentSkillActivationSource, ++ AgentSkillSummary, AgentSkillsDiscoveredProps, AgentSubClosedProps, AgentSubCompletedProps, ++ AgentSubFailedProps, AgentSubSpawnedProps, AgentToolStartedProps, CheckpointCompletedProps, ++ InterviewCompletedProps, InterviewOption, InterviewStartedProps, RunCompletedProps, ++ RunControlEffectProps, StageCompletedProps, StageFailedProps, StagePromptProps, ++ StageRetryingProps, StageStartedProps, + }; + use fabro_types::{ + AgentBackend, BilledModelUsage, BilledTokenCounts, BlockedReason, Checkpoint, +@@ -3462,6 +3458,7 @@ mod tests { + tool_call_count: 0, + visit: 1, + message: None, ++ context_window: None, + } + } + +@@ -4816,7 +4813,7 @@ mod tests { + } + + #[test] +- fn context_window_snapshots_replace_latest_for_matching_stage() { ++ fn agent_messages_replace_latest_context_window_for_matching_stage() { + let mut state = initialized_projection(); + let stage_id = stage_id(); + let first = context_window_snapshot(10); +@@ -4825,22 +4822,14 @@ mod tests { + state + .apply_event(&test_stage_event( + 7, +- EventBody::AgentContextWindowSnapshot(AgentContextWindowSnapshotProps { +- stage_id: stage_id.clone(), +- visit: 1, +- snapshot: first, +- }), ++ EventBody::AgentMessage(agent_message_with_context_window(first)), + stage_id.clone(), + )) + .unwrap(); + state + .apply_event(&test_stage_event( + 8, +- EventBody::AgentContextWindowSnapshot(AgentContextWindowSnapshotProps { +- stage_id: stage_id.clone(), +- visit: 1, +- snapshot: second, +- }), ++ EventBody::AgentMessage(agent_message_with_context_window(second)), + stage_id.clone(), + )) + .unwrap(); +@@ -4852,25 +4841,44 @@ mod tests { + } + + #[test] +- fn context_window_snapshot_does_not_update_other_stage() { ++ fn agent_message_without_context_window_preserves_existing_context_window() { + let mut state = initialized_projection(); +- let target = stage_id(); +- let other = StageId::new("review", 1); ++ let stage_id = stage_id(); + + state + .apply_event(&test_stage_event( + 7, +- EventBody::AgentContextWindowSnapshot(AgentContextWindowSnapshotProps { +- stage_id: target.clone(), +- visit: 1, +- snapshot: context_window_snapshot(10), +- }), +- target.clone(), ++ EventBody::AgentMessage(agent_message_with_context_window( ++ context_window_snapshot(10), ++ )), ++ stage_id.clone(), ++ )) ++ .unwrap(); ++ state ++ .apply_event(&test_stage_event( ++ 8, ++ EventBody::AgentMessage(live_agent_message_props(live_counts(1, 1))), ++ stage_id.clone(), + )) + .unwrap(); + +- assert!(state.stage(&target).unwrap().context_window.is_some()); +- assert!(state.stage(&other).is_none()); ++ let snapshot = state ++ .stage(&stage_id) ++ .unwrap() ++ .context_window ++ .as_ref() ++ .unwrap(); ++ assert_eq!(snapshot.input_tokens, 10); ++ assert_eq!(snapshot.event_seq, Some(7)); ++ } ++ ++ fn agent_message_with_context_window( ++ context_window: StageContextWindowProjection, ++ ) -> AgentMessageProps { ++ AgentMessageProps { ++ context_window: Some(context_window), ++ ..live_agent_message_props(live_counts(1, 1)) ++ } + } + + fn context_window_snapshot(input_tokens: u64) -> StageContextWindowProjection { +diff --git a/lib/crates/fabro-types/src/run_event/agent.rs b/lib/crates/fabro-types/src/run_event/agent.rs +index d172b2c9e..b1e567e6e 100644 +--- a/lib/crates/fabro-types/src/run_event/agent.rs ++++ b/lib/crates/fabro-types/src/run_event/agent.rs +@@ -6,7 +6,7 @@ use super::BilledTokenCounts; + use crate::transcript::{ToolCall, ToolResult, TranscriptMessage}; + use crate::{ + MessageId, ModelRef, PairId, PairMessageId, PairSystemMessageKind, PermissionLevel, +- StageContextWindowProjection, StageId, TurnId, ++ StageContextWindowProjection, TurnId, + }; + + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +@@ -77,6 +77,10 @@ pub struct AgentMessageProps { + /// payloads so older events still deserialize. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, ++ /// Latest content-free context-window projection for this agent stage, ++ /// computed from the request that produced this assistant response. ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ pub context_window: Option, + } + + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +@@ -218,14 +222,6 @@ pub struct AgentLlmRetryProps { + pub visit: u32, + } + +-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +-pub struct AgentContextWindowSnapshotProps { +- pub stage_id: StageId, +- pub visit: u32, +- #[serde(flatten)] +- pub snapshot: StageContextWindowProjection, +-} +- + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] + pub struct AgentSubSpawnedProps { + pub agent_id: String, +@@ -357,6 +353,7 @@ mod tests { + let props: AgentMessageProps = serde_json::from_value(v).unwrap(); + assert_eq!(props.text, "hello"); + assert!(props.message.is_none()); ++ assert!(props.context_window.is_none()); + } + + #[test] +@@ -371,6 +368,7 @@ mod tests { + tool_call_count: 0, + visit: 1, + message: Some(msg.clone()), ++ context_window: None, + }; + let v = serde_json::to_value(&props).unwrap(); + assert_eq!(v["message"]["kind"], "agent"); +diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs +index 84a58ef16..571a3d860 100644 +--- a/lib/crates/fabro-types/src/run_event/mod.rs ++++ b/lib/crates/fabro-types/src/run_event/mod.rs +@@ -238,8 +238,6 @@ pub enum EventBody { + AgentCompactionCompleted(AgentCompactionCompletedProps), + #[serde(rename = "agent.llm.retry")] + AgentLlmRetry(AgentLlmRetryProps), +- #[serde(rename = "agent.context_window.snapshot")] +- AgentContextWindowSnapshot(AgentContextWindowSnapshotProps), + #[serde(rename = "agent.sub.spawned")] + AgentSubSpawned(AgentSubSpawnedProps), + #[serde(rename = "agent.sub.completed")] +@@ -520,7 +518,6 @@ impl EventBody { + Self::AgentCompactionStarted(_) => "agent.compaction.started", + Self::AgentCompactionCompleted(_) => "agent.compaction.completed", + Self::AgentLlmRetry(_) => "agent.llm.retry", +- Self::AgentContextWindowSnapshot(_) => "agent.context_window.snapshot", + Self::AgentSubSpawned(_) => "agent.sub.spawned", + Self::AgentSubCompleted(_) => "agent.sub.completed", + Self::AgentSubFailed(_) => "agent.sub.failed", +@@ -702,7 +699,6 @@ fn is_known_event_name(event: &str) -> bool { + | "agent.compaction.started" + | "agent.compaction.completed" + | "agent.llm.retry" +- | "agent.context_window.snapshot" + | "agent.sub.spawned" + | "agent.sub.completed" + | "agent.sub.failed" +@@ -2156,43 +2152,95 @@ mod tests { + } + + #[test] +- fn agent_context_window_snapshot_serializes_with_canonical_name() { +- let body = EventBody::AgentContextWindowSnapshot(AgentContextWindowSnapshotProps { +- stage_id: crate::StageId::new("implement", 1), +- visit: 1, +- snapshot: crate::StageContextWindowProjection { +- provider: "openai".to_string(), +- model: "gpt-5.4".to_string(), +- context_window_tokens: 400_000, +- input_tokens: 123_456, +- usage_percent: 30.864, +- count_method: +- crate::StageContextWindowCountMethod::ProviderApiScaledBreakdown, +- staleness: crate::StageContextWindowStaleness::Live, +- generated_at: DateTime::parse_from_rfc3339("2026-05-23T12:34:56Z") +- .unwrap() +- .with_timezone(&Utc), +- event_seq: None, +- breakdown: vec![crate::StageContextWindowBreakdownItem { +- category: crate::StageContextWindowCategory::SystemPrompt, +- tokens: 30_000, +- usage_percent: 7.5, +- }], +- warnings: vec![crate::StageContextWindowWarning { +- code: "local_token_estimate".to_string(), +- message: "input token count is a local estimate".to_string(), +- }], ++ fn agent_message_omits_context_window_when_absent() { ++ let body = EventBody::AgentMessage(AgentMessageProps { ++ text: "ok".to_string(), ++ model: crate::ModelRef { ++ provider: fabro_model::ProviderId::openai(), ++ model_id: "gpt-5.4".to_string(), ++ speed: None, + }, ++ billing: BilledTokenCounts::default(), ++ tool_call_count: 0, ++ visit: 1, ++ message: None, ++ context_window: None, + }); ++ + let value = serde_json::to_value(&body).unwrap(); +- assert_eq!(value["event"], "agent.context_window.snapshot"); +- assert_eq!(value["properties"]["stage_id"], "implement@1"); ++ assert_eq!(value["event"], "agent.message"); ++ assert!( ++ value["properties"] ++ .as_object() ++ .unwrap() ++ .get("context_window") ++ .is_none() ++ ); ++ let parsed: EventBody = serde_json::from_value(value).unwrap(); ++ assert_eq!(parsed.event_name(), "agent.message"); ++ } ++ ++ #[test] ++ fn agent_message_round_trips_optional_context_window() { ++ let context_window = crate::StageContextWindowProjection { ++ provider: "openai".to_string(), ++ model: "gpt-5.4".to_string(), ++ context_window_tokens: 400_000, ++ input_tokens: 123_456, ++ usage_percent: 30.864, ++ count_method: ++ crate::StageContextWindowCountMethod::ResponseUsageScaledBreakdown, ++ staleness: crate::StageContextWindowStaleness::Live, ++ generated_at: DateTime::parse_from_rfc3339("2026-05-23T12:34:56Z") ++ .unwrap() ++ .with_timezone(&Utc), ++ event_seq: None, ++ breakdown: vec![crate::StageContextWindowBreakdownItem { ++ category: crate::StageContextWindowCategory::SystemPrompt, ++ tokens: 30_000, ++ usage_percent: 7.5, ++ }], ++ warnings: vec![crate::StageContextWindowWarning { ++ code: "local_token_estimate".to_string(), ++ message: "input token count is a local estimate".to_string(), ++ }], ++ }; ++ let body = EventBody::AgentMessage(AgentMessageProps { ++ text: "ok".to_string(), ++ model: crate::ModelRef { ++ provider: fabro_model::ProviderId::openai(), ++ model_id: "gpt-5.4".to_string(), ++ speed: None, ++ }, ++ billing: BilledTokenCounts::default(), ++ tool_call_count: 0, ++ visit: 1, ++ message: None, ++ context_window: Some(context_window), ++ }); ++ ++ let value = serde_json::to_value(&body).unwrap(); ++ assert_eq!(value["event"], "agent.message"); + assert_eq!( +- value["properties"]["breakdown"][0]["category"], ++ value["properties"]["context_window"]["breakdown"][0]["category"], + "system_prompt" + ); ++ assert_eq!( ++ value["properties"]["context_window"]["count_method"], ++ "response_usage_scaled_breakdown" ++ ); + let parsed: EventBody = serde_json::from_value(value).unwrap(); +- assert_eq!(parsed.event_name(), "agent.context_window.snapshot"); ++ match parsed { ++ EventBody::AgentMessage(props) => { ++ let context_window = props.context_window.expect("context window present"); ++ assert_eq!(context_window.input_tokens, 123_456); ++ assert_eq!( ++ context_window.count_method, ++ crate::StageContextWindowCountMethod::ResponseUsageScaledBreakdown ++ ); ++ } ++ other => panic!("expected AgentMessage body, got {other:?}"), ++ } + } + + #[test] +diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs +index ff098e6f0..3f4b90098 100644 +--- a/lib/crates/fabro-workflow/src/event/convert.rs ++++ b/lib/crates/fabro-workflow/src/event/convert.rs +@@ -591,7 +591,7 @@ fn event_body_from_event(event: &Event) -> EventBody { + billing: billing.clone(), + }), + Event::Agent { +- stage, ++ stage: _, + visit, + event, + .. +@@ -610,6 +610,7 @@ fn event_body_from_event(event: &Event) -> EventBody { + model, + usage, + tool_call_count, ++ context_window, + } => { + let billing = billed_token_counts_from_llm(usage); + EventBody::AgentMessage(fabro_types::AgentMessageProps { +@@ -619,6 +620,7 @@ fn event_body_from_event(event: &Event) -> EventBody { + tool_call_count: *tool_call_count, + visit: *visit, + message: None, ++ context_window: context_window.clone(), + }) + } + AgentEvent::ToolCallStarted { +@@ -711,13 +713,6 @@ fn event_body_from_event(event: &Event) -> EventBody { + error: serde_json::to_value(error).expect("serializable sdk error"), + visit: *visit, + }), +- AgentEvent::ContextWindowSnapshot(snapshot) => EventBody::AgentContextWindowSnapshot( +- fabro_types::AgentContextWindowSnapshotProps { +- stage_id: ::fabro_types::StageId::new(stage.clone(), *visit), +- visit: *visit, +- snapshot: snapshot.clone(), +- }, +- ), + AgentEvent::SubAgentSpawned { + agent_id, + depth, +@@ -2172,6 +2167,7 @@ mod tests { + }, + usage: LlmTokenCounts::default(), + tool_call_count: 0, ++ context_window: None, + }, + session_id: Some("ses_agent".to_string()), + parent_session_id: None, +@@ -2203,6 +2199,7 @@ mod tests { + ..LlmTokenCounts::default() + }, + tool_call_count: 0, ++ context_window: None, + }, + session_id: Some("ses_agent".to_string()), + parent_session_id: None, +@@ -2219,6 +2216,55 @@ mod tests { + assert_eq!(message.billing.total_usd_micros, None); + } + ++ #[test] ++ fn agent_assistant_message_copies_context_window_to_props() { ++ let context_window = ::fabro_types::StageContextWindowProjection { ++ provider: "openai".to_string(), ++ model: "gpt-5.4".to_string(), ++ context_window_tokens: 400_000, ++ input_tokens: 123, ++ usage_percent: 0.03075, ++ count_method: ::fabro_types::StageContextWindowCountMethod::LocalEstimate, ++ staleness: ::fabro_types::StageContextWindowStaleness::Live, ++ generated_at: Utc::now(), ++ event_seq: None, ++ breakdown: vec![::fabro_types::StageContextWindowBreakdownItem { ++ category: ::fabro_types::StageContextWindowCategory::Conversation, ++ tokens: 123, ++ usage_percent: 0.03075, ++ }], ++ warnings: Vec::new(), ++ }; ++ let stored = to_run_event(&fixtures::RUN_1, &Event::Agent { ++ stage: "code".to_string(), ++ visit: 1, ++ event: AgentEvent::AssistantMessage { ++ text: "ok".to_string(), ++ model: ModelRef { ++ provider: ProviderId::openai(), ++ model_id: "gpt-5.4".to_string(), ++ speed: None, ++ }, ++ usage: LlmTokenCounts::default(), ++ tool_call_count: 0, ++ context_window: Some(context_window), ++ }, ++ session_id: Some("ses_agent".to_string()), ++ parent_session_id: None, ++ tool_call_id: None, ++ }); ++ ++ let EventBody::AgentMessage(message) = stored.body else { ++ panic!("expected agent message body"); ++ }; ++ let context_window = message.context_window.expect("context window copied"); ++ assert_eq!(context_window.input_tokens, 123); ++ assert_eq!( ++ context_window.count_method, ++ ::fabro_types::StageContextWindowCountMethod::LocalEstimate ++ ); ++ } ++ + #[test] + fn agent_acp_events_map_to_event_bodies_with_stage_scope() { + let scope = StageScope { +diff --git a/lib/crates/fabro-workflow/src/event/names.rs b/lib/crates/fabro-workflow/src/event/names.rs +index 958f8fdeb..c4d19d832 100644 +--- a/lib/crates/fabro-workflow/src/event/names.rs ++++ b/lib/crates/fabro-workflow/src/event/names.rs +@@ -86,7 +86,6 @@ pub fn event_name(event: &Event) -> &'static str { + AgentEvent::CompactionStarted { .. } => "agent.compaction.started", + AgentEvent::CompactionCompleted { .. } => "agent.compaction.completed", + AgentEvent::LlmRetry { .. } => "agent.llm.retry", +- AgentEvent::ContextWindowSnapshot(_) => "agent.context_window.snapshot", + AgentEvent::SubAgentSpawned { .. } => "agent.sub.spawned", + AgentEvent::SubAgentCompleted { .. } => "agent.sub.completed", + AgentEvent::SubAgentFailed { .. } => "agent.sub.failed", +diff --git a/lib/packages/fabro-api-client/package.json b/lib/packages/fabro-api-client/package.json +index 7709ed33a..de8ac9883 100644 +--- a/lib/packages/fabro-api-client/package.json ++++ b/lib/packages/fabro-api-client/package.json +@@ -4,7 +4,7 @@ + "private": true, + "type": "module", + "scripts": { +- "generate": "bunx @openapitools/openapi-generator-cli generate -i ../../../docs/public/api-reference/fabro-api.yaml -g typescript-axios --additional-properties=supportsES6=true,typescriptThreePlus=true,withSeparateModelsAndApi=true,apiPackage=api,modelPackage=models,useTags=true,enumPropertyNaming=UPPERCASE -o src && bun run scripts/normalize-generated.ts", ++ "generate": "bunx @openapitools/openapi-generator-cli@2.20.2 generate -i ../../../docs/public/api-reference/fabro-api.yaml -g typescript-axios --additional-properties=supportsES6=true,typescriptThreePlus=true,withSeparateModelsAndApi=true,apiPackage=api,modelPackage=models,useTags=true,enumPropertyNaming=UPPERCASE -o src && bun run scripts/normalize-generated.ts", + "typecheck": "tsc" + }, + "devDependencies": { +diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES +index f8d333e14..61f64e490 100644 +--- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES ++++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES +@@ -23,6 +23,7 @@ configuration.ts + index.ts + models/activated-skill.ts + models/agent-mcp-tool-summary.ts ++models/agent-message-props.ts + models/agent-permissions.ts + models/agent-session-activated-props.ts + models/agent-skill-activation-source.ts +diff --git a/lib/packages/fabro-api-client/src/models/agent-message-props.ts b/lib/packages/fabro-api-client/src/models/agent-message-props.ts +new file mode 100644 +index 000000000..3aa5c34b6 +--- /dev/null ++++ b/lib/packages/fabro-api-client/src/models/agent-message-props.ts +@@ -0,0 +1,40 @@ ++/* tslint:disable */ ++/* eslint-disable */ ++/** ++ * Fabro Run API ++ * HTTP API for managing Fabro workflow run executions. ++ * ++ * The version of the OpenAPI document: 0.1.0 ++ * ++ * ++ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). ++ * https://openapi-generator.tech ++ * Do not edit the class manually. ++ */ ++ ++ ++// May contain unused imports in some cases ++// @ts-ignore ++import type { BilledTokenCounts } from './billed-token-counts'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { BillingModelRef } from './billing-model-ref'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { StageContextWindowProjection } from './stage-context-window-projection'; ++ ++/** ++ * Properties for the `agent.message` event. ++ */ ++export interface AgentMessageProps { ++ 'text': string; ++ 'model': BillingModelRef; ++ 'billing': BilledTokenCounts; ++ 'tool_call_count': number; ++ 'visit': number; ++ /** ++ * Canonical replay-authoritative transcript message, when present. ++ */ ++ 'message'?: { [key: string]: any; }; ++ 'context_window'?: StageContextWindowProjection | null; ++} +diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts +index 78d106d70..387306be1 100644 +--- a/lib/packages/fabro-api-client/src/models/index.ts ++++ b/lib/packages/fabro-api-client/src/models/index.ts +@@ -1,5 +1,6 @@ + export * from './activated-skill'; + export * from './agent-mcp-tool-summary'; ++export * from './agent-message-props'; + export * from './agent-permissions'; + export * from './agent-session-activated-props'; + export * from './agent-skill-activation-source'; diff --git a/stages/005-implement@1/response.md b/stages/005-implement@1/response.md new file mode 100644 index 000000000..c4a54b1eb --- /dev/null +++ b/stages/005-implement@1/response.md @@ -0,0 +1,35 @@ +Implemented the plan end-to-end. + +Summary: +- Removed standalone `agent.context_window.snapshot` event from Rust event contracts, workflow conversion, event names, store reducer, frontend invalidation, OpenAPI/API client surface. +- Added optional `context_window` to `agent.message`: + - `fabro-types::AgentMessageProps` + - `fabro-agent::AgentEvent::AssistantMessage` + - workflow conversion into `EventBody::AgentMessage` +- Updated `Session::run_single_input` so context-window projection is computed locally from the exact request and attached only after a successful assistant response. +- Removed normal-execution provider token-count API calls from `fabro-agent::Session`. +- Updated store projection to persist latest `AgentMessageProps.context_window` and stamp `event_seq`. +- Kept the context-window GET endpoint projection-backed and updated endpoint tests to seed data via `agent.message`. +- Regenerated the TS API client and added generated `AgentMessageProps`. +- Removed frontend snapshot event handling/tests; `agent.message` continues to invalidate stage context-window queries via stage activity handling. +- Used red/green TDD: first added a failing `fabro-agent` test proving standalone snapshot events were still emitted, then implemented until green. +- Used subagent-driven development for read-only investigation of Rust/API/frontend surfaces. + +Validation run: +- `cargo build -p fabro-api` +- `cargo nextest run -p fabro-types agent_message` +- `cargo nextest run -p fabro-agent` +- `cargo nextest run -p fabro-workflow` +- `cargo nextest run -p fabro-store` +- `cargo nextest run -p fabro-server get_run_stage_context_window` +- `cargo check -q --workspace` +- `cargo +nightly-2026-04-14 fmt --check --all` +- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings` +- `cd lib/packages/fabro-api-client && bun run generate` +- `cd lib/packages/fabro-api-client && bun run typecheck` +- `cd apps/fabro-web && bun run typecheck` +- `cd apps/fabro-web && bun run test` +- Final search: `agent\.context_window\.snapshot|AgentContextWindowSnapshot|ContextWindowSnapshot` — no matches. + +Note: +- I pinned the API client `generate` script to `@openapitools/openapi-generator-cli@2.20.2` because current `bunx` was resolving the unpinned command to an incompatible latest wrapper in this environment. \ No newline at end of file diff --git a/stages/005-implement@1/status.json b/stages/005-implement@1/status.json new file mode 100644 index 000000000..f9355a3c2 --- /dev/null +++ b/stages/005-implement@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "succeeded", + "notes": "Stage completed: implement", + "failure_reason": null, + "timestamp": "2026-05-24T18:01:18.862852Z" +} \ No newline at end of file diff --git a/stages/006-simplify_opus@1/prompt.md b/stages/006-simplify_opus@1/prompt.md new file mode 100644 index 000000000..c7e3a045f --- /dev/null +++ b/stages/006-simplify_opus@1/prompt.md @@ -0,0 +1,196 @@ +Goal: # Fold Context Window Into Agent Messages Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the chatty `agent.context_window.snapshot` event and persist context-window data through existing `agent.message` events. + +**Architecture:** Compute the context-window breakdown locally while the agent still has the exact request, attach the final content-free projection to the next `agent.message`, and let the run projection reducer store that latest projection for `GET /runs/{id}/stages/{stageId}/context-window`. Do not call provider token-count APIs during normal execution. + +**Tech Stack:** Rust, Serde, Fabro agent/session events, Fabro workflow event conversion, Fabro store projections, OpenAPI/progenitor, generated TypeScript client, React/SWR. + +--- + +## Scope And Decisions + +- Remove `agent.context_window.snapshot` completely from new code. This is a greenfield/no-prod app, so do not preserve backward-compatible deserialization or frontend handling for old snapshot events. +- Keep `StageContextWindowProjection`, `StageContextWindow`, `StageProjection.context_window`, and the existing context-window GET endpoint. +- Add optional `context_window` data to `agent.message` properties. +- Normal execution uses only local estimates and token usage returned by normal LLM responses. +- Do not call `Client::count_input_tokens` from `fabro-agent::Session`. +- The GET endpoint remains projection-backed for this pass. It must not require an in-memory session and must not trigger provider token-count API calls. +- Failed-before-response turns do not persist context-window data. +- Live pre-response context-window updates are not required. The UI updates after `agent.message`. + +## File Map + +- Modify `lib/crates/fabro-types/src/run_event/agent.rs` + - Add `context_window: Option` to `AgentMessageProps`. + - Remove `AgentContextWindowSnapshotProps`. +- Modify `lib/crates/fabro-types/src/run_event/mod.rs` + - Remove the `AgentContextWindowSnapshot` event variant, name mapping, known-event entry, and serde tests. + - Update `agent.message` serde tests to cover optional context-window data. +- Modify `lib/crates/fabro-agent/src/types.rs` + - Remove `AgentEvent::ContextWindowSnapshot`. + - Add `context_window: Option` to `AgentEvent::AssistantMessage`. +- Modify `lib/crates/fabro-agent/src/session.rs` + - Remove async provider-count task and response-usage snapshot event emission. + - Keep local context-window snapshot construction at request-build time. + - Attach the scaled or local projection to `AssistantMessage`. +- Keep `lib/crates/fabro-agent/src/context_window.rs` + - Reuse `build_local_snapshot` and `scaled_snapshot`. + - Remove only tests or helpers that exist solely for provider-count snapshot emission. +- Modify `lib/crates/fabro-workflow/src/event/convert.rs` + - Remove conversion for `AgentEvent::ContextWindowSnapshot`. + - Copy `context_window` from `AgentEvent::AssistantMessage` into `AgentMessageProps`. +- Modify `lib/crates/fabro-workflow/src/event/names.rs` + - Remove the snapshot event name. +- Modify `lib/crates/fabro-store/src/run_state.rs` + - Remove reducer support for `EventBody::AgentContextWindowSnapshot`. + - When reducing `EventBody::AgentMessage`, copy `props.context_window` into `stage.context_window` when present and stamp `event_seq`. +- Modify `lib/crates/fabro-server/src/server/tests.rs` + - Seed context-window endpoint tests with `agent.message` events that include context-window data. +- Modify `docs/public/api-reference/fabro-api.yaml` + - Add optional `context_window` to `AgentMessageProps`. + - Remove the snapshot event schema/variant. +- Regenerate/update `lib/packages/fabro-api-client/src`. +- Modify `apps/fabro-web/app/lib/run-events.ts` and tests + - Remove special handling for `agent.context_window.snapshot`. + - Rely on existing `agent.message` invalidation for stage events and context-window data. + +## Implementation Steps + +### Task 1: Move The Event Contract Onto `agent.message` + +- [ ] Add optional `context_window` to Rust `AgentMessageProps`. +- [ ] Remove `AgentContextWindowSnapshotProps` and the `agent.context_window.snapshot` `EventBody` variant. +- [ ] Update run-event serde tests so `agent.message` round-trips with and without `context_window`. +- [ ] Remove tests whose only assertion is that `agent.context_window.snapshot` is known or serializes. + +### Task 2: Stop Emitting Snapshot Events + +- [ ] Remove `AgentEvent::ContextWindowSnapshot` and its trace/debug handling. +- [ ] Change `AgentEvent::AssistantMessage` to carry `context_window: Option`. +- [ ] In `Session::run_single_input`, keep the local context-window snapshot returned from request construction. +- [ ] Delete the spawned `count_input_tokens(... PreferProvider)` task and its fingerprint suppression state. +- [ ] After the normal LLM response arrives, compute: + - `ResponseUsageScaledBreakdown` when response input/cache usage is positive. + - `LocalEstimate` when response usage has no usable input tokens. +- [ ] Attach that projection to the emitted `AssistantMessage`. + +### Task 3: Update Workflow Conversion And Store Projection + +- [ ] Remove snapshot event name/conversion branches. +- [ ] Include `context_window` when converting `AgentEvent::AssistantMessage` to `EventBody::AgentMessage`. +- [ ] In the store reducer, update `stage.context_window` from `AgentMessageProps.context_window`. +- [ ] Stamp the copied projection with the `agent.message` event sequence. +- [ ] Replace store tests for snapshot replacement with message-carried context-window tests. + +### Task 4: Keep The GET Endpoint Projection-Backed + +- [ ] Keep the endpoint route and response type unchanged. +- [ ] Keep existing `not_agent_stage`, `not_observed`, and terminal `stored` behavior. +- [ ] Update endpoint tests to seed context-window data via `agent.message`. +- [ ] Do not add endpoint-time provider token-count calls. + +### Task 5: Remove Frontend And API Trace + +- [ ] Remove frontend constants/tests for `agent.context_window.snapshot`. +- [ ] Ensure `agent.message` still invalidates `stageContextWindow` through existing stage activity handling. +- [ ] Update OpenAPI and regenerated TypeScript client so no snapshot event model remains. +- [ ] Run a final search for `agent.context_window.snapshot`, `AgentContextWindowSnapshot`, and `ContextWindowSnapshot`; only historical docs or this plan may remain. + +## Test Plan + +- Run `cargo build -p fabro-api` after OpenAPI/Rust type changes. +- Run targeted Rust tests: + - `cargo nextest run -p fabro-agent` + - `cargo nextest run -p fabro-workflow` + - `cargo nextest run -p fabro-store` + - `cargo nextest run -p fabro-server get_run_stage_context_window` + - `cargo nextest run -p fabro-types agent_message` +- Regenerate TypeScript client with `cd lib/packages/fabro-api-client && bun run generate`. +- Run web checks: + - `cd apps/fabro-web && bun test` + - `cd apps/fabro-web && bun run typecheck` + - `cd lib/packages/fabro-api-client && bun run typecheck` +- Run final targeted searches: + - `rg -n "agent\\.context_window\\.snapshot|AgentContextWindowSnapshot|ContextWindowSnapshot" lib/crates apps/fabro-web lib/packages docs/public/api-reference/fabro-api.yaml` + - Expected: no implementation/API/frontend matches. + +## Acceptance Criteria + +- Normal agent runs do not emit `agent.context_window.snapshot`. +- Normal agent runs do not call provider token-count endpoints for context-window reporting. +- `agent.message` includes context-window data when the agent produced a response. +- `GET /runs/{id}/stages/{stageId}/context-window` still returns the latest context-window projection. +- The event log contains no standalone context-window snapshot events. +- Public API/client/types no longer expose `agent.context_window.snapshot`. + + +## Completed stages +- **toolchain**: succeeded + - Script: `command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1` + - Output: + ``` + cargo 1.95.0 (f2d3ce0bd 2026-03-21) + ``` +- **preflight_compile**: succeeded + - Script: `cargo check -q --workspace 2>&1` + - Output: (empty) +- **preflight_lint**: succeeded + - Script: `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1` + - Output: (empty) +- **implement**: succeeded + - Model: gpt-5.5, 6.5m tokens in / 33.3k out + + +# Simplify: Code Review and Cleanup + +Review changes vs. origin for reuse, quality, and efficiency. Fix any issues found. + +## Phase 1: Identify Changes + +Run git diff (or git diff HEAD if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation. + +## Phase 2: Launch Three Review Agents in Parallel + +Use the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context. + +### Agent 1: Code Reuse Review + +For each change: + +1. Search for existing utilities and helpers that could replace newly written code. Use Grep to find similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones. +2. Flag any new function that duplicates existing functionality. Suggest the existing function to use instead. +3. Flag any inline logic that could use an existing utility — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates. + +Note: This is a greenfield app, so focus on maximizing simplicity and don't worry about changing things to achieve it. + +### Agent 2: Code Quality Review + +Review the same changes for hacky patterns: + +1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls +2. Parameter sprawl: adding new parameters to a function instead of generalizing or restructuring existing ones +3. Copy-paste with slight variation: near-duplicate code blocks that should be unified with a shared abstraction +4. Leaky abstractions: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries +5. Stringly-typed code: using raw strings where constants, enums (string unions), or branded types already exist in the codebase + +Note: This is a greenfield app, so be aggressive in optimizing quality. + +### Agent 3: Efficiency Review + +Review the same changes for efficiency: + +1. Unnecessary work: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns +2. Missed concurrency: independent operations run sequentially when they could run in parallel +3. Hot-path bloat: new blocking work added to startup or per-request/per-render hot paths +4. Unnecessary existence checks: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error +5. Memory: unbounded data structures, missing cleanup, event listener leaks +6. Overly broad operations: reading entire files when only a portion is needed, loading all items when filtering for one + +## Phase 3: Fix Issues + +Wait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it. + +When done, briefly summarize what was fixed (or confirm the code was already clean). \ No newline at end of file diff --git a/stages/006-simplify_opus@1/provider_used.json b/stages/006-simplify_opus@1/provider_used.json new file mode 100644 index 000000000..672cc50e5 --- /dev/null +++ b/stages/006-simplify_opus@1/provider_used.json @@ -0,0 +1,5 @@ +{ + "mode": "agent", + "provider": "anthropic", + "model": "claude-opus-4-7" +} \ No newline at end of file diff --git a/stages/006-simplify_opus@1/response.md b/stages/006-simplify_opus@1/response.md new file mode 100644 index 000000000..c6e974cf9 --- /dev/null +++ b/stages/006-simplify_opus@1/response.md @@ -0,0 +1,18 @@ +Cleanup complete. Here's what was fixed based on the parallel reviews: + +**Fixed (3 issues consolidated from reviews):** + +1. **Moved `context_window_from_response_usage` from `session.rs` to `context_window.rs`** — it was a free function that only operated on `StageContextWindowProjection` + `TokenCounts` and just delegated to `scaled_snapshot`. Co-located with its only collaborator. This also let `session.rs` drop its imports of `scaled_snapshot` and `StageContextWindowCountMethod`. + +2. **Eliminated avoidable `StageContextWindowProjection.clone()` in the per-LLM-turn hot path** — replaced `let local_context_window = built_request.context_window.clone(); let request = built_request.request;` with a destructuring let `BuiltRequest { request, context_window: local_context_window } = self.build_request();`. + +3. **Downgraded `warnings_from_llm` from `pub(crate)` to private `fn`** — its only outside caller was removed when the provider token-count path was deleted; it's now only used within `context_window.rs`. + +**Reviewed but skipped (false positives or below-the-bar):** + +- `BuiltRequest` 2-field struct could be a tuple, but the named-field destructure now reads cleanly and avoids the clone, so it earns its keep. +- `AssistantMessage` 5-field variant — borderline but tightening it to a struct would just rename without compressing. +- Reducer inline clone+stamp pattern — single use site, no helper warranted. +- The producer-side `Option` always being `Some` in `Session::run_single_input` — kept as `Option` because tests and non-AssistantMessage emit sites need `None`, and the wire schema is optional anyway. + +**Verification:** `cargo check --workspace --all-targets`, `cargo nextest run -p fabro-agent -p fabro-workflow -p fabro-store -p fabro-types` (2089 tests pass), and the 7 `get_run_stage_context_window` server tests all pass. Pre-existing nightly clippy warning in `fabro-sandbox` (large_enum_variant) is unrelated to this change. \ No newline at end of file