diff --git a/crates/config-source/src/lib.rs b/crates/config-source/src/lib.rs index d5c1f7eb5..71264ef60 100644 --- a/crates/config-source/src/lib.rs +++ b/crates/config-source/src/lib.rs @@ -2738,6 +2738,110 @@ mod tests { } } + fn fixture_decodal(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Object(fields) => format!( + "{{ {} }}", + fields + .iter() + .map(|(key, value)| format!("{key} = {};", fixture_decodal(value))) + .collect::>() + .join(" ") + ), + serde_json::Value::Array(values) => format!( + "[{}]", + values + .iter() + .map(fixture_decodal) + .collect::>() + .join(", ") + ), + serde_json::Value::Null => panic!("fixture omission must not be represented as null"), + value => value.to_string(), + } + } + + #[test] + fn actual_profile_authoring_value_corpus_preserves_supported_forms() { + let cases: Vec = serde_json::from_str(include_str!( + "../../../resources/config-schema/profile-authoring-values.json" + )) + .unwrap(); + for case in cases { + let recipe = fixture_decodal(&case["profile"]); + for (form, patched) in [ + (recipe.clone(), false), + (r#"import "./recipe.dcdl""#.into(), false), + ( + r#"(import "./recipe.dcdl") // { description = "patched"; }"#.into(), + true, + ), + ] { + let source = format!( + r#"{{ profile.entries = [{{ selector = "project:corpus"; profile = {form}; }}]; }} as WorkspaceConfigSchema"# + ); + let snapshot = ConfigTreeSnapshot::from_entries( + 10, + [entry("main.dcdl", &source), entry("recipe.dcdl", &recipe)], + ) + .unwrap(); + let schema = actual_profile_schema(); + let environment = + SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone()); + let contract = + ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema); + let before = environment.evaluate_contract(&contract).unwrap(); + let diagnostics = environment.analyze(&path("main.dcdl"), None); + assert!( + diagnostics.is_empty(), + "{}: {form}: {diagnostics:?}", + case["name"] + ); + let mut expected = case["profile"].clone(); + if patched { + expected["description"] = serde_json::json!("patched"); + } + assert_eq!( + before.projections[0] + .data_json + .pointer("/profile/entries/0/profile"), + Some(&expected), + "{}", + case["name"] + ); + assert_eq!(environment.evaluate_contract(&contract).unwrap(), before); + } + } + let source = "{} as WorkspaceConfigSchema"; + let environment = SnapshotEnvironment::new( + ConfigTreeSnapshot::from_entries(10, [entry("main.dcdl", source)]).unwrap(), + ) + .with_schema_bundle(actual_profile_schema()); + for (profile_path, prefix, label) in [ + ("compaction", "reta", "retained_tokens"), + ("compaction", "prune_min", "prune_min_savings"), + ("compaction", "req", "request"), + ("compaction", "wor", "worker"), + ("compaction", "compact_ret", "compact_retained_tokens"), + ("feature.memory.extraction", "reas", "reasoning"), + ("feature.subjektiv.extraction", "reas", "reasoning"), + ] { + let marked = format!( + "{{ profile.entries = [{{ profile.{profile_path} = {{ {prefix}| }}; }}]; }} as WorkspaceConfigSchema" + ); + let cursor = marked.find('|').unwrap(); + let source = marked.replace('|', ""); + let result = environment + .complete_config(&path("main.dcdl"), &source, cursor, true) + .unwrap() + .unwrap(); + assert!( + result.items.iter().any(|item| item.label == label), + "{source}: {result:?}" + ); + } + } + #[test] fn completion_authoring_sources_compose_with_validation_source_fallbacks() { let schema = WorkspaceConfigSchemaBundle::compose([ diff --git a/crates/workspace-server/src/server/tests/value_profiles_tests.rs b/crates/workspace-server/src/server/tests/value_profiles_tests.rs index 72c72f6ad..b375fdeb9 100644 --- a/crates/workspace-server/src/server/tests/value_profiles_tests.rs +++ b/crates/workspace-server/src/server/tests/value_profiles_tests.rs @@ -303,6 +303,141 @@ async fn value_profiles_scalar_and_table_scopes_analyze_save_and_resolve_equally assert_eq!(scopes[1], scopes[2]); } +fn profile_fixture_decodal(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Object(fields) => format!( + "{{ {} }}", + fields + .iter() + .map(|(key, value)| format!("{key} = {};", profile_fixture_decodal(value))) + .collect::>() + .join(" ") + ), + serde_json::Value::Array(values) => format!( + "[{}]", + values + .iter() + .map(profile_fixture_decodal) + .collect::>() + .join(", ") + ), + serde_json::Value::Null => panic!("fixture omission must not be represented as null"), + value => value.to_string(), + } +} + +#[tokio::test] +async fn value_profiles_authoring_corpus_preserves_effective_runtime_settings() { + let cases: Vec = serde_json::from_str(include_str!( + "../../../../../resources/config-schema/profile-authoring-values.json" + )) + .unwrap(); + // The canonical token fixture must exercise every serializable setting, + // so adding a field to CompactionConfigPartial cannot silently outgrow the + // editor-shape coverage. Aliases/ratio helpers have separate corpus cases. + let default = serde_json::to_value(manifest::CompactionConfigPartial::default()).unwrap(); + let canonical = cases + .iter() + .find(|case| case["name"] == "canonical_compaction_tokens") + .unwrap(); + assert_eq!( + default + .as_object() + .unwrap() + .keys() + .collect::>(), + canonical["profile"]["compaction"] + .as_object() + .unwrap() + .keys() + .filter(|name| name.as_str() != "kind") + .collect() + ); + for case in cases { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let recipe = profile_fixture_decodal(&case["profile"]); + for (index, (form, patched)) in [ + (recipe.clone(), false), + (r#"import "./recipe.dcdl""#.into(), false), + ( + r#"(import "./recipe.dcdl") // { description = "patched"; }"#.into(), + true, + ), + ] + .iter() + .enumerate() + { + let extra = if index == 0 { + vec![config_source::ConfigTreeChange::Create { + path: config_source::VirtualPath::parse("recipe.dcdl").unwrap(), + content_type: config_source::ConfigContentType::Decodal, + content: recipe.clone(), + }] + } else { + vec![] + }; + let authored = format!("{} as WorkspaceConfigSchema", source(form)); + let state = commit_workspace_config_tree( + &api, + TEST_WORKSPACE_ID, + &request(&api, &authored, extra), + ) + .unwrap(); + let environment = config_source::SnapshotEnvironment::new(state.snapshot.clone()) + .with_schema_bundle(state.contract.schema_bundle.clone()); + let diagnostics = environment.analyze( + &config_source::VirtualPath::parse("main.dcdl").unwrap(), + None, + ); + assert!(diagnostics.is_empty(), "{}: {diagnostics:?}", case["name"]); + let mut expected = case["profile"].clone(); + if *patched { + expected["description"] = serde_json::json!("patched"); + } + let evaluated = environment.evaluate_contract(&state.contract).unwrap(); + assert_eq!( + evaluated.projections[0] + .data_json + .pointer("/profile/entries/0/profile"), + Some(&expected) + ); + let projection = + project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap(); + let bundle = build_virtual_profile_config_bundle( + &projection, + &state, + TEST_WORKSPACE_ID, + "created", + "project:alpha", + ) + .unwrap() + .unwrap(); + let resolved = bundle + .profile_source_archive + .as_ref() + .unwrap() + .verify() + .unwrap() + .resolve_profile( + "project:alpha", + Path::new("/runtime-worker"), + "corpus-worker", + ) + .unwrap(); + let manifest = serde_json::to_value(resolved).unwrap(); + for (pointer, value) in case["manifest_expect"].as_object().unwrap() { + assert_eq!( + manifest.pointer(pointer), + Some(value), + "{}: {pointer}", + case["name"] + ); + } + } + } +} + #[tokio::test] async fn value_profiles_invalid_saves_preserve_the_active_config() { let dir = tempfile::tempdir().unwrap(); diff --git a/docs/design/profiles-manifests-prompts.md b/docs/design/profiles-manifests-prompts.md index f89c33192..f791eb084 100644 --- a/docs/design/profiles-manifests-prompts.md +++ b/docs/design/profiles-manifests-prompts.md @@ -47,6 +47,8 @@ The schema contribution separates its materialized registration schema from an o `scope` and `delegation_scope` retain both supported forms: a string such as `"workspace_read"` / `"workspace_write"`, or a table such as `{ intent = "workspace_read"; deny_write = ["private"]; symlink_policy = "resolved"; }`. Decodal 0.4 has no union range, so these fields are opaque to the editor constraint pass; a non-materialized table hint supplies nested completion without rejecting the string form or rewriting it. The shared Backend save validator checks scope forms, intent values and table fields (including their types and unknown keys). Other structurally typed fields such as `worker.mode` and `feature.task.enabled` retain editor type diagnostics. Completion hints never become Profile defaults or saved values. +Extraction `reasoning` in both `feature.memory` and `feature.subjektiv`, like `engine.reasoning`, accepts either a string effort or a signed integer budget. These union values are also opaque to editor constraints and semantically checked on save. Compaction authoring covers the complete token settings and their supported `compact_*` aliases, plus the ratio helper's `request` / `worker` aliases; values are not renamed or defaulted during Workspace evaluation. The shared test corpus `resources/config-schema/profile-authoring-values.json` proves exact values in native/WASM analysis and effective settings through save/projection/Runtime consumption. Its canonical compaction case must cover every serialized `CompactionConfigPartial` field, making future contract drift visible in tests. + ## Manifests A resolved Manifest is the concrete contract used to create or restore a Worker. It carries defaults, resolved paths, permissions, scope, prompt references, provider/model decisions, and runtime identity. diff --git a/resources/config-schema/profile-authoring-values.json b/resources/config-schema/profile-authoring-values.json new file mode 100644 index 000000000..4038f72f4 --- /dev/null +++ b/resources/config-schema/profile-authoring-values.json @@ -0,0 +1,116 @@ +[ + { + "name": "canonical_compaction_tokens", + "profile": { + "compaction": { + "kind": "tokens", + "prune_enabled": false, + "prune_protected_tokens": 3000, + "prune_min_savings": 1000, + "threshold": 80000, + "request_threshold": 90000, + "retained_tokens": 12000, + "overview_target_tokens": 1000, + "overview_warning_tokens": 2000, + "overview_deadline_tokens": 3000, + "worker_context_max_tokens": 20000, + "finish_warning_remaining_tokens": 1000, + "final_reserve_tokens": 2000, + "worker_max_turns": 5, + "summary_target_tokens": 1000, + "summary_max_tokens": 2000, + "auto_read_budget_tokens": 500, + "result_context_max_tokens": 4000, + "model": { "ref": "codex-oauth/gpt-5.6-sol" } + } + }, + "manifest_expect": { + "/compaction/prune_enabled": false, + "/compaction/prune_protected_tokens": 3000, + "/compaction/prune_min_savings": 1000, + "/compaction/threshold": 80000, + "/compaction/request_threshold": 90000, + "/compaction/retained_tokens": 12000, + "/compaction/overview_target_tokens": 1000, + "/compaction/overview_warning_tokens": 2000, + "/compaction/overview_deadline_tokens": 3000, + "/compaction/worker_context_max_tokens": 20000, + "/compaction/finish_warning_remaining_tokens": 1000, + "/compaction/final_reserve_tokens": 2000, + "/compaction/worker_max_turns": 5, + "/compaction/summary_target_tokens": 1000, + "/compaction/summary_max_tokens": 2000, + "/compaction/auto_read_budget_tokens": 500, + "/compaction/result_context_max_tokens": 4000, + "/compaction/model/ref": "codex-oauth/gpt-5.6-sol" + } + }, + { + "name": "compaction_token_aliases_without_kind", + "profile": { + "compaction": { + "compact_threshold": 80000, + "compact_request_threshold": 90000, + "compact_retained_tokens": 12000, + "compact_worker_max_input_tokens": 20000, + "compact_worker_max_turns": 5, + "compact_auto_read_budget": 500 + } + }, + "manifest_expect": { + "/compaction/threshold": 80000, + "/compaction/request_threshold": 90000, + "/compaction/retained_tokens": 12000, + "/compaction/worker_context_max_tokens": 20000, + "/compaction/worker_max_turns": 5, + "/compaction/auto_read_budget_tokens": 500 + } + }, + { + "name": "compaction_ratio_aliases", + "profile": { + "model": { "context_window": 100000 }, + "compaction": { + "kind": "ratio", + "threshold": 0.9, + "request": 0.8, + "worker": 0.2 + } + }, + "manifest_expect": { + "/compaction/threshold": 90000, + "/compaction/request_threshold": 80000, + "/compaction/worker_context_max_tokens": 20000 + } + }, + { + "name": "numeric_extraction_reasoning", + "profile": { + "engine": { "reasoning": -1 }, + "feature": { + "memory": { "extraction": { "enabled": true, "reasoning": 4096 } }, + "subjektiv": { "extraction": { "enabled": true, "reasoning": 4096 } } + } + }, + "manifest_expect": { + "/engine/reasoning": -1, + "/feature/memory/profile/extraction/reasoning": 4096, + "/feature/subjektiv/profile/extraction/reasoning": 4096 + } + }, + { + "name": "string_extraction_reasoning", + "profile": { + "feature": { + "memory": { "extraction": { "enabled": true, "reasoning": "high" } }, + "subjektiv": { + "extraction": { "enabled": true, "reasoning": "medium" } + } + } + }, + "manifest_expect": { + "/feature/memory/profile/extraction/reasoning": "high", + "/feature/subjektiv/profile/extraction/reasoning": "medium" + } + } +] diff --git a/resources/config-schema/profile-authoring.dcdl b/resources/config-schema/profile-authoring.dcdl index ffd1b5e83..d2cb97ce3 100644 --- a/resources/config-schema/profile-authoring.dcdl +++ b/resources/config-schema/profile-authoring.dcdl @@ -9,7 +9,8 @@ let capability = Unknown; context_window = Int; max_context_window = Int; }; Extraction = { - enabled = Bool; model = Model; reasoning = String; + # ReasoningControl accepts string effort or signed integer budget. + enabled = Bool; model = Model; reasoning = Unknown; threshold = Int; worker_max_turns = Int; }; # Profile scope accepts either a string intent or a table. Decodal 0.4 has @@ -56,9 +57,19 @@ let env = { inherit = [...String]; set = { ...Unknown }; }; }]; }; compaction = { + # Token settings (CompactionConfigPartial) and ratio helper fields. + # Threshold/context values accept integer tokens or numeric ratios. kind = String; threshold = Unknown; request_threshold = Unknown; - prune_enabled = Bool; worker_context_max_tokens = Unknown; - worker_max_turns = Int; model = Model; + worker_context_max_tokens = Unknown; request = Unknown; worker = Unknown; + prune_enabled = Bool; prune_protected_tokens = Int; prune_min_savings = Int; + retained_tokens = Int; + overview_target_tokens = Int; overview_warning_tokens = Int; overview_deadline_tokens = Int; + finish_warning_remaining_tokens = Int; final_reserve_tokens = Int; + worker_max_turns = Int; summary_target_tokens = Int; summary_max_tokens = Int; + auto_read_budget_tokens = Int; result_context_max_tokens = Int; model = Model; + # Supported token aliases remain authorable without rewriting recipes. + compact_threshold = Int; compact_request_threshold = Int; compact_retained_tokens = Int; + compact_worker_max_input_tokens = Int; compact_worker_max_turns = Int; compact_auto_read_budget = Int; }; web = { enabled = Bool; allow_private_addresses = Bool; diff --git a/web/workspace/test/config-source/wasm-parity.test.ts b/web/workspace/test/config-source/wasm-parity.test.ts index be59ab095..905303f1d 100644 --- a/web/workspace/test/config-source/wasm-parity.test.ts +++ b/web/workspace/test/config-source/wasm-parity.test.ts @@ -382,6 +382,22 @@ Deno.test("generated WASM completes asserted WorkspaceConfigSchema keys", () => assertEquals(result.items.some((item) => item.label === "profile"), true); }); +function fixtureDecodal(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(fixtureDecodal).join(", ")}]`; + if (value !== null && typeof value === "object") { + return `{ ${ + Object.entries(value).map(([key, value]) => + `${key} = ${fixtureDecodal(value)};` + ).join(" ") + } }`; + } + if ( + typeof value === "string" || typeof value === "number" || + typeof value === "boolean" + ) return JSON.stringify(value); + throw new Error("fixture omission must not be represented as null/undefined"); +} + Deno.test("generated WASM authors value-based Profiles using the Backend schema without materializing authoring fields", async () => { const source = await Deno.readTextFile( new URL( @@ -656,6 +672,85 @@ Deno.test("generated WASM authors value-based Profiles using the Backend schema ); } } + const cases = JSON.parse( + await Deno.readTextFile( + new URL( + "../../../../resources/config-schema/profile-authoring-values.json", + import.meta.url, + ), + ), + ) as Array<{ name: string; profile: Record }>; + for (const fixture of cases) { + const recipe = fixtureDecodal(fixture.profile); + for ( + const [form, patched] of [[recipe, false], [ + 'import "./recipe.dcdl"', + false, + ], [ + '(import "./recipe.dcdl") // { description = "patched"; }', + true, + ]] as const + ) { + const fixtureContent = content.replace( + "profile = {};", + `profile = ${form};`, + ); + const fixtureTree = { + ...tree, + entries: { + "main.dcdl": { + ...tree.entries["main.dcdl"], + content: fixtureContent, + content_digest: await digestText(fixtureContent), + }, + "recipe.dcdl": { + path: "recipe.dcdl", + content_type: "decodal", + content: recipe, + content_digest: await digestText(recipe), + }, + }, + }; + assertEquals( + analyze_snapshot(fixtureTree, "main.dcdl", undefined), + [], + `${fixture.name}: ${form}`, + ); + const result = evaluate_snapshot( + fixtureTree, + valueContract, + ) as typeof evaluated; + assertEquals( + result.projections[0].data_json.profile.entries[0].profile, + patched + ? { ...fixture.profile, description: "patched" } + : fixture.profile, + ); + } + } + for ( + const [path, prefix, label] of [ + ["compaction", "reta", "retained_tokens"], + ["compaction", "prune_min", "prune_min_savings"], + ["compaction", "req", "request"], + ["compaction", "wor", "worker"], + ["compaction", "compact_ret", "compact_retained_tokens"], + ["feature.memory.extraction", "reas", "reasoning"], + ["feature.subjektiv.extraction", "reas", "reasoning"], + ] + ) { + const body = + `{ profile.entries = [{ profile.${path} = { ${prefix} } }]; } as WorkspaceConfigSchema`; + const cursor = body.lastIndexOf(prefix) + prefix.length; + const result = complete_current("main.dcdl", body, cursor, true) as { + items: Array<{ label: string }>; + }; + assertEquals( + result.items.some((item) => item.label === label), + true, + `${path}.${label}`, + ); + } // Neither completion nor diagnostic shape checking changes the saved value. assertEquals(evaluate_snapshot(tree, valueContract), evaluated); });