From 68fb57a477b81013b81c905aec065db698c1bf67 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:23:36 +0800 Subject: [PATCH] Integrate main updates into the canonical retirement branch Preserve the reviewed combined source tree while excluding inherited unsigned squash history from this contribution range. The original integration branch and its history remain unchanged. Source-main: https://github.com/loopx-project/loopx/commit/7c1f12a47c75a39cb37514e26b3570f3ef22f3f3 Source-integration: https://github.com/loopx-project/loopx/commit/e97c5e774c2fb6cdd54375381c82d24bce27de81 Co-authored-by: DDL Co-authored-by: Jason Cat Co-authored-by: aihop <631479666@qq.com> Co-authored-by: chyax98 <108393263+chyax98@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: huangruiteng Co-authored-by: song Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .github/workflows/python-tests.yml | 2 +- apps/desktop/loopx-control-plane/README.md | 44 ++ .../docs/recovery-after.png | Bin 0 -> 19532 bytes .../docs/recovery-before.png | Bin 0 -> 17744 bytes .../src-tauri/src/services.rs | 7 +- .../src-tauri/src/services_tests.rs | 64 ++ .../src-tauri/src/update_backup.rs | 68 +- .../loopx-control-plane/static/boot.js | 4 +- ...goal-acceptance-contract-browser-smoke.mjs | 52 +- .../smoke/goal-acceptance-contract-smoke.mjs | 10 +- .../smoke/team-evidence-return-smoke.mjs | 20 + apps/presentation/dashboard/src/data/chat.ts | 4 +- .../dashboard/src/data/delivery-review.ts | 4 +- .../src/data/goal-acceptance-actions.ts | 41 ++ .../src/data/goal-acceptance-observation.ts | 29 +- .../attached-agent-conversations.css | 61 ++ .../attached-agent-conversations.tsx | 284 ++++++++ .../attached-conversation-record.tsx | 73 ++ .../personal-workspace/channel-header.tsx | 8 +- .../delivery-review-copy.ts | 8 +- .../personal-workspace/delivery-review.css | 27 +- .../personal-workspace/delivery-review.tsx | 4 +- .../personal-workspace/desktop-update.tsx | 13 +- .../goal-acceptance-contract.tsx | 196 +++-- .../goal-agent-work-page.tsx | 41 ++ .../goal-oracle-evidence.tsx | 15 + .../personal-workspace/goal-overview.tsx | 9 +- .../personal-workspace/goal-team-episode.tsx | 3 + .../personal-workspace/goal-team-evidence.tsx | 8 +- .../goal-workspace-work.css | 4 +- .../goal-workspace-work.tsx | 12 +- .../src/features/personal-workspace/i18n.tsx | 48 ++ .../personal-workspace-contract.test.mjs | 15 + .../personal-workspace-model.ts | 27 +- .../personal-workspace-page.tsx | 101 ++- .../personal-workspace/personal-workspace.css | 23 + .../workspace-settings-page.tsx | 3 + apps/presentation/dashboard/src/router.tsx | 2 + .../dashboard/src/views/dashboard-page.tsx | 105 ++- benchmark/LHTB/run.sh | 3 +- benchmark/README.md | 47 ++ benchmark/edgebench/checkpoint_worker.py | 5 +- benchmark/edgebench/run.py | 18 +- benchmark/runtime/checkpoint.py | 130 ++-- benchmark/runtime/checkpoint_handoff.py | 32 +- benchmark/runtime/checkpoint_wake.py | 105 +-- benchmark/runtime/sforge.py | 28 +- benchmark/runtime/sforge_warm_start.py | 69 +- benchmark/tests/test_checkpoint_handoff.py | 111 ++- benchmark/tests/test_checkpoint_worker.py | 2 + benchmark/tests/test_lhtb_preflight.py | 49 ++ benchmark/tests/test_sforge_runtime.py | 105 +++ benchmark/tests/test_sforge_warm_start.py | 163 +++++ .../rfcs/complete-state-recovery-v0.md | 2 +- .../rfcs/complete-state-recovery-v0.zh-CN.md | 2 +- .../rfcs/desktop-execution-frontends-v0.md | 33 + .../hierarchical-agent-stride-control-v0.md | 45 +- ...rarchical-agent-stride-control-v0.zh-CN.md | 29 +- .../rfcs/live-team-workspace-v0.md | 27 + .../typescript-control-plane-migration-v0.md | 8 + ...script-control-plane-migration-v0.zh-CN.md | 6 + .../first-goal-home-desktop.png | Bin 0 -> 111794 bytes docs/development/design.md | 17 + docs/development/frontend-delivery.md | 28 +- docs/development/testing-and-quality.md | 21 +- docs/guides/getting-started.md | 9 +- docs/guides/personal-workspace-user-guide.md | 17 + docs/integrations/ego-source-reader.md | 20 +- .../session-runtime-control-plane-adapter.md | 121 ++++ docs/project-agent-todo-contract.md | 35 +- docs/quota-allocation.md | 9 +- .../reference/goal-acceptance-observations.md | 162 ++++- docs/reference/local-delegation.md | 13 + .../protocols/host-integration-surface-v0.md | 23 +- docs/reference/protocols/loopx-turn-v0.md | 12 + docs/reference/research-checkpoint.md | 11 + ...claude-install-no-system-mutation-smoke.py | 217 ++++++ examples/desktop-update-browser-smoke.mjs | 21 +- examples/personal-workspace-browser-smoke.mjs | 2 + .../agent-work-after-desktop.png | Bin 0 -> 103781 bytes .../agent-work-after-phone.png | Bin 0 -> 44412 bytes .../agent-work-before-desktop.png | Bin 0 -> 118157 bytes .../agent-work-overview-after.png | Bin 0 -> 179174 bytes .../agent-work-overview-before.png | Bin 0 -> 178048 bytes .../agent-work-tool-result.png | Bin 0 -> 112275 bytes .../attached-host-follow-up.mjs | 2 + .../codex-attached.mjs | 361 ++++++++++ .../codex_attached_fixture.py | 210 ++++++ .../codex_attached_peer.py | 224 ++++++ .../first-goal-home.mjs | 141 ++++ .../personal-workspace-browser/fixture.mjs | 6 + .../goal-acceptance-oracle-backend.py | 111 +++ .../goal-acceptance-oracle.mjs | 119 ++++ .../goal-workspace-create.mjs | 7 +- .../goal-workspace-m2.mjs | 8 +- .../oracle-controls-desktop.png | Bin 0 -> 143689 bytes .../oracle-controls-mobile.png | Bin 0 -> 65486 bytes .../oracle-current-desktop.png | Bin 0 -> 133090 bytes .../oracle-current-mobile.png | Bin 0 -> 55028 bytes .../screenshots/codex-attached/README.md | 19 + .../attached-codex-conversation-desktop.png | Bin 0 -> 118149 bytes .../attached-codex-conversation-phone.png | Bin 0 -> 49423 bytes .../attached-codex-entry-desktop.png | Bin 0 -> 109134 bytes .../attached-codex-open-error-desktop.png | Bin 0 -> 113887 bytes .../attached-codex-pending-desktop.png | Bin 0 -> 122041 bytes .../showcase-workspace.mjs | 7 + .../team-evidence.mjs | 18 +- examples/repository-hygiene-smoke.py | 6 + examples/semantic-vocabulary-drift-smoke.py | 2 +- loopx/canary/premerge.py | 18 +- .../native_chat/codex_attached.py | 267 +++++++ .../capabilities/native_chat/codex_history.py | 279 ++++++++ .../pr_review_queue/review_contract.py | 20 +- loopx/chat_action_normalization.py | 21 + loopx/chat_action_store.py | 1 + loopx/chat_actions.py | 13 +- loopx/chat_agent.py | 17 +- loopx/chat_runtime.py | 6 +- loopx/chat_server.py | 5 + loopx/chat_status_api.py | 22 +- loopx/claude_goal_mode/scripts/install.py | 95 ++- loopx/cli_commands/goal_acceptance.py | 21 +- loopx/cli_commands/quota_monitor_poll.py | 2 + loopx/cli_commands/quota_request.py | 9 + loopx/cli_commands/support_control_backup.py | 2 +- loopx/cli_commands/todo_registration.py | 2 +- loopx/cli_commands/turn.py | 329 ++++----- loopx/contract.py | 132 +++- .../control_plane/collaboration/delegation.ts | 24 +- .../collaboration/delegation_validation.py | 1 + .../control_plane/coordination/todo_create.ts | 6 +- .../control_plane/effect_runtime_handlers.ts | 2 + loopx/control_plane/effect_runtime_io.ts | 34 +- loopx/control_plane/goals/acceptance.py | 94 ++- .../control_plane/goals/acceptance_actions.py | 136 ++++ .../goals/acceptance_authority.ts | 12 +- .../goals/acceptance_contract.ts | 18 +- loopx/control_plane/goals/acceptance_http.py | 130 ++++ .../control_plane/goals/acceptance_oracle.ts | 135 ++++ .../goals/acceptance_oracle_io.py | 105 +++ .../goals/goal_frontier/__init__.py | 7 +- .../presentation/action_review_plan.ts | 10 + loopx/control_plane/quota/live_decision.py | 14 +- loopx/control_plane/quota/monitor_poll.py | 10 + .../quota/monitor_poll_commit.ts | 32 +- .../quota/unsettled_host_turn.py | 58 ++ .../control_plane/scheduler/monitor_batch.ts | 7 +- .../scheduler/monitor_poll_writeback.py | 7 + .../scheduler/monitor_successor.ts | 18 +- .../state_recovery_io.py} | 4 +- loopx/control_plane/status/collection.py | 2 + .../control_plane/todos/mutation_response.py | 3 + loopx/control_plane/todos/priority.ts | 14 +- loopx/control_plane/todos/text.py | 5 +- .../work_items/replan_context.ts | 38 +- .../work_items/replan_context_codec.py | 4 +- loopx/extensions/ego_source_reader.py | 56 +- .../extensions/lark/private_conversations.py | 12 +- .../opencode2-goal-worker.mjs | 31 +- loopx/presentation/codex_attached_api.py | 354 +++++++++ loopx/presentation/configuration_api.py | 5 + loopx/presentation/state_recovery_api.py | 2 +- loopx/quota.py | 4 + loopx/runtime.py | 73 +- loopx/semantics/vocabulary_v0.json | 2 +- loopx/status.py | 2 + packages/dsh-loopx-plugin/pnpm-lock.yaml | 24 +- .../admission-closeout.integration.spec.ts | 25 + scripts/ci/test_review_gate.py | 2 +- .../test_semantic_vocabulary_drift.py | 2 +- .../test_premerge_public_boundary_run.py | 80 +++ tests/capabilities/test_pr_review_contract.py | 19 + .../test_deferred_original_turn_binding.py | 96 +++ .../test_effect_runtime_integration.py | 15 +- .../test_effective_turn_replan.py | 68 +- .../control_plane/test_goal_acceptance_cli.py | 2 + .../test_goal_acceptance_oracle.py | 376 ++++++++++ .../test_goal_acceptance_prepared_action.py | 141 ++++ .../test_lifecycle_guard_reentry.py | 110 +++ .../test_monitor_poll_transaction_recovery.py | 2 + .../test_monitor_successor_validation.py | 58 +- .../test_replan_successor_guard_reentry.py | 4 +- .../test_research_checkpoint_capture.py | 15 +- .../test_todo_priority_authoring.py | 16 +- tests/control_plane/test_turn_resume_entry.py | 162 +++++ .../content_digest_single_owner.test.ts | 1 + .../effect_runtime_io.test.ts | 71 ++ .../goal_acceptance_authority.test.ts | 31 + .../goal_acceptance_oracle.test.ts | 55 ++ .../goal_acceptance_runtime.test.ts | 16 + tests/control_plane_ts/monitor_batch.test.ts | 36 + .../quota_monitor_poll_commit.test.ts | 19 +- tests/control_plane_ts/replan_context.test.ts | 58 +- .../todo_create_reviewed.test.ts | 1 + tests/control_plane_ts/todo_priority.test.ts | 59 +- tests/opencode2_goal_worker.test.mjs | 59 ++ .../test_codex_attached_conversation.py | 669 ++++++++++++++++++ tests/test_chat_agent.py | 24 +- tests/test_chat_server_cors.py | 38 + tests/test_chat_session_active_turn.py | 13 + tests/test_chat_todo_priority.py | 10 + ...laude_goal_harden_settings_preservation.py | 182 +++++ ...st_contract_scan_unreadable_directories.py | 297 ++++++++ tests/test_ego_source_reader.py | 68 +- .../test_independent_delegation_validation.py | 43 ++ tests/test_lark_private_conversations.py | 51 +- tests/test_runtime_archive_concurrency.py | 106 +++ tests/test_state_recovery.py | 14 +- tests/test_status_missing_registry.py | 35 + 209 files changed, 9653 insertions(+), 689 deletions(-) create mode 100644 apps/desktop/loopx-control-plane/docs/recovery-after.png create mode 100644 apps/desktop/loopx-control-plane/docs/recovery-before.png create mode 100644 apps/presentation/dashboard/src/data/goal-acceptance-actions.ts create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/attached-conversation-record.tsx create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/goal-agent-work-page.tsx create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/goal-oracle-evidence.tsx create mode 100644 docs/assets/personal-workspace/first-goal-home-desktop.png create mode 100644 examples/personal-workspace-browser/agent-work-after-desktop.png create mode 100644 examples/personal-workspace-browser/agent-work-after-phone.png create mode 100644 examples/personal-workspace-browser/agent-work-before-desktop.png create mode 100644 examples/personal-workspace-browser/agent-work-overview-after.png create mode 100644 examples/personal-workspace-browser/agent-work-overview-before.png create mode 100644 examples/personal-workspace-browser/agent-work-tool-result.png create mode 100644 examples/personal-workspace-browser/codex-attached.mjs create mode 100644 examples/personal-workspace-browser/codex_attached_fixture.py create mode 100644 examples/personal-workspace-browser/codex_attached_peer.py create mode 100644 examples/personal-workspace-browser/first-goal-home.mjs create mode 100644 examples/personal-workspace-browser/goal-acceptance-oracle-backend.py create mode 100644 examples/personal-workspace-browser/goal-acceptance-oracle.mjs create mode 100644 examples/personal-workspace-browser/oracle-controls-desktop.png create mode 100644 examples/personal-workspace-browser/oracle-controls-mobile.png create mode 100644 examples/personal-workspace-browser/oracle-current-desktop.png create mode 100644 examples/personal-workspace-browser/oracle-current-mobile.png create mode 100644 examples/personal-workspace-browser/screenshots/codex-attached/README.md create mode 100644 examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-conversation-desktop.png create mode 100644 examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-conversation-phone.png create mode 100644 examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-entry-desktop.png create mode 100644 examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-open-error-desktop.png create mode 100644 examples/personal-workspace-browser/screenshots/codex-attached/attached-codex-pending-desktop.png create mode 100644 loopx/capabilities/native_chat/codex_attached.py create mode 100644 loopx/capabilities/native_chat/codex_history.py create mode 100644 loopx/control_plane/goals/acceptance_actions.py create mode 100644 loopx/control_plane/goals/acceptance_http.py create mode 100644 loopx/control_plane/goals/acceptance_oracle.ts create mode 100644 loopx/control_plane/goals/acceptance_oracle_io.py rename loopx/{state_recovery.py => control_plane/state_recovery_io.py} (99%) create mode 100644 loopx/presentation/codex_attached_api.py create mode 100644 tests/canary/test_premerge_public_boundary_run.py create mode 100644 tests/control_plane/test_deferred_original_turn_binding.py create mode 100644 tests/control_plane/test_goal_acceptance_oracle.py create mode 100644 tests/control_plane/test_goal_acceptance_prepared_action.py create mode 100644 tests/control_plane/test_lifecycle_guard_reentry.py create mode 100644 tests/control_plane/test_turn_resume_entry.py create mode 100644 tests/control_plane_ts/goal_acceptance_oracle.test.ts create mode 100644 tests/presentation/test_codex_attached_conversation.py create mode 100644 tests/test_claude_goal_harden_settings_preservation.py create mode 100644 tests/test_contract_scan_unreadable_directories.py create mode 100644 tests/test_status_missing_registry.py diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 966c43a0a9..cd5b2ad444 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -507,7 +507,7 @@ jobs: group: loopx-python-test-shard-${{ matrix.shard }} queue: max cancel-in-progress: false - # All four shards were still progressing at 83-94% when the 45m job + # All shards were still progressing at 83-94% when the 45m job # deadline cancelled run 37916979857. Reserve setup/reporting headroom # without changing test-level deadlines, collection or worker count. timeout-minutes: 60 diff --git a/apps/desktop/loopx-control-plane/README.md b/apps/desktop/loopx-control-plane/README.md index 77ab956618..7dc229897c 100644 --- a/apps/desktop/loopx-control-plane/README.md +++ b/apps/desktop/loopx-control-plane/README.md @@ -50,6 +50,13 @@ previous runtime. Core's installation-only doctor qualifies non-editable wheel fingerprints: a local reuse fence, not publisher or source attestation. Older CLIs without that readback can use the automatic App-owned fallback. +Wheel qualification has a separate ten-second subprocess deadline because it +starts Core, checks the TypeScript runtime and hashes package contents. The +one-second environment/version probes remain unchanged. A slow valid wheel can +therefore qualify without asking the user to repair it; invalid or timed-out +qualification still fails closed, and timed-out process groups are terminated +and reaped. This does not speed up doctor or certify every cold-start workload. + A launch-time `LOOPX_BIN` remains the developer's explicit pin. Development mode also accepts a source runtime without promoting an installation. A previously saved path is a discovery candidate, not a permanent version pin. Corrupt @@ -305,6 +312,43 @@ cd .. npm run build ``` +On macOS, also qualify a released artifact using the opt-in checks below +(run from `src-tauri`, replacing the paths with downloaded release artifacts). +Keep the updater archive's `.sig` file beside it: + +```bash +LOOPX_TEST_APP="/path/to/LoopX.app" cargo test --lib \ + real_release_backup_restores_damage_and_rejects_tampering -- --ignored +LOOPX_TEST_SIGNED_ARCHIVE="/path/to/LoopX.app.tar.gz" cargo test \ + --test signed_bundle -- --ignored +``` + +The first check copies the signed App into temporary directories, then exercises +the production restore path after removing its executable, a sealed runtime +resource, or the entire target bundle. It also rejects a tampered backup before +journaling or replacement and checks that the source artifact remains intact. +The second verifies the updater archive with the shipped public key and rejects +modified bytes. Neither check launches the App or writes to an installed App or +user data. These qualify the native recovery and signature boundaries; they do +not replace packaged UI checks of download failure, restart, or runtime handoff. + +### Recover An Interrupted App Update + +If replacement leaves the App incomplete and a backup is available, the update +panel exposes **Restore previous version** directly. Restore it, then choose +**Restart to finish**. This removes two navigation disclosures from the failure +path; choosing a healthy installation's rollback remains under Advanced options. +The native updater still verifies the backup before replacement. An unavailable +or invalid backup reports its cause without recommending an unavailable runtime +repair. Restoring the App does not overwrite a separately managed CLI. + +| Previous recovery panel | Direct recovery action | +| --- | --- | +| ![Recovery hidden in Advanced options](docs/recovery-before.png) | ![Restore action beside the incomplete-installation message](docs/recovery-after.png) | + +These packaged-panel captures use synthetic native status for presentation +comparison; they do not certify an installed update or an executing task's recovery. + `npm run build` produces the configured platform bundles under `src-tauri/target/release/bundle/`. The release workflow builds macOS `.dmg` and `.app.zip` artifacts on macOS, Windows `.msi` and `.exe` artifacts on diff --git a/apps/desktop/loopx-control-plane/docs/recovery-after.png b/apps/desktop/loopx-control-plane/docs/recovery-after.png new file mode 100644 index 0000000000000000000000000000000000000000..e9e1266d8248e20709fb9a76576b44c156b68629 GIT binary patch literal 19532 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
}V7+J5ty&Zs=hH166jAQfF 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 (bool, Option) { } const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(1); +// Installation qualification boots Core, checks the TypeScript runtime and +// hashes the installed package. It is not a version-banner query: a valid cold +// wheel can exceed one second. Keep that work bounded without weakening Core's +// qualification or extending the lightweight environment probes. +const INSTALLATION_PROBE_TIMEOUT: Duration = Duration::from_secs(10); pub(crate) fn timed_output(command: Command) -> Option { timed_output_with_timeout(command, VERSION_PROBE_TIMEOUT) @@ -986,7 +991,7 @@ fn runtime_identity_for_executable_with_path( let mut command = Command::new(canonical); configure_runtime_environment(&mut command); command.args(["--format", "json", "doctor", "--installation-only"]); - let output = timed_output(command)?; + let output = timed_output_with_timeout(command, INSTALLATION_PROBE_TIMEOUT)?; if !output.status.success() { return None; } diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs b/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs index 8bc670752a..72f0d3b6df 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs @@ -1,5 +1,69 @@ use super::*; +#[cfg(unix)] +fn installation_probe_fixture(body: &str) -> (tempfile::TempDir, PathBuf) { + use std::os::unix::fs::PermissionsExt; + let root = tempfile::tempdir().expect("fixture directory"); + let bin = root.path().join("bin"); + fs::create_dir(&bin).expect("fixture bin"); + let executable = bin.join("loopx"); + fs::write( + &executable, + format!("#!/bin/sh\n[ \"$*\" = \"--format json doctor --installation-only\" ] || exit 2\n{body}\n"), + ) + .expect("fixture executable"); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + (root, executable) +} + +#[test] +#[cfg(unix)] +fn installation_probe_allows_cold_qualification_beyond_version_budget() { + let expected = serde_json::json!({ + "schema_version": "loopx_runtime_identity_v1", + "package_version": "1.4.1", "release_id": null, "source_revision": null, + "package_fingerprint": format!("sha256:{}", "a".repeat(64)), + }); + let payload = serde_json::json!({ + "ok": true, "scope": "installation_only", "service_runtime_identity": expected, + }); + let (_root, executable) = + installation_probe_fixture(&format!("sleep 1.2\nprintf '%s\\n' '{payload}'")); + assert_eq!( + runtime_identity_for_executable(executable.to_str().unwrap()), + Some(expected), + "slow, fully qualified wheels must not be treated as missing runtimes" + ); + let mut banner = Command::new("sh"); + banner.args(["-c", "sleep 2; printf 'Python 3.12.0\\n'"]); + assert!( + timed_output(banner).is_none(), + "version probes keep their short bound" + ); +} + +#[test] +#[cfg(unix)] +fn installation_probe_still_bounds_and_reaps_hung_qualification() { + let (root, executable) = installation_probe_fixture("echo $$ > \"$0.pid\"\nexec sleep 30"); + let started = Instant::now(); + assert!(runtime_identity_for_executable(executable.to_str().unwrap()).is_none()); + assert!( + started.elapsed() < Duration::from_secs(13), + "qualification must remain bounded" + ); + let pid = fs::read_to_string(root.path().join("bin/loopx.pid")).unwrap(); + assert!( + !Command::new("kill") + .args(["-0", pid.trim()]) + .stderr(Stdio::null()) + .status() + .unwrap() + .success(), + "timed out probe must be reaped" + ); +} + #[test] fn status_readiness_is_explicit_and_fails_closed() { assert_eq!(ServiceKind::Status.probe_path(), "/?readiness=1"); diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/update_backup.rs b/apps/desktop/loopx-control-plane/src-tauri/src/update_backup.rs index 2ee7ebce2c..1be2e407ae 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/update_backup.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/update_backup.rs @@ -623,15 +623,71 @@ mod tests { } #[test] + #[cfg(target_os = "macos")] #[ignore = "requires LOOPX_TEST_APP from a signed macOS build"] - fn real_backup_copy_retains_signature() { + fn real_release_backup_restores_damage_and_rejects_tampering() { + use std::os::unix::fs::MetadataExt; + let source = std::env::var("LOOPX_TEST_APP").unwrap(); + let source = Path::new(&source); + let executable_name = declared_bundle_executable(source).unwrap(); + let resource = "Contents/Resources/runtime/identity.json"; + let identity = fs::read(source.join(resource)).unwrap(); let dir = tempfile::tempdir().unwrap(); - let copied = dir.path().join("LoopX.app"); - copy(Path::new(&source), &copied).unwrap(); - assert!(copied - .join("Contents/Resources/runtime/identity.json") - .is_file()); + let temporary_root = dir.path().canonicalize().unwrap(); + let assert_temporary = |path: &Path| { + assert!(path.canonicalize().unwrap().starts_with(&temporary_root)); + }; + let previous = dir.path().join("previous"); + let backup = previous.join("LoopX.app"); + let target = dir.path().join("LoopX.app"); + let executable = target.join("Contents/MacOS").join(executable_name); + let journal = dir.path().join("pending-rollback"); + copy(source, &backup).unwrap(); + copy(source, &target).unwrap(); + assert!(installed_bundle_verifies(&executable)); + + // Only disposable copies are damaged. Exercise the same locator, + // verified copy and atomic replacement used by the recovery button + // with a shipped bundle, including its actual sealed runtime resource. + for damaged in [&executable, &target.join(resource), &target] { + assert_temporary(damaged); + if damaged.is_dir() { + fs::remove_dir_all(damaged).unwrap(); + } else { + fs::remove_file(damaged).unwrap(); + } + assert!(!installed_bundle_verifies(&executable)); + restore_verified_backup(&executable, &previous, || { + // The journal callback must precede replacement: the damaged + // target must still be observable here. + assert!(!installed_bundle_verifies(&executable)); + fs::write(&journal, "rollback").map_err(|error| error.to_string()) + }) + .unwrap(); + assert_eq!(fs::read_to_string(&journal).unwrap(), "rollback"); + assert!(installed_bundle_verifies(&executable)); + assert_eq!(fs::read(target.join(resource)).unwrap(), identity); + assert!(signature_verifies(&backup)); + fs::remove_file(&journal).unwrap(); + } + + // An invalid release backup must fail before journaling or swapping, + // leaving the exact installed directory and its signature intact. + let installed_inode = fs::metadata(&target).unwrap().ino(); + assert_temporary(&backup.join(resource)); + fs::write(backup.join(resource), "tampered").unwrap(); + assert_eq!( + restore_verified_backup(&executable, &previous, || { + panic!("a tampered backup must never reach the commit callback") + }), + Err("backup_failed".into()) + ); + assert_eq!(fs::metadata(&target).unwrap().ino(), installed_inode); + assert!(installed_bundle_verifies(&executable)); + assert!(!journal.exists()); + assert_eq!(fs::read(source.join(resource)).unwrap(), identity); + assert!(signature_verifies(source)); } #[test] diff --git a/apps/desktop/loopx-control-plane/static/boot.js b/apps/desktop/loopx-control-plane/static/boot.js index fe8f06932e..b40b0f05f5 100644 --- a/apps/desktop/loopx-control-plane/static/boot.js +++ b/apps/desktop/loopx-control-plane/static/boot.js @@ -116,7 +116,9 @@ const errors = { app_install_failed: "App 安装未能完成,本次更新未生效;已确认当前版本完好且运行时可用,可直接重启继续使用,或重新检查更新后再试。", app_install_incomplete: "App 安装中断,且无法确认当前版本是否完整,请勿直接重启。请在恢复与更新面板还原上一版本(或重新安装)后再试。", runtime_pairing_required: "旧版本未能自动选择可用组件。请检查 App 更新,或通过恢复入口重新连接。", - backup_failed: "无法备份当前版本,更新已停止。请检查磁盘空间后重试。", + backup_unavailable: "没有可验证的 App 备份,未执行恢复。请重新安装完整 App;当前窗口仍可保留。", + backup_failed: "App 备份无法保存或未通过完整性校验,替换已停止。请检查磁盘空间与权限,或重新安装完整 App。", + rollback_failed: "恢复 App 未完成,请保持窗口打开。可重试恢复;若仍失败,请重新安装完整 App。", }; function codeText(code, phase) { if (runtimeExplicit && ["runtime_identity_unavailable", "runtime_selection_explicit"].includes(code)) { diff --git a/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs b/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs index 23c4fa8257..490ef2a445 100644 --- a/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Exercise the shipped delivery view with synthetic status and read-only API fixtures. +// Exercise owner contract readback and unavailable operation controls with synthetic sources. import assert from "node:assert/strict"; import {mkdir} from "node:fs/promises"; import { execFileSync, spawn } from "node:child_process"; @@ -39,9 +39,12 @@ try { const writes = []; let response = structuredClone(snapshot); let responseStatus = 200; + let authorityStatus = 200; await page.addInitScript(language => localStorage.setItem("loopx-pw-locale", language), locale); await page.route("**/api/**", route => { - if (route.request().method() !== "GET") writes.push(route.request().url()); + // Model-option discovery is an existing read-only POST. Every effect + // endpoint, including the new acceptance operation, remains in this ledger. + if (route.request().method() !== "GET" && new URL(route.request().url()).pathname !== "/api/chat/agent-model-options") writes.push(route.request().url()); return route.fulfill({ status: 404, json: { error: "Unavailable in read-only fixture" } }); }); await page.route(url => url.pathname === "/status.json", route => route.fulfill({ json: payload })); @@ -50,6 +53,11 @@ try { assert.equal(new URL(route.request().url()).searchParams.get("goal_id"), "release-demo"); return route.fulfill({ status: responseStatus, json: response }); }); + await page.route("**/api/actions?*", route => route.fulfill({json: {ok: true, schema_version: "loopx_chat_action_list_v1", proposals: []}})); + await page.route("**/api/chat/goal-acceptance?*", route => route.fulfill({status: authorityStatus, json: { + ok: true, goal_id: response.acceptance.goal_id, provider_revision: "fixture:current", + goal_acceptance_contract: response.acceptance.goal_acceptance_contract ?? {enabled: false}, + }})); await page.goto(`http://127.0.0.1:${port}/${packaged ? "chat/" : ""}?statusUrl=/status.json`, { waitUntil: "networkidle" }); const navigation = page.getByRole("button", { name: locale === "en" ? "Open Goal navigation" : "打开 Goal 导航" }); if (await navigation.isVisible()) await navigation.click(); @@ -82,12 +90,25 @@ try { const section = review.locator(".delivery-acceptance-contract"); const refresh = review.getByRole("button", { name: locale === "en" ? "Refresh snapshot" : "刷新快照", exact: true }); const exportButton = review.getByRole("button", { name: locale === "en" ? "Export delivery snapshot" : "导出交付快照", exact: true }); + const openAdvanced = async () => { + const advanced = section.locator(".delivery-check-advanced"); + if (await advanced.isVisible() && await advanced.getAttribute("open") === null) { + await advanced.locator(":scope > summary").focus(); + await page.keyboard.press("Enter"); + assert.notEqual(await advanced.getAttribute("open"), null, "Optional diagnostics are keyboard accessible"); + } + }; const refreshSnapshot = async value => { response = { ...snapshot, acceptance: { ...acceptance, goal_acceptance_contract: value } }; const read = page.waitForResponse(url => url.url().includes("/api/chat/delivery-review?")); await refresh.click(); await read; await page.waitForFunction(() => !document.querySelector(".delivery-review-toolbar button")?.disabled); + // The native check reader is independent of the delivery-map snapshot. + // Reload verifies both read owners; polling/recovery is covered by the + // real SQLite/HTTP/CLI journey. + if (responseStatus === 200) await page.reload({waitUntil: "networkidle"}); + await openAdvanced(); }; await review.locator(".delivery-chain").waitFor(); const baseline = await review.innerText(); @@ -98,35 +119,28 @@ try { assert.equal(await review.innerText(), baseline, "Absent/off contract must preserve the existing view"); } await refreshSnapshot(contract); - assert.equal(await section.getAttribute("open"), null, "Contract starts collapsed"); - const summary = section.locator(":scope > summary"); - await summary.focus(); - await page.keyboard.press("Enter"); - assert.notEqual(await section.getAttribute("open"), null, "Disclosure is keyboard accessible"); const text = await section.innerText(); for (const value of [contract.objective, contract.digest, "release-demo", "todo_confirmed", "todo_unbound", "todo_stale", "recovery"]) assert.ok(text.includes(value)); for (const value of locale === "en" ? ["Task association confirmed", "Task association missing", "Task association stale", "Task associations require confirmation", "Outside the current task gate"] : ["任务关联已确认", "任务关联缺失", "任务关联已过期", "任务关联需要确认", "不属于当前任务门禁范围"]) assert.ok(text.includes(value), value); - assert.equal(await section.locator("button,input,select,textarea").count(), 0, "Readback must offer no mutation controls"); - await section.locator("details > summary").last().click(); - await section.getByText(locale === "en" ? /The local Goal owner/ : /本地 Goal 所有者/).waitFor(); + await section.getByText(locale === "en" ? /Import an existing JSON contract/ : /导入现有 JSON 合同/).waitFor(); for (const [status, en, zh] of [["unverified", "Artifact checks not verified", "产物检查未验证"], ["failed", "Artifact checks failed", "产物检查失败"], ["stale", "Artifact checks stale", "产物检查已过期"], ["partial", "Task checks passed; Goal-wide verification unknown", "任务检查通过;Goal 整体验证未知"], ["accepted", "Artifact checks passed", "产物检查通过"]]) { await refreshSnapshot(verifiedContract(status)); - await section.getByText(locale === "en" ? en : zh, { exact: true }).waitFor(); - assert.ok((await section.innerText()).includes(locale === "en" ? "neither automatically approves or completes the Goal" : "均不会自动批准或完成 Goal")); + await section.getByText(locale === "en" ? en : zh, { exact: true }).first().waitFor(); + assert.ok((await section.innerText()).includes(locale === "en" ? "independent review" : "独立复核")); } await refreshSnapshot({ ...contract, scope: { kind: "selected_work", todo_ids: contract.tasks.map(task => task.todo_id) } }); await section.getByText(locale === "en" ? "Only explicitly selected tasks" : "仅明确选定的任务", { exact: true }).waitFor(); - await section.locator("dl").first().scrollIntoViewIfNeeded(); + await section.locator(".delivery-check-advanced dl").scrollIntoViewIfNeeded(); await page.screenshot({ path: process.env.LOOPX_ACCEPTANCE_SCOPE_SCREENSHOT_DIR ? `${process.env.LOOPX_ACCEPTANCE_SCOPE_SCREENSHOT_DIR}/${locale}.png` : `/tmp/loopx-acceptance-scope-${locale}.png` }); const next = { ...verifiedContract("stale"), revision: 8, digest: "b".repeat(64) }; await refreshSnapshot(next); await section.getByText(next.digest, { exact: true }).waitFor(); assert.equal(await section.getByText(contract.digest, { exact: true }).count(), 0); - assert.match(await section.locator("dl").first().innerText(), /8/); + assert.match(await section.locator(".delivery-check-advanced dl").innerText(), /8/); await section.locator("details > summary").first().click(); await section.getByText("c".repeat(64), { exact: true }).waitFor(); - assert.match(await section.locator("dl").last().innerText(), /6/); + assert.match(await section.locator(".delivery-check-result dl").innerText(), /6/); assert.equal(await section.evaluate(element => element.scrollWidth > element.clientWidth + 2), false, "Long digests must wrap at narrow widths"); const downloadPromise = page.waitForEvent("download"); await exportButton.click(); @@ -136,16 +150,20 @@ try { for await (const chunk of stream) markdown += chunk.toString(); assert.ok(markdown.includes(next.digest) && markdown.includes("8") && markdown.includes(contract.objective)); responseStatus = 503; + authorityStatus = 503; await refreshSnapshot(next); - await section.getByText(locale === "en" ? /Retained snapshot/ : /当前保留旧快照/).waitFor(); + await section.getByText(locale === "en" ? /Retained snapshot/ : /当前保留旧快照/).first().waitFor(); + assert.equal(await section.getByRole("button", {name: locale === "en" ? "Check delivery" : "检查交付", exact: true}).count(), 0, + "An unavailable current authority must not offer a command from retained state"); assert.equal(await exportButton.isDisabled(), true, "Failed refresh must not export retained contract as current"); responseStatus = 200; + authorityStatus = 200; response = { ...snapshot, acceptance: { ...acceptance, goal_id: "other-goal", goal_acceptance_contract: next } }; const mismatch = page.waitForResponse(url => url.url().includes("/api/chat/delivery-review?")); await refresh.click(); await mismatch; await page.waitForFunction(() => !document.querySelector(".delivery-review-toolbar button")?.disabled); - await section.getByText(locale === "en" ? /Retained snapshot/ : /当前保留旧快照/).waitFor(); + await section.getByText(locale === "en" ? /Retained snapshot/ : /当前保留旧快照/).first().waitFor(); assert.equal(await section.getByText("other-goal", { exact: true }).count(), 0); await refreshSnapshot({ enabled: false }); assert.equal(await section.count(), 0, "Disabling removes the previous enabled readback"); diff --git a/apps/presentation/dashboard/smoke/goal-acceptance-contract-smoke.mjs b/apps/presentation/dashboard/smoke/goal-acceptance-contract-smoke.mjs index c6acf089e3..04015e5eab 100644 --- a/apps/presentation/dashboard/smoke/goal-acceptance-contract-smoke.mjs +++ b/apps/presentation/dashboard/smoke/goal-acceptance-contract-smoke.mjs @@ -47,7 +47,15 @@ assert.notEqual(next.acceptance.goal_acceptance_contract.digest, contract.digest assert.deepEqual(contract, before, "Rendering must not mutate the supplied contract"); console.log("Goal acceptance contract API/export: off parity, association/verification distinction, source/revision and negative cases passed"); -const {projectGoalAcceptanceCriteria} = await import("../node_modules/.cache/delivery-review/data/goal-acceptance-observation.js"); +const {goalAcceptanceDisplayStatus, projectGoalAcceptanceCriteria} = await import("../node_modules/.cache/delivery-review/data/goal-acceptance-observation.js"); +for (const [observation, expected] of Object.entries({current: "accepted", stale: "stale", failed: "failed", recorded: "unverified", unavailable: "unverified"})) { + const value = structuredClone(verifiedContract("accepted")); + value.criteria[0].oracle = {state: observation}; + const before = structuredClone(value); + assert.equal(goalAcceptanceDisplayStatus(value), expected, "Historical success cannot override current file observations"); + assert.equal(projectGoalAcceptanceCriteria(value)[0].state, expected); + assert.deepEqual(value, before, "Display freshness cannot mutate canonical acceptance"); +} for (const status of ["stale", "held", "partial"]) { const value = verifiedContract(status); value.verification.contract_revision = value.revision; diff --git a/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs b/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs index 49447a3e05..83bb04e95f 100644 --- a/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs +++ b/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs @@ -133,6 +133,26 @@ try { await verificationGap.scrollIntoViewIfNeeded(); await page.screenshot({path: resolve(outputDir, "team-verifier-gap-mobile.png"), animations: "disabled"}); await page.setViewportSize({width: 1512, height: 980}); + // A local pinned check is useful evidence, but cannot authenticate an + // independent reviewer. Keep the existing gap alongside the optional receipt. + validation = {...currentValidation, oracle_checks: [{criterion_id: "report-format", source_id: "report-checker", + source_digest: "sha256:" + "8".repeat(64), checked_at: checkTime, + input_versions: [], output_versions: outputVersions}]}; + await page.route("**/api/chat/sessions/*/loopx", checkedRead); + await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click(); + const correction = evidence.getByRole("region", {name: "纠偏证据路径", exact: true}); + await correction.getByText("负责人指定的版本固定检查器", {exact: true}).waitFor(); + await verificationGap.waitFor(); + await correction.getByText("检查在声明文件的隔离副本上执行;不代表远程复核者身份或异议已解决。", {exact: true}).waitFor(); + await selectedOutput().waitFor(); + await verificationGap.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "team-local-checker-gap-desktop.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + assert.ok(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth)); + await verificationGap.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "team-local-checker-gap-mobile.png"), animations: "disabled"}); + await page.setViewportSize({width: 1512, height: 980}); + await page.unroute("**/api/chat/sessions/*/loopx", checkedRead); // A completed source can retain its output bytes while its dependency // chain becomes unavailable. Reading an adoption candidate must withdraw // the earlier correction trace and must never offer a confirmation. diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index cb5b66fba5..989d0b8c81 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1,3 +1,4 @@ +import type {OracleEvidence} from "./goal-acceptance-observation"; import { HANDOFF_MODES, EXECUTION_HANDOFF_MODES, type ExecutionHandoffMode } from "../../../../../loopx/control_plane/coordination/handoff_mode_vocabulary.js"; import { normalizeGoalDraft } from "../../../../../loopx/control_plane/collaboration/goal_draft.js"; import { parseTurnStep, type TurnStep } from "./turn-steps.js"; @@ -410,6 +411,7 @@ export class ChatApiError extends Error { } export const typedActionKindSchema = z.enum([ + "goal.acceptance.check", "goal.create", "goal.update", "goal.lifecycle", @@ -1174,7 +1176,7 @@ export type DelegationReadback = { artifacts?: Array<{ref: string; sha256: string; text: string}>; error?: string; validation?: {source: "goal_acceptance" | "todo_validation"; basis_sha256: string; check_count: number; pinned_file_count: number; checked_at?: string; - output_versions?: Array<{ref: string; sha256: string}>}; + output_versions?: Array<{ref: string; sha256: string}>; oracle_checks?: Array}; task_failure?: {stage: "task_postcondition"; status: "failed" | "inconclusive" | "unavailable"; recovery_kind: "repair_required" | "replan_required"; summary: string; validator_kind: string; turn_key: string; resume_mode: "revalidate_cached_result"}; diff --git a/apps/presentation/dashboard/src/data/delivery-review.ts b/apps/presentation/dashboard/src/data/delivery-review.ts index bf886b7cd7..3eb32199f2 100644 --- a/apps/presentation/dashboard/src/data/delivery-review.ts +++ b/apps/presentation/dashboard/src/data/delivery-review.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { goalAcceptanceObservationSchema, type GoalAcceptanceContract } from "./goal-acceptance-observation.js"; +import { goalAcceptanceDisplayStatus, goalAcceptanceObservationSchema, type GoalAcceptanceContract } from "./goal-acceptance-observation.js"; const refsSchema = z.record(z.string(), z.array(z.string())); const nodeSchema = z.object({ @@ -211,7 +211,7 @@ export function deliveryReviewMarkdown(snapshot: DeliveryReviewSnapshot, labels: if (task.reason) rows.push(` ${line(task.reason)}`); if (task.applicable === false) rows.push(` ${copy.notApplicable}`); } - rows.push("", `### ${copy.verification}`, copy.verificationState[contract.status]); + rows.push("", `### ${copy.verification}`, copy.verificationState[goalAcceptanceDisplayStatus(contract)]); if (contract.held_todo_ids.length) rows.push(`${copy.heldTasks}: ${line(contract.held_todo_ids.join(", "))}`); rows.push("", `### ${copy.receipt}`); const receipt = contract.verification; diff --git a/apps/presentation/dashboard/src/data/goal-acceptance-actions.ts b/apps/presentation/dashboard/src/data/goal-acceptance-actions.ts new file mode 100644 index 0000000000..de4f91e505 --- /dev/null +++ b/apps/presentation/dashboard/src/data/goal-acceptance-actions.ts @@ -0,0 +1,41 @@ +import {z} from "zod"; +import {goalAcceptanceContractSchema} from "./goal-acceptance-observation"; +import type {TypedActionProposal} from "./chat"; +const responseSchema = z.object({ok: z.literal(true), goal_id: z.string(), provider_revision: z.string(), + goal_acceptance_contract: goalAcceptanceContractSchema, checks_passed: z.boolean().optional()}); +export type AcceptanceReadback = z.infer; +export async function acceptanceRequest(goalId: string, operation?: Record, signal?: AbortSignal): Promise { + const url = "/api/chat/goal-acceptance"; + const response = await fetch(operation ? url : `${url}?goal_id=${encodeURIComponent(goalId)}`, operation + ? {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({...operation, goal_id: goalId}), signal} : {signal}); + if (!response.ok) throw new Error("Acceptance unavailable"); + const result = responseSchema.parse(await response.json()); + if (result.goal_id !== goalId) throw new Error("Acceptance source changed"); + return result; +} + +// Read model only. The existing typed acceptance owner validates the complete +// document and its command/file bindings again at apply time. +const preparedDocument = z.object({objective: z.string().min(1), + scope: z.discriminatedUnion("kind", [z.object({kind: z.literal("all_advancement")}), + z.object({kind: z.literal("selected_work"), todo_ids: z.array(z.string()).min(1)})]), + criteria: z.array(z.object({id: z.string(), description: z.string(), + validation_argv: z.array(z.string()).min(1), + oracle: z.object({output_refs: z.array(z.string())}).passthrough().optional(), + }).passthrough()).min(1), +}).passthrough(); +export function preparedAcceptancePlan(proposals: TypedActionProposal[], goalId: string) { + // Never resurrect an older draft after the newest plan was applied, cancelled + // or invalidated. Ambiguous creation order requires a fresh Agent proposal. + const matching = proposals.filter(proposal => proposal.action_kind === "goal.acceptance.check" + && proposal.context.kind === "goal" && proposal.context.goal_id === goalId + && proposal.normalized_parameters.goal_id === goalId) + .sort((a, b) => b.created_at.localeCompare(a.created_at)); + const proposal = matching[0]; + if (!proposal || matching[1]?.created_at === proposal.created_at) return null; + if (proposal.permission_classification !== "local_command_execution" + || !["preview_ready", "deferred"].includes(proposal.status) || proposal.gate || proposal.error + || !proposal.available_transitions.includes("apply")) return null; + const parsed = preparedDocument.safeParse(proposal.normalized_parameters.document); + return parsed.success ? {proposal, document: parsed.data} : null; +} diff --git a/apps/presentation/dashboard/src/data/goal-acceptance-observation.ts b/apps/presentation/dashboard/src/data/goal-acceptance-observation.ts index 118c0101e7..c2be3ac4ce 100644 --- a/apps/presentation/dashboard/src/data/goal-acceptance-observation.ts +++ b/apps/presentation/dashboard/src/data/goal-acceptance-observation.ts @@ -2,7 +2,11 @@ import { z } from "zod"; const nullableText = z.string().nullable(); // Readback only: the Goal owner supplies association and verification states. -const goalAcceptanceContractSchema = z.discriminatedUnion("enabled", [ +export const oracleEvidenceSchema = z.object({source_id: z.string(), source_digest: z.string(), checked_at: z.string(), + input_versions: z.array(z.object({ref: z.string(), sha256: z.string()})), + output_versions: z.array(z.object({ref: z.string(), sha256: z.string()}))}); +export type OracleEvidence = z.infer; +export const goalAcceptanceContractSchema = z.discriminatedUnion("enabled", [ z.object({ enabled: z.literal(false) }), z.object({ enabled: z.literal(true), revision: z.number().int().positive(), digest: z.string().min(1), objective: z.string(), @@ -12,7 +16,11 @@ const goalAcceptanceContractSchema = z.discriminatedUnion("enabled", [ ]).optional(), non_goals: z.array(z.string()), held_todo_ids: z.array(z.string()), status: z.enum(["unverified", "stale", "failed", "partial", "accepted", "held"]), - criteria: z.array(z.object({ id: z.string(), description: z.string() })), + criteria: z.array(z.object({ id: z.string(), description: z.string(), oracle: z.object({ + kind: z.literal("owner_pinned_oracle"), source_id: z.string(), source_digest: z.string(), + input_refs: z.array(z.string()), output_refs: z.array(z.string()), + state: z.enum(["unverified", "recorded", "current", "stale", "failed", "unavailable"]), + }).optional() })), tasks: z.array(z.object({ todo_id: z.string(), state: z.enum(["ready", "unbound", "stale"]), criterion_ids: z.array(z.string()), reason: z.string().optional(), @@ -21,7 +29,7 @@ const goalAcceptanceContractSchema = z.discriminatedUnion("enabled", [ verification: z.object({ operation_id: z.string(), contract_revision: z.number().int().positive(), contract_digest: z.string(), todo_id: z.string().nullable(), - results: z.array(z.object({ criterion_id: z.string(), passed: z.boolean(), exit_code: z.number().int().nullable() })), + results: z.array(z.object({ criterion_id: z.string(), passed: z.boolean(), exit_code: z.number().int().nullable(), oracle_evidence: oracleEvidenceSchema.optional() })), }).nullable(), }), ]); @@ -67,6 +75,21 @@ export function projectGoalAcceptanceCriteria(contract: Extract) { + if (contract.status !== "accepted" && contract.status !== "partial") return contract.status; + const criteria = projectGoalAcceptanceCriteria(contract); + if (criteria.some(item => item.state === "failed")) return "failed"; + if (criteria.some(item => item.state === "stale")) return "stale"; + if (criteria.some(item => item.state === "unverified")) return "unverified"; + return contract.status; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css new file mode 100644 index 0000000000..c41ae101b3 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css @@ -0,0 +1,61 @@ +.attached-agent-conversations {margin: 12px 24px;} +.attached-conversation-entry {font: inherit; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); cursor: pointer;} +.attached-conversation-heading {display: flex; align-items: center; justify-content: space-between; padding: 16px; gap: 16px; border-bottom: 1px solid var(--pw-line);} +.attached-conversation-heading h2 {margin: 0; font-size: var(--text-heading);} +.attached-conversation-heading button {font: inherit; padding: 8px 12px; color: inherit; background: var(--pw-card); border: 1px solid var(--pw-line); border-radius: 6px;} +.attached-conversation-content {padding: 0 16px 16px; font-size: var(--text-body); min-height: 0; overflow: auto;} +.attached-conversation-content > p {color: var(--pw-text);} +.attached-conversation-content label {display: grid; gap: 8px; margin-block: 12px;} +.attached-conversation-content select, .attached-conversation-content textarea {font: inherit; padding: 8px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text);} +.attached-conversation-content textarea {min-height: 88px; width: 100%; resize: vertical;} +.attached-conversation-content button {font: inherit; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); cursor: pointer;} +.attached-conversation-content button:disabled, .attached-conversation-content button[aria-disabled="true"] {opacity: .5; cursor: default;} +.attached-conversation-content .attached-conversation-open {border: 0; padding: 0; border-radius: 0; background: transparent; text-decoration: underline; text-underline-offset: 3px;} +.attached-conversation-messages {max-height: min(40vh, 360px); overflow: auto; overscroll-behavior: contain; margin-block: 16px;} +.attached-conversation-messages article {padding-block: 16px; border-top: 1px solid var(--pw-line); overflow-wrap: anywhere;} +.attached-conversation-messages header {display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline;} +.attached-conversation-messages time {font-size: var(--text-caption); color: var(--pw-muted);} +.attached-conversation-messages [data-role="user"] {padding-inline: 12px; background: var(--pw-hover); border-radius: 6px;} +@media (max-width: 640px) {.attached-agent-conversations {margin: 12px;} .attached-conversation-messages {max-height: 48vh;}} + +.attached-conversation-messages [data-role="tool"], .attached-conversation-messages [data-role="activity"] {padding-block: 0; color: var(--pw-muted);} +.attached-conversation-messages details {margin-block: 0;} +.attached-conversation-messages summary {cursor: pointer; min-height: 44px; align-content: center; color: var(--pw-text);} +.attached-conversation-messages pre {font: var(--text-body) / var(--leading-prose) var(--font-mono); color: var(--pw-text); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 24rem; overflow: auto;} +.attached-history-navigation {display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: var(--text-caption);} +.personal-composer-wrap[hidden] {display: none;} +.personal-channel-scroll[data-agent-work="true"] {padding-block: 0; overflow: hidden;} +.personal-channel-scroll[data-agent-work="true"] > .personal-goal-view-panel:not([hidden]) {height: 100%;} +.goal-agent-work-page {height: 100%; display: flex; flex-direction: column; min-width: 0;} +.goal-agent-work-page .attached-conversation-heading {padding: 16px 0; flex-wrap: wrap; gap: 12px;} +.goal-agent-work-page .attached-conversation-heading h2 {overflow-wrap: anywhere;} +.agent-work-navigation {display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0;} +.agent-work-navigation select, .agent-work-directory button {font: inherit; border: 1px solid var(--pw-line); background: var(--pw-card); color: var(--pw-text); border-radius: 6px; padding: 8px 12px; min-height: 44px; max-width: 100%;} +.agent-work-directory {display: grid; gap: 12px; padding-block: 24px;} +.agent-work-directory button {text-align: left; cursor: pointer;} +.agent-work-unavailable {padding-block: 24px; color: var(--pw-muted); line-height: var(--leading-prose);} +.goal-agent-work-page .attached-conversation-content {flex: 1; display: flex; flex-direction: column; padding: 0 0 16px; overflow: auto;} +.goal-agent-work-page .attached-conversation-content > p {margin-block: 8px;} +.goal-agent-work-page .attached-conversation-source {font-size: var(--text-caption); color: var(--pw-muted);} +.goal-agent-work-page .attached-conversation-messages {flex: 1; min-height: 160px; max-height: none; margin-block: 12px;} +.goal-agent-work-page .attached-conversation-content > button {align-self: flex-start; min-height: 44px;} +.goal-agent-work-page form {flex-shrink: 0; display: flex; gap: 12px; align-items: flex-end; border-top: 1px solid var(--pw-line); padding-top: 8px;} +.goal-agent-work-page form label {margin: 0; flex: 1; min-width: 0;} +.goal-agent-work-page form textarea {min-height: 64px; max-height: 160px;} +.goal-agent-work-page form button[type="submit"] {background: var(--pw-text); color: var(--pw-card); min-height: 44px;} +@media (max-width: 640px) {.agent-work-navigation {width: 100%;} .agent-work-navigation select {flex: 1; min-width: 0;} .goal-agent-work-page .attached-conversation-messages {min-height: 120px;}} + +.attached-connection-status {display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px 16px; padding-block: 8px;} +.attached-connection-status p {margin: 0;} +.attached-connection-status .attached-conversation-open {min-height: 44px; flex-shrink: 0;} +@media (max-width: 640px) {.goal-agent-work-page .attached-conversation-heading h2 {font-size: var(--text-lead);} .agent-work-navigation {width: auto; flex: 1;} .agent-work-navigation select {max-width: 140px;} .attached-conversation-heading button {padding-inline: 8px;}} + +.attached-conversation-messages summary > strong {font-weight: 500;} +.attached-conversation-messages summary > time {margin-left: 8px;} +.attached-tool-metadata {font-size: var(--text-caption); margin-top: 8px;} +.attached-conversation-messages .attached-tool-source {font-size: var(--text-caption);} +@media (max-width: 640px) { + .goal-agent-work-page form {flex-wrap: wrap;} + .goal-agent-work-page form label {flex-basis: 100%;} + .goal-agent-work-page form button[type="submit"] {margin-left: auto;} +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx new file mode 100644 index 0000000000..8df54a9f03 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx @@ -0,0 +1,284 @@ +import {useEffect, useRef, useState} from "react"; +import {AttachedConversationRecord, type ConversationRecord, type ContentPage} from "./attached-conversation-record"; +import "./attached-agent-conversations.css"; + +type Binding = {agent_id: string; thread_id: string; binding_ref: string}; +type Message = ConversationRecord; +type Snapshot = {messages?: Message[]; before?: number | null; after?: number; has_more?: boolean; reset?: boolean; source_ref?: string; history_size?: number; revision: string; unchanged?: boolean; connected?: boolean; can_reply?: boolean; activity: {state: string}}; +type Submission = {operation_id: string; text: string; status?: string}; + +class ConversationRequestError extends Error { + constructor(message: string, readonly unsubmittedOperation?: string) {super(message);} +} + +async function request(url: string, options?: RequestInit): Promise { + const response = await fetch(url, {...options, signal: AbortSignal.timeout(6_000)}); + const value = await response.json(); + if (!response.ok || value.ok !== true) throw new ConversationRequestError( + value.error || "Conversation unavailable", + response.status === 409 && value.input_not_submitted === true && typeof value.operation_id === "string" ? value.operation_id : undefined, + ); + return value as T; +} + +// A bounded file window may contain only metadata. Advance without making the +// reader click through empty pages; keep each request and the whole burst bounded. +async function readVisiblePage(params: URLSearchParams, current: () => boolean): Promise { + const forward = params.has("after"); + let page = await request(`/api/chat/codex-attached?${params}`); + const newestBoundary = page.after; + const newerAvailable = page.has_more; + for (let windows = 1; windows < 8 && current() && !page.unchanged && !page.reset && !page.messages?.length; windows++) { + const cursor = forward ? (page.has_more ? page.after : null) : page.before; + const previous = params.get(forward ? "after" : "before"); + if (cursor == null) break; + if (previous !== null && (forward ? cursor <= Number(previous) : cursor >= Number(previous))) { + throw new Error("History position did not advance; reconnect to read it."); + } + params.set(forward ? "after" : "before", String(cursor)); + params.set("source_ref", page.source_ref ?? ""); + params.delete("revision"); + const next = await request(`/api/chat/codex-attached?${params}`); + if (next.reset || next.source_ref !== page.source_ref) return next; + page = next; + } + // Backward skipping must not move the tail cursor backwards: those bytes + // contained no visible records, and polling should resume at the original tail. + return forward ? page : {...page, after: newestBoundary, has_more: newerAvailable}; +} + +/** Owner-only original conversation. It never changes the selected executor. */ +export function AttachedAgentConversations({goalId, zh, targetAgentId}: { + goalId: string; zh: boolean; targetAgentId: string; +}) { + const [discovering, setDiscovering] = useState(true); + const [agents, setAgents] = useState([]); + const [selected, setSelected] = useState(""); + const [messages, setMessages] = useState([]); + const [snapshot, setSnapshot] = useState(null); + const [before, setBefore] = useState(null); + const [error, setError] = useState(""); + const [recoveryError, setRecoveryError] = useState(""); + const [sendNotice, setSendNotice] = useState(""); + const [draft, setDraft] = useState(""); + const [submission, setSubmission] = useState(null); + const [sending, setSending] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [opening, setOpening] = useState(false); + const [openRequested, setOpenRequested] = useState(false); + const [openError, setOpenError] = useState(""); + const [refresh, setRefresh] = useState(0); + const [readingHistory, setReadingHistory] = useState(false); + const [unread, setUnread] = useState(false); + const following = useRef(true); + const after = useRef(null); + const historyEpoch = useRef(0); + const generation = useRef(0); + const receiptGeneration = useRef(0); + const revision = useRef(""); + const history = useRef<{source: string; size: number} | null>(null); + const transcript = useRef(null); + const binding = agents.find(agent => agent.agent_id === selected); + const awaitingReceipt = Boolean(submission?.status && submission.status !== "delivered"); + const cacheKey = binding ? `loopx-attached-input:${goalId}:${binding.binding_ref}` : ""; + const query = binding ? new URLSearchParams({goal_id: goalId, agent_id: binding.agent_id, binding_ref: binding.binding_ref}) : null; + + useEffect(() => { + let active = true; + setAgents([]); setSelected(""); setMessages([]); setSnapshot(null); setError(""); + setDiscovering(true); + void request<{agents: Binding[]}>(`/api/chat/codex-attached?${new URLSearchParams({goal_id: goalId})}`) + .then(value => {if (active) {setAgents(value.agents); setSelected(targetAgentId);}}) + .catch(failure => {if (active) setError(String(failure));}) + .finally(() => {if (active) setDiscovering(false);}); + return () => {active = false;}; + }, [goalId, refresh, targetAgentId]); + + useEffect(() => { + const current = ++generation.current; + revision.current = ""; + history.current = null; + after.current = null; following.current = true; historyEpoch.current++; + setReadingHistory(false); setUnread(false); + setOpening(false); setOpenRequested(false); setOpenError(""); + setSendNotice(""); + setMessages([]); setSnapshot(null); setBefore(null); setDraft(""); setSubmission(null); setSending(false); setLoadingOlder(false); setError(""); setRecoveryError(""); + if (!binding) return; + try { + setDraft(sessionStorage.getItem(`${cacheKey}:draft`) ?? ""); + const stored = sessionStorage.getItem(cacheKey); + if (stored) { + const pending = JSON.parse(stored) as Submission; + if (!pending || typeof pending.operation_id !== "string" || typeof pending.text !== "string" || !["pending", "delivered"].includes(pending.status ?? "")) throw new Error("Invalid recovery record"); + if (pending.status === "pending") setDraft(pending.text); + setSubmission(pending); + if (pending.status === "pending") void readReceipt(pending, current); + } + } catch {setRecoveryError(zh ? "浏览器回复恢复记录不可读,暂不能安全发送。请先在原 App 核对。" : "Reply recovery is unreadable; check the original App before clearing it.");} + let timer: ReturnType; + async function read() { + const epoch = historyEpoch.current; + const params = new URLSearchParams(query!); + if (following.current && after.current !== null) params.set("after", String(after.current)); + if (history.current) params.set("source_ref", history.current.source); + if (revision.current) params.set("revision", revision.current); + try { + const next = await readVisiblePage(params, () => current === generation.current && epoch === historyEpoch.current); + if (current !== generation.current || epoch !== historyEpoch.current) return; + const follow = !transcript.current || transcript.current.scrollHeight - transcript.current.scrollTop - transcript.current.clientHeight < 64; + revision.current = next.revision; + if (!next.unchanged) { + const reset = next.reset || !history.current || history.current.source !== next.source_ref || (next.history_size ?? 0) < history.current.size; + history.current = {source: next.source_ref ?? "", size: next.history_size ?? 0}; + if (following.current || reset) { + after.current = next.after ?? null; + setMessages(previous => { + const combined = [...new Map([...(reset ? [] : previous), ...next.messages ?? []].map(message => [message.id, message])).values()].sort((a,b) => Number(a.id) - Number(b.id)); + if (combined.length > 300) setBefore(Number(combined[combined.length - 300].id)); + return combined.slice(-300); + }); + if (reset) {setBefore(next.before ?? null); following.current = true; setReadingHistory(false); setUnread(false);} + } else setUnread(true); + } + setSnapshot(previous => ({...previous, ...next})); setError(""); + if (follow && following.current) requestAnimationFrame(() => transcript.current?.scrollTo({top: transcript.current.scrollHeight})); + } catch (failure) {if (current === generation.current) {setError(String(failure)); setSnapshot(null);}} + finally {if (current === generation.current) timer = setTimeout(read, 1000);} + } + void read(); + return () => {generation.current++; clearTimeout(timer);}; + }, [goalId, binding?.binding_ref]); + + async function openOriginal() { + if (!binding || opening) return; + const current = generation.current; + setOpening(true); setOpenRequested(false); setOpenError(""); + try { + await request<{requested: true}>("/api/chat/codex-attached/open", {method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({goal_id: goalId, agent_id: binding.agent_id, binding_ref: binding.binding_ref})}); + if (current === generation.current) setOpenRequested(true); + } catch (failure) {if (current === generation.current) setOpenError(String(failure));} + finally {if (current === generation.current) setOpening(false);} + } + + async function older(newer = false) { + if ((!newer && before === null) || !query || loadingOlder) return; + const current = generation.current; + const epoch = ++historyEpoch.current; + following.current = false; setReadingHistory(true); + setLoadingOlder(true); + const params = new URLSearchParams(query); + params.set(newer ? "after" : "before", String(newer ? after.current ?? 0 : before)); + if (history.current) params.set("source_ref", history.current.source); + try { + const next = await readVisiblePage(params, () => current === generation.current && epoch === historyEpoch.current); + if (current === generation.current && epoch === historyEpoch.current) { + if (next.reset || !history.current || history.current.source !== next.source_ref || (next.history_size ?? 0) < history.current.size) throw new Error(zh ? "原会话历史已更新,请重新连接。" : "Original history changed; reconnect to read it."); + const previousHeight = transcript.current?.scrollHeight ?? 0; + setMessages(previous => { + const combined = [...new Map([...(next.messages ?? []), ...previous].map(row => [row.id, row])).values()].sort((a,b) => Number(a.id) - Number(b.id)); + const retained = newer ? combined.slice(-300) : combined.slice(0,300); + after.current = newer ? next.after ?? after.current : retained.at(-1)?.end ?? next.after ?? null; + setBefore(newer ? Number(retained[0]?.id) || null : next.before ?? null); + return retained; + }); + if (!newer) requestAnimationFrame(() => {if (transcript.current) transcript.current.scrollTop += transcript.current.scrollHeight - previousHeight;}); + if (newer && !next.has_more) {following.current = true; setReadingHistory(false); setUnread(false);} + setError(""); + } + } catch (failure) {if (current === generation.current) setError(String(failure));} + finally {if (current === generation.current) setLoadingOlder(false);} + } + + function pauseHistory() {following.current = false; setReadingHistory(true);} + function latest() { + historyEpoch.current++; after.current = null; history.current = null; revision.current = ""; + following.current = true; setReadingHistory(false); setUnread(false); setMessages([]); setBefore(null); + } + async function loadContent(record: Message, offset: number): Promise { + const params = new URLSearchParams(query!); + params.set("entry_id", record.id); params.set("content_offset", String(offset)); + params.set("source_ref", history.current?.source ?? ""); params.set("content_ref", record.content_ref ?? ""); + return request(`/api/chat/codex-attached?${params}`); + } + + async function send() { + if (!binding || sending || error || recoveryError || !snapshot?.can_reply || !draft.trim() || awaitingReceipt) return; + const current = generation.current; + const pending = {operation_id: crypto.randomUUID(), text: draft, status: "pending"}; + try {sessionStorage.setItem(cacheKey, JSON.stringify(pending));} + catch {setError(zh ? "无法保存回复回执,未发送。" : "Cannot save reply recovery; nothing was sent."); return;} + receiptGeneration.current++; + setSubmission(pending); setSending(true); setSendNotice(""); + try { + const result = await request<{status: string}>("/api/chat/codex-attached", {method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({operation_id: pending.operation_id, text: pending.text, goal_id: goalId, agent_id: binding.agent_id, binding_ref: binding.binding_ref})}); + if (current === generation.current) { + const delivered = {...pending, status: result.status}; + sessionStorage.setItem(cacheKey, JSON.stringify(delivered)); setSubmission(delivered); + if (result.status === "delivered") {sessionStorage.removeItem(`${cacheKey}:draft`); setDraft("");} + else void readReceipt(pending, current); + revision.current = ""; + } + } catch (failure) { + if (current === generation.current) { + if (failure instanceof ConversationRequestError && failure.unsubmittedOperation === pending.operation_id) { + // Only the owning server can prove no native effect. Keep the draft, + // discard this attempt, and require a fresh explicit send when idle. + try { + sessionStorage.removeItem(cacheKey); + receiptGeneration.current++; + setSubmission(null); setRecoveryError(""); + setSnapshot(previous => previous ? {...previous, can_reply: false} : null); + setSendNotice(zh ? "Agent 正在忙碌或状态尚未确认;消息未发送,草稿已保留。" : "The Agent is busy or its state is unconfirmed; nothing was sent. Your draft is retained."); + } catch {setRecoveryError(zh ? "消息未发送,但浏览器回复恢复记录无法清理。" : "Nothing was sent, but the browser recovery record could not be cleared.");} + } else {setError(String(failure)); void readReceipt(pending, current);} + } + } + finally {if (current === generation.current) setSending(false);} + } + async function readReceipt(pending = submission, current = generation.current) { + // Recover from existing evidence only. Never retry the native effect. + if (!query || !pending || current !== generation.current) return; + const receiptVersion = receiptGeneration.current; + const params = new URLSearchParams(query); params.set("operation_id", pending.operation_id); + try { + const receipt = await request<{status: string}>(`/api/chat/codex-attached?${params}`); + if (current === generation.current && receiptVersion === receiptGeneration.current) { + const next = {...pending, status: receipt.status}; + sessionStorage.setItem(cacheKey, JSON.stringify(next)); setSubmission(next); + setRecoveryError(""); + if (receipt.status === "delivered") {sessionStorage.removeItem(`${cacheKey}:draft`); receiptGeneration.current++; setDraft("");} + } + } catch (failure) {if (current === generation.current && receiptVersion === receiptGeneration.current) setRecoveryError(String(failure));} + } + function editDraft(text: string) { + setDraft(text); setSubmission(null); setSendNotice(""); + try {sessionStorage.setItem(`${cacheKey}:draft`, text);} + catch {setRecoveryError(zh ? "草稿无法保存在此浏览器,请复制保留后重试。" : "This browser cannot retain the draft; copy it before retrying.");} + } + function nextReply() { + try {sessionStorage.removeItem(cacheKey); sessionStorage.removeItem(`${cacheKey}:draft`); receiptGeneration.current++; setSubmission(null); setDraft(""); setError(""); setRecoveryError("");} + catch {setError(zh ? "无法清理回复回执。" : "Reply recovery could not be cleared.");} + } + return
+ + {discovering ?

{zh ? "正在连接原会话…" : "Connecting to the original conversation…"}

: !binding && !error ?

{zh ? "此 Agent 暂无已绑定的本机 Codex App 原会话。" : "This Agent has no bound local Codex App conversation."}

: null} + {recoveryError ?

{recoveryError}

: null} + {error ?

{error}

: null} + {sendNotice ?

{sendNotice}

: null} + {binding ? <> +

{zh ? "Codex App · 已保存消息与工具记录自动更新" : "Codex App · saved messages and tools update automatically"}

{!snapshot ? (zh ? "正在连接或连接不可用" : "Connecting or unavailable") : snapshot.can_reply ? (zh ? "原会话可回复" : "Original conversation accepts replies") : snapshot.activity.state === "turn_open" ? (zh ? "原会话有未结束执行;可在原 App 中追加输入" : "The original conversation has an unfinished turn; add input in the original App") : (zh ? "历史可读;回复连接或执行状态尚未确认" : "History available; reply connection or execution state unconfirmed")}

+
+ {openRequested ?

{zh ? "已请求打开原会话,请在 Codex App 中查看。" : "Requested opening the original conversation; check Codex App."}

: null} + {openError ?

{openError}

: null} + {before !== null ? : null} + {readingHistory ?
{zh ? "正在回看历史" : "Reading history"}{unread ? (zh ? " · 有新活动" : " · new activity") : ""}
: null} +
{if (event.deltaY < 0) pauseHistory();}} onTouchMove={pauseHistory} onClick={pauseHistory} onKeyDown={event => {if (["ArrowUp", "PageUp", "Home"].includes(event.key)) pauseHistory();}} aria-label={zh ? "原会话消息" : "Original conversation messages"}> + {messages.map(message => )} + {!messages.length && snapshot ?

{zh ? "当前有界历史窗口内没有可显示的消息,可读取更早记录。" : "No displayable messages in this bounded window; read earlier records."}

: null} +
+ {submission?.status ?

{submission.status === "delivered" ? (zh ? "已由原 App 接纳;等待 Agent 结果。" : "Accepted by the original App; awaiting the Agent result.") : (zh ? "投递尚未确认,请先在原 App 核对,不会自动重发。" : "Delivery unconfirmed; check the original App. No automatic resend.")} {submission.status === "pending" ? : null}{awaitingReceipt ? : null}

: null} + {!awaitingReceipt ?
{event.preventDefault(); void send();}}>