diff --git a/crates/console-ui/Cargo.toml b/crates/console-ui/Cargo.toml index d9c92bf9f..e20ece1f7 100644 --- a/crates/console-ui/Cargo.toml +++ b/crates/console-ui/Cargo.toml @@ -13,9 +13,11 @@ repository = "https://github.com/iii-hq/workers" publish = false [dependencies] -# Same exact pin the workers carry — path-linking this crate must never be -# what drags a worker onto a different SDK build. -iii-sdk = "=0.21.6" +# Range, not an exact pin: workers on this repo's SDK line carry their own +# exact pins (0.21.6 for state, 0.21.8 for the harness) and cargo must be able +# to unify them with this crate. Path-linking this crate must never be what +# drags a worker onto a different SDK build, nor what blocks one. +iii-sdk = ">=0.21.6, <0.22" tokio = { version = "1", features = ["rt", "sync", "time", "fs"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/eval/Cargo.lock b/eval/Cargo.lock index cd8a235de..4be2ec8f8 100644 --- a/eval/Cargo.lock +++ b/eval/Cargo.lock @@ -532,12 +532,13 @@ dependencies = [ [[package]] name = "harness" -version = "1.6.1" +version = "1.6.7" dependencies = [ "anyhow", "async-trait", "clap", "globset", + "iii-console-ui", "iii-helpers", "iii-sdk", "jsonschema", @@ -790,9 +791,9 @@ dependencies = [ [[package]] name = "iii-helpers" -version = "0.21.6" +version = "0.21.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0d84d5c149ae4404365a79feca28aa66f6a7dbed56423b4b8c4e2421e0b5add" +checksum = "84bdc7bbc3abfde934a62cdc5d3045adf52914dfc1ed6c20f8af691fc561dc55" dependencies = [ "futures-util", "opentelemetry", @@ -811,9 +812,9 @@ dependencies = [ [[package]] name = "iii-sdk" -version = "0.21.6" +version = "0.21.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07dd060fddcc9153b0dd07c038a14cf172ce15ce1d4edb98155563ed55b2caba" +checksum = "4dd563a1d2f55f893d9a433b747f0bf9bc426656413b136b6ed3a699f3c757b2" dependencies = [ "async-trait", "futures-util", diff --git a/eval/Cargo.toml b/eval/Cargo.toml index 34505fcf3..388ecf0fb 100644 --- a/eval/Cargo.toml +++ b/eval/Cargo.toml @@ -16,8 +16,8 @@ path = "src/lib.rs" [dependencies] harness = { path = "../harness" } -iii-sdk = "=0.21.6" -iii-helpers = "=0.21.6" +iii-sdk = "=0.21.8" +iii-helpers = "=0.21.8" iii-console-ui = { path = "../crates/console-ui" } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time"] } serde = { version = "1", features = ["derive"] } diff --git a/eval/src/runtime.rs b/eval/src/runtime.rs index cca0cdd4a..7be7d4490 100644 --- a/eval/src/runtime.rs +++ b/eval/src/runtime.rs @@ -693,6 +693,7 @@ fn send_options(job: &EvalJobRecordV1, variant: &crate::contract::EvalVariantV1) output: Some(job.request.output.clone()), functions: Some(job.request.functions.clone()), metadata: job.request.metadata.clone(), + max_validation_retries: None, } } diff --git a/eval/tests/golden/schemas/eval.assert.exact.json b/eval/tests/golden/schemas/eval.assert.exact.json index 9e3e0a983..42071cae9 100644 --- a/eval/tests/golden/schemas/eval.assert.exact.json +++ b/eval/tests/golden/schemas/eval.assert.exact.json @@ -3,6 +3,104 @@ "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "ContextSnapshotV1": { + "description": "One generation's context accounting. `free = usable - total`, floored at zero: once provider usage lands, `total` is what was billed, which can exceed the `usable` budget the window was fit into — that budget was derived before the generation from an estimate.", + "properties": { + "categories": { + "$ref": "#/definitions/SnapshotCategoriesV1" + }, + "compacted": { + "type": "boolean" + }, + "effective_max_output_tokens": { + "description": "Output allocation `usable` was derived against.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "estimator": { + "description": "Which estimator produced the numbers (`heuristic` until the context-manager resolves a real tokenizer). Absent when the context-manager predates the breakdown response.", + "type": [ + "string", + "null" + ] + }, + "free": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "model": { + "type": "string" + }, + "provider": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string" + }, + "step": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "summarized_head_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "timestamp": { + "format": "int64", + "type": "integer" + }, + "total": { + "description": "Final request estimate: categories plus post-assembly growth.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "turn_id": { + "type": "string" + }, + "usable": { + "description": "The input budget the window was fit into.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "usage": { + "anyOf": [ + { + "$ref": "#/definitions/Usage" + }, + { + "type": "null" + } + ], + "description": "Actual provider usage for this generation, stamped after the terminal frame; absent when the provider returned none (or the generation never completed)." + } + }, + "required": [ + "categories", + "compacted", + "effective_max_output_tokens", + "free", + "model", + "session_id", + "step", + "timestamp", + "total", + "turn_id", + "usable" + ], + "type": "object" + }, "SessionMetricsResponseV1": { "additionalProperties": false, "properties": { @@ -229,6 +327,17 @@ "null" ] }, + "context": { + "anyOf": [ + { + "$ref": "#/definitions/ContextSnapshotV1" + }, + { + "type": "null" + } + ], + "description": "The session's latest per-generation context snapshot (categories, budget, usage) — absent for sessions that have not generated since snapshots landed." + }, "cost_usd": { "format": "double", "type": [ @@ -299,6 +408,129 @@ ], "type": "object" }, + "SnapshotCategoriesV1": { + "description": "Where the request's tokens sit. Categories are assembly-time estimates; `hook_guidance` is the measured growth after assembly (pre-generate hook appends and orphan-repair patches), 0 when the request left assembly unchanged.", + "properties": { + "hook_guidance": { + "default": 0, + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "messages": { + "$ref": "#/definitions/SnapshotMessagesV1" + }, + "overhead": { + "description": "Provider framing plus response_format / provider_options fields.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "system_prompt": { + "description": "Final assembled system prompt: mode paragraph, identity, per-step aids, and any compaction summary section.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "tools": { + "description": "Function schemas exposed to the model.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "messages", + "overhead", + "system_prompt", + "tools" + ], + "type": "object" + }, + "SnapshotMessagesV1": { + "description": "Estimated tokens of the assembled window's messages, by role.", + "properties": { + "assistant": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "custom": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "function_result": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "user": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "assistant", + "custom", + "function_result", + "user" + ], + "type": "object" + }, + "Usage": { + "properties": { + "cache_read": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cache_write": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cost_usd": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "input": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "output": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "reasoning": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, "VariantRoleV1": { "enum": [ "control", diff --git a/eval/tests/golden/schemas/eval.assert.normalized_text.json b/eval/tests/golden/schemas/eval.assert.normalized_text.json index b8cceef70..d5c8744df 100644 --- a/eval/tests/golden/schemas/eval.assert.normalized_text.json +++ b/eval/tests/golden/schemas/eval.assert.normalized_text.json @@ -3,6 +3,104 @@ "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "ContextSnapshotV1": { + "description": "One generation's context accounting. `free = usable - total`, floored at zero: once provider usage lands, `total` is what was billed, which can exceed the `usable` budget the window was fit into — that budget was derived before the generation from an estimate.", + "properties": { + "categories": { + "$ref": "#/definitions/SnapshotCategoriesV1" + }, + "compacted": { + "type": "boolean" + }, + "effective_max_output_tokens": { + "description": "Output allocation `usable` was derived against.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "estimator": { + "description": "Which estimator produced the numbers (`heuristic` until the context-manager resolves a real tokenizer). Absent when the context-manager predates the breakdown response.", + "type": [ + "string", + "null" + ] + }, + "free": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "model": { + "type": "string" + }, + "provider": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string" + }, + "step": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "summarized_head_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "timestamp": { + "format": "int64", + "type": "integer" + }, + "total": { + "description": "Final request estimate: categories plus post-assembly growth.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "turn_id": { + "type": "string" + }, + "usable": { + "description": "The input budget the window was fit into.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "usage": { + "anyOf": [ + { + "$ref": "#/definitions/Usage" + }, + { + "type": "null" + } + ], + "description": "Actual provider usage for this generation, stamped after the terminal frame; absent when the provider returned none (or the generation never completed)." + } + }, + "required": [ + "categories", + "compacted", + "effective_max_output_tokens", + "free", + "model", + "session_id", + "step", + "timestamp", + "total", + "turn_id", + "usable" + ], + "type": "object" + }, "SessionMetricsResponseV1": { "additionalProperties": false, "properties": { @@ -229,6 +327,17 @@ "null" ] }, + "context": { + "anyOf": [ + { + "$ref": "#/definitions/ContextSnapshotV1" + }, + { + "type": "null" + } + ], + "description": "The session's latest per-generation context snapshot (categories, budget, usage) — absent for sessions that have not generated since snapshots landed." + }, "cost_usd": { "format": "double", "type": [ @@ -299,6 +408,129 @@ ], "type": "object" }, + "SnapshotCategoriesV1": { + "description": "Where the request's tokens sit. Categories are assembly-time estimates; `hook_guidance` is the measured growth after assembly (pre-generate hook appends and orphan-repair patches), 0 when the request left assembly unchanged.", + "properties": { + "hook_guidance": { + "default": 0, + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "messages": { + "$ref": "#/definitions/SnapshotMessagesV1" + }, + "overhead": { + "description": "Provider framing plus response_format / provider_options fields.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "system_prompt": { + "description": "Final assembled system prompt: mode paragraph, identity, per-step aids, and any compaction summary section.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "tools": { + "description": "Function schemas exposed to the model.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "messages", + "overhead", + "system_prompt", + "tools" + ], + "type": "object" + }, + "SnapshotMessagesV1": { + "description": "Estimated tokens of the assembled window's messages, by role.", + "properties": { + "assistant": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "custom": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "function_result": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "user": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "assistant", + "custom", + "function_result", + "user" + ], + "type": "object" + }, + "Usage": { + "properties": { + "cache_read": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cache_write": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cost_usd": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "input": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "output": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "reasoning": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, "VariantRoleV1": { "enum": [ "control", diff --git a/eval/tests/golden/schemas/eval.result.json b/eval/tests/golden/schemas/eval.result.json index fd603052e..814c8abbf 100644 --- a/eval/tests/golden/schemas/eval.result.json +++ b/eval/tests/golden/schemas/eval.result.json @@ -121,6 +121,104 @@ ], "type": "string" }, + "ContextSnapshotV1": { + "description": "One generation's context accounting. `free = usable - total`, floored at zero: once provider usage lands, `total` is what was billed, which can exceed the `usable` budget the window was fit into — that budget was derived before the generation from an estimate.", + "properties": { + "categories": { + "$ref": "#/definitions/SnapshotCategoriesV1" + }, + "compacted": { + "type": "boolean" + }, + "effective_max_output_tokens": { + "description": "Output allocation `usable` was derived against.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "estimator": { + "description": "Which estimator produced the numbers (`heuristic` until the context-manager resolves a real tokenizer). Absent when the context-manager predates the breakdown response.", + "type": [ + "string", + "null" + ] + }, + "free": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "model": { + "type": "string" + }, + "provider": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string" + }, + "step": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "summarized_head_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "timestamp": { + "format": "int64", + "type": "integer" + }, + "total": { + "description": "Final request estimate: categories plus post-assembly growth.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "turn_id": { + "type": "string" + }, + "usable": { + "description": "The input budget the window was fit into.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "usage": { + "anyOf": [ + { + "$ref": "#/definitions/Usage" + }, + { + "type": "null" + } + ], + "description": "Actual provider usage for this generation, stamped after the terminal frame; absent when the provider returned none (or the generation never completed)." + } + }, + "required": [ + "categories", + "compacted", + "effective_max_output_tokens", + "free", + "model", + "session_id", + "step", + "timestamp", + "total", + "turn_id", + "usable" + ], + "type": "object" + }, "EvalBenchmarkV1": { "additionalProperties": false, "properties": { @@ -1251,6 +1349,17 @@ "null" ] }, + "context": { + "anyOf": [ + { + "$ref": "#/definitions/ContextSnapshotV1" + }, + { + "type": "null" + } + ], + "description": "The session's latest per-generation context snapshot (categories, budget, usage) — absent for sessions that have not generated since snapshots landed." + }, "cost_usd": { "format": "double", "type": [ @@ -1345,6 +1454,77 @@ ], "type": "object" }, + "SnapshotCategoriesV1": { + "description": "Where the request's tokens sit. Categories are assembly-time estimates; `hook_guidance` is the measured growth after assembly (pre-generate hook appends and orphan-repair patches), 0 when the request left assembly unchanged.", + "properties": { + "hook_guidance": { + "default": 0, + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "messages": { + "$ref": "#/definitions/SnapshotMessagesV1" + }, + "overhead": { + "description": "Provider framing plus response_format / provider_options fields.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "system_prompt": { + "description": "Final assembled system prompt: mode paragraph, identity, per-step aids, and any compaction summary section.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "tools": { + "description": "Function schemas exposed to the model.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "messages", + "overhead", + "system_prompt", + "tools" + ], + "type": "object" + }, + "SnapshotMessagesV1": { + "description": "Estimated tokens of the assembled window's messages, by role.", + "properties": { + "assistant": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "custom": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "function_result": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "user": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "assistant", + "custom", + "function_result", + "user" + ], + "type": "object" + }, "SystemPromptStrategy": { "description": "How a caller-supplied system prompt combines with the built-in identity prompt.", "oneOf": [ @@ -1381,6 +1561,58 @@ ], "type": "string" }, + "Usage": { + "properties": { + "cache_read": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cache_write": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cost_usd": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "input": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "output": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "reasoning": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, "VariantAggregateV1": { "additionalProperties": false, "properties": { diff --git a/harness/Cargo.lock b/harness/Cargo.lock index 7f7aae428..dca943141 100644 --- a/harness/Cargo.lock +++ b/harness/Cargo.lock @@ -508,6 +508,7 @@ dependencies = [ "async-trait", "clap", "globset", + "iii-console-ui", "iii-helpers", "iii-sdk", "jsonschema", @@ -803,6 +804,18 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "iii-console-ui" +version = "0.1.0" +dependencies = [ + "iii-sdk", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "iii-helpers" version = "0.21.8" diff --git a/harness/Cargo.toml b/harness/Cargo.toml index 0ee1332b8..963ae34f3 100644 --- a/harness/Cargo.toml +++ b/harness/Cargo.toml @@ -25,7 +25,10 @@ iii-sdk = "=0.21.8" # 0.21.5 adds the live span-start push (`LiveSpanStartProcessor`), so the # console renders `harness::turn step` while it runs instead of on close. iii-helpers = "=0.21.8" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time"] } +# Worker-side injectable console UI (content function + console:script/style +# triggers + hot-reload watcher) — direct link, never published. +iii-console-ui = { path = "../crates/console-ui" } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time", "fs"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" diff --git a/harness/build.rs b/harness/build.rs index 81caa36d6..029f6d310 100644 --- a/harness/build.rs +++ b/harness/build.rs @@ -1,6 +1,179 @@ +//! Build script for the `harness` worker. +//! +//! 1. Forwards the build-time target triple to the binary as `env!("TARGET")` +//! (used by `manifest.rs` for the registry `supported_targets` field). +//! 2. Ensures the injected console UI assets exist: `src/ui.rs` embeds +//! `ui/dist/page.js` and `ui/dist/styles.css` via `include_str!`, so if +//! either is missing or stale we run `pnpm install && pnpm build` inside +//! `ui/` first (the console worker's `web/` precedent). Set +//! `SKIP_UI_BUILD=1` to use the existing `ui/dist/` outputs as-is. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::SystemTime; + fn main() { println!( "cargo:rustc-env=TARGET={}", std::env::var("TARGET").unwrap() ); + + // `dist/` itself is not listed: include_str! reads it directly, and + // listing it would rebuild-loop on our own output. + println!("cargo:rerun-if-changed=ui/page.tsx"); + println!("cargo:rerun-if-changed=ui/styles.css"); + println!("cargo:rerun-if-changed=ui/src"); + println!("cargo:rerun-if-changed=ui/build.mjs"); + println!("cargo:rerun-if-changed=ui/package.json"); + // The lockfile lives at the workers-repo root (pnpm workspace: the ui + // project links @iii-dev/console-ui from packages/console-ui). + println!("cargo:rerun-if-changed=../pnpm-lock.yaml"); + println!("cargo:rerun-if-changed=ui/tsconfig.json"); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let ui_dir = manifest_dir.join("ui"); + let dist_assets = [ + ui_dir.join("dist").join("page.js"), + ui_dir.join("dist").join("styles.css"), + ]; + + if dist_assets + .iter() + .all(|a| a.exists() && dist_is_fresh(a, &ui_dir)) + { + return; + } + + if std::env::var_os("SKIP_UI_BUILD").is_some() { + for asset in &dist_assets { + if !asset.exists() { + panic!( + "SKIP_UI_BUILD set but {} is missing — build the UI manually \ + (cd ui && pnpm install && pnpm build) or unset the env var", + asset.display() + ); + } + } + return; + } + + let pnpm = locate_pnpm(); + + let status = Command::new(&pnpm) + .args(["install"]) + .current_dir(&ui_dir) + .status() + .unwrap_or_else(|e| { + panic!( + "failed to spawn `pnpm install` in {}: {e}", + ui_dir.display() + ) + }); + if !status.success() { + panic!("`pnpm install` exited with {status} — see logs above"); + } + + let status = Command::new(&pnpm) + .args(["build"]) + .current_dir(&ui_dir) + .status() + .unwrap_or_else(|e| panic!("failed to spawn `pnpm build` in {}: {e}", ui_dir.display())); + if !status.success() { + panic!("`pnpm build` exited with {status} — see logs above"); + } + + for asset in &dist_assets { + if !asset.exists() { + panic!( + "`pnpm build` finished but {} is still missing — check the esbuild \ + output above", + asset.display() + ); + } + } +} + +/// `true` when the built asset is at least as new as every source that +/// contributes to it. Conservative: any I/O failure forces a rebuild. +fn dist_is_fresh(dist_asset: &Path, ui_dir: &Path) -> bool { + let Ok(dist_mtime) = dist_asset.metadata().and_then(|m| m.modified()) else { + return false; + }; + + let watched_files = [ + ui_dir.join("page.tsx"), + ui_dir.join("styles.css"), + ui_dir.join("build.mjs"), + ui_dir.join("package.json"), + ui_dir.join("../../pnpm-lock.yaml"), + ui_dir.join("tsconfig.json"), + ]; + for f in watched_files.iter() { + if !f.exists() { + continue; + } + let Ok(m) = f.metadata().and_then(|m| m.modified()) else { + return false; + }; + if m > dist_mtime { + return false; + } + } + + for dir in [ui_dir.join("src")] { + if dir.exists() && !subtree_older_than(&dir, dist_mtime) { + return false; + } + } + + true +} + +fn subtree_older_than(root: &Path, ceiling: SystemTime) -> bool { + let Ok(read) = std::fs::read_dir(root) else { + return false; + }; + for entry in read.flatten() { + let path = entry.path(); + let Ok(meta) = entry.metadata() else { + return false; + }; + if meta.is_dir() { + if !subtree_older_than(&path, ceiling) { + return false; + } + } else { + let Ok(m) = meta.modified() else { + return false; + }; + if m > ceiling { + return false; + } + } + } + true +} + +fn locate_pnpm() -> PathBuf { + if let Ok(explicit) = std::env::var("PNPM") { + return PathBuf::from(explicit); + } + let candidates = if cfg!(windows) { + ["pnpm.cmd", "pnpm.exe", "pnpm"].as_slice() + } else { + ["pnpm"].as_slice() + }; + let path = std::env::var_os("PATH").unwrap_or_default(); + for dir in std::env::split_paths(&path) { + for name in candidates { + let candidate = dir.join(name); + if candidate.is_file() { + return candidate; + } + } + } + panic!( + "pnpm not found on PATH — install Node + pnpm, or set SKIP_UI_BUILD=1 \ + after building the UI manually with `cd ui && pnpm install && pnpm build`" + ); } diff --git a/harness/src/clients/context.rs b/harness/src/clients/context.rs index 78863ac8f..f4a8e00cd 100644 --- a/harness/src/clients/context.rs +++ b/harness/src/clients/context.rs @@ -23,6 +23,34 @@ pub struct AssembleOutput { pub effective_max_output_tokens: u64, #[serde(default)] pub applied: Applied, + /// Per-category estimates of `token_count`; `None` when the installed + /// context-manager predates the breakdown response. + #[serde(default)] + pub breakdown: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct AssembleBreakdown { + #[serde(default)] + pub system_prompt_tokens: u64, + #[serde(default)] + pub tools_tokens: u64, + #[serde(default)] + pub by_role: ByRoleTokens, + #[serde(default)] + pub estimator: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ByRoleTokens { + #[serde(default)] + pub user: u64, + #[serde(default)] + pub assistant: u64, + #[serde(default)] + pub function_result: u64, + #[serde(default)] + pub custom: u64, } #[derive(Debug, Clone, Default, Deserialize)] diff --git a/harness/src/clients/router.rs b/harness/src/clients/router.rs index 3344a9c9c..8f311c1df 100644 --- a/harness/src/clients/router.rs +++ b/harness/src/clients/router.rs @@ -54,6 +54,14 @@ pub struct ChatOutcome { pub error: Option, } +/// Ceiling for `count_tokens`, independent of the generation-sized router +/// timeout. Counting is a tokenizer pass, not a model call, and it runs on the +/// path that finalizes a turn: inheriting a timeout measured in minutes would +/// let one slow counting endpoint hold `turn-completed` open for as long as a +/// whole generation. A count that has not answered by now is not worth the +/// wait — the caller keeps its estimate. +const COUNT_TOKENS_TIMEOUT_MS: u64 = 10_000; + #[derive(Clone)] pub struct RouterClient { iii: Arc, @@ -450,6 +458,48 @@ impl RouterClient { .map(String::from) } + /// Exact token count for a request shape via the resolved provider's + /// tokenizer (`None` when the router is absent, the provider has no + /// counter, or the call fails — the caller falls back to its estimate). + /// Returns `(tokens, estimator)`. Counting never runs the model and + /// never enters the session context. + pub async fn count_tokens( + &self, + model: &str, + provider: Option<&str>, + system_prompt: Option<&str>, + tools: Option<&[AgentFunction]>, + messages: &[Value], + ) -> Option<(u64, String)> { + let mut payload = json!({ "model": model, "messages": messages }); + if let Some(p) = provider { + payload["provider"] = json!(p); + } + if let Some(sp) = system_prompt { + payload["system_prompt"] = json!(sp); + } + if let Some(t) = tools { + payload["tools"] = serde_json::to_value(t).ok()?; + } + let resp = self + .iii + .trigger(TriggerRequest { + function_id: "router::count_tokens".into(), + payload, + action: None, + timeout_ms: Some(self.timeout_ms.min(COUNT_TOKENS_TIMEOUT_MS)), + }) + .await + .ok()?; + let tokens = resp.get("tokens").and_then(Value::as_u64)?; + let estimator = resp + .get("estimator") + .and_then(Value::as_str) + .unwrap_or("provider") + .to_string(); + Some((tokens, estimator)) + } + /// Look up one model's capabilities (`None` when unregistered or router /// absent — the caller degrades). pub async fn models_get(&self, provider: Option<&str>, id: &str) -> Option { diff --git a/harness/src/context_snapshot.rs b/harness/src/context_snapshot.rs new file mode 100644 index 000000000..032ae7462 --- /dev/null +++ b/harness/src/context_snapshot.rs @@ -0,0 +1,429 @@ +//! Per-generation context snapshot (`harness_context/`): what +//! the last generation's model window held, by category, plus the provider +//! usage that came back for it. Built from the assembly the loop already +//! performs (no extra counting round trips), stamped with usage after the +//! terminal frame, stored once per generate step. Read back by +//! `harness::metrics` and pushed on `harness::turn-completed`. + +use std::collections::hash_map::DefaultHasher; +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::Mutex; +use std::sync::OnceLock; + +use iii_sdk::IIIClient; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::clients::RouterClient; +use crate::error::HarnessError; +use crate::types::event::Usage; +use crate::types::model::AgentFunction; + +pub const CONTEXT_SCOPE: &str = "harness_context"; + +/// Estimated tokens of the assembled window's messages, by role. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SnapshotMessagesV1 { + pub user: u64, + pub assistant: u64, + pub function_result: u64, + pub custom: u64, +} + +impl SnapshotMessagesV1 { + /// What the whole messages category contributes to the request total. + pub fn total(&self) -> u64 { + self.user + self.assistant + self.function_result + self.custom + } + + /// The same by-role proportions carried onto a different total + /// (`numerator / denominator`), summing to exactly `numerator`: the + /// largest bucket absorbs the flooring remainder so category totals + /// keep reconciling with the exact request total. `denominator == 0` + /// has no proportions to carry, so it yields all-zero. + fn scaled(&self, numerator: u64, denominator: u64) -> Self { + if denominator == 0 { + return Self::default(); + } + let scale = numerator as f64 / denominator as f64; + let apply = |tokens: u64| (tokens as f64 * scale) as u64; + let mut scaled = Self { + user: apply(self.user), + assistant: apply(self.assistant), + function_result: apply(self.function_result), + custom: apply(self.custom), + }; + let shortfall = numerator.saturating_sub(scaled.total()); + let largest = [ + (&mut scaled.user, self.user), + (&mut scaled.assistant, self.assistant), + (&mut scaled.function_result, self.function_result), + (&mut scaled.custom, self.custom), + ] + .into_iter() + .max_by_key(|(_, original)| *original) + .map(|(bucket, _)| bucket); + if let Some(bucket) = largest { + *bucket += shortfall; + } + scaled + } +} + +impl From for SnapshotMessagesV1 { + fn from(by_role: crate::clients::context::ByRoleTokens) -> Self { + Self { + user: by_role.user, + assistant: by_role.assistant, + function_result: by_role.function_result, + custom: by_role.custom, + } + } +} + +/// Where the request's tokens sit. Categories are assembly-time estimates; +/// `hook_guidance` is the measured growth after assembly (pre-generate hook +/// appends and orphan-repair patches), 0 when the request left assembly +/// unchanged. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SnapshotCategoriesV1 { + /// Final assembled system prompt: mode paragraph, identity, per-step + /// aids, and any compaction summary section. + pub system_prompt: u64, + /// Function schemas exposed to the model. + pub tools: u64, + pub messages: SnapshotMessagesV1, + /// Provider framing plus response_format / provider_options fields. + pub overhead: u64, + #[serde(default)] + pub hook_guidance: u64, +} + +/// One generation's context accounting. `free = usable - total`, floored at +/// zero: once provider usage lands, `total` is what was billed, which can +/// exceed the `usable` budget the window was fit into — that budget was +/// derived before the generation from an estimate. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ContextSnapshotV1 { + pub session_id: String, + pub turn_id: String, + pub step: u64, + pub model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// Which estimator produced the numbers (`heuristic` until the + /// context-manager resolves a real tokenizer). Absent when the + /// context-manager predates the breakdown response. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub estimator: Option, + /// The input budget the window was fit into. + pub usable: u64, + /// Output allocation `usable` was derived against. + pub effective_max_output_tokens: u64, + /// Final request estimate: categories plus post-assembly growth. + pub total: u64, + pub free: u64, + pub categories: SnapshotCategoriesV1, + pub compacted: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summarized_head_tokens: Option, + /// Actual provider usage for this generation, stamped after the + /// terminal frame; absent when the provider returned none (or the + /// generation never completed). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, + pub timestamp: i64, +} + +/// Store the session's latest snapshot (whole-value write; the loop holds +/// the only writer per session). +pub async fn put( + iii: &IIIClient, + snapshot: &ContextSnapshotV1, + timeout_ms: u64, +) -> Result<(), HarnessError> { + let value = serde_json::to_value(snapshot) + .map_err(|e| HarnessError::State(format!("context snapshot serialize: {e}")))?; + crate::state::state_set(iii, CONTEXT_SCOPE, &snapshot.session_id, value, timeout_ms).await +} + +/// The session's latest snapshot (`None` when absent or unparseable — a +/// snapshot from a newer harness must degrade to "no data", never an error). +pub async fn get( + iii: &IIIClient, + session_id: &str, + timeout_ms: u64, +) -> Result, HarnessError> { + let v = crate::state::state_get(iii, CONTEXT_SCOPE, session_id, timeout_ms).await?; + if v.is_null() { + return Ok(None); + } + Ok(serde_json::from_value(v).ok()) +} + +/// Drop a session's snapshot row. Called when the session itself is deleted +/// (`harness::on-session-deleted`), so the state worker does not keep a row +/// for a session nobody can read any more. +pub async fn delete( + iii: &IIIClient, + session_id: &str, + timeout_ms: u64, +) -> Result<(), HarnessError> { + crate::state::state_delete(iii, CONTEXT_SCOPE, session_id, timeout_ms).await +} + +/// A cached count and the estimator that answered it. Caching the estimator +/// is what keeps the provenance honest: without it every step after the first +/// would report its counts as coming from the provider's own default rather +/// than from the tokenizer that actually produced them. +type CachedCount = (u64, Option); + +/// Process-lifetime cache of provider token counts keyed by +/// (model, kind, content hash) — the probe base, the system prompt and the +/// tool schemas are stable across a session's steps, so each is counted over +/// the wire once. +fn count_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn cache_key(model: &str, provider: Option<&str>, kind: &str, content: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + model.hash(&mut hasher); + provider.hash(&mut hasher); + kind.hash(&mut hasher); + content.hash(&mut hasher); + hasher.finish() +} + +/// Cache miss and a poisoned lock are the same thing to the caller: count it +/// over the wire again. +fn cached(key: u64) -> Option { + count_cache().lock().ok()?.get(&key).cloned() +} + +fn cache(key: u64, tokens: u64, estimator: Option) { + if let Ok(mut cache) = count_cache().lock() { + cache.insert(key, (tokens, estimator)); + } +} + +/// One-message probe the delta counts subtract away: provider metering +/// endpoints refuse an empty messages array, so category counts are +/// measured as count(probe + part) - count(probe). +fn probe_messages() -> Vec { + vec![json!({ + "role": "user", + "content": [{ "type": "text", "text": "x" }], + "timestamp": 0 + })] +} + +/// The request part being counted. Carries everything the count needs: which +/// `count_tokens` field it fills, its cache kind, and its cache content key. +enum Part<'a> { + System(&'a str), + Tools(&'a [AgentFunction]), +} + +impl Part<'_> { + fn kind(&self) -> &'static str { + match self { + Part::System(_) => "system_prompt", + Part::Tools(_) => "tools", + } + } + + fn content_key(&self) -> String { + match self { + Part::System(prompt) => (*prompt).to_string(), + Part::Tools(tools) => serde_json::to_string(tools).unwrap_or_default(), + } + } +} + +/// One part's provider-exact token count. +#[derive(Debug, Default)] +struct Counted { + tokens: u64, + /// Which estimator answered this count, over the wire or from the cache. + /// `None` only for a part the request did not carry. + estimator: Option, +} + +/// Tokens the probe alone costs, so every part's delta subtracts the same +/// base. Counted once per (model, provider) and cached for the process. +async fn probe_base(router: &RouterClient, model: &str, provider: Option<&str>) -> Option { + let key = cache_key(model, provider, "base", ""); + if let Some((tokens, _)) = cached(key) { + return Some(tokens); + } + // The base is subtracted away from every part, so which estimator counted + // it never reaches the snapshot; only the parts' estimators do. + let (base, _) = router + .count_tokens(model, provider, None, None, &probe_messages()) + .await?; + cache(key, base, None); + Some(base) +} + +/// Provider-exact tokens for one part: count(probe + part) - `base`, cached +/// by content. `None` means the provider count failed and the caller must +/// keep its heuristic numbers. +async fn counted_delta( + router: &RouterClient, + model: &str, + provider: Option<&str>, + base: u64, + part: Part<'_>, +) -> Option { + let key = cache_key(model, provider, part.kind(), &part.content_key()); + if let Some((tokens, estimator)) = cached(key) { + return Some(Counted { tokens, estimator }); + } + let (system_prompt, tools) = match part { + Part::System(prompt) => (Some(prompt), None), + Part::Tools(tools) => (None, Some(tools)), + }; + let (with_part, estimator) = router + .count_tokens(model, provider, system_prompt, tools, &probe_messages()) + .await?; + let tokens = with_part.saturating_sub(base); + cache(key, tokens, Some(estimator.clone())); + Some(Counted { + tokens, + estimator: Some(estimator), + }) +} + +/// Replace the snapshot's estimated categories with provider-exact numbers +/// where they exist: the generation's billed usage is the exact total, the +/// system prompt and tool schemas are counted once via the provider's +/// tokenizer (cached by content), and the message window is the remainder. +/// A rig without a provider counter keeps the heuristic snapshot untouched. +pub async fn exactify( + snapshot: &mut ContextSnapshotV1, + router: &RouterClient, + system_prompt: Option<&str>, + tools: &[AgentFunction], +) { + let Some(usage) = snapshot.usage.as_ref() else { + return; + }; + let billed = + usage.input.unwrap_or(0) + usage.cache_read.unwrap_or(0) + usage.cache_write.unwrap_or(0); + if billed == 0 { + return; + } + let provider = snapshot.provider.clone(); + let provider = provider.as_deref(); + let model = snapshot.model.clone(); + + // Every part's delta subtracts the same probe base, so count it once for + // the whole snapshot rather than once per part. + let Some(base) = probe_base(router, &model, provider).await else { + return; + }; + + let system_part = system_prompt.filter(|p| !p.is_empty()).map(Part::System); + let tools_part = (!tools.is_empty()).then_some(Part::Tools(tools)); + // Independent round trips: run them together. + let (counted_system, counted_tools) = tokio::join!( + async { + match system_part { + Some(part) => counted_delta(router, &model, provider, base, part).await, + None => Some(Counted::default()), + } + }, + async { + match tools_part { + Some(part) => counted_delta(router, &model, provider, base, part).await, + None => Some(Counted::default()), + } + }, + ); + let (Some(counted_system), Some(counted_tools)) = (counted_system, counted_tools) else { + return; + }; + let estimator = counted_system + .estimator + .or(counted_tools.estimator) + .unwrap_or_else(|| "provider".into()); + + let remainder = billed + .saturating_sub(counted_system.tokens) + .saturating_sub(counted_tools.tokens); + let heuristic_messages = snapshot.categories.messages.total(); + // Keep the by-role proportions from the estimate but rescale them onto + // the exact remainder (providers report only the request total). + let messages = if heuristic_messages > 0 { + snapshot + .categories + .messages + .scaled(remainder, heuristic_messages) + } else { + SnapshotMessagesV1 { + user: remainder, + ..SnapshotMessagesV1::default() + } + }; + + snapshot.categories.system_prompt = counted_system.tokens; + snapshot.categories.tools = counted_tools.tokens; + snapshot.categories.messages = messages; + snapshot.categories.overhead = 0; + snapshot.categories.hook_guidance = 0; + snapshot.total = billed; + snapshot.free = snapshot.usable.saturating_sub(billed); + snapshot.estimator = Some(estimator); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshot_round_trips_and_tolerates_unknown_fields() { + let snapshot = ContextSnapshotV1 { + session_id: "s_1".into(), + turn_id: "t_1".into(), + step: 3, + model: "m".into(), + provider: Some("p".into()), + estimator: Some("heuristic".into()), + usable: 100_000, + effective_max_output_tokens: 8_192, + total: 62_000, + free: 38_000, + categories: SnapshotCategoriesV1 { + system_prompt: 2_400, + tools: 9_800, + messages: SnapshotMessagesV1 { + user: 10_000, + assistant: 20_000, + function_result: 19_000, + custom: 0, + }, + overhead: 300, + hook_guidance: 500, + }, + compacted: true, + summarized_head_tokens: Some(3_100), + usage: Some(Usage { + input: Some(61_400), + output: Some(900), + cache_read: Some(55_000), + cache_write: None, + reasoning: None, + cost_usd: Some(0.42), + }), + timestamp: 1_722_700_000_000, + }; + let mut value = serde_json::to_value(&snapshot).unwrap(); + value["from_the_future"] = serde_json::json!(true); + let parsed: ContextSnapshotV1 = serde_json::from_value(value).unwrap(); + assert_eq!(parsed, snapshot); + } +} diff --git a/harness/src/events.rs b/harness/src/events.rs index 35d720fb7..f90e53b03 100644 --- a/harness/src/events.rs +++ b/harness/src/events.rs @@ -361,6 +361,7 @@ impl TurnEvents { parent: Option<&ParentLink>, display_parent: Option<&str>, terminal: bool, + context: Option<&crate::context_snapshot::ContextSnapshotV1>, ) { tracing::info!( session_id, @@ -397,6 +398,11 @@ impl TurnEvents { if let Some(dp) = display_parent { payload["parent_session_id"] = Value::String(dp.to_string()); } + // The latest generation's context accounting (categories, budget, + // usage) so live consumers never re-walk the transcript for it. + if let Some(snapshot) = context { + payload["context"] = serde_json::to_value(snapshot).unwrap_or(Value::Null); + } self.fan_out( &self.completed, TURN_COMPLETED, diff --git a/harness/src/functions/metrics.rs b/harness/src/functions/metrics.rs index fc4b63f82..6fae6b611 100644 --- a/harness/src/functions/metrics.rs +++ b/harness/src/functions/metrics.rs @@ -9,6 +9,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::json; +use crate::context_snapshot::ContextSnapshotV1; use crate::deps::Deps; use crate::error::HarnessError; use crate::types::content::ContentBlock; @@ -77,6 +78,11 @@ pub struct SessionUsageV1 { pub reasoning_tokens: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cost_usd: Option, + /// The session's latest per-generation context snapshot (categories, + /// budget, usage) — absent for sessions that have not generated since + /// snapshots landed. + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -131,6 +137,7 @@ pub async fn handle( let mut complete = tree.complete; let mut total = UsageAccumulator::default(); let mut by_session = Vec::with_capacity(tree.sessions.len()); + let mut missing_context = Vec::new(); for node in &tree.sessions { if !session.exists(&node.session_id).await? { complete = false; @@ -153,7 +160,28 @@ pub async fn handle( total.observe(message); } } - by_session.push(current.finish_session(node)); + // The turn record usually carries the session's latest snapshot for + // free. A freshly seeded turn has not generated yet, so its record has + // none and the durable `harness_context` row is the fallback — noted + // here, read after the walk. + let context = turn.context_snapshot.clone(); + if context.is_none() { + missing_context.push(by_session.len()); + } + by_session.push(current.finish_session(node, context)); + } + // One state read per session whose record carried no snapshot — collected + // for the final response only, so a large tree does not pay a round trip + // per session on every poll (see the note below on partial responses). + if complete { + for index in missing_context { + let session_id = by_session[index].session_id.clone(); + if let Ok(snapshot) = + crate::context_snapshot::get(&deps.iii, &session_id, cfg.session_timeout_ms).await + { + by_session[index].context = snapshot; + } + } } // Partial snapshots are polled as progress signals. Trace aggregation is // comparatively expensive and does not help the watchdog decide whether @@ -323,7 +351,11 @@ impl UsageAccumulator { } } - fn finish_session(self, node: &SessionTreeNodeV1) -> SessionUsageV1 { + fn finish_session( + self, + node: &SessionTreeNodeV1, + context: Option, + ) -> SessionUsageV1 { SessionUsageV1 { session_id: node.session_id.clone(), parent_session_id: node.parent_session_id.clone(), @@ -337,6 +369,7 @@ impl UsageAccumulator { cache_write_tokens: self.cache_write.finish(), reasoning_tokens: self.reasoning.finish(), cost_usd: self.cost_usd.finish(), + context, } } diff --git a/harness/src/functions/on_session_deleted.rs b/harness/src/functions/on_session_deleted.rs index 161665c29..4fbf9b7c9 100644 --- a/harness/src/functions/on_session_deleted.rs +++ b/harness/src/functions/on_session_deleted.rs @@ -40,6 +40,7 @@ pub async fn handle( let cfg = deps.cfg().await; crate::filesystem_grants::purge(&deps.iii, &event.session_id, cfg.session_timeout_ms).await?; crate::budget::purge(deps, &event.session_id, cfg.session_timeout_ms).await?; + crate::context_snapshot::delete(&deps.iii, &event.session_id, cfg.session_timeout_ms).await?; Ok(SessionDeletedAck { ok: true, removed: swept, diff --git a/harness/src/functions/send.rs b/harness/src/functions/send.rs index d74636531..465f6613f 100644 --- a/harness/src/functions/send.rs +++ b/harness/src/functions/send.rs @@ -748,6 +748,7 @@ pub(crate) async fn seed_new( parent: lineage.parent.clone(), display_parent_session_id: lineage.display_parent_session_id.clone(), functions_generation, + context_snapshot: None, result: None, result_error: None, validation_retries: 0, diff --git a/harness/src/lib.rs b/harness/src/lib.rs index 31bb8df18..8128fe9b5 100644 --- a/harness/src/lib.rs +++ b/harness/src/lib.rs @@ -13,6 +13,7 @@ pub mod clients; pub mod conditions; pub mod config; pub mod configuration; +pub mod context_snapshot; pub mod contract; pub mod deferred; pub mod deps; diff --git a/harness/src/main.rs b/harness/src/main.rs index 343d12451..c551cabb5 100644 --- a/harness/src/main.rs +++ b/harness/src/main.rs @@ -34,6 +34,10 @@ use harness::events::TurnEvents; use harness::hooks::HookRegistry; use harness::{config, discovery, functions, manifest, queue}; +/// Asset names + embedded bytes for the injected console UI; the registration +/// contract itself lives in the shared `iii-console-ui` crate. +mod ui; + #[derive(Parser, Debug)] #[command( name = "harness", @@ -134,6 +138,12 @@ async fn main() -> Result<()> { functions::register_all(&iii, &deps); + // Injectable console UI: content function + console:script/style triggers. + // Ordering doesn't matter for the console side (the engine parks the + // registration until a console owns the type), but the content function + // must exist before the trigger names it. + ui::register(&iii); + // The one-shot deadline provider, registered with the ENGINE so `timer` // shows up in engine::triggers::list and live registrations replay to it // after a restart (re-armed at the same absolute `at` — the registration diff --git a/harness/src/subagent.rs b/harness/src/subagent.rs index 8939757a6..69a481e0d 100644 --- a/harness/src/subagent.rs +++ b/harness/src/subagent.rs @@ -501,6 +501,7 @@ mod tests { parent: None, display_parent_session_id: None, functions_generation: None, + context_snapshot: None, result: None, result_error: None, validation_retries: 0, diff --git a/harness/src/turn_loop.rs b/harness/src/turn_loop.rs index 6000509d5..123ec9fe6 100644 --- a/harness/src/turn_loop.rs +++ b/harness/src/turn_loop.rs @@ -507,7 +507,10 @@ pub async fn run_step( &assembled.system_prompt, ); let final_request_tokens = if request_unchanged { - assembled.token_count + // `assembled.token_count` was fit against the inflated overhead + // (base + reservation); the reservation went unused on an + // unchanged request, so it is not part of the real total. + assembled.token_count.saturating_sub(extra_overhead_tokens) } else { let final_count = deps .context() @@ -529,6 +532,14 @@ pub async fn run_step( .max_output_tokens .unwrap_or(assembled.effective_max_output_tokens) .min(assembled.effective_max_output_tokens); + let snapshot = build_context_snapshot( + &record, + payload.step, + &assembled, + final_request_tokens, + request_overhead_tokens, + ); + record.context_snapshot = Some(snapshot); break ( gen_system_prompt, gen_annotations, @@ -630,9 +641,11 @@ pub async fn run_step( request_id: format!("{}:{}", record.turn_id, payload.step), model: record.options.model.clone(), provider: record.options.provider.clone(), - system_prompt: gen_system_prompt, + // Cloned into the request so the originals stay available for the + // post-generation exact recount below. + system_prompt: gen_system_prompt.clone(), messages: gen_messages, - tools, + tools: tools.clone(), response_format, // Forward a cap only when the caller set one. `generation_max_output_tokens` // is the internal reservation context assembly budgets against; sending it @@ -720,6 +733,26 @@ pub async fn run_step( // advance()/finalize call that ends this step). record.watermark_entry_id = watermark; + // Stamp the generation's actual usage into the snapshot, replace the + // estimated categories with provider-exact counts where a counter + // exists, and store the session's latest copy. Best-effort: accounting + // must never fail a turn that generated successfully. + if let Some(snapshot) = record.context_snapshot.as_mut() { + snapshot.usage = outcome.message.usage.clone(); + crate::context_snapshot::exactify(snapshot, &router, gen_system_prompt.as_deref(), &tools) + .await; + if let Err(error) = + crate::context_snapshot::put(&deps.iii, snapshot, cfg.session_timeout_ms).await + { + tracing::warn!( + session_id = %record.session_id, + turn_id = %record.turn_id, + %error, + "context snapshot store failed" + ); + } + } + // Persist the final assistant message into the streamed entry. let _ = session .update_message( @@ -1523,6 +1556,7 @@ async fn finalize_completed( record.parent.as_ref(), record.display_parent_session_id.as_deref(), turn_is_terminal(deps, &record.session_id).await, + record.context_snapshot.as_ref(), ) .await; // Sub-agent turns resolve the parent's pending call with their result. @@ -1611,6 +1645,7 @@ async fn finalize_failed( record.parent.as_ref(), record.display_parent_session_id.as_deref(), turn_is_terminal(deps, &record.session_id).await, + record.context_snapshot.as_ref(), ) .await; if let Some(parent) = record.parent.clone() { @@ -1806,6 +1841,7 @@ async fn finalize_cancelled( record.parent.as_ref(), record.display_parent_session_id.as_deref(), turn_is_terminal(deps, &record.session_id).await, + record.context_snapshot.as_ref(), ) .await; if let Some(parent) = record.parent.clone() { @@ -2150,6 +2186,8 @@ async fn assemble_context( usable: out.usable, token_count: out.token_count, effective_max_output_tokens: out.effective_max_output_tokens, + applied: out.applied, + breakdown: out.breakdown, }) } @@ -2157,6 +2195,46 @@ fn is_context_overflow_error(error: &str) -> bool { error.contains("context/overflow:") } +/// Fold the assembly the loop already performed into the session's context +/// snapshot. `final_request_tokens >= assembled.token_count` when hooks or +/// orphan repair grew the request; the difference is the hook_guidance +/// category. No counting round trips happen here. +fn build_context_snapshot( + record: &TurnRecord, + step: u64, + assembled: &Assembled, + final_request_tokens: u64, + request_overhead_tokens: u64, +) -> crate::context_snapshot::ContextSnapshotV1 { + use crate::context_snapshot::{ContextSnapshotV1, SnapshotCategoriesV1}; + // A context-manager that predates the breakdown response reports nothing, + // which is what the all-zero default says. + let b = assembled.breakdown.clone().unwrap_or_default(); + ContextSnapshotV1 { + session_id: record.session_id.clone(), + turn_id: record.turn_id.clone(), + step, + model: record.options.model.clone(), + provider: record.options.provider.clone(), + estimator: b.estimator, + usable: assembled.usable, + effective_max_output_tokens: assembled.effective_max_output_tokens, + total: final_request_tokens, + free: assembled.usable.saturating_sub(final_request_tokens), + categories: SnapshotCategoriesV1 { + system_prompt: b.system_prompt_tokens, + tools: b.tools_tokens, + messages: b.by_role.into(), + overhead: request_overhead_tokens, + hook_guidance: final_request_tokens.saturating_sub(assembled.token_count), + }, + compacted: assembled.applied.compacted, + summarized_head_tokens: assembled.applied.summarized_head_tokens, + usage: None, + timestamp: AgentMessage::now_ms(), + } +} + /// Append model-facing context aid lines to the system prompt: the session id /// (always — it makes the prompt's "" recipes actionable, e.g. /// `turn-completed` filters and reactive spawns that deliver into this chat), @@ -2228,6 +2306,10 @@ struct Assembled { token_count: u64, /// Model/output ceiling resolved by context-manager for this request. effective_max_output_tokens: u64, + /// What context-manager did to fit the window (compaction and its + /// bookkeeping), carried whole for the snapshot. + applied: crate::clients::context::Applied, + breakdown: Option, } struct ContextAssemblyInputs<'a> { diff --git a/harness/src/types/turn.rs b/harness/src/types/turn.rs index e2fa4dc57..f7dd106ed 100644 --- a/harness/src/types/turn.rs +++ b/harness/src/types/turn.rs @@ -248,6 +248,10 @@ pub struct TurnRecord { /// contracts get re-fetched. #[serde(default, skip_serializing_if = "Option::is_none")] pub functions_generation: Option, + /// Latest generation's context accounting (also stored under + /// `harness_context/` once the generation completes). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_snapshot: Option, #[serde(skip_serializing_if = "Option::is_none")] pub result: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -335,6 +339,7 @@ mod tests { parent: None, display_parent_session_id: None, functions_generation: None, + context_snapshot: None, result: None, result_error: None, validation_retries: 0, diff --git a/harness/src/ui.rs b/harness/src/ui.rs new file mode 100644 index 000000000..c11964d9c --- /dev/null +++ b/harness/src/ui.rs @@ -0,0 +1,77 @@ +//! Injectable console UI for the harness worker +//! (iii/tech-specs/2026-07-17-injectable-ui; authoring SOP: +//! workers/docs/sops/injectable-console-ui.md). +//! +//! Ships two assets into any running console: +//! +//! - `harness/page.js` (`console:script`) — the `context` session chip +//! (live context-window usage with a category-breakdown popover; the +//! `chat.registerSessionChip` slot is feature-detected, older consoles +//! skip it) plus the `harness::metrics` function-trigger renderer its +//! `setup(host)` registers. +//! - `harness/styles.css` (`console:style`) — the stylesheet, every rule +//! scoped under `[data-iii-ui="harness"]`; the console mounts it as a +//! `` and link-swaps it on change, styles-before-scripts on boot. +//! +//! The registration machinery (content function `harness::ui-content`, one +//! Message-path trigger per asset, `III_HARNESS_UI_WATCH` hot-reload watcher) +//! lives in the shared `iii-console-ui` crate (path-linked from +//! `workers/crates/console-ui`); this module only names the assets and +//! embeds their bytes. +//! +//! The assets are compiled from `ui/` by esbuild (react + @iii-dev/console-ui +//! external — they resolve through the console's import map at runtime) and +//! embedded at compile time so the worker stays one self-contained binary. +//! For the dev loop, set `III_HARNESS_UI_WATCH` to the build output +//! directory (or `1` for `ui/dist`): the worker polls both files and +//! re-registers a changed asset's trigger — every open console tab +//! hot-swaps it. + +use std::sync::Arc; + +use iii_console_ui::ConsoleUi; +use iii_sdk::IIIClient; + +pub const PAGE_PATH: &str = "harness/page.js"; +pub const STYLES_PATH: &str = "harness/styles.css"; + +/// Built by `build.rs` (esbuild over `ui/`). +const PAGE_JS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/page.js")); +const STYLES_CSS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/styles.css")); + +fn console_ui() -> ConsoleUi { + ConsoleUi::new("harness") + .script(PAGE_PATH, PAGE_JS) + .style(STYLES_PATH, STYLES_CSS) +} + +/// Register the harness's console UI. Call after `functions::register_all`. +pub fn register(iii: &Arc) { + console_ui().register(iii); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ui_builder_accepts_the_assets() { + // The builder panics on any path/kind the console would reject. + let _ = console_ui(); + } + + #[test] + fn embedded_page_is_nonempty_esm() { + assert!(PAGE_JS.contains("export"), "built page.js looks wrong"); + } + + #[test] + fn embedded_styles_are_scoped() { + // esbuild prints the attribute selector unquoted ([data-iii-ui=harness]). + assert!( + STYLES_CSS.contains(r#"[data-iii-ui="harness"]"#) + || STYLES_CSS.contains("[data-iii-ui=harness]"), + "built styles.css must be scoped under the worker's data-iii-ui attribute" + ); + } +} diff --git a/harness/tests/golden/schemas/harness.metrics.json b/harness/tests/golden/schemas/harness.metrics.json index b01c6cd8c..2d0ab07e0 100644 --- a/harness/tests/golden/schemas/harness.metrics.json +++ b/harness/tests/golden/schemas/harness.metrics.json @@ -17,6 +17,104 @@ "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "definitions": { + "ContextSnapshotV1": { + "description": "One generation's context accounting. `free = usable - total`, floored at zero: once provider usage lands, `total` is what was billed, which can exceed the `usable` budget the window was fit into — that budget was derived before the generation from an estimate.", + "properties": { + "categories": { + "$ref": "#/definitions/SnapshotCategoriesV1" + }, + "compacted": { + "type": "boolean" + }, + "effective_max_output_tokens": { + "description": "Output allocation `usable` was derived against.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "estimator": { + "description": "Which estimator produced the numbers (`heuristic` until the context-manager resolves a real tokenizer). Absent when the context-manager predates the breakdown response.", + "type": [ + "string", + "null" + ] + }, + "free": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "model": { + "type": "string" + }, + "provider": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string" + }, + "step": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "summarized_head_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "timestamp": { + "format": "int64", + "type": "integer" + }, + "total": { + "description": "Final request estimate: categories plus post-assembly growth.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "turn_id": { + "type": "string" + }, + "usable": { + "description": "The input budget the window was fit into.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "usage": { + "anyOf": [ + { + "$ref": "#/definitions/Usage" + }, + { + "type": "null" + } + ], + "description": "Actual provider usage for this generation, stamped after the terminal frame; absent when the provider returned none (or the generation never completed)." + } + }, + "required": [ + "categories", + "compacted", + "effective_max_output_tokens", + "free", + "model", + "session_id", + "step", + "timestamp", + "total", + "turn_id", + "usable" + ], + "type": "object" + }, "SessionTraceMetricsV1": { "additionalProperties": false, "properties": { @@ -205,6 +303,17 @@ "null" ] }, + "context": { + "anyOf": [ + { + "$ref": "#/definitions/ContextSnapshotV1" + }, + { + "type": "null" + } + ], + "description": "The session's latest per-generation context snapshot (categories, budget, usage) — absent for sessions that have not generated since snapshots landed." + }, "cost_usd": { "format": "double", "type": [ @@ -274,6 +383,129 @@ "turns" ], "type": "object" + }, + "SnapshotCategoriesV1": { + "description": "Where the request's tokens sit. Categories are assembly-time estimates; `hook_guidance` is the measured growth after assembly (pre-generate hook appends and orphan-repair patches), 0 when the request left assembly unchanged.", + "properties": { + "hook_guidance": { + "default": 0, + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "messages": { + "$ref": "#/definitions/SnapshotMessagesV1" + }, + "overhead": { + "description": "Provider framing plus response_format / provider_options fields.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "system_prompt": { + "description": "Final assembled system prompt: mode paragraph, identity, per-step aids, and any compaction summary section.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "tools": { + "description": "Function schemas exposed to the model.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "messages", + "overhead", + "system_prompt", + "tools" + ], + "type": "object" + }, + "SnapshotMessagesV1": { + "description": "Estimated tokens of the assembled window's messages, by role.", + "properties": { + "assistant": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "custom": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "function_result": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "user": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "assistant", + "custom", + "function_result", + "user" + ], + "type": "object" + }, + "Usage": { + "properties": { + "cache_read": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cache_write": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cost_usd": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "input": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "output": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "reasoning": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" } }, "properties": { diff --git a/harness/tests/integration/src/probe.rs b/harness/tests/integration/src/probe.rs index 0f8958874..0a743508e 100644 --- a/harness/tests/integration/src/probe.rs +++ b/harness/tests/integration/src/probe.rs @@ -626,6 +626,7 @@ mod tests { parent: None, parent_session_id: None, reactive_depth: None, + context: None, }, }; let observations = vec![ diff --git a/harness/tests/integration/src/scenario/floor.rs b/harness/tests/integration/src/scenario/floor.rs index f3865f885..7cbb4ff12 100644 --- a/harness/tests/integration/src/scenario/floor.rs +++ b/harness/tests/integration/src/scenario/floor.rs @@ -267,6 +267,7 @@ fn lifecycle_failure(run: &RunEvidence, expected: &FloorExpectations) -> Option< "parent", "parent_session_id", "reactive_depth", + "context", ]); let shape = payloads.iter().all(|payload| { let Some(map) = payload.as_object() else { diff --git a/harness/tests/integration/src/types/probe.rs b/harness/tests/integration/src/types/probe.rs index d17def7ea..daf306864 100644 --- a/harness/tests/integration/src/types/probe.rs +++ b/harness/tests/integration/src/types/probe.rs @@ -39,6 +39,11 @@ pub struct LifecycleEventV1 { pub parent_session_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub reactive_depth: Option, + /// The generation's context snapshot (categories, budget, usage) — the + /// consumer-facing shape is pinned by the `harness::metrics` schema + /// golden, so the lifecycle sink carries it opaquely. + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] diff --git a/harness/ui/build.mjs b/harness/ui/build.mjs new file mode 100644 index 000000000..16d3131d5 --- /dev/null +++ b/harness/ui/build.mjs @@ -0,0 +1,121 @@ +/** + * Build the worker's two console assets: + * + * page.tsx → dist/page.js (injected over `console:script`) + * styles.css → dist/styles.css (injected over `console:style`) + * + * The five shared specifiers stay EXTERNAL — they resolve at runtime + * through the console's import map (a bundled React copy would surface as + * a cryptic "Invalid hook call"). Everything else the page needs gets + * bundled in. `--watch` pairs with the worker's III_HARNESS_UI_WATCH + * poller for the hot-reload dev loop. + */ + +import { readFileSync } from 'node:fs' +import esbuild from 'esbuild' + +const watch = process.argv.includes('--watch') + +const options = { + entryPoints: ['page.tsx', 'styles.css'], + bundle: true, + format: 'esm', + jsx: 'automatic', + outdir: 'dist', + // The bundle is `include_str!`'d into the worker binary and the injectable-UI + // protocol rejects an asset over 8 MiB outright, so ship it minified. Left + // off under --watch, where readable stack traces matter more than bytes. + minify: !watch, + external: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + '@iii-dev/console-ui', + ], + logLevel: 'info', +} + +// Minification strips the quotes from an attribute selector, so the scope +// prefix has to be matched in both spellings. +const SCOPE = '[data-iii-ui="harness"]' +const SCOPE_RE = /^\[data-iii-ui=("harness"|'harness'|harness)\]/ +const KEYFRAME_PREFIXES = ['harness-ui-'] + +/** + * Fail the build if any rule could escape this worker's subtree. + * + * The console mounts every worker's UI into one document, so a single + * unscoped selector restyles the whole app. The Rust side already asserts + * that the scope string appears *somewhere* in the sheet, which a file + * containing one stray `body { }` would also pass. This checks every + * selector, and runs in CI for free because CI runs `pnpm build`. + */ +function assertScoped(css) { + // Strip comments, then strings, so a brace or selector inside either can't + // desynchronise the scan. + const src = css + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""') + + const problems = [] + let depth = 0 + let buf = '' + // Nested at-rules (@media, @container, @supports) open a block whose + // children are still top-level selectors, so track them rather than + // skipping their contents. + const atStack = [] + + for (let i = 0; i < src.length; i++) { + const ch = src[i] + if (ch === '{') { + const head = buf.trim() + buf = '' + if (head.startsWith('@')) { + const name = head.split(/[\s(]/)[0] + atStack.push(name) + if (name === '@keyframes') { + const kf = head.slice('@keyframes'.length).trim() + if (!KEYFRAME_PREFIXES.some((p) => kf.startsWith(p))) { + problems.push(`@keyframes ${kf} is not prefixed ${KEYFRAME_PREFIXES.join(' or ')}`) + } + } else if (name === '@font-face') { + problems.push('@font-face is not allowed in an injected sheet') + } + depth++ + continue + } + // Inside @keyframes the "selectors" are 0%/from/to, not real ones. + if (!atStack.includes('@keyframes') && head) { + for (const sel of head.split(',')) { + const s = sel.trim() + if (s && !SCOPE_RE.test(s)) { + problems.push(`selector not scoped: ${s}`) + } + } + } + depth++ + } else if (ch === '}') { + depth-- + buf = '' + if (atStack.length && depth < atStack.length) atStack.pop() + } else { + buf += ch + } + } + + if (problems.length) { + console.error(`\n${problems.length} unscoped rule(s) in dist/styles.css:\n`) + for (const p of [...new Set(problems)]) console.error(` ${p}`) + console.error(`\nEvery rule must start with ${SCOPE}.\n`) + process.exit(1) + } +} + +if (watch) { + const ctx = await esbuild.context(options) + await ctx.watch() +} else { + await esbuild.build(options) + assertScoped(readFileSync('dist/styles.css', 'utf8')) +} diff --git a/harness/ui/package.json b/harness/ui/package.json new file mode 100644 index 000000000..44530f14e --- /dev/null +++ b/harness/ui/package.json @@ -0,0 +1,18 @@ +{ + "name": "@iii-workers/harness-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "tsc --noEmit && node build.mjs", + "watch": "node build.mjs --watch" + }, + "dependencies": { + "@iii-dev/console-ui": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "esbuild": "^0.25.0", + "typescript": "^5.9.2" + } +} diff --git a/harness/ui/page.tsx b/harness/ui/page.tsx new file mode 100644 index 000000000..1d681c728 --- /dev/null +++ b/harness/ui/page.tsx @@ -0,0 +1,38 @@ +/** + * Entry for the harness worker's injected console UI — compiled by esbuild + * (react + @iii-dev/console-ui external) into dist/page.js and served over + * the `console:script` trigger (see src/ui.rs). The stylesheet is its own + * asset: ../styles.css ships over `console:style` as harness/styles.css. + * + * `setup(host)` composes the worker's two console contributions: + * + * - src/context-chip/ — the `context` session chip (live context + * window usage). The `chat.registerSessionChip` slot is newer than the + * published Host type, so it is feature-detected: an older console simply + * gets no chip. + * - src/function-trigger-message/ — how `harness::metrics` calls render. + * + * Registrations go through `host` so the loader disposes them on hot + * reload / worker disconnect. + */ + +import type { ComponentType } from 'react' +import type { Host } from '@iii-dev/console-ui' +import { createContextChip, type SessionChipProps } from './src/context-chip' +import { createMetricsRenderer } from './src/function-trigger-message' + +type SessionChipHost = Host & { + chat?: { + registerSessionChip?: (chip: { + id: string + render: ComponentType + }) => () => void + } +} + +export default function setup(host: Host) { + const chat = (host as SessionChipHost).chat + chat?.registerSessionChip?.({ id: 'context', render: createContextChip(host) }) + + host.functionTriggers.register(createMetricsRenderer()) +} diff --git a/harness/ui/src/context-chip/index.tsx b/harness/ui/src/context-chip/index.tsx new file mode 100644 index 000000000..36d9aeffe --- /dev/null +++ b/harness/ui/src/context-chip/index.tsx @@ -0,0 +1,465 @@ +/** + * The `context` session chip — a live view of the session's context window + * matching the console's ContextUsage aesthetic (`ctx` label, bordered bar, + * percent, `12.3k/200k` counts). Hydrates from the stored snapshot + * (`state::get` on `harness_context/`) on mount and per session + * change; stays live over the state worker's own `state` trigger for that + * key (Message-path binding, GC'd with the tab), which fires on every + * generate step. Click toggles an anchored popover breaking the window down + * by category with a stacked segment bar, legend, and last-turn actuals. + */ + +import { useEffect, useRef, useState } from 'react' +import type { Host } from '@iii-dev/console-ui' +import { formatCost, formatTokens } from '../lib/format' +import { + type ContextSnapshot, + type SnapshotUsage, + isSnapshot, +} from '../lib/metrics' +import { TONE_COLOR, toneFor } from '../lib/tone' + +/** Per-tab handler id (host.iii.on namespaces it `::`). */ +const STATE_FN = 'iii::harness-ui::ctx-state' + +export interface SessionChipProps { + sessionId: string + modelId?: string + contextWindow?: number +} + +/** The message a `state` function trigger delivers per write (the state + worker's own streaming trigger type — no polling anywhere). */ +interface StateEvent { + type?: string + event_type?: string + scope?: string + key?: string + new_value?: unknown +} + +const ink = (opacity: number) => + `color-mix(in srgb, var(--color-ink) ${opacity}%, transparent)` +const accent = (opacity: number) => + `color-mix(in srgb, var(--color-accent) ${opacity}%, transparent)` + +const COLOR_FREE = 'var(--color-rule-2)' + +type CategoryKey = + | 'system' + | 'tools' + | 'user' + | 'assistant' + | 'results' + | 'hooks' + | 'overhead' + +interface Category { + key: CategoryKey + label: string + color: string + tokens: number +} + +/** The three categories the legend shows as one "Conversation" row. */ +const CONVERSATION_KEYS: CategoryKey[] = ['user', 'assistant', 'results'] + +/** + * Every category of the assembled window, in bar order. The single source + * for both the stacked bar (which draws each entry) and the legend (which + * merges the conversation entries into one row). + */ +function categories(snapshot: ContextSnapshot): Category[] { + const cats = snapshot.categories + const messages = cats.messages + return [ + { + key: 'system', + label: 'System prompt', + color: ink(80), + tokens: cats.system_prompt, + }, + { + key: 'tools', + label: 'Function schemas', + color: ink(55), + tokens: cats.tools, + }, + { key: 'user', label: 'User', color: accent(95), tokens: messages.user }, + { + key: 'assistant', + label: 'Assistant', + color: accent(70), + tokens: messages.assistant, + }, + { + key: 'results', + label: 'Function results', + color: accent(45), + tokens: messages.function_result + messages.custom, + }, + { + key: 'hooks', + label: 'Hook guidance', + color: ink(35), + // Optional on the wire (serde default): absent in snapshots written + // before the category existed. + tokens: cats.hook_guidance ?? 0, + }, + { + key: 'overhead', + label: 'Overhead', + color: ink(20), + tokens: cats.overhead, + }, + ] +} + +/** + * The prompt cache view of the last generation. Providers bill a cache read + * at a fraction of fresh input and a cache write at a premium, so on a long + * session the hit rate drives cost more than the window size does. `null` + * when the provider reported no cache activity at all. + */ +function cacheSummary(usage: SnapshotUsage | undefined) { + const read = usage?.cache_read ?? 0 + const write = usage?.cache_write ?? 0 + if (read === 0 && write === 0) return null + const prompt = (usage?.input ?? 0) + read + write + const hitPct = prompt > 0 ? Math.round((read / prompt) * 100) : 0 + return { + read, + write, + hitPct, + // A cold or broken prefix means the whole prompt is re-billed at the + // write premium every turn, which is worth flagging rather than dimming. + tone: hitPct >= 70 ? 'ok' : hitPct < 30 ? 'warn' : 'plain', + } +} + +interface LegendEntry { + key: string + label: string + /** `null` renders an invisible swatch — the row is not a bar segment. */ + color: string | null + tokens: number + badge?: string +} + +/** + * The legend, derived from the same category array the bar draws: the three + * conversation entries collapse into one row, an empty hook row is dropped, + * and the two rows that are not bar segments (the compaction summary, free + * space) take their place around the overhead row. + */ +function legendRows( + snapshot: ContextSnapshot, + cats: Category[], +): LegendEntry[] { + const conversation = cats.filter((c) => CONVERSATION_KEYS.includes(c.key)) + const rows: LegendEntry[] = [] + for (const category of cats) { + if (category.key === 'assistant') { + rows.push({ + ...category, + label: 'Conversation', + tokens: conversation.reduce((sum, entry) => sum + entry.tokens, 0), + }) + continue + } + if (CONVERSATION_KEYS.includes(category.key)) continue + if (category.key === 'hooks' && category.tokens <= 0) continue + if (category.key === 'overhead' && snapshot.compacted) { + rows.push({ + key: 'summary', + label: 'Summary', + color: null, + tokens: snapshot.summarized_head_tokens ?? 0, + badge: 'compacted', + }) + } + rows.push(category) + } + rows.push({ + key: 'free', + label: 'Free', + color: COLOR_FREE, + tokens: snapshot.free, + }) + return rows +} + +function LegendRow({ + color, + label, + tokens, + usable, + badge, +}: { + color: string | null + label: string + tokens: number + usable: number + badge?: string +}) { + const pct = usable > 0 ? Math.round((tokens / usable) * 100) : 0 + return ( +
+ + {label} + {badge ? {badge} : null} + {formatTokens(tokens)} + {pct}% +
+ ) +} + +function ContextPopover({ + snapshot, + modelId, +}: { + snapshot: ContextSnapshot + modelId?: string +}) { + const usable = snapshot.usable + const pct = + usable > 0 ? Math.round(Math.min(1, snapshot.total / usable) * 100) : 0 + const cats = categories(snapshot) + const usage = snapshot.usage + const hasActuals = + usage != null && (usage.input != null || usage.cache_read != null) + const cache = cacheSummary(usage) + return ( +
+
+ + {snapshot.model || modelId || 'model'} + + + {pct}% of {formatTokens(usable)} + +
+
+ {cats + .filter((segment) => segment.tokens > 0) + .map((segment) => ( + 0 ? (segment.tokens / usable) * 100 : 0}%`, + background: segment.color, + }} + /> + ))} +
+
+ {legendRows(snapshot, cats).map((row) => ( + + ))} +
+
+ + {!snapshot.estimator || snapshot.estimator === 'heuristic' + ? `est. ${snapshot.estimator ?? 'unknown'}` + : `exact · ${ + snapshot.estimator === 'provider' + ? 'provider tokenizer' + : snapshot.estimator + }`} + + {hasActuals ? ( + + last step {formatTokens(usage?.input ?? 0)} in · output{' '} + {formatTokens(usage?.output ?? 0)} + + ) : null} + {cache ? ( + + cache {formatTokens(cache.read)} read ·{' '} + {formatTokens(cache.write)} write ·{' '} + + {cache.hitPct}% hit + + + ) : null} + {usage?.cost_usd != null ? ( + cost {formatCost(usage.cost_usd)} + ) : null} +
+
+ ) +} + +export function createContextChip(host: Host) { + return function ContextChip({ + sessionId, + modelId, + contextWindow, + }: SessionChipProps) { + const [snapshot, setSnapshot] = useState(null) + const [open, setOpen] = useState(false) + const rootRef = useRef(null) + + // Both the hydration read and the streamed trigger write this state; + // keep whichever snapshot is newest so a slow state::get can never + // overwrite a fresher streamed step. + const acceptNewer = (value: ContextSnapshot) => + setSnapshot((current) => + current && current.timestamp >= value.timestamp ? current : value, + ) + + useEffect(() => { + let cancelled = false + setSnapshot(null) + setOpen(false) + host.iii + .trigger('state::get', { scope: 'harness_context', key: sessionId }) + .then((value) => { + if (cancelled) return + if (isSnapshot(value) && value.session_id === sessionId) + acceptNewer(value) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [host, sessionId]) + + // Snapshots are written after every generate step; the state worker's + // `state` trigger streams each write (engine-side scope/key filter), so + // long multi-step turns tick live without any polling. + useEffect(() => { + // The id carries the session, because the engine-side filter is keyed to + // one: two chips mounted at once (two sessions visible, or an unmount + // racing the next mount) would otherwise register conflicting filters + // under one id, and either teardown would take the other's stream with + // it. `on()` appends `::` itself, so the trigger repeats it + // to address the same handler. + const eventFn = `${STATE_FN}::${sessionId}` + const offHandler = host.iii.on(eventFn, (event) => { + if (!event || event.key !== sessionId) return + if (event.event_type === 'state:deleted') return + if (isSnapshot(event.new_value) && event.new_value.session_id === sessionId) + acceptNewer(event.new_value) + }) + const offTrigger = host.iii.registerTrigger({ + type: 'state', + function_id: `${eventFn}::${host.iii.browserId}`, + config: { scope: 'harness_context', key: sessionId }, + }) + return () => { + offTrigger() + offHandler() + } + }, [host, sessionId]) + + useEffect(() => { + if (!open) return + const onPointerDown = (event: MouseEvent) => { + const root = rootRef.current + if (root && !root.contains(event.target as Node)) setOpen(false) + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false) + } + document.addEventListener('mousedown', onPointerDown) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('mousedown', onPointerDown) + document.removeEventListener('keydown', onKeyDown) + } + }, [open]) + + if (!snapshot || snapshot.usable <= 0) { + if (contextWindow && contextWindow > 0) { + return ( +
+ ctx + + + + 0% + + 0/{formatTokens(contextWindow)} + +
+ ) + } + return ( +
+ ctx + +
+ ) + } + + const ratio = Math.min(1, snapshot.total / snapshot.usable) + const pct = Math.round(ratio * 100) + const tone = toneFor(ratio) + + return ( +
+ + {open ? : null} +
+ ) + } +} diff --git a/harness/ui/src/function-trigger-message/index.tsx b/harness/ui/src/function-trigger-message/index.tsx new file mode 100644 index 000000000..bcca9e363 --- /dev/null +++ b/harness/ui/src/function-trigger-message/index.tsx @@ -0,0 +1,147 @@ +/** + * Injected function-trigger message renderer for `harness::metrics` — + * registered through `host.functionTriggers`, so it dispatches BEFORE the + * console's built-in families and replaces the raw JSON card in chat and + * traces with a totals row plus a compact per-session table (depth-indented + * ids, turn/token/cost columns, and a mini context-usage bar when the + * session carries a snapshot). Anything unparseable returns null and falls + * through to the default rendering. + */ + +import type { + FunctionTriggerMessage, + FunctionTriggerRenderer, +} from '@iii-dev/console-ui' +import { formatCost, formatTokens } from '../lib/format' +import { + type MetricsResponse, + type SessionUsage, + parseMetrics, +} from '../lib/metrics' +import { TONE_COLOR, toneFor } from '../lib/tone' + +const METRICS_ID = 'harness::metrics' + +function TotalsChip({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ) +} + +function TotalsRow({ metrics }: { metrics: MetricsResponse }) { + const totals = metrics.totals + return ( +
+ + + {totals.input_tokens != null ? ( + + ) : null} + {totals.output_tokens != null ? ( + + ) : null} + {totals.cache_read_tokens != null ? ( + + ) : null} + {totals.cost_usd != null ? ( + + ) : null} +
+ ) +} + +function MiniUsageBar({ total, usable }: { total: number; usable: number }) { + if (usable <= 0) return null + const ratio = Math.min(1, total / usable) + const pct = Math.round(ratio * 100) + const color = TONE_COLOR[toneFor(ratio)] + return ( + + + + ) +} + +function SessionRow({ row }: { row: SessionUsage }) { + return ( + + + + {row.session_id} + + + {row.turns ?? 0} + + {row.input_tokens != null ? formatTokens(row.input_tokens) : '—'} + + + {row.output_tokens != null ? formatTokens(row.output_tokens) : '—'} + + + {row.cost_usd != null ? formatCost(row.cost_usd) : '—'} + + + {row.context ? ( + + ) : null} + + + ) +} + +function MetricsCard({ metrics }: { metrics: MetricsResponse }) { + return ( +
+
+ metrics + {metrics.complete === false ? ( + partial + ) : null} + harness ui +
+ + {metrics.by_session.length > 0 ? ( +
+ + + + + + + + + + + + + {metrics.by_session.map((row) => ( + + ))} + +
sessionturnsinoutcostctx
+
+ ) : null} +
+ ) +} + +export function createMetricsRenderer(): FunctionTriggerRenderer { + return { + id: 'harness/page.js#metrics', + isMatch: (functionId) => functionId === METRICS_ID, + tryRender: (message: FunctionTriggerMessage) => { + const metrics = parseMetrics(message.output) + if (!metrics) return null + return + }, + } +} diff --git a/harness/ui/src/lib/format.ts b/harness/ui/src/lib/format.ts new file mode 100644 index 000000000..8947d478b --- /dev/null +++ b/harness/ui/src/lib/format.ts @@ -0,0 +1,17 @@ +/** Token-count formatting shared by the chip, popover, and metrics card. */ + +export function formatTokens(n: number): string { + if (!Number.isFinite(n) || n < 0) return '0' + if (n >= 1_000_000) return `${trimmed(n / 1_000_000)}m` + if (n >= 1000) return `${trimmed(n / 1000)}k` + return String(Math.round(n)) +} + +export function formatCost(usd: number): string { + return `$${usd.toFixed(4)}` +} + +function trimmed(value: number): string { + const fixed = value.toFixed(1) + return fixed.endsWith('.0') ? fixed.slice(0, -2) : fixed +} diff --git a/harness/ui/src/lib/metrics.ts b/harness/ui/src/lib/metrics.ts new file mode 100644 index 000000000..bc75ba0cf --- /dev/null +++ b/harness/ui/src/lib/metrics.ts @@ -0,0 +1,125 @@ +/** + * Wire shapes of `harness::metrics` (SessionMetricsResponseV1) and the + * per-generation context snapshot (ContextSnapshotV1) it carries, plus the + * tolerant parsers the chip and the function-trigger renderer share. A + * snapshot from a newer harness must degrade to "no data", never a crash. + */ + +export interface SnapshotMessages { + user: number + assistant: number + function_result: number + custom: number +} + +export interface SnapshotCategories { + system_prompt: number + tools: number + messages: SnapshotMessages + overhead: number + /** `#[serde(default)]` on the Rust side — absent in snapshots written + before the category existed. */ + hook_guidance?: number +} + +export interface SnapshotUsage { + input?: number + output?: number + cache_read?: number + cache_write?: number + reasoning?: number + cost_usd?: number +} + +export interface ContextSnapshot { + session_id: string + turn_id: string + step: number + model: string + provider?: string + estimator?: string + usable: number + effective_max_output_tokens: number + total: number + free: number + categories: SnapshotCategories + compacted: boolean + summarized_head_tokens?: number + usage?: SnapshotUsage + timestamp: number +} + +export interface SessionUsage { + session_id: string + parent_session_id?: string + depth: number + turns: number + function_calls: number + function_call_errors: number + input_tokens?: number + output_tokens?: number + cache_read_tokens?: number + cache_write_tokens?: number + reasoning_tokens?: number + cost_usd?: number + context?: ContextSnapshot +} + +export interface MetricsTotals { + sessions: number + turns: number + function_calls: number + function_call_errors: number + input_tokens?: number + output_tokens?: number + cache_read_tokens?: number + cache_write_tokens?: number + reasoning_tokens?: number + cost_usd?: number +} + +export interface MetricsResponse { + root_session_id?: string + complete?: boolean + totals: MetricsTotals + by_session: SessionUsage[] +} + +/** `{ content: [...], details }` harness result envelope → details. */ +export function unwrapEnvelope(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value + const obj = value as Record + if (Array.isArray(obj.content) && 'details' in obj) return obj.details + return value +} + +export function parseMetrics(value: unknown): MetricsResponse | null { + const raw = unwrapEnvelope(value) + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null + const obj = raw as Record + if (!Array.isArray(obj.by_session)) return null + if (!obj.totals || typeof obj.totals !== 'object' || Array.isArray(obj.totals)) { + return null + } + return obj as unknown as MetricsResponse +} + +export function isSnapshot(value: unknown): value is ContextSnapshot { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const snap = value as Record + if (typeof snap.total !== 'number' || typeof snap.usable !== 'number') + return false + const categories = snap.categories as Record | undefined + if (!categories || typeof categories !== 'object' || Array.isArray(categories)) + return false + const messages = categories.messages as Record | undefined + return ( + !!messages && + typeof messages === 'object' && + !Array.isArray(messages) && + typeof messages.user === 'number' && + typeof messages.assistant === 'number' && + typeof messages.function_result === 'number' && + typeof messages.custom === 'number' + ) +} diff --git a/harness/ui/src/lib/tone.ts b/harness/ui/src/lib/tone.ts new file mode 100644 index 000000000..5a58ae455 --- /dev/null +++ b/harness/ui/src/lib/tone.ts @@ -0,0 +1,24 @@ +/** + * Context-usage tone: one pair of thresholds and one colour map, shared by + * every surface that draws a usage bar (the session chip and the metrics + * card's mini bar) so a bar can never say "fine" in one place and "alert" in + * another. + */ + +export type Tone = 'ok' | 'warn' | 'alert' + +const WARN_THRESHOLD = 0.75 +const DANGER_THRESHOLD = 0.9 + +export const TONE_COLOR: Record = { + ok: 'var(--color-accent)', + warn: 'var(--color-warn)', + alert: 'var(--color-alert)', +} + +/** Tone for a used/usable ratio (already clamped by the caller). */ +export function toneFor(ratio: number): Tone { + if (ratio >= DANGER_THRESHOLD) return 'alert' + if (ratio >= WARN_THRESHOLD) return 'warn' + return 'ok' +} diff --git a/harness/ui/styles.css b/harness/ui/styles.css new file mode 100644 index 000000000..eaba102d2 --- /dev/null +++ b/harness/ui/styles.css @@ -0,0 +1,290 @@ +/* + * The harness worker's console stylesheet, shipped as its own `console:style` + * asset (harness/styles.css) — the console mounts it as a in + * document.head and link-swaps it on hot reload, styles-before-scripts on + * boot. Every rule is scoped under `[data-iii-ui="harness"]`, the wrapper the + * console mounts around every injected render — session chip and + * function-trigger message alike. Styling uses the console's design tokens, + * so light/dark theming is free. + */ + +/* --- session chip (context) ------------------------------------------ */ +[data-iii-ui="harness"] .harness-ui-chip { + position: relative; + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-ink-faint); +} +[data-iii-ui="harness"] .harness-ui-chip-empty { + color: var(--color-ink-ghost); +} +[data-iii-ui="harness"] .harness-ui-chip-btn { + display: flex; + align-items: center; + gap: 6px; + background: transparent; + border: 0; + padding: 0; + cursor: pointer; + font-family: inherit; + font-size: inherit; + text-transform: inherit; + letter-spacing: inherit; + color: inherit; +} +[data-iii-ui="harness"] .harness-ui-chip-bar { + position: relative; + display: block; + width: 56px; + height: 6px; + background: var(--color-rule-2); + border: 1px solid var(--color-rule); + overflow: hidden; +} +[data-iii-ui="harness"] .harness-ui-chip-fill { + display: block; + height: 100%; + transition: width 0.2s, background-color 0.2s; +} +[data-iii-ui="harness"] .harness-ui-chip-pct { + font-variant-numeric: tabular-nums; +} +[data-iii-ui="harness"] .harness-ui-chip-counts { + color: var(--color-ink-ghost); + text-transform: none; + letter-spacing: normal; + font-variant-numeric: tabular-nums; +} + +/* --- context popover -------------------------------------------------- */ +[data-iii-ui="harness"] .harness-ui-pop { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 1000; + width: 300px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + background: var(--color-panel); + border: 1px solid var(--color-rule); + color: var(--color-ink); + font-size: 12px; + text-transform: none; + letter-spacing: normal; + text-align: left; + cursor: default; +} +[data-iii-ui="harness"] .harness-ui-pop-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} +[data-iii-ui="harness"] .harness-ui-pop-model { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +[data-iii-ui="harness"] .harness-ui-pop-usage { + flex: none; + color: var(--color-ink-faint); + font-variant-numeric: tabular-nums; +} +[data-iii-ui="harness"] .harness-ui-stack { + display: flex; + width: 100%; + height: 8px; + background: var(--color-rule-2); + border: 1px solid var(--color-rule); + overflow: hidden; + box-sizing: border-box; +} +[data-iii-ui="harness"] .harness-ui-seg { + display: block; + height: 100%; + flex: none; +} +[data-iii-ui="harness"] .harness-ui-legend { + display: flex; + flex-direction: column; + gap: 4px; +} +[data-iii-ui="harness"] .harness-ui-legend-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 11.5px; +} +[data-iii-ui="harness"] .harness-ui-swatch { + flex: none; + width: 8px; + height: 8px; +} +[data-iii-ui="harness"] .harness-ui-legend-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-ink-faint); +} +[data-iii-ui="harness"] .harness-ui-legend-val { + font-variant-numeric: tabular-nums; +} +[data-iii-ui="harness"] .harness-ui-legend-pct { + flex: none; + width: 36px; + text-align: right; + color: var(--color-ink-ghost); + font-variant-numeric: tabular-nums; +} +[data-iii-ui="harness"] .harness-ui-badge { + flex: none; + border: 1px solid var(--color-warn); + color: var(--color-warn); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 0 4px; +} +[data-iii-ui="harness"] .harness-ui-pop-foot { + display: flex; + flex-direction: column; + gap: 2px; + padding-top: 8px; + border-top: 1px solid var(--color-rule-2); + font-size: 11px; + /* Provenance, actuals, cache and cost are findings, not chrome: they stay + at reading weight instead of the ghost tint used for decoration. */ + color: var(--color-ink); +} + +[data-iii-ui="harness"] .harness-ui-cache-hit { + font-variant-numeric: tabular-nums; + color: var(--color-ink); +} + +[data-iii-ui="harness"] .harness-ui-cache-hit[data-tone="ok"] { + color: var(--color-ok); +} + +[data-iii-ui="harness"] .harness-ui-cache-hit[data-tone="warn"] { + color: var(--color-warn); +} + +/* --- function-trigger message (chat / traces card) ------------------- */ +[data-iii-ui="harness"] .harness-ui-msg { + border-top: 1px solid var(--color-rule-2); + background: var(--color-bg); + font-family: var(--font-mono, ui-monospace, monospace); +} +[data-iii-ui="harness"] .harness-ui-msg-head { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + flex-wrap: wrap; +} +[data-iii-ui="harness"] .harness-ui-pill { + border: 1px solid var(--color-accent); + color: var(--color-accent); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 1px 8px; +} +[data-iii-ui="harness"] .harness-ui-partial { + border: 1px solid var(--color-warn); + color: var(--color-warn); + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 1px 6px; +} +[data-iii-ui="harness"] .harness-ui-msg-tag { + margin-left: auto; + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--color-ink-ghost); +} +[data-iii-ui="harness"] .harness-ui-totals { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 0 12px 8px; +} +[data-iii-ui="harness"] .harness-ui-chip-stat { + border: 1px solid var(--color-rule); + color: var(--color-ink); + font-size: 11.5px; + padding: 1px 8px; + font-variant-numeric: tabular-nums; +} +[data-iii-ui="harness"] .harness-ui-chip-stat .k { + color: var(--color-ink-ghost); +} +[data-iii-ui="harness"] .harness-ui-table-wrap { + padding: 0 12px 10px; + overflow-x: auto; +} +[data-iii-ui="harness"] .harness-ui-table { + width: 100%; + border-collapse: collapse; + font-size: 11.5px; +} +[data-iii-ui="harness"] .harness-ui-table th { + text-align: left; + font-weight: 400; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-ink-ghost); + border-bottom: 1px solid var(--color-rule-2); + padding: 3px 8px 3px 0; +} +[data-iii-ui="harness"] .harness-ui-table td { + color: var(--color-ink); + border-bottom: 1px solid var(--color-rule-2); + padding: 3px 8px 3px 0; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +[data-iii-ui="harness"] .harness-ui-table tr:last-child td { + border-bottom: 0; +} +[data-iii-ui="harness"] .harness-ui-table th.harness-ui-num, +[data-iii-ui="harness"] .harness-ui-table td.harness-ui-num { + text-align: right; +} +[data-iii-ui="harness"] .harness-ui-sid span { + display: inline-block; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; +} +[data-iii-ui="harness"] .harness-ui-mini-bar { + position: relative; + display: inline-block; + width: 48px; + height: 4px; + background: var(--color-rule-2); + border: 1px solid var(--color-rule); + overflow: hidden; + vertical-align: middle; +} +[data-iii-ui="harness"] .harness-ui-mini-fill { + display: block; + height: 100%; +} diff --git a/harness/ui/tsconfig.json b/harness/ui/tsconfig.json new file mode 100644 index 000000000..e5ac60540 --- /dev/null +++ b/harness/ui/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": [] + }, + "include": ["page.tsx", "src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1ec22789..aad9d6fa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,22 @@ importers: specifier: ^5.9.2 version: 5.9.3 + harness/ui: + dependencies: + '@iii-dev/console-ui': + specifier: workspace:* + version: link:../../packages/console-ui + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.17 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + iii-directory/ui: dependencies: '@iii-dev/console-ui': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9f00b7779..d635bda3f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,7 @@ packages: - database/ui - editor/ui - eval/ui + - harness/ui - memory/ui - pdf/ui - state/ui