diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 975d09338..9eb067bc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -298,6 +298,15 @@ jobs: env: CI_RUST_SHARD: ${{ matrix.shard }} run: python3 scripts/ci_rust_test.py --report target/ci-test-reports/${{ matrix.shard }}.json -- cargo test --locked ${{ matrix.packages }} + - name: Run experimental Code Mode and receipt tests + if: matrix.shard == 'server' + run: | + CI_RUST_SHARD=experimental-code-mode \ + python3 scripts/ci_rust_test.py --report target/ci-test-reports/experimental-code-mode.json -- \ + cargo test --locked -p webcodex --features experimental-code-mode code_mode + CI_RUST_SHARD=experimental-code-mode-receipts \ + python3 scripts/ci_rust_test.py --report target/ci-test-reports/experimental-code-mode-receipts.json -- \ + cargo test --locked -p webcodex --features experimental-code-mode receipt_tests - name: Upload bounded Rust test evidence if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 diff --git a/src/tool_runtime/control_sidecar.rs b/src/tool_runtime/control_sidecar.rs index bd84bc4c3..606ecaf25 100644 --- a/src/tool_runtime/control_sidecar.rs +++ b/src/tool_runtime/control_sidecar.rs @@ -398,6 +398,7 @@ fn rejection_result(result: ToolResult) -> ToolCallOutcome { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), } } diff --git a/src/tool_runtime/kernel.rs b/src/tool_runtime/kernel.rs index da2b07955..aebac3e57 100644 --- a/src/tool_runtime/kernel.rs +++ b/src/tool_runtime/kernel.rs @@ -118,6 +118,11 @@ pub(crate) struct ToolCallOutcome { /// Existing privacy-safe audit projection captured before model compaction. /// Never serialized into ToolResult or interpreted as execution authority. pub(crate) canonical_audit_output: Option, + /// Canonical pre-model-projection state-change truth for request-local + /// internal effect accounting. This is never serialized into ToolResult and + /// carries no authorization, source-fence, or replay authority. + #[cfg_attr(not(feature = "experimental-code-mode"), allow(dead_code))] + pub(crate) canonical_state_changed: Option, /// Trusted internal Window/Workflow Session correlation evidence. This is /// adapter metadata only and is never part of the public ToolResult. pub(crate) correlation: super::window_activity::ToolCallCorrelation, @@ -132,6 +137,7 @@ fn session_selector_failure(error: super::SessionSelectorError) -> ToolCallOutco project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }, super::SessionSelectorError::RetentionExpired { session_id } => ToolCallOutcome { @@ -143,6 +149,7 @@ fn session_selector_failure(error: super::SessionSelectorError) -> ToolCallOutco project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }, } @@ -447,6 +454,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), } } @@ -487,6 +495,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -501,6 +510,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -521,6 +531,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -535,6 +546,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -560,6 +572,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -582,6 +595,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -605,6 +619,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -626,6 +641,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -644,6 +660,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -664,6 +681,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -738,6 +756,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -773,6 +792,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -787,6 +807,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -825,6 +846,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -854,6 +876,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -920,6 +943,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -938,6 +962,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -989,6 +1014,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -1011,6 +1037,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -1064,6 +1091,7 @@ impl ToolRuntime { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; } @@ -1237,6 +1265,7 @@ impl ToolRuntime { recorder_ack_requested, ); } + let canonical_state_changed = result.output.get("state_changed").and_then(Value::as_bool); // Session/permission evidence is sealed above. The response stage owns // canonical audit capture, one-shot model projection, and late sidecars. let postprocess::PostRecordResult { @@ -1260,6 +1289,7 @@ impl ToolRuntime { project, model_ergonomics: None, canonical_audit_output, + canonical_state_changed, correlation, } } diff --git a/src/tool_runtime/orchestration_host.rs b/src/tool_runtime/orchestration_host.rs index 2146b1324..cb19edc7c 100644 --- a/src/tool_runtime/orchestration_host.rs +++ b/src/tool_runtime/orchestration_host.rs @@ -355,7 +355,12 @@ impl OrchestrationEffectAccumulator { self.children.remove(&ordinal); } - fn finish(&mut self, ordinal: usize, result: &super::ToolResult) { + fn finish( + &mut self, + ordinal: usize, + result: &super::ToolResult, + canonical_state_changed: Option, + ) { let output = &result.output; let execution_state = output.get("execution_state").and_then(Value::as_str); let failure_kind = output.get("failure_kind").and_then(Value::as_str); @@ -413,9 +418,7 @@ impl OrchestrationEffectAccumulator { .children .get(&ordinal) .is_some_and(|child| runtime_tool_metadata(&child.tool).effect == ToolEffect::Mutate); - let mutation_state_changed = is_mutation - .then(|| output.get("state_changed").and_then(Value::as_bool)) - .flatten(); + let mutation_state_changed = is_mutation.then_some(canonical_state_changed).flatten(); if let Some(child) = self.children.get_mut(&ordinal) { child.job_id = None; child.continuation = None; @@ -879,7 +882,7 @@ impl CanonicalOrchestrationHost { if outcome.error_status.is_some() { effects.remove(child_ordinal); } else if let Some(result) = outcome.result.as_ref() { - effects.finish(child_ordinal, result); + effects.finish(child_ordinal, result, outcome.canonical_state_changed); } } // Publish effect truth before releasing the sequential scheduling fence: @@ -935,24 +938,52 @@ mod receipt_tests { use super::*; #[test] - fn mutation_result_without_authoritative_state_changed_fails_closed() { - let mut effects = OrchestrationEffectAccumulator::default(); - effects.begin_if_consequential(1, "edit_project_files"); - effects.finish( - 1, - &crate::tool_runtime::ToolResult::ok(serde_json::json!({ - "execution_state": "completed" - })), - ); + fn mutation_result_uses_preprojection_state_changed_truth() { + for state_changed in [false, true] { + let mut effects = OrchestrationEffectAccumulator::default(); + effects.begin_if_consequential(1, "edit_project_files"); + effects.finish( + 1, + &crate::tool_runtime::ToolResult::ok(serde_json::json!({ + "changed": state_changed + })), + Some(state_changed), + ); + + let receipt = effects.receipt(); + assert_eq!(receipt.consequential_calls, 1); + assert_eq!(receipt.known_results, 1); + assert_eq!(receipt.outcome_unknown, 0); + assert_eq!( + receipt.children[0].outcome, + ConsequentialChildOutcome::KnownResult + ); + assert_eq!(receipt.children[0].success, Some(true)); + assert_eq!(receipt.children[0].state_changed, Some(state_changed)); + } + } - let receipt = effects.receipt(); - assert_eq!(receipt.consequential_calls, 1); - assert_eq!(receipt.known_results, 0); - assert_eq!(receipt.outcome_unknown, 1); - assert_eq!( - receipt.children[0].outcome, - ConsequentialChildOutcome::OutcomeUnknown - ); - assert_eq!(receipt.children[0].state_changed, None); + #[test] + fn mutation_result_without_authoritative_state_changed_fails_closed() { + for projected_state_changed in [None, Some(false), Some(true)] { + let mut effects = OrchestrationEffectAccumulator::default(); + effects.begin_if_consequential(1, "edit_project_files"); + let mut output = serde_json::json!({"execution_state": "completed"}); + if let Some(state_changed) = projected_state_changed { + output["state_changed"] = serde_json::json!(state_changed); + } + effects.finish(1, &crate::tool_runtime::ToolResult::ok(output), None); + + let receipt = effects.receipt(); + assert_eq!(receipt.consequential_calls, 1); + assert_eq!(receipt.known_results, 0); + assert_eq!(receipt.outcome_unknown, 1); + assert_eq!( + receipt.children[0].outcome, + ConsequentialChildOutcome::OutcomeUnknown + ); + assert_eq!(receipt.children[0].success, None); + assert_eq!(receipt.children[0].state_changed, None); + } } } diff --git a/src/tool_runtime/specialized.rs b/src/tool_runtime/specialized.rs index b20b1f0ca..38cee26d0 100644 --- a/src/tool_runtime/specialized.rs +++ b/src/tool_runtime/specialized.rs @@ -57,6 +57,7 @@ pub(crate) async fn try_dispatch_specialized_gateway( project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }); } @@ -142,6 +143,7 @@ pub(crate) async fn try_dispatch_specialized_gateway( project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation, } } @@ -152,6 +154,7 @@ pub(crate) async fn try_dispatch_specialized_gateway( project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }, Err(SpecializedGovernanceDenial::Scope { @@ -167,6 +170,7 @@ pub(crate) async fn try_dispatch_specialized_gateway( project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }, }) diff --git a/src/tool_runtime/tests/code_mode_e2b.rs b/src/tool_runtime/tests/code_mode_e2b.rs index 1b63a2425..892e1e7c7 100644 --- a/src/tool_runtime/tests/code_mode_e2b.rs +++ b/src/tool_runtime/tests/code_mode_e2b.rs @@ -392,7 +392,7 @@ async fn e2b_read_guarded_edit_post_read_preserves_canonical_mutation_truth() { const after = await tools.read_files({items:[{path:"src/example.rs"}]}); text(JSON.stringify({ edit_success: edit.success, - changed: edit.output.state_changed, + changed: edit.output.changed, after: after.output.items[0].output.text })); "#; @@ -440,7 +440,7 @@ async fn e2b_noop_mutation_is_known_false_without_edit_provenance() { kind:"edit", path:"src/example.rs", expected_read_revision:revision, edits:[{kind:"replace_exact", old_text:"same", new_text:"same"}] }]}); - text(JSON.stringify({success:edit.success, changed:edit.output.state_changed})); + text(JSON.stringify({success:edit.success, changed:edit.output.changed})); "#; let task = spawn_e2b_call(&runtime, &project, &session_id, source, None); service_e2b_call( @@ -483,7 +483,7 @@ async fn e2b_dry_run_is_known_false_and_never_creates_edit_provenance() { edits:[{kind:"replace_exact", old_text:"before", new_text:"after"}] }] }); - text(JSON.stringify({success:edit.success, changed:edit.output.state_changed, would_change:edit.output.would_change})); + text(JSON.stringify({success:edit.success, changed:edit.output.changed, would_change:edit.output.would_change})); "#; let task = spawn_e2b_call(&runtime, &project, &session_id, source, None); service_e2b_call( @@ -1135,7 +1135,7 @@ async fn e2b_nested_edit_drives_real_final_changes_baseline_to_full_final_worksp kind:"edit",path:"README.md",expected_read_revision:revision, edits:[{kind:"replace_exact",old_text:"base readme",new_text:"E2b readme"}] }]}); - text(JSON.stringify({success:edit.success,changed:edit.output.state_changed})); + text(JSON.stringify({success:edit.success,changed:edit.output.changed})); "#; let e2b = spawn_e2b_call(&runtime, &project, &session.session_id, source, None); service_e2b_call( @@ -1281,7 +1281,7 @@ async fn e2b_outer_only_noop_and_prestart_failure_do_not_create_final_changes_el &runtime, &project, &noop.session_id, - "const before=await tools.read_files({items:[{path:'README.md'}]}); const revision=before.output.items[0].output.read_revision; const r=await tools.edit_project_files({changes:[{kind:'edit',path:'README.md',expected_read_revision:revision,edits:[{kind:'replace_exact',old_text:'base',new_text:'base'}]}]}); text(String(r.output.state_changed));", + "const before=await tools.read_files({items:[{path:'README.md'}]}); const revision=before.output.items[0].output.read_revision; const r=await tools.edit_project_files({changes:[{kind:'edit',path:'README.md',expected_read_revision:revision,edits:[{kind:'replace_exact',old_text:'base',new_text:'base'}]}]}); text(String(r.output.changed));", None, ); service_e2b_call( @@ -1446,7 +1446,7 @@ async fn e2b_parent_omits_retired_continuity_overlays_after_nested_edit() { arguments: json!({ "project": project_for_call, "session_id": session_for_call, - "source": "const before=await tools.read_files({items:[{path:'src/example.rs'}]}); const revision=before.output.items[0].output.read_revision; const e=await tools.edit_project_files({changes:[{kind:'edit',path:'src/example.rs',expected_read_revision:revision,edits:[{kind:'replace_exact',old_text:'before',new_text:'after'}]}]}); text(String(e.output.state_changed));" + "source": "const before=await tools.read_files({items:[{path:'src/example.rs'}]}); const revision=before.output.items[0].output.read_revision; const e=await tools.edit_project_files({changes:[{kind:'edit',path:'src/example.rs',expected_read_revision:revision,edits:[{kind:'replace_exact',old_text:'before',new_text:'after'}]}]}); text(String(e.output.changed));" }), }, ToolCallContext { diff --git a/src/tool_runtime/tests/code_mode_e2c.rs b/src/tool_runtime/tests/code_mode_e2c.rs index 16e171f52..950cdfb0b 100644 --- a/src/tool_runtime/tests/code_mode_e2c.rs +++ b/src/tool_runtime/tests/code_mode_e2c.rs @@ -237,7 +237,7 @@ async fn direct_edit( ); let result = task.await.unwrap(); assert!(result.success, "{result:?}"); - assert_eq!(result.output["state_changed"], true); + assert_eq!(result.output["changed"], true); } fn validator_receipt(result: &ToolResult) -> &Value { @@ -267,7 +267,7 @@ async fn e2c_guarded_edit_then_check_or_test_keeps_execution_and_source_truth_se ] { let client = format!("e2c-known-{tool}-{noop}-{exit}"); let (root, runtime, project, session) = fixture(&client).await; - let source = format!("{EDIT} const validation = await tools.{tool}({args}); text({{changed:edit.output.state_changed,success:validation.success,passed:validation.output.passed,source:validation.output.source_state}});"); + let source = format!("{EDIT} const validation = await tools.{tool}({args}); text({{changed:edit.output.changed,success:validation.success,passed:validation.output.passed,source:validation.output.source_state}});"); let task = spawn_e2b_call(&runtime, &project, &session, &source, None); let request = reach_validation( &runtime, diff --git a/src/tool_runtime/tests/control_sidecars.rs b/src/tool_runtime/tests/control_sidecars.rs index 992f6652e..983f0f3da 100644 --- a/src/tool_runtime/tests/control_sidecars.rs +++ b/src/tool_runtime/tests/control_sidecars.rs @@ -99,6 +99,7 @@ impl Fixture { project: None, model_ergonomics: None, canonical_audit_output: None, + canonical_state_changed: None, correlation: Default::default(), }; execution.decorate(&mut outcome);