From 3aec926fe52e117c7dd5a91e359f76ef29d8a715 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:20:29 -0700 Subject: [PATCH] Chat renames are saved as the chat's name, so Session History shows them after the tab closes (#31602) * Chat renames are saved as the chat's name, so Session History shows them after the tab closes Renaming a native chat tab only changed that window's tab. Session History, phones and other windows read the host's saved name, so the rename vanished from History once the tab closed or Orca restarted. A rename now goes to the host that owns the chat (agentSession.rename) and is saved as the chat's one name, the field History, tabs, sidebars and phones already read. The record remembers that the user chose it, so automatic naming never replaces it. A window's pre-upgrade tab rename is uploaded once and only fills a name Orca chose. Hosts without the new capability keep the old tab-only rename. Every native agent's History row now shows the chat's name once it has one. * Keep saved chat names consistent across readers and reconnects * Carry qualified chat names into cached readers * Reuse the history recorder context across platforms * Use recorded chat ownership for legacy name transfer * Transfer legacy chat names only from known catalog ownership * Correct recorder path matching and History subscription order * Require complete evidence for bridged parity exclusions * Preserve sparse array lengths in complete recording comparisons * Initialize History observation before loading workspace context * Integrate measured History recordings and scoped parity evidence * fix chat naming retries and certify both replay modes * fix ambiguous chat rename labels after navigation * preserve chat rename intent across tab updates * type dashboard snapshot fixtures without assertions * fix: align chat name observation with store contracts * test: update chat name consumer fixtures * test: distinguish sender naming metadata from status * Preserve saved Activity chat names across tab focus * Clarify Activity tab projection identity * Remove package-manager lockfile churn from main merge * fix(runtime): validate tab property edits before saving chat names * fix: refresh History ownership after conversation clear * fix: commit History request identity before refresh effects * Fix History scan scope typing and integration test placement * test: cover saved chat name reader contracts * test: make chat naming fixtures deterministic --- .../aivault-history-scan-fulfilled.json | 58 +- .../aivault-history-scan-unsupported.json | 46 +- .../aivault-history-scan-worktrees-late.json | 165 ++-- .../aivault-history-screen-listed.json | 368 ++++--- .../aivault-history-screen-worktrees.json | 324 +++--- ...ivault.history-aivault.listsessions-1.json | 874 +++++++++------- ...ivault.history-screen-platform-status.json | 651 +++++++++--- ...x-aivault.history-screen-status.get-2.json | 933 +++++++++++------- ...-aivault.history-screen-worktree.ps-1.json | 917 ++++++++++++----- .../matrix-aivault.history-status.get-1.json | 781 +++++++++------ .../MobileAgentSessionHistoryPanel.tsx | 24 +- .../agent-history-association.test.tsx | 240 +++++ ...t-history-current-host-capability.test.tsx | 90 ++ .../use-mobile-agent-history-state.ts | 126 ++- .../mobile-structured-session-status-feed.ts | 31 +- .../bridged-parity/absent-result-evidence.ts | 188 ++++ .../bridged-parity/c5-page-closure.test.ts | 13 +- .../bridged-parity/c5-page-closure.ts | 12 +- .../bridged-parity/divergence-classes.test.ts | 34 +- .../bridged-parity/divergence-classes.ts | 61 +- .../divergence-evidence.test.ts | 247 +++++ .../bridged-parity/divergence-evidence.ts | 62 +- .../bridged-parity/replay-verdict.ts | 65 ++ .../adapters/agent-history-mount-adapters.ts | 15 +- .../rpc-recording/golden-difference.ts | 68 +- .../operation-module-loader.test.ts | 6 +- .../rpc-recording/operation-module-loader.ts | 8 +- .../rpc-recording-through-bridge.test.ts | 68 +- ...tured-session-ownership-acp-and-pi.test.ts | 50 +- .../ai-vault/structured-session-ownership.ts | 12 +- src/main/ipc/ai-vault-host-leg-cache.ts | 74 +- .../ipc/ai-vault-projection-dependency.ts | 112 +++ src/main/ipc/ai-vault-scan-coalescing.test.ts | 220 ++++- src/main/ipc/ai-vault.ts | 91 +- .../register-core-handlers.ts | 1 + ...ntime-environment-subscription-handlers.ts | 33 +- .../runtime-environments.ts | 4 + ...ructured-agent-session-host-status-feed.ts | 3 +- .../structured-agent-session-host-types.ts | 10 + .../structured-agent-session-host.ts | 8 +- ...sion-status-feed-conversation-name.test.ts | 31 +- ...ructured-agent-session-status-feed-deps.ts | 17 +- .../structured-agent-session-status-feed.ts | 13 +- ...structured-agent-session-status-summary.ts | 17 +- .../structured-chat-naming.test.ts | 105 +- .../native-chat/structured-chat-naming.ts | 31 +- .../agent-session-record-conversation-name.ts | 54 +- .../runtime/agent-session-record-store.ts | 8 +- .../renderer-owned-tab-properties.test.ts | 145 ++- ...ession-gate-classification.test-fixture.ts | 1 + .../structured-agent-session-rename.test.ts | 42 + .../structured-agent-session-rename.ts | 26 + .../structured-agent-session-schemas.ts | 1 + .../structured-agent-session-status-stream.ts | 8 + .../methods/structured-agent-session.test.ts | 2 +- .../rpc/methods/structured-agent-session.ts | 2 + ...time-desktop-pane-title-forwarding.test.ts | 77 +- .../session-tabs/headless-tab-properties.ts | 41 +- .../structured-session-tab-rename.ts | 34 + .../src/app-shell/use-app-shell-services.ts | 2 + .../ActivityPrototypePage-test-fixtures.ts | 1 + .../activity/ActivityPrototypePage.test.ts | 9 + ...ivityPrototypePage.thread-grouping.test.ts | 3 + .../activity-answered-ask-timeline.test.tsx | 80 ++ .../activity-event-builder-context.ts | 27 +- ...ivity-event-builder.identity-reuse.test.ts | 6 + .../activity/activity-event-builder.ts | 8 +- .../activity/activity-tab-projection.ts | 12 +- ...ivity-thread-grouping.status-order.test.ts | 2 + .../activity/use-agent-pane-threads.ts | 9 +- ...ashboard-snapshot-folder-workspace.test.ts | 8 +- ...ard-snapshot-orchestration-routing.test.ts | 13 +- ...uild-dashboard-snapshot.rows-cache.test.ts | 6 +- .../build-dashboard-snapshot.test.ts | 12 +- .../dashboard/build-dashboard-snapshot.ts | 9 +- .../dashboard/dashboard-card-labels.test.ts | 12 +- .../dashboard/dashboard-card-labels.ts | 21 +- .../useDashboardPopoutBridge.test.tsx | 5 + .../dashboard/useDashboardPopoutBridge.ts | 20 +- .../dashboard/useLiveDashboardSnapshot.ts | 4 + .../ReviewNotesSendMenuContent.test.tsx | 5 + .../editor/ReviewNotesSendMenuContent.tsx | 17 +- ...hat-agent-message-sender-identity.test.tsx | 46 +- ...iVaultSessionRow.structured-title.test.tsx | 16 +- .../ai-vault-association-observation.ts | 65 ++ .../ai-vault-forced-rescan-budget.ts | 24 + ...-session-conditional-refresh-hook.test.tsx | 97 ++ .../ai-vault-session-refresh.test.ts | 39 +- .../right-sidebar/ai-vault-session-refresh.ts | 104 +- .../ai-vault-session-result-cache.ts | 13 +- .../use-ai-vault-search.test.tsx | 117 ++- .../right-sidebar/use-ai-vault-search.ts | 37 +- .../use-ai-vault-session-display-title.ts | 9 +- .../WorkspaceAttachmentOrigins.test.tsx | 94 +- .../sidebar/WorkspaceAttachmentOrigins.tsx | 6 + .../src/components/tab-bar/TabBarItemRow.tsx | 3 +- .../tab-bar/recent-tab-switching.test.ts | 17 +- .../tab-bar/recent-tab-switching.ts | 32 +- .../components/tab-bar/tab-bar-item-label.ts | 24 + .../components/tab-bar/tab-bar-item-model.ts | 44 +- .../tab-bar/use-tab-bar-item-projection.ts | 7 +- src/renderer/src/i18n/locales/en.json | 4 + src/renderer/src/i18n/locales/es.json | 4 + src/renderer/src/i18n/locales/fr.json | 4 + src/renderer/src/i18n/locales/ja.json | 4 + src/renderer/src/i18n/locales/ko.json | 4 + src/renderer/src/i18n/locales/zh.json | 4 + .../src/lib/notes-send-agent-targets.test.ts | 27 + .../src/lib/notes-send-agent-targets.ts | 22 +- src/renderer/src/lib/running-agent-targets.ts | 4 +- .../structured-agent-session-action-launch.ts | 8 +- ...e-session-ssh-partition-round-trip.test.ts | 49 +- .../legacy-chat-rename-migration.test.tsx | 639 ++++++++++++ .../runtime/legacy-chat-rename-migration.ts | 98 ++ .../structured-agent-session-client.ts | 11 + .../runtime/structured-agent-session-owner.ts | 55 +- ...ructured-agent-session-status-feed.test.ts | 62 ++ .../structured-agent-session-status-feed.ts | 14 + .../structured-chat-name-observation.ts | 147 +++ .../runtime/structured-chat-rename-action.ts | 53 + .../src/runtime/structured-chat-rename.ts | 201 ++++ .../src/runtime/structured-chat-tab-name.ts | 62 ++ .../runtime/structured-conversation-name.ts | 6 +- .../workspace-layout-frame-tab-props.test.ts | 5 +- .../src/store/pinned-tab-close-guard.ts | 5 +- .../structured-chat-tab-rename.test.ts | 272 ++++- .../terminals/terminal-tab-appearance.ts | 17 +- .../terminal-tab-layout-owner-actions.ts | 30 +- .../agent-session-conversation-names.ts | 68 ++ src/shared/agent-session-record.ts | 8 +- .../agent-session-status-snapshot-fold.ts | 73 ++ src/shared/agent-session-wire.ts | 13 +- src/shared/protocol-version.ts | 1 + .../rpc-params-catalog.generated.ts | 2 + .../structured-agent-session-rename-params.ts | 11 + .../rpc-contract/workspace-layout-params.ts | 5 +- ...ured-agent-session-surface-capabilities.ts | 6 +- src/shared/structured-chat-row-name.ts | 4 +- .../workspace-layout-command-types.ts | 7 +- .../workspace-layout-tab-commands.ts | 18 +- .../workspace-layout-tab-rename.ts | 31 + .../ai-vault-manual-name-clear.unit.test.ts | 345 +++++++ ...session-saved-name-old-client.unit.test.ts | 124 +++ .../release-checkout-publication.ts | 2 +- .../release-checkout-tree.ts | 17 +- .../structured-agent-session-host-fixture.ts | 1 + .../structured-agent-session-surface-calls.ts | 7 + ...ructured-agent-session-surface-manifest.ts | 2 + 148 files changed, 9017 insertions(+), 2372 deletions(-) create mode 100644 mobile/src/agent-history/agent-history-association.test.tsx create mode 100644 mobile/src/test-support/bridged-parity/absent-result-evidence.ts create mode 100644 mobile/src/test-support/bridged-parity/replay-verdict.ts create mode 100644 src/main/ipc/ai-vault-projection-dependency.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-rename.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-rename.ts create mode 100644 src/main/runtime/session-tabs/structured-session-tab-rename.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-association-observation.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-forced-rescan-budget.ts create mode 100644 src/renderer/src/components/tab-bar/tab-bar-item-label.ts create mode 100644 src/renderer/src/runtime/legacy-chat-rename-migration.test.tsx create mode 100644 src/renderer/src/runtime/legacy-chat-rename-migration.ts create mode 100644 src/renderer/src/runtime/structured-chat-name-observation.ts create mode 100644 src/renderer/src/runtime/structured-chat-rename-action.ts create mode 100644 src/renderer/src/runtime/structured-chat-rename.ts create mode 100644 src/renderer/src/runtime/structured-chat-tab-name.ts create mode 100644 src/shared/agent-session-conversation-names.ts create mode 100644 src/shared/rpc-contract/structured-agent-session-rename-params.ts create mode 100644 src/shared/workspace-layout/workspace-layout-tab-rename.ts create mode 100644 tests/e2e/ai-vault-manual-name-clear.unit.test.ts create mode 100644 tests/e2e/cross-version-wire/agent-session-saved-name-old-client.unit.test.ts diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json b/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json index 23aaf96c667..675d05d6aa8 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json @@ -4,9 +4,25 @@ "family": "aiVault.history", "namedDeltas": [], "values": { - "3719c54df702": { - "name": "aiVault.listSessions#1", + "44a27dbf05e2": { + "name": "status.get#1", "ordinal": 3, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + }, + "758b3fde1948": { + "name": "streams-registered-at-teardown", + "ordinal": 6, + "value": [ + { + "cancelled": false, + "method": "agentSession.subscribeStatus", + "payload": "agentSession.subscribeStatus#1" + } + ] + }, + "887a32dd2b5e": { + "name": "aiVault.listSessions#1", + "ordinal": 4, "args": [ { "name": "method", @@ -32,7 +48,7 @@ "startedAt": 0, "settledAt": 0, "value": { - "id": "frame-2", + "id": "frame-3", "ok": true, "result": { "issues": [], @@ -47,9 +63,9 @@ } } }, - "4a76a58cbdac": { + "acf501fa9958": { "name": "status.get#1", - "ordinal": 1, + "ordinal": 2, "args": [ { "name": "method", @@ -73,7 +89,7 @@ "startedAt": 0, "settledAt": 0, "value": { - "id": "frame-1", + "id": "frame-2", "ok": true, "result": { "capabilities": ["aiVault.v1"] @@ -81,10 +97,10 @@ } } }, - "97bd894604ee": { + "b6a16cc83e18": { "name": "aiVault.listSessions#1", - "ordinal": 4, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}" + "ordinal": 5, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}" }, "cd739a80b7a8": { "activeWorktreePath": "/repo/feature", @@ -105,10 +121,10 @@ ] } }, - "d08f74d65ee6": { - "name": "status.get#1", - "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + "e28c488ce6b2": { + "name": "agentSession.subscribeStatus#1", + "ordinal": 1, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"agentSession.subscribeStatus\",\"params\":{}}" }, "eb79a9b3682a": { "status": "fulfilled", @@ -125,14 +141,26 @@ { "id": "ready", "observation": { - "sender": ["4a76a58cbdac", "3719c54df702"], - "payloads": ["d08f74d65ee6", "97bd894604ee"], + "sender": ["acf501fa9958", "887a32dd2b5e"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], "settlements": { "mount": "eb79a9b3682a" }, "state": "cd739a80b7a8", "effects": [] } + }, + { + "id": "cleanup", + "observation": { + "sender": ["acf501fa9958", "887a32dd2b5e"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "cd739a80b7a8", + "effects": ["758b3fde1948"] + } } ] } diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json b/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json index e10f0a8ea9c..8259b9001d2 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json @@ -15,9 +15,25 @@ "kind": "unsupported" } }, - "a3b75b607f63": { + "111e9a5009ba": { + "name": "streams-registered-at-teardown", + "ordinal": 4, + "value": [ + { + "cancelled": false, + "method": "agentSession.subscribeStatus", + "payload": "agentSession.subscribeStatus#1" + } + ] + }, + "44a27dbf05e2": { "name": "status.get#1", - "ordinal": 1, + "ordinal": 3, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + }, + "d570a877b081": { + "name": "status.get#1", + "ordinal": 2, "args": [ { "name": "method", @@ -41,7 +57,7 @@ "startedAt": 0, "settledAt": 0, "value": { - "id": "frame-1", + "id": "frame-2", "ok": true, "result": { "capabilities": ["mobile.tasks.v1"] @@ -49,10 +65,10 @@ } } }, - "d08f74d65ee6": { - "name": "status.get#1", - "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + "e28c488ce6b2": { + "name": "agentSession.subscribeStatus#1", + "ordinal": 1, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"agentSession.subscribeStatus\",\"params\":{}}" }, "eb79a9b3682a": { "status": "fulfilled", @@ -69,14 +85,26 @@ { "id": "unsupported", "observation": { - "sender": ["a3b75b607f63"], - "payloads": ["d08f74d65ee6"], + "sender": ["d570a877b081"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], "settlements": { "mount": "eb79a9b3682a" }, "state": "053ddb72973f", "effects": [] } + }, + { + "id": "cleanup", + "observation": { + "sender": ["d570a877b081"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "053ddb72973f", + "effects": ["111e9a5009ba"] + } } ] } diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json b/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json index 25542a41c02..28dddeba550 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json @@ -4,9 +4,71 @@ "family": "aiVault.history", "namedDeltas": [], "values": { - "05215ca2ea35": { + "1e294d39caf5": { + "name": "status.get#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "capabilities": ["aiVault.v1"] + } + } + } + }, + "33d053404f10": { + "activeWorktreePath": { + "$rpc": "null" + }, + "hostStatusResult": { + "capabilities": ["aiVault.v1"] + }, + "refreshing": false, + "scope": "workspace", + "screenState": { + "kind": "loading" + } + }, + "44a27dbf05e2": { + "name": "status.get#1", + "ordinal": 3, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + }, + "595e6c3bbfed": { "name": "aiVault.listSessions#1", + "ordinal": 7, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}" + }, + "6c80e490239f": { + "name": "status.get#2", "ordinal": 5, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + }, + "79fccda27236": { + "name": "aiVault.listSessions#1", + "ordinal": 6, "args": [ { "name": "method", @@ -32,7 +94,7 @@ "startedAt": 0, "settledAt": 0, "value": { - "id": "frame-3", + "id": "frame-4", "ok": true, "result": { "issues": [], @@ -47,56 +109,20 @@ } } }, - "33d053404f10": { - "activeWorktreePath": { - "$rpc": "null" - }, - "hostStatusResult": { - "capabilities": ["aiVault.v1"] - }, - "refreshing": false, - "scope": "workspace", - "screenState": { - "kind": "loading" - } + "7e8b2964ad63": { + "name": "streams-registered-at-teardown", + "ordinal": 8, + "value": [ + { + "cancelled": false, + "method": "agentSession.subscribeStatus", + "payload": "agentSession.subscribeStatus#1" + } + ] }, - "4a76a58cbdac": { + "acf501fa9958": { "name": "status.get#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "status.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "capabilities": ["aiVault.v1"] - } - } - } - }, - "4e35efd1ee45": { - "name": "status.get#2", - "ordinal": 3, + "ordinal": 2, "args": [ { "name": "method", @@ -128,11 +154,6 @@ } } }, - "72d9a278aca3": { - "name": "aiVault.listSessions#1", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}" - }, "cd739a80b7a8": { "activeWorktreePath": "/repo/feature", "hostStatusResult": { @@ -152,10 +173,10 @@ ] } }, - "d08f74d65ee6": { - "name": "status.get#1", - "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + "e28c488ce6b2": { + "name": "agentSession.subscribeStatus#1", + "ordinal": 1, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"agentSession.subscribeStatus\",\"params\":{}}" }, "eb79a9b3682a": { "status": "fulfilled", @@ -164,11 +185,6 @@ "value": { "$rpc": "undefined" } - }, - "f1834177e593": { - "name": "status.get#2", - "ordinal": 4, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" } }, "recording": { @@ -177,8 +193,8 @@ { "id": "held", "observation": { - "sender": ["4a76a58cbdac"], - "payloads": ["d08f74d65ee6"], + "sender": ["acf501fa9958"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], "settlements": { "mount": "eb79a9b3682a" }, @@ -189,8 +205,8 @@ { "id": "ready", "observation": { - "sender": ["4a76a58cbdac", "4e35efd1ee45", "05215ca2ea35"], - "payloads": ["d08f74d65ee6", "f1834177e593", "72d9a278aca3"], + "sender": ["acf501fa9958", "1e294d39caf5", "79fccda27236"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "6c80e490239f", "595e6c3bbfed"], "settlements": { "mount": "eb79a9b3682a", "worktrees-loaded": "eb79a9b3682a" @@ -198,6 +214,19 @@ "state": "cd739a80b7a8", "effects": [] } + }, + { + "id": "cleanup", + "observation": { + "sender": ["acf501fa9958", "1e294d39caf5", "79fccda27236"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "6c80e490239f", "595e6c3bbfed"], + "settlements": { + "mount": "eb79a9b3682a", + "worktrees-loaded": "eb79a9b3682a" + }, + "state": "cd739a80b7a8", + "effects": ["7e8b2964ad63"] + } } ] } diff --git a/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json b/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json index 941a90e1738..0acfff96f4f 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json +++ b/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json @@ -4,6 +4,11 @@ "family": "aiVault.history-screen", "namedDeltas": [], "values": { + "0180d22edc2e": { + "name": "worktree.ps#1", + "ordinal": 5, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.ps\",\"params\":{\"limit\":10000,\"supportsWorktreeVisibilitySourceDefaults\":true}}" + }, "03106dceb986": { "name": "status.get#1", "ordinal": 2, @@ -47,152 +52,9 @@ "labels": ["Back", "Refresh agent sessions"], "text": ["Agent Session History", "orca-history", "Workspace", "Project", "All"] }, - "3a91d2093c6e": { - "name": "worktree.ps#1", - "ordinal": 3, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.ps\",\"params\":{\"limit\":10000,\"supportsWorktreeVisibilitySourceDefaults\":true}}" - }, - "3aaa7e04cda5": { + "0ddf5d4952fd": { "name": "aiVault.listSessions#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "aiVault.listSessions" - }, - { - "name": "params", - "value": { - "force": false, - "limit": 500, - "scopePaths": ["/repo/feature"] - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "4cebf6258ec1": { - "name": "worktree.ps#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "worktree.ps" - }, - { - "name": "params", - "value": { - "limit": 10000 - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "4d676e55076c": { - "name": "status.get#2", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "status.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": { - "capabilities": ["aiVault.v1"] - } - } - } - }, - "4d68bff46cff": { - "name": "status.get#1", - "ordinal": 4, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" - }, - "8d930b47bf2f": { - "name": "worktree.ps#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "worktree.ps" - }, - { - "name": "params", - "value": { - "limit": 10000 - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "worktrees": [ - { - "path": "/repo/feature", - "repoId": "repo-1", - "worktreeId": "wt-history" - }, - { - "path": "/repo/sibling", - "repoId": "repo-1", - "worktreeId": "wt-2" - } - ] - } - } - } - }, - "a6bb0efe5318": { - "name": "aiVault.listSessions#1", - "ordinal": 7, + "ordinal": 8, "args": [ { "name": "method", @@ -218,7 +80,7 @@ "startedAt": 0, "settledAt": 0, "value": { - "id": "frame-4", + "id": "frame-5", "ok": true, "result": { "issues": [], @@ -254,6 +116,165 @@ } } }, + "222a3ba1d974": { + "name": "worktree.ps#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "worktree.ps" + }, + { + "name": "params", + "value": { + "limit": 10000 + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "worktrees": [ + { + "path": "/repo/feature", + "repoId": "repo-1", + "worktreeId": "wt-history" + }, + { + "path": "/repo/sibling", + "repoId": "repo-1", + "worktreeId": "wt-2" + } + ] + } + } + } + }, + "40fa6811eb00": { + "name": "streams-registered-at-teardown", + "ordinal": 10, + "value": [ + { + "cancelled": false, + "method": "agentSession.subscribeStatus", + "payload": "agentSession.subscribeStatus#1" + } + ] + }, + "4d68bff46cff": { + "name": "status.get#1", + "ordinal": 4, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + }, + "613355526cb5": { + "name": "aiVault.listSessions#1", + "ordinal": 9, + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}" + }, + "7368414c8786": { + "name": "worktree.ps#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "worktree.ps" + }, + { + "name": "params", + "value": { + "limit": 10000 + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "7a008ca8521f": { + "name": "status.get#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "capabilities": ["aiVault.v1"] + } + } + } + }, + "98d7022b1300": { + "name": "aiVault.listSessions#1", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "aiVault.listSessions" + }, + { + "name": "params", + "value": { + "force": false, + "limit": 500, + "scopePaths": ["/repo/feature"] + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "a26eb8ffaa7c": { + "name": "status.get#2", + "ordinal": 7, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + }, "acf501fa9958": { "name": "status.get#1", "ordinal": 2, @@ -288,11 +309,6 @@ } } }, - "cee4e3edb0a1": { - "name": "status.get#2", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" - }, "d22bb2f62cea": { "crash": { "$rpc": "null" @@ -309,10 +325,10 @@ "labels": ["Back", "Refresh agent sessions"], "text": ["Agent Session History", "orca-history"] }, - "d3c3ce66625d": { - "name": "aiVault.listSessions#1", - "ordinal": 8, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}" + "e28c488ce6b2": { + "name": "agentSession.subscribeStatus#1", + "ordinal": 1, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"agentSession.subscribeStatus\",\"params\":{}}" }, "eb79a9b3682a": { "status": "fulfilled", @@ -329,8 +345,8 @@ { "id": "worktrees-pending", "observation": { - "sender": ["4cebf6258ec1", "03106dceb986"], - "payloads": ["3a91d2093c6e", "4d68bff46cff"], + "sender": ["03106dceb986", "7368414c8786"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e"], "settlements": { "mount": "eb79a9b3682a" }, @@ -341,8 +357,14 @@ { "id": "worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "acf501fa9958", "4d676e55076c", "3aaa7e04cda5"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"], + "sender": ["acf501fa9958", "222a3ba1d974", "7a008ca8521f", "98d7022b1300"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -353,14 +375,38 @@ { "id": "ready", "observation": { - "sender": ["8d930b47bf2f", "acf501fa9958", "4d676e55076c", "a6bb0efe5318"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"], + "sender": ["acf501fa9958", "222a3ba1d974", "7a008ca8521f", "0ddf5d4952fd"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], "settlements": { "mount": "eb79a9b3682a" }, "state": "035edfd9a1b9", "effects": [] } + }, + { + "id": "cleanup", + "observation": { + "sender": ["acf501fa9958", "222a3ba1d974", "7a008ca8521f", "0ddf5d4952fd"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "035edfd9a1b9", + "effects": ["40fa6811eb00"] + } } ] } diff --git a/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json b/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json index fc4ec4c344c..10abf7b8d9e 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json +++ b/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json @@ -4,6 +4,11 @@ "family": "aiVault.history-screen", "namedDeltas": [], "values": { + "0180d22edc2e": { + "name": "worktree.ps#1", + "ordinal": 5, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.ps\",\"params\":{\"limit\":10000,\"supportsWorktreeVisibilitySourceDefaults\":true}}" + }, "03106dceb986": { "name": "status.get#1", "ordinal": 2, @@ -30,14 +35,169 @@ "startedAt": 0 } }, - "3a91d2093c6e": { + "222a3ba1d974": { "name": "worktree.ps#1", "ordinal": 3, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.ps\",\"params\":{\"limit\":10000,\"supportsWorktreeVisibilitySourceDefaults\":true}}" + "args": [ + { + "name": "method", + "value": "worktree.ps" + }, + { + "name": "params", + "value": { + "limit": 10000 + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "worktrees": [ + { + "path": "/repo/feature", + "repoId": "repo-1", + "worktreeId": "wt-history" + }, + { + "path": "/repo/sibling", + "repoId": "repo-1", + "worktreeId": "wt-2" + } + ] + } + } + } }, - "3aaa7e04cda5": { + "40fa6811eb00": { + "name": "streams-registered-at-teardown", + "ordinal": 10, + "value": [ + { + "cancelled": false, + "method": "agentSession.subscribeStatus", + "payload": "agentSession.subscribeStatus#1" + } + ] + }, + "4d68bff46cff": { + "name": "status.get#1", + "ordinal": 4, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + }, + "613355526cb5": { "name": "aiVault.listSessions#1", - "ordinal": 7, + "ordinal": 9, + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}" + }, + "7368414c8786": { + "name": "worktree.ps#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "worktree.ps" + }, + { + "name": "params", + "value": { + "limit": 10000 + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "79fc75a7b709": { + "name": "aiVault.listSessions#1", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "aiVault.listSessions" + }, + { + "name": "params", + "value": { + "force": false, + "limit": 500, + "scopePaths": ["/repo/feature"] + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, + "7a008ca8521f": { + "name": "status.get#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "capabilities": ["aiVault.v1"] + } + } + } + }, + "98d7022b1300": { + "name": "aiVault.listSessions#1", + "ordinal": 8, "args": [ { "name": "method", @@ -63,115 +223,10 @@ "startedAt": 0 } }, - "4cebf6258ec1": { - "name": "worktree.ps#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "worktree.ps" - }, - { - "name": "params", - "value": { - "limit": 10000 - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "4d676e55076c": { + "a26eb8ffaa7c": { "name": "status.get#2", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "status.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": { - "capabilities": ["aiVault.v1"] - } - } - } - }, - "4d68bff46cff": { - "name": "status.get#1", - "ordinal": 4, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" - }, - "8d930b47bf2f": { - "name": "worktree.ps#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "worktree.ps" - }, - { - "name": "params", - "value": { - "limit": 10000 - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "worktrees": [ - { - "path": "/repo/feature", - "repoId": "repo-1", - "worktreeId": "wt-history" - }, - { - "path": "/repo/sibling", - "repoId": "repo-1", - "worktreeId": "wt-2" - } - ] - } - } - } + "ordinal": 7, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" }, "acf501fa9958": { "name": "status.get#1", @@ -207,11 +262,6 @@ } } }, - "cee4e3edb0a1": { - "name": "status.get#2", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" - }, "d22bb2f62cea": { "crash": { "$rpc": "null" @@ -228,10 +278,10 @@ "labels": ["Back", "Refresh agent sessions"], "text": ["Agent Session History", "orca-history"] }, - "d3c3ce66625d": { - "name": "aiVault.listSessions#1", - "ordinal": 8, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}" + "e28c488ce6b2": { + "name": "agentSession.subscribeStatus#1", + "ordinal": 1, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"agentSession.subscribeStatus\",\"params\":{}}" }, "eb79a9b3682a": { "status": "fulfilled", @@ -248,8 +298,8 @@ { "id": "worktrees-pending", "observation": { - "sender": ["4cebf6258ec1", "03106dceb986"], - "payloads": ["3a91d2093c6e", "4d68bff46cff"], + "sender": ["03106dceb986", "7368414c8786"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e"], "settlements": { "mount": "eb79a9b3682a" }, @@ -260,14 +310,38 @@ { "id": "worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "acf501fa9958", "4d676e55076c", "3aaa7e04cda5"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"], + "sender": ["acf501fa9958", "222a3ba1d974", "7a008ca8521f", "98d7022b1300"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], "settlements": { "mount": "eb79a9b3682a" }, "state": "d22bb2f62cea", "effects": [] } + }, + { + "id": "cleanup", + "observation": { + "sender": ["acf501fa9958", "222a3ba1d974", "7a008ca8521f", "79fc75a7b709"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } } ] } diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json index e4911d18bbd..07616dde809 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json @@ -4,293 +4,9 @@ "family": "aiVault.history", "namedDeltas": [], "values": { - "03941f1abe9e": { + "0c2fe6d169c7": { "name": "aiVault.listSessions#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "aiVault.listSessions" - }, - { - "name": "params", - "value": { - "force": false, - "limit": 500, - "scopePaths": ["/repo/feature"] - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "error": { - "message": "inner refused" - }, - "ok": false - } - } - } - }, - "04f87f24d8d3": { - "name": "aiVault.listSessions#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "aiVault.listSessions" - }, - { - "name": "params", - "value": { - "force": false, - "limit": 500, - "scopePaths": ["/repo/feature"] - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - } - }, - "23523c413cbd": { - "activeWorktreePath": "/repo/feature", - "hostStatusResult": { - "$rpc": "null" - }, - "refreshing": false, - "scope": "workspace", - "screenState": { - "kind": "error", - "message": "Unable to load agent sessions" - } - }, - "3719c54df702": { - "name": "aiVault.listSessions#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "aiVault.listSessions" - }, - { - "name": "params", - "value": { - "force": false, - "limit": 500, - "scopePaths": ["/repo/feature"] - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "issues": [], - "sessions": [ - { - "agent": "claude", - "cwd": "/repo/feature", - "id": "s1" - } - ] - } - } - } - }, - "42c8943b1c10": { - "name": "aiVault.listSessions#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "aiVault.listSessions" - }, - { - "name": "params", - "value": { - "force": false, - "limit": 500, - "scopePaths": ["/repo/feature"] - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "outer refused" - }, - "id": "frame-2", - "ok": false - } - } - }, - "4a76a58cbdac": { - "name": "status.get#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "status.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "capabilities": ["aiVault.v1"] - } - } - } - }, - "4aab3e54002b": { - "name": "aiVault.listSessions#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "aiVault.listSessions" - }, - { - "name": "params", - "value": { - "force": false, - "limit": 500, - "scopePaths": ["/repo/feature"] - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "$rpc": "null" - } - } - } - }, - "5bd663d5906f": { - "name": "aiVault.listSessions#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "aiVault.listSessions" - }, - { - "name": "params", - "value": { - "force": false, - "limit": 500, - "scopePaths": ["/repo/feature"] - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "error": "inner refused", - "ok": false - } - } - } - }, - "698f848e6967": { - "activeWorktreePath": "/repo/feature", - "hostStatusResult": { - "$rpc": "null" - }, - "refreshing": false, - "scope": "workspace", - "screenState": { - "kind": "error", - "message": "Unknown method" - } - }, - "768971aec76e": { - "name": "aiVault.listSessions#1", - "ordinal": 3, + "ordinal": 4, "args": [ { "name": "method", @@ -322,46 +38,9 @@ } } }, - "7bad42e9328e": { + "198e875b66b9": { "name": "aiVault.listSessions#1", - "ordinal": 3, - "args": [ - { - "name": "method", - "value": "aiVault.listSessions" - }, - { - "name": "params", - "value": { - "force": false, - "limit": 500, - "scopePaths": ["/repo/feature"] - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "method_not_found", - "message": "Unknown method" - }, - "id": "frame-2", - "ok": false - } - } - }, - "82f5db4fce4d": { - "name": "aiVault.listSessions#1", - "ordinal": 3, + "ordinal": 4, "args": [ { "name": "method", @@ -391,14 +70,26 @@ "code": "refused", "message": "" }, - "id": "frame-2", + "id": "frame-3", "ok": false } } }, - "8af00090aaf6": { + "23523c413cbd": { + "activeWorktreePath": "/repo/feature", + "hostStatusResult": { + "$rpc": "null" + }, + "refreshing": false, + "scope": "workspace", + "screenState": { + "kind": "error", + "message": "Unable to load agent sessions" + } + }, + "256c4052adba": { "name": "aiVault.listSessions#1", - "ordinal": 3, + "ordinal": 4, "args": [ { "name": "method", @@ -419,6 +110,182 @@ } } ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "44a27dbf05e2": { + "name": "status.get#1", + "ordinal": 3, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + }, + "4f042c5596d1": { + "name": "aiVault.listSessions#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "aiVault.listSessions" + }, + { + "name": "params", + "value": { + "force": false, + "limit": 500, + "scopePaths": ["/repo/feature"] + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "698f848e6967": { + "activeWorktreePath": "/repo/feature", + "hostStatusResult": { + "$rpc": "null" + }, + "refreshing": false, + "scope": "workspace", + "screenState": { + "kind": "error", + "message": "Unknown method" + } + }, + "758b3fde1948": { + "name": "streams-registered-at-teardown", + "ordinal": 6, + "value": [ + { + "cancelled": false, + "method": "agentSession.subscribeStatus", + "payload": "agentSession.subscribeStatus#1" + } + ] + }, + "887a32dd2b5e": { + "name": "aiVault.listSessions#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "aiVault.listSessions" + }, + { + "name": "params", + "value": { + "force": false, + "limit": 500, + "scopePaths": ["/repo/feature"] + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "issues": [], + "sessions": [ + { + "agent": "claude", + "cwd": "/repo/feature", + "id": "s1" + } + ] + } + } + } + }, + "a5e42042eb1c": { + "name": "aiVault.listSessions#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "aiVault.listSessions" + }, + { + "name": "params", + "value": { + "force": false, + "limit": 500, + "scopePaths": ["/repo/feature"] + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "acf501fa9958": { + "name": "status.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], "settlement": { "status": "fulfilled", "startedAt": 0, @@ -427,19 +294,19 @@ "id": "frame-2", "ok": true, "result": { - "error": "refused" + "capabilities": ["aiVault.v1"] } } } }, - "97bd894604ee": { + "b6a16cc83e18": { + "name": "aiVault.listSessions#1", + "ordinal": 5, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}" + }, + "c2f7bc5d0cf4": { "name": "aiVault.listSessions#1", "ordinal": 4, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}" - }, - "c75783a264f1": { - "name": "aiVault.listSessions#1", - "ordinal": 3, "args": [ { "name": "method", @@ -465,8 +332,12 @@ "startedAt": 0, "settledAt": 0, "value": { - "id": "frame-2", - "ok": true + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-3", + "ok": false } } }, @@ -501,10 +372,42 @@ ] } }, - "d08f74d65ee6": { - "name": "status.get#1", - "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + "d50a16f2b2f0": { + "name": "aiVault.listSessions#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "aiVault.listSessions" + }, + { + "name": "params", + "value": { + "force": false, + "limit": 500, + "scopePaths": ["/repo/feature"] + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-3", + "ok": false + } + } }, "d86e3b3b7ca7": { "activeWorktreePath": "/repo/feature", @@ -518,6 +421,80 @@ "message": "transport failure" } }, + "dd3c9440cd48": { + "name": "aiVault.listSessions#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "aiVault.listSessions" + }, + { + "name": "params", + "value": { + "force": false, + "limit": 500, + "scopePaths": ["/repo/feature"] + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "e0fc903b0ff2": { + "name": "aiVault.listSessions#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "aiVault.listSessions" + }, + { + "name": "params", + "value": { + "force": false, + "limit": 500, + "scopePaths": ["/repo/feature"] + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true + } + } + }, + "e28c488ce6b2": { + "name": "agentSession.subscribeStatus#1", + "ordinal": 1, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"agentSession.subscribeStatus\",\"params\":{}}" + }, "e8d512f74641": { "activeWorktreePath": "/repo/feature", "hostStatusResult": { @@ -549,6 +526,45 @@ "kind": "error", "message": "The host sent a reply this app could not read (aiVault.listSessions)" } + }, + "ff7c49364deb": { + "name": "aiVault.listSessions#1", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "aiVault.listSessions" + }, + { + "name": "params", + "value": { + "force": false, + "limit": 500, + "scopePaths": ["/repo/feature"] + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } } }, "recording": { @@ -557,8 +573,8 @@ { "id": "aivault-history-scan-fulfilled.normal:ready", "observation": { - "sender": ["4a76a58cbdac", "3719c54df702"], - "payloads": ["d08f74d65ee6", "97bd894604ee"], + "sender": ["acf501fa9958", "887a32dd2b5e"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], "settlements": { "mount": "eb79a9b3682a" }, @@ -566,11 +582,23 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.normal:cleanup", + "observation": { + "sender": ["acf501fa9958", "887a32dd2b5e"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "cd739a80b7a8", + "effects": ["758b3fde1948"] + } + }, { "id": "aivault-history-scan-fulfilled.result-absent:ready", "observation": { - "sender": ["4a76a58cbdac", "c75783a264f1"], - "payloads": ["d08f74d65ee6", "97bd894604ee"], + "sender": ["acf501fa9958", "e0fc903b0ff2"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], "settlements": { "mount": "eb79a9b3682a" }, @@ -578,11 +606,23 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.result-absent:cleanup", + "observation": { + "sender": ["acf501fa9958", "e0fc903b0ff2"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "fead2d57ab18", + "effects": ["758b3fde1948"] + } + }, { "id": "aivault-history-scan-fulfilled.result-null:ready", "observation": { - "sender": ["4a76a58cbdac", "4aab3e54002b"], - "payloads": ["d08f74d65ee6", "97bd894604ee"], + "sender": ["acf501fa9958", "dd3c9440cd48"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], "settlements": { "mount": "eb79a9b3682a" }, @@ -590,11 +630,23 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.result-null:cleanup", + "observation": { + "sender": ["acf501fa9958", "dd3c9440cd48"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "fead2d57ab18", + "effects": ["758b3fde1948"] + } + }, { "id": "aivault-history-scan-fulfilled.inner-ok-missing:ready", "observation": { - "sender": ["4a76a58cbdac", "8af00090aaf6"], - "payloads": ["d08f74d65ee6", "97bd894604ee"], + "sender": ["acf501fa9958", "4f042c5596d1"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], "settlements": { "mount": "eb79a9b3682a" }, @@ -602,11 +654,23 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.inner-ok-missing:cleanup", + "observation": { + "sender": ["acf501fa9958", "4f042c5596d1"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "fead2d57ab18", + "effects": ["758b3fde1948"] + } + }, { "id": "aivault-history-scan-fulfilled.inner-false-string-error:ready", "observation": { - "sender": ["4a76a58cbdac", "5bd663d5906f"], - "payloads": ["d08f74d65ee6", "97bd894604ee"], + "sender": ["acf501fa9958", "a5e42042eb1c"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], "settlements": { "mount": "eb79a9b3682a" }, @@ -614,11 +678,23 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.inner-false-string-error:cleanup", + "observation": { + "sender": ["acf501fa9958", "a5e42042eb1c"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "fead2d57ab18", + "effects": ["758b3fde1948"] + } + }, { "id": "aivault-history-scan-fulfilled.inner-false-object-error:ready", "observation": { - "sender": ["4a76a58cbdac", "03941f1abe9e"], - "payloads": ["d08f74d65ee6", "97bd894604ee"], + "sender": ["acf501fa9958", "ff7c49364deb"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], "settlements": { "mount": "eb79a9b3682a" }, @@ -626,11 +702,23 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.inner-false-object-error:cleanup", + "observation": { + "sender": ["acf501fa9958", "ff7c49364deb"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "fead2d57ab18", + "effects": ["758b3fde1948"] + } + }, { "id": "aivault-history-scan-fulfilled.outer-refused:ready", "observation": { - "sender": ["4a76a58cbdac", "42c8943b1c10"], - "payloads": ["d08f74d65ee6", "97bd894604ee"], + "sender": ["acf501fa9958", "d50a16f2b2f0"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], "settlements": { "mount": "eb79a9b3682a" }, @@ -638,11 +726,23 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.outer-refused:cleanup", + "observation": { + "sender": ["acf501fa9958", "d50a16f2b2f0"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "c9c67b3f0119", + "effects": ["758b3fde1948"] + } + }, { "id": "aivault-history-scan-fulfilled.outer-refused-no-message:ready", "observation": { - "sender": ["4a76a58cbdac", "82f5db4fce4d"], - "payloads": ["d08f74d65ee6", "97bd894604ee"], + "sender": ["acf501fa9958", "198e875b66b9"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], "settlements": { "mount": "eb79a9b3682a" }, @@ -650,11 +750,23 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.outer-refused-no-message:cleanup", + "observation": { + "sender": ["acf501fa9958", "198e875b66b9"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "23523c413cbd", + "effects": ["758b3fde1948"] + } + }, { "id": "aivault-history-scan-fulfilled.method-not-found:ready", "observation": { - "sender": ["4a76a58cbdac", "7bad42e9328e"], - "payloads": ["d08f74d65ee6", "97bd894604ee"], + "sender": ["acf501fa9958", "c2f7bc5d0cf4"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], "settlements": { "mount": "eb79a9b3682a" }, @@ -662,11 +774,23 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.method-not-found:cleanup", + "observation": { + "sender": ["acf501fa9958", "c2f7bc5d0cf4"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "698f848e6967", + "effects": ["758b3fde1948"] + } + }, { "id": "aivault-history-scan-fulfilled.transport-rejection:ready", "observation": { - "sender": ["4a76a58cbdac", "768971aec76e"], - "payloads": ["d08f74d65ee6", "97bd894604ee"], + "sender": ["acf501fa9958", "0c2fe6d169c7"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], "settlements": { "mount": "eb79a9b3682a" }, @@ -674,17 +798,41 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.transport-rejection:cleanup", + "observation": { + "sender": ["acf501fa9958", "0c2fe6d169c7"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d86e3b3b7ca7", + "effects": ["758b3fde1948"] + } + }, { "id": "aivault-history-scan-fulfilled.transport-rejection-no-message:ready", "observation": { - "sender": ["4a76a58cbdac", "04f87f24d8d3"], - "payloads": ["d08f74d65ee6", "97bd894604ee"], + "sender": ["acf501fa9958", "256c4052adba"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], "settlements": { "mount": "eb79a9b3682a" }, "state": "e8d512f74641", "effects": [] } + }, + { + "id": "aivault-history-scan-fulfilled.transport-rejection-no-message:cleanup", + "observation": { + "sender": ["acf501fa9958", "256c4052adba"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "e8d512f74641", + "effects": ["758b3fde1948"] + } } ] } diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json index 9655b966cda..2759fede4d3 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json @@ -4,6 +4,11 @@ "family": "aiVault.history-screen", "namedDeltas": [], "values": { + "0180d22edc2e": { + "name": "worktree.ps#1", + "ordinal": 5, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.ps\",\"params\":{\"limit\":10000,\"supportsWorktreeVisibilitySourceDefaults\":true}}" + }, "03106dceb986": { "name": "status.get#1", "ordinal": 2, @@ -65,25 +70,18 @@ } } }, - "3a91d2093c6e": { + "222a3ba1d974": { "name": "worktree.ps#1", "ordinal": 3, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.ps\",\"params\":{\"limit\":10000,\"supportsWorktreeVisibilitySourceDefaults\":true}}" - }, - "3aaa7e04cda5": { - "name": "aiVault.listSessions#1", - "ordinal": 7, "args": [ { "name": "method", - "value": "aiVault.listSessions" + "value": "worktree.ps" }, { "name": "params", "value": { - "force": false, - "limit": 500, - "scopePaths": ["/repo/feature"] + "limit": 10000 } }, { @@ -94,10 +92,40 @@ } ], "settlement": { - "status": "pending", - "startedAt": 0 + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "worktrees": [ + { + "path": "/repo/feature", + "repoId": "repo-1", + "worktreeId": "wt-history" + }, + { + "path": "/repo/sibling", + "repoId": "repo-1", + "worktreeId": "wt-2" + } + ] + } + } } }, + "40fa6811eb00": { + "name": "streams-registered-at-teardown", + "ordinal": 10, + "value": [ + { + "cancelled": false, + "method": "agentSession.subscribeStatus", + "payload": "agentSession.subscribeStatus#1" + } + ] + }, "471bd606315a": { "name": "status.get#1", "ordinal": 2, @@ -132,66 +160,6 @@ } } }, - "4cebf6258ec1": { - "name": "worktree.ps#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "worktree.ps" - }, - { - "name": "params", - "value": { - "limit": 10000 - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "4d676e55076c": { - "name": "status.get#2", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "status.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": { - "capabilities": ["aiVault.v1"] - } - } - } - }, "4d68bff46cff": { "name": "status.get#1", "ordinal": 4, @@ -260,6 +228,105 @@ } } }, + "613355526cb5": { + "name": "aiVault.listSessions#1", + "ordinal": 9, + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}" + }, + "7368414c8786": { + "name": "worktree.ps#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "worktree.ps" + }, + { + "name": "params", + "value": { + "limit": 10000 + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "79fc75a7b709": { + "name": "aiVault.listSessions#1", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "aiVault.listSessions" + }, + { + "name": "params", + "value": { + "force": false, + "limit": 500, + "scopePaths": ["/repo/feature"] + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, + "7a008ca8521f": { + "name": "status.get#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "capabilities": ["aiVault.v1"] + } + } + } + }, "7b715c94cd95": { "name": "status.get#1", "ordinal": 2, @@ -295,6 +362,17 @@ } } }, + "7e8b2964ad63": { + "name": "streams-registered-at-teardown", + "ordinal": 8, + "value": [ + { + "cancelled": false, + "method": "agentSession.subscribeStatus", + "payload": "agentSession.subscribeStatus#1" + } + ] + }, "824e7d747f2a": { "name": "status.get#1", "ordinal": 2, @@ -330,18 +408,20 @@ } } }, - "8d930b47bf2f": { - "name": "worktree.ps#1", - "ordinal": 1, + "98d7022b1300": { + "name": "aiVault.listSessions#1", + "ordinal": 8, "args": [ { "name": "method", - "value": "worktree.ps" + "value": "aiVault.listSessions" }, { "name": "params", "value": { - "limit": 10000 + "force": false, + "limit": 500, + "scopePaths": ["/repo/feature"] } }, { @@ -352,26 +432,39 @@ } ], "settlement": { - "status": "fulfilled", + "status": "pending", + "startedAt": 0 + } + }, + "9b8c00f534c6": { + "name": "status.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", "startedAt": 0, "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "worktrees": [ - { - "path": "/repo/feature", - "repoId": "repo-1", - "worktreeId": "wt-history" - }, - { - "path": "/repo/sibling", - "repoId": "repo-1", - "worktreeId": "wt-2" - } - ] - } + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true } } }, @@ -441,6 +534,11 @@ } } }, + "a26eb8ffaa7c": { + "name": "status.get#2", + "ordinal": 7, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + }, "a5e3c1935fd2": { "name": "status.get#1", "ordinal": 2, @@ -510,10 +608,37 @@ } } }, - "cee4e3edb0a1": { + "ba8394a4ce03": { "name": "status.get#2", "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } }, "d22bb2f62cea": { "crash": { @@ -568,10 +693,10 @@ } } }, - "d3c3ce66625d": { - "name": "aiVault.listSessions#1", - "ordinal": 8, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}" + "e28c488ce6b2": { + "name": "agentSession.subscribeStatus#1", + "ordinal": 1, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"agentSession.subscribeStatus\",\"params\":{}}" }, "eb79a9b3682a": { "status": "fulfilled", @@ -588,8 +713,8 @@ { "id": "aivault-history-screen-worktrees.prelude:worktrees-pending", "observation": { - "sender": ["4cebf6258ec1", "03106dceb986"], - "payloads": ["3a91d2093c6e", "4d68bff46cff"], + "sender": ["03106dceb986", "7368414c8786"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e"], "settlements": { "mount": "eb79a9b3682a" }, @@ -597,11 +722,29 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.prelude:cleanup", + "observation": { + "sender": ["9b8c00f534c6", "222a3ba1d974", "ba8394a4ce03"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["7e8b2964ad63"] + } + }, { "id": "aivault-history-screen-worktrees.normal:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "acf501fa9958", "4d676e55076c", "3aaa7e04cda5"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"], + "sender": ["acf501fa9958", "222a3ba1d974", "7a008ca8521f", "98d7022b1300"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -609,11 +752,35 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.normal:cleanup", + "observation": { + "sender": ["acf501fa9958", "222a3ba1d974", "7a008ca8521f", "79fc75a7b709"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.result-absent:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "59290155f27a", "4d676e55076c", "3aaa7e04cda5"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"], + "sender": ["59290155f27a", "222a3ba1d974", "7a008ca8521f", "98d7022b1300"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -621,11 +788,35 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.result-absent:cleanup", + "observation": { + "sender": ["59290155f27a", "222a3ba1d974", "7a008ca8521f", "79fc75a7b709"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.result-null:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "471bd606315a", "4d676e55076c", "3aaa7e04cda5"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"], + "sender": ["471bd606315a", "222a3ba1d974", "7a008ca8521f", "98d7022b1300"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -633,11 +824,35 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.result-null:cleanup", + "observation": { + "sender": ["471bd606315a", "222a3ba1d974", "7a008ca8521f", "79fc75a7b709"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.inner-ok-missing:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "9efd8381401a", "4d676e55076c", "3aaa7e04cda5"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"], + "sender": ["9efd8381401a", "222a3ba1d974", "7a008ca8521f", "98d7022b1300"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -645,11 +860,35 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.inner-ok-missing:cleanup", + "observation": { + "sender": ["9efd8381401a", "222a3ba1d974", "7a008ca8521f", "79fc75a7b709"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.inner-false-string-error:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "7b715c94cd95", "4d676e55076c", "3aaa7e04cda5"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"], + "sender": ["7b715c94cd95", "222a3ba1d974", "7a008ca8521f", "98d7022b1300"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -657,11 +896,35 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.inner-false-string-error:cleanup", + "observation": { + "sender": ["7b715c94cd95", "222a3ba1d974", "7a008ca8521f", "79fc75a7b709"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.inner-false-object-error:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "d302f4eada82", "4d676e55076c", "3aaa7e04cda5"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"], + "sender": ["d302f4eada82", "222a3ba1d974", "7a008ca8521f", "98d7022b1300"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -669,11 +932,35 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.inner-false-object-error:cleanup", + "observation": { + "sender": ["d302f4eada82", "222a3ba1d974", "7a008ca8521f", "79fc75a7b709"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.outer-refused:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "824e7d747f2a", "4d676e55076c", "3aaa7e04cda5"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"], + "sender": ["824e7d747f2a", "222a3ba1d974", "7a008ca8521f", "98d7022b1300"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -681,11 +968,35 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.outer-refused:cleanup", + "observation": { + "sender": ["824e7d747f2a", "222a3ba1d974", "7a008ca8521f", "79fc75a7b709"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.outer-refused-no-message:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "a5e3c1935fd2", "4d676e55076c", "3aaa7e04cda5"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"], + "sender": ["a5e3c1935fd2", "222a3ba1d974", "7a008ca8521f", "98d7022b1300"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -693,11 +1004,35 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.outer-refused-no-message:cleanup", + "observation": { + "sender": ["a5e3c1935fd2", "222a3ba1d974", "7a008ca8521f", "79fc75a7b709"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.method-not-found:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "1675b1df2d81", "4d676e55076c", "3aaa7e04cda5"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"], + "sender": ["1675b1df2d81", "222a3ba1d974", "7a008ca8521f", "98d7022b1300"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -705,11 +1040,35 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.method-not-found:cleanup", + "observation": { + "sender": ["1675b1df2d81", "222a3ba1d974", "7a008ca8521f", "79fc75a7b709"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.transport-rejection:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "51e670a85520", "4d676e55076c", "3aaa7e04cda5"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"], + "sender": ["51e670a85520", "222a3ba1d974", "7a008ca8521f", "98d7022b1300"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -717,17 +1076,59 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.transport-rejection:cleanup", + "observation": { + "sender": ["51e670a85520", "222a3ba1d974", "7a008ca8521f", "79fc75a7b709"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.transport-rejection-no-message:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "9cfa291517f9", "4d676e55076c", "3aaa7e04cda5"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"], + "sender": ["9cfa291517f9", "222a3ba1d974", "7a008ca8521f", "98d7022b1300"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], "settlements": { "mount": "eb79a9b3682a" }, "state": "d22bb2f62cea", "effects": [] } + }, + { + "id": "aivault-history-screen-worktrees.transport-rejection-no-message:cleanup", + "observation": { + "sender": ["9cfa291517f9", "222a3ba1d974", "7a008ca8521f", "79fc75a7b709"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } } ] } diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json index e6d02c968f7..29503fe7370 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json @@ -4,39 +4,10 @@ "family": "aiVault.history-screen", "namedDeltas": [], "values": { - "01800f06bc8f": { - "name": "status.get#2", + "0180d22edc2e": { + "name": "worktree.ps#1", "ordinal": 5, - "args": [ - { - "name": "method", - "value": "status.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": { - "$rpc": "null" - } - } - } + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.ps\",\"params\":{\"limit\":10000,\"supportsWorktreeVisibilitySourceDefaults\":true}}" }, "03106dceb986": { "name": "status.get#1", @@ -84,9 +55,9 @@ "Update Orca on this host to browse agent session history." ] }, - "14cef45e16d2": { + "14d0e2ecb181": { "name": "status.get#2", - "ordinal": 5, + "ordinal": 6, "args": [ { "name": "method", @@ -106,13 +77,12 @@ } ], "settlement": { - "status": "rejected", + "status": "fulfilled", "startedAt": 0, "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true + "value": { + "id": "frame-4", + "ok": true } } }, @@ -146,18 +116,18 @@ "labels": ["Back", "Refresh agent sessions"], "text": ["Agent Session History", "orca-history", "Unable to Load", "Unknown method", "Retry"] }, - "2813efdd7233": { - "name": "status.get#2", - "ordinal": 5, + "222a3ba1d974": { + "name": "worktree.ps#1", + "ordinal": 3, "args": [ { "name": "method", - "value": "status.get" + "value": "worktree.ps" }, { "name": "params", "value": { - "$rpc": "absent" + "limit": 10000 } }, { @@ -175,50 +145,25 @@ "id": "frame-3", "ok": true, "result": { - "error": { - "message": "inner refused" - }, - "ok": false + "worktrees": [ + { + "path": "/repo/feature", + "repoId": "repo-1", + "worktreeId": "wt-history" + }, + { + "path": "/repo/sibling", + "repoId": "repo-1", + "worktreeId": "wt-2" + } + ] } } } }, - "3a91d2093c6e": { - "name": "worktree.ps#1", - "ordinal": 3, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.ps\",\"params\":{\"limit\":10000,\"supportsWorktreeVisibilitySourceDefaults\":true}}" - }, - "3aaa7e04cda5": { - "name": "aiVault.listSessions#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "aiVault.listSessions" - }, - { - "name": "params", - "value": { - "force": false, - "limit": 500, - "scopePaths": ["/repo/feature"] - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "48418cbcede7": { + "2b1949ab1dec": { "name": "status.get#2", - "ordinal": 5, + "ordinal": 6, "args": [ { "name": "method", @@ -246,11 +191,22 @@ "code": "refused", "message": "outer refused" }, - "id": "frame-3", + "id": "frame-4", "ok": false } } }, + "40fa6811eb00": { + "name": "streams-registered-at-teardown", + "ordinal": 10, + "value": [ + { + "cancelled": false, + "method": "agentSession.subscribeStatus", + "payload": "agentSession.subscribeStatus#1" + } + ] + }, "4af220e500d6": { "crash": { "$rpc": "null" @@ -266,9 +222,122 @@ "labels": ["Back", "Refresh agent sessions"], "text": ["Agent Session History", "orca-history", "Unable to Load", "outer refused", "Retry"] }, - "4cebf6258ec1": { + "4d68bff46cff": { + "name": "status.get#1", + "ordinal": 4, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + }, + "5683252d4a53": { + "name": "status.get#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "613355526cb5": { + "name": "aiVault.listSessions#1", + "ordinal": 9, + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}" + }, + "6d4cf84c26db": { + "name": "status.get#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "6d992d979d7c": { + "name": "status.get#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "7368414c8786": { "name": "worktree.ps#1", - "ordinal": 1, + "ordinal": 3, "args": [ { "name": "method", @@ -292,9 +361,43 @@ "startedAt": 0 } }, - "4d676e55076c": { + "79fc75a7b709": { + "name": "aiVault.listSessions#1", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "aiVault.listSessions" + }, + { + "name": "params", + "value": { + "force": false, + "limit": 500, + "scopePaths": ["/repo/feature"] + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, + "7a008ca8521f": { "name": "status.get#2", - "ordinal": 5, + "ordinal": 6, "args": [ { "name": "method", @@ -318,7 +421,7 @@ "startedAt": 0, "settledAt": 0, "value": { - "id": "frame-3", + "id": "frame-4", "ok": true, "result": { "capabilities": ["aiVault.v1"] @@ -326,111 +429,16 @@ } } }, - "4d68bff46cff": { - "name": "status.get#1", - "ordinal": 4, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" - }, - "505d46dbbde3": { - "name": "status.get#2", - "ordinal": 5, - "args": [ + "7e8b2964ad63": { + "name": "streams-registered-at-teardown", + "ordinal": 8, + "value": [ { - "name": "method", - "value": "status.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } + "cancelled": false, + "method": "agentSession.subscribeStatus", + "payload": "agentSession.subscribeStatus#1" } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true - } - } - }, - "6eacae1aa018": { - "name": "status.get#2", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "status.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "method_not_found", - "message": "Unknown method" - }, - "id": "frame-3", - "ok": false - } - } - }, - "74bb5a1c3c97": { - "name": "status.get#2", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "status.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": { - "error": "inner refused", - "ok": false - } - } - } + ] }, "7f2073d8dcc1": { "crash": { @@ -453,9 +461,93 @@ "Retry" ] }, - "82a983e39762": { + "81b771b56e59": { "name": "status.get#2", - "ordinal": 5, + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "8cc4f2d607a6": { + "crash": { + "$rpc": "null" + }, + "elements": { + "ChevronLeft": 1, + "Pressable": 3, + "RefreshCw": 1, + "SafeAreaView": 1, + "Text": 5, + "View": 4 + }, + "labels": ["Back", "Refresh agent sessions"], + "text": [ + "Agent Session History", + "orca-history", + "Unable to Load", + "The host sent a reply this app could not read (status.get)", + "Retry" + ] + }, + "98d7022b1300": { + "name": "aiVault.listSessions#1", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "aiVault.listSessions" + }, + { + "name": "params", + "value": { + "force": false, + "limit": 500, + "scopePaths": ["/repo/feature"] + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "9fb09ddc7fe6": { + "name": "status.get#2", + "ordinal": 6, "args": [ { "name": "method", @@ -483,110 +575,15 @@ "code": "refused", "message": "" }, - "id": "frame-3", + "id": "frame-4", "ok": false } } }, - "8cc4f2d607a6": { - "crash": { - "$rpc": "null" - }, - "elements": { - "ChevronLeft": 1, - "Pressable": 3, - "RefreshCw": 1, - "SafeAreaView": 1, - "Text": 5, - "View": 4 - }, - "labels": ["Back", "Refresh agent sessions"], - "text": [ - "Agent Session History", - "orca-history", - "Unable to Load", - "The host sent a reply this app could not read (status.get)", - "Retry" - ] - }, - "8d930b47bf2f": { - "name": "worktree.ps#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "worktree.ps" - }, - { - "name": "params", - "value": { - "limit": 10000 - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "worktrees": [ - { - "path": "/repo/feature", - "repoId": "repo-1", - "worktreeId": "wt-history" - }, - { - "path": "/repo/sibling", - "repoId": "repo-1", - "worktreeId": "wt-2" - } - ] - } - } - } - }, - "95fe4caff6f8": { + "a26eb8ffaa7c": { "name": "status.get#2", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "status.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": { - "error": "refused" - } - } - } + "ordinal": 7, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" }, "acf501fa9958": { "name": "status.get#1", @@ -643,35 +640,110 @@ "Retry" ] }, - "cee4e3edb0a1": { + "b51021287bd8": { "name": "status.get#2", "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "error": "refused" + } + } + } }, - "d22bb2f62cea": { - "crash": { - "$rpc": "null" - }, - "elements": { - "ActivityIndicator": 1, - "ChevronLeft": 1, - "Pressable": 2, - "RefreshCw": 1, - "SafeAreaView": 1, - "Text": 2, - "View": 4 - }, - "labels": ["Back", "Refresh agent sessions"], - "text": ["Agent Session History", "orca-history"] - }, - "d3c3ce66625d": { - "name": "aiVault.listSessions#1", - "ordinal": 8, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}" - }, - "e94db4168535": { + "ba295d07dfd2": { "name": "status.get#2", - "ordinal": 5, + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-4", + "ok": false + } + } + }, + "ba8394a4ce03": { + "name": "status.get#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, + "bab43f71f94f": { + "name": "status.get#2", + "ordinal": 6, "args": [ { "name": "method", @@ -701,6 +773,27 @@ } } }, + "d22bb2f62cea": { + "crash": { + "$rpc": "null" + }, + "elements": { + "ActivityIndicator": 1, + "ChevronLeft": 1, + "Pressable": 2, + "RefreshCw": 1, + "SafeAreaView": 1, + "Text": 2, + "View": 4 + }, + "labels": ["Back", "Refresh agent sessions"], + "text": ["Agent Session History", "orca-history"] + }, + "e28c488ce6b2": { + "name": "agentSession.subscribeStatus#1", + "ordinal": 1, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"agentSession.subscribeStatus\",\"params\":{}}" + }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -716,8 +809,8 @@ { "id": "aivault-history-screen-worktrees.prelude:worktrees-pending", "observation": { - "sender": ["4cebf6258ec1", "03106dceb986"], - "payloads": ["3a91d2093c6e", "4d68bff46cff"], + "sender": ["03106dceb986", "7368414c8786"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e"], "settlements": { "mount": "eb79a9b3682a" }, @@ -725,11 +818,29 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.prelude:cleanup", + "observation": { + "sender": ["acf501fa9958", "222a3ba1d974", "ba8394a4ce03"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["7e8b2964ad63"] + } + }, { "id": "aivault-history-screen-worktrees.normal:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "acf501fa9958", "4d676e55076c", "3aaa7e04cda5"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"], + "sender": ["acf501fa9958", "222a3ba1d974", "7a008ca8521f", "98d7022b1300"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -737,11 +848,29 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.normal:cleanup", + "observation": { + "sender": ["acf501fa9958", "222a3ba1d974", "7a008ca8521f", "79fc75a7b709"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.result-absent:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "acf501fa9958", "505d46dbbde3"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1"], + "sender": ["acf501fa9958", "222a3ba1d974", "14d0e2ecb181"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], "settlements": { "mount": "eb79a9b3682a" }, @@ -749,11 +878,23 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.result-absent:cleanup", + "observation": { + "sender": ["acf501fa9958", "222a3ba1d974", "14d0e2ecb181"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "8cc4f2d607a6", + "effects": ["7e8b2964ad63"] + } + }, { "id": "aivault-history-screen-worktrees.result-null:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "acf501fa9958", "01800f06bc8f"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1"], + "sender": ["acf501fa9958", "222a3ba1d974", "6d4cf84c26db"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], "settlements": { "mount": "eb79a9b3682a" }, @@ -761,11 +902,23 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.result-null:cleanup", + "observation": { + "sender": ["acf501fa9958", "222a3ba1d974", "6d4cf84c26db"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "8cc4f2d607a6", + "effects": ["7e8b2964ad63"] + } + }, { "id": "aivault-history-screen-worktrees.inner-ok-missing:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "acf501fa9958", "95fe4caff6f8"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1"], + "sender": ["acf501fa9958", "222a3ba1d974", "b51021287bd8"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], "settlements": { "mount": "eb79a9b3682a" }, @@ -773,11 +926,23 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.inner-ok-missing:cleanup", + "observation": { + "sender": ["acf501fa9958", "222a3ba1d974", "b51021287bd8"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "0be231dddda1", + "effects": ["7e8b2964ad63"] + } + }, { "id": "aivault-history-screen-worktrees.inner-false-string-error:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "acf501fa9958", "74bb5a1c3c97"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1"], + "sender": ["acf501fa9958", "222a3ba1d974", "81b771b56e59"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], "settlements": { "mount": "eb79a9b3682a" }, @@ -785,11 +950,23 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.inner-false-string-error:cleanup", + "observation": { + "sender": ["acf501fa9958", "222a3ba1d974", "81b771b56e59"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "0be231dddda1", + "effects": ["7e8b2964ad63"] + } + }, { "id": "aivault-history-screen-worktrees.inner-false-object-error:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "acf501fa9958", "2813efdd7233"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1"], + "sender": ["acf501fa9958", "222a3ba1d974", "5683252d4a53"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], "settlements": { "mount": "eb79a9b3682a" }, @@ -797,11 +974,23 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.inner-false-object-error:cleanup", + "observation": { + "sender": ["acf501fa9958", "222a3ba1d974", "5683252d4a53"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "0be231dddda1", + "effects": ["7e8b2964ad63"] + } + }, { "id": "aivault-history-screen-worktrees.outer-refused:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "acf501fa9958", "48418cbcede7"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1"], + "sender": ["acf501fa9958", "222a3ba1d974", "2b1949ab1dec"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], "settlements": { "mount": "eb79a9b3682a" }, @@ -809,11 +998,23 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.outer-refused:cleanup", + "observation": { + "sender": ["acf501fa9958", "222a3ba1d974", "2b1949ab1dec"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "4af220e500d6", + "effects": ["7e8b2964ad63"] + } + }, { "id": "aivault-history-screen-worktrees.outer-refused-no-message:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "acf501fa9958", "82a983e39762"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1"], + "sender": ["acf501fa9958", "222a3ba1d974", "9fb09ddc7fe6"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], "settlements": { "mount": "eb79a9b3682a" }, @@ -821,11 +1022,23 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.outer-refused-no-message:cleanup", + "observation": { + "sender": ["acf501fa9958", "222a3ba1d974", "9fb09ddc7fe6"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "af9574769167", + "effects": ["7e8b2964ad63"] + } + }, { "id": "aivault-history-screen-worktrees.method-not-found:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "acf501fa9958", "6eacae1aa018"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1"], + "sender": ["acf501fa9958", "222a3ba1d974", "ba295d07dfd2"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], "settlements": { "mount": "eb79a9b3682a" }, @@ -833,11 +1046,23 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.method-not-found:cleanup", + "observation": { + "sender": ["acf501fa9958", "222a3ba1d974", "ba295d07dfd2"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "22141179786a", + "effects": ["7e8b2964ad63"] + } + }, { "id": "aivault-history-screen-worktrees.transport-rejection:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "acf501fa9958", "e94db4168535"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1"], + "sender": ["acf501fa9958", "222a3ba1d974", "bab43f71f94f"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], "settlements": { "mount": "eb79a9b3682a" }, @@ -845,17 +1070,41 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.transport-rejection:cleanup", + "observation": { + "sender": ["acf501fa9958", "222a3ba1d974", "bab43f71f94f"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "7f2073d8dcc1", + "effects": ["7e8b2964ad63"] + } + }, { "id": "aivault-history-screen-worktrees.transport-rejection-no-message:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "acf501fa9958", "14cef45e16d2"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1"], + "sender": ["acf501fa9958", "222a3ba1d974", "6d992d979d7c"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], "settlements": { "mount": "eb79a9b3682a" }, "state": "18ef6596b779", "effects": [] } + }, + { + "id": "aivault-history-screen-worktrees.transport-rejection-no-message:cleanup", + "observation": { + "sender": ["acf501fa9958", "222a3ba1d974", "6d992d979d7c"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e", "a26eb8ffaa7c"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "18ef6596b779", + "effects": ["7e8b2964ad63"] + } } ] } diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json index 51eb10049c2..40dea3c585d 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json @@ -4,6 +4,45 @@ "family": "aiVault.history-screen", "namedDeltas": [], "values": { + "00b7efbaf87f": { + "name": "worktree.ps#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "worktree.ps" + }, + { + "name": "params", + "value": { + "limit": 10000 + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "0180d22edc2e": { + "name": "worktree.ps#1", + "ordinal": 5, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.ps\",\"params\":{\"limit\":10000,\"supportsWorktreeVisibilitySourceDefaults\":true}}" + }, "03106dceb986": { "name": "status.get#1", "ordinal": 2, @@ -30,9 +69,9 @@ "startedAt": 0 } }, - "0c5e517bcdb4": { + "0569ef4f06ba": { "name": "worktree.ps#1", - "ordinal": 1, + "ordinal": 3, "args": [ { "name": "method", @@ -52,19 +91,183 @@ } ], "settlement": { - "status": "rejected", + "status": "fulfilled", "startedAt": 0, "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } } } }, - "14379cad1cb5": { + "19cf1a6a7639": { "name": "worktree.ps#1", - "ordinal": 1, + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "worktree.ps" + }, + { + "name": "params", + "value": { + "limit": 10000 + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true + } + } + }, + "1ff410f2eb07": { + "name": "aiVault.listSessions#1", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "aiVault.listSessions" + }, + { + "name": "params", + "value": { + "force": false, + "limit": 500, + "scopePaths": [] + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "222a3ba1d974": { + "name": "worktree.ps#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "worktree.ps" + }, + { + "name": "params", + "value": { + "limit": 10000 + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "worktrees": [ + { + "path": "/repo/feature", + "repoId": "repo-1", + "worktreeId": "wt-history" + }, + { + "path": "/repo/sibling", + "repoId": "repo-1", + "worktreeId": "wt-2" + } + ] + } + } + } + }, + "30b5ef327e47": { + "name": "worktree.ps#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "worktree.ps" + }, + { + "name": "params", + "value": { + "limit": 10000 + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "40fa6811eb00": { + "name": "streams-registered-at-teardown", + "ordinal": 10, + "value": [ + { + "cancelled": false, + "method": "agentSession.subscribeStatus", + "payload": "agentSession.subscribeStatus#1" + } + ] + }, + "4d68bff46cff": { + "name": "status.get#1", + "ordinal": 4, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + }, + "613355526cb5": { + "name": "aiVault.listSessions#1", + "ordinal": 9, + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}" + }, + "6405fa9d6a94": { + "name": "worktree.ps#1", + "ordinal": 3, "args": [ { "name": "method", @@ -89,17 +292,17 @@ "settledAt": 0, "value": { "error": { - "code": "refused", - "message": "" + "code": "method_not_found", + "message": "Unknown method" }, - "id": "frame-1", + "id": "frame-3", "ok": false } } }, - "2b2ff73e3290": { + "7182750ca2d1": { "name": "worktree.ps#1", - "ordinal": 1, + "ordinal": 3, "args": [ { "name": "method", @@ -123,53 +326,18 @@ "startedAt": 0, "settledAt": 0, "value": { - "id": "frame-1", + "id": "frame-3", "ok": true, "result": { - "error": { - "message": "inner refused" - }, + "error": "inner refused", "ok": false } } } }, - "3a91d2093c6e": { + "7368414c8786": { "name": "worktree.ps#1", "ordinal": 3, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.ps\",\"params\":{\"limit\":10000,\"supportsWorktreeVisibilitySourceDefaults\":true}}" - }, - "3aaa7e04cda5": { - "name": "aiVault.listSessions#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "aiVault.listSessions" - }, - { - "name": "params", - "value": { - "force": false, - "limit": 500, - "scopePaths": ["/repo/feature"] - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "4cebf6258ec1": { - "name": "worktree.ps#1", - "ordinal": 1, "args": [ { "name": "method", @@ -193,9 +361,54 @@ "startedAt": 0 } }, - "4d676e55076c": { + "758b3fde1948": { + "name": "streams-registered-at-teardown", + "ordinal": 6, + "value": [ + { + "cancelled": false, + "method": "agentSession.subscribeStatus", + "payload": "agentSession.subscribeStatus#1" + } + ] + }, + "79fc75a7b709": { + "name": "aiVault.listSessions#1", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "aiVault.listSessions" + }, + { + "name": "params", + "value": { + "force": false, + "limit": 500, + "scopePaths": ["/repo/feature"] + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, + "7a008ca8521f": { "name": "status.get#2", - "ordinal": 5, + "ordinal": 6, "args": [ { "name": "method", @@ -219,7 +432,7 @@ "startedAt": 0, "settledAt": 0, "value": { - "id": "frame-3", + "id": "frame-4", "ok": true, "result": { "capabilities": ["aiVault.v1"] @@ -227,14 +440,43 @@ } } }, - "4d68bff46cff": { - "name": "status.get#1", - "ordinal": 4, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + "8123e6fb3b52": { + "name": "aiVault.listSessions#1", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "aiVault.listSessions" + }, + { + "name": "params", + "value": { + "force": false, + "limit": 500, + "scopePaths": [] + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } }, - "5bb113724a38": { + "858c23d8f3ef": { "name": "worktree.ps#1", - "ordinal": 1, + "ordinal": 3, "args": [ { "name": "method", @@ -264,9 +506,9 @@ } } }, - "8d930b47bf2f": { + "8854a58ee704": { "name": "worktree.ps#1", - "ordinal": 1, + "ordinal": 3, "args": [ { "name": "method", @@ -286,98 +528,19 @@ } ], "settlement": { - "status": "fulfilled", + "status": "rejected", "startedAt": 0, "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "worktrees": [ - { - "path": "/repo/feature", - "repoId": "repo-1", - "worktreeId": "wt-history" - }, - { - "path": "/repo/sibling", - "repoId": "repo-1", - "worktreeId": "wt-2" - } - ] - } + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true } } }, - "9684c348fb80": { - "name": "worktree.ps#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "worktree.ps" - }, - { - "name": "params", - "value": { - "limit": 10000 - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true - } - } - }, - "9ee642822405": { - "name": "worktree.ps#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "worktree.ps" - }, - { - "name": "params", - "value": { - "limit": 10000 - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "error": "inner refused", - "ok": false - } - } - } - }, - "9efb4ec6ee5c": { + "98d7022b1300": { "name": "aiVault.listSessions#1", - "ordinal": 7, + "ordinal": 8, "args": [ { "name": "method", @@ -388,7 +551,7 @@ "value": { "force": false, "limit": 500, - "scopePaths": [] + "scopePaths": ["/repo/feature"] } }, { @@ -403,9 +566,9 @@ "startedAt": 0 } }, - "a37b23294454": { + "9a209a9534f9": { "name": "worktree.ps#1", - "ordinal": 1, + "ordinal": 3, "args": [ { "name": "method", @@ -429,14 +592,52 @@ "startedAt": 0, "settledAt": 0, "value": { - "id": "frame-1", - "ok": true, - "result": { - "error": "refused" - } + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-3", + "ok": false } } }, + "9b8c00f534c6": { + "name": "status.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, + "a26eb8ffaa7c": { + "name": "status.get#2", + "ordinal": 7, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + }, "acf501fa9958": { "name": "status.get#1", "ordinal": 2, @@ -471,9 +672,9 @@ } } }, - "b7bbf0d88929": { + "c4a0ee2acc32": { "name": "worktree.ps#1", - "ordinal": 1, + "ordinal": 3, "args": [ { "name": "method", @@ -493,55 +694,13 @@ } ], "settlement": { - "status": "fulfilled", + "status": "rejected", "startedAt": 0, "settledAt": 0, - "value": { - "error": { - "code": "method_not_found", - "message": "Unknown method" - }, - "id": "frame-1", - "ok": false - } - } - }, - "cee4e3edb0a1": { - "name": "status.get#2", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" - }, - "d1800200eda0": { - "name": "worktree.ps#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "worktree.ps" - }, - { - "name": "params", - "value": { - "limit": 10000 - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "$rpc": "null" - } + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true } } }, @@ -561,14 +720,14 @@ "labels": ["Back", "Refresh agent sessions"], "text": ["Agent Session History", "orca-history"] }, - "d3c3ce66625d": { + "ddb026ba4968": { "name": "aiVault.listSessions#1", - "ordinal": 8, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}" + "ordinal": 9, + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[]}}" }, - "d5622eb9f591": { + "de6f51a372a1": { "name": "worktree.ps#1", - "ordinal": 1, + "ordinal": 3, "args": [ { "name": "method", @@ -594,17 +753,17 @@ "value": { "error": { "code": "refused", - "message": "outer refused" + "message": "" }, - "id": "frame-1", + "id": "frame-3", "ok": false } } }, - "e72c29cf3f52": { - "name": "aiVault.listSessions#1", - "ordinal": 8, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[]}}" + "e28c488ce6b2": { + "name": "agentSession.subscribeStatus#1", + "ordinal": 1, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"agentSession.subscribeStatus\",\"params\":{}}" }, "eb79a9b3682a": { "status": "fulfilled", @@ -621,8 +780,8 @@ { "id": "aivault-history-screen-worktrees.prelude:worktrees-pending", "observation": { - "sender": ["4cebf6258ec1", "03106dceb986"], - "payloads": ["3a91d2093c6e", "4d68bff46cff"], + "sender": ["03106dceb986", "7368414c8786"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e"], "settlements": { "mount": "eb79a9b3682a" }, @@ -630,11 +789,29 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.prelude:cleanup", + "observation": { + "sender": ["9b8c00f534c6", "8854a58ee704"], + "payloads": ["e28c488ce6b2", "4d68bff46cff", "0180d22edc2e"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["758b3fde1948"] + } + }, { "id": "aivault-history-screen-worktrees.normal:worktrees-listed", "observation": { - "sender": ["8d930b47bf2f", "acf501fa9958", "4d676e55076c", "3aaa7e04cda5"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"], + "sender": ["acf501fa9958", "222a3ba1d974", "7a008ca8521f", "98d7022b1300"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -642,11 +819,35 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.normal:cleanup", + "observation": { + "sender": ["acf501fa9958", "222a3ba1d974", "7a008ca8521f", "79fc75a7b709"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "613355526cb5" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.result-absent:worktrees-listed", "observation": { - "sender": ["9684c348fb80", "acf501fa9958", "4d676e55076c", "9efb4ec6ee5c"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "e72c29cf3f52"], + "sender": ["acf501fa9958", "19cf1a6a7639", "7a008ca8521f", "1ff410f2eb07"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -654,11 +855,35 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.result-absent:cleanup", + "observation": { + "sender": ["acf501fa9958", "19cf1a6a7639", "7a008ca8521f", "8123e6fb3b52"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.result-null:worktrees-listed", "observation": { - "sender": ["d1800200eda0", "acf501fa9958", "4d676e55076c", "9efb4ec6ee5c"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "e72c29cf3f52"], + "sender": ["acf501fa9958", "30b5ef327e47", "7a008ca8521f", "1ff410f2eb07"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -666,11 +891,35 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.result-null:cleanup", + "observation": { + "sender": ["acf501fa9958", "30b5ef327e47", "7a008ca8521f", "8123e6fb3b52"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.inner-ok-missing:worktrees-listed", "observation": { - "sender": ["a37b23294454", "acf501fa9958", "4d676e55076c", "9efb4ec6ee5c"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "e72c29cf3f52"], + "sender": ["acf501fa9958", "00b7efbaf87f", "7a008ca8521f", "1ff410f2eb07"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -678,11 +927,35 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.inner-ok-missing:cleanup", + "observation": { + "sender": ["acf501fa9958", "00b7efbaf87f", "7a008ca8521f", "8123e6fb3b52"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.inner-false-string-error:worktrees-listed", "observation": { - "sender": ["9ee642822405", "acf501fa9958", "4d676e55076c", "9efb4ec6ee5c"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "e72c29cf3f52"], + "sender": ["acf501fa9958", "7182750ca2d1", "7a008ca8521f", "1ff410f2eb07"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -690,11 +963,35 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.inner-false-string-error:cleanup", + "observation": { + "sender": ["acf501fa9958", "7182750ca2d1", "7a008ca8521f", "8123e6fb3b52"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.inner-false-object-error:worktrees-listed", "observation": { - "sender": ["2b2ff73e3290", "acf501fa9958", "4d676e55076c", "9efb4ec6ee5c"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "e72c29cf3f52"], + "sender": ["acf501fa9958", "0569ef4f06ba", "7a008ca8521f", "1ff410f2eb07"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -702,11 +999,35 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.inner-false-object-error:cleanup", + "observation": { + "sender": ["acf501fa9958", "0569ef4f06ba", "7a008ca8521f", "8123e6fb3b52"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.outer-refused:worktrees-listed", "observation": { - "sender": ["d5622eb9f591", "acf501fa9958", "4d676e55076c", "9efb4ec6ee5c"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "e72c29cf3f52"], + "sender": ["acf501fa9958", "9a209a9534f9", "7a008ca8521f", "1ff410f2eb07"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -714,11 +1035,35 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.outer-refused:cleanup", + "observation": { + "sender": ["acf501fa9958", "9a209a9534f9", "7a008ca8521f", "8123e6fb3b52"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.outer-refused-no-message:worktrees-listed", "observation": { - "sender": ["14379cad1cb5", "acf501fa9958", "4d676e55076c", "9efb4ec6ee5c"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "e72c29cf3f52"], + "sender": ["acf501fa9958", "de6f51a372a1", "7a008ca8521f", "1ff410f2eb07"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -726,11 +1071,35 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.outer-refused-no-message:cleanup", + "observation": { + "sender": ["acf501fa9958", "de6f51a372a1", "7a008ca8521f", "8123e6fb3b52"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.method-not-found:worktrees-listed", "observation": { - "sender": ["b7bbf0d88929", "acf501fa9958", "4d676e55076c", "9efb4ec6ee5c"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "e72c29cf3f52"], + "sender": ["acf501fa9958", "6405fa9d6a94", "7a008ca8521f", "1ff410f2eb07"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -738,11 +1107,35 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.method-not-found:cleanup", + "observation": { + "sender": ["acf501fa9958", "6405fa9d6a94", "7a008ca8521f", "8123e6fb3b52"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.transport-rejection:worktrees-listed", "observation": { - "sender": ["0c5e517bcdb4", "acf501fa9958", "4d676e55076c", "9efb4ec6ee5c"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "e72c29cf3f52"], + "sender": ["acf501fa9958", "c4a0ee2acc32", "7a008ca8521f", "1ff410f2eb07"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], "settlements": { "mount": "eb79a9b3682a" }, @@ -750,17 +1143,59 @@ "effects": [] } }, + { + "id": "aivault-history-screen-worktrees.transport-rejection:cleanup", + "observation": { + "sender": ["acf501fa9958", "c4a0ee2acc32", "7a008ca8521f", "8123e6fb3b52"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } + }, { "id": "aivault-history-screen-worktrees.transport-rejection-no-message:worktrees-listed", "observation": { - "sender": ["5bb113724a38", "acf501fa9958", "4d676e55076c", "9efb4ec6ee5c"], - "payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "e72c29cf3f52"], + "sender": ["acf501fa9958", "858c23d8f3ef", "7a008ca8521f", "1ff410f2eb07"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], "settlements": { "mount": "eb79a9b3682a" }, "state": "d22bb2f62cea", "effects": [] } + }, + { + "id": "aivault-history-screen-worktrees.transport-rejection-no-message:cleanup", + "observation": { + "sender": ["acf501fa9958", "858c23d8f3ef", "7a008ca8521f", "8123e6fb3b52"], + "payloads": [ + "e28c488ce6b2", + "4d68bff46cff", + "0180d22edc2e", + "a26eb8ffaa7c", + "ddb026ba4968" + ], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d22bb2f62cea", + "effects": ["40fa6811eb00"] + } } ] } diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json index 3a3617c092b..d5e31971fb4 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json @@ -4,71 +4,16 @@ "family": "aiVault.history", "namedDeltas": [], "values": { - "0474a1603b70": { - "name": "status.get#1", - "ordinal": 1, - "args": [ + "111e9a5009ba": { + "name": "streams-registered-at-teardown", + "ordinal": 4, + "value": [ { - "name": "method", - "value": "status.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } + "cancelled": false, + "method": "agentSession.subscribeStatus", + "payload": "agentSession.subscribeStatus#1" } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "error": "inner refused", - "ok": false - } - } - } - }, - "0ccee4def5e0": { - "name": "status.get#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "status.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true - } - } + ] }, "11509bbb0b2a": { "activeWorktreePath": "/repo/feature", @@ -84,9 +29,9 @@ "kind": "unsupported" } }, - "16a71e555cb2": { + "1675b1df2d81": { "name": "status.get#1", - "ordinal": 1, + "ordinal": 2, "args": [ { "name": "method", @@ -111,17 +56,56 @@ "settledAt": 0, "value": { "error": { - "code": "refused", - "message": "outer refused" + "code": "method_not_found", + "message": "Unknown method" }, - "id": "frame-1", + "id": "frame-2", "ok": false } } }, - "2b8c44a85130": { + "44a27dbf05e2": { "name": "status.get#1", - "ordinal": 1, + "ordinal": 3, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + }, + "471bd606315a": { + "name": "status.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "51e670a85520": { + "name": "status.get#1", + "ordinal": 2, "args": [ { "name": "method", @@ -151,20 +135,72 @@ } } }, - "3719c54df702": { - "name": "aiVault.listSessions#1", - "ordinal": 3, + "59290155f27a": { + "name": "status.get#1", + "ordinal": 2, "args": [ { "name": "method", - "value": "aiVault.listSessions" + "value": "status.get" }, { "name": "params", "value": { - "force": false, - "limit": 500, - "scopePaths": ["/repo/feature"] + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true + } + } + }, + "698f848e6967": { + "activeWorktreePath": "/repo/feature", + "hostStatusResult": { + "$rpc": "null" + }, + "refreshing": false, + "scope": "workspace", + "screenState": { + "kind": "error", + "message": "Unknown method" + } + }, + "758b3fde1948": { + "name": "streams-registered-at-teardown", + "ordinal": 6, + "value": [ + { + "cancelled": false, + "method": "agentSession.subscribeStatus", + "payload": "agentSession.subscribeStatus#1" + } + ] + }, + "7b715c94cd95": { + "name": "status.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" } }, { @@ -182,169 +218,12 @@ "id": "frame-2", "ok": true, "result": { - "issues": [], - "sessions": [ - { - "agent": "claude", - "cwd": "/repo/feature", - "id": "s1" - } - ] - } - } - } - }, - "41e8e39cc47e": { - "name": "status.get#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "status.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "error": { - "message": "inner refused" - }, + "error": "inner refused", "ok": false } } } }, - "4a76a58cbdac": { - "name": "status.get#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "status.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "capabilities": ["aiVault.v1"] - } - } - } - }, - "698f848e6967": { - "activeWorktreePath": "/repo/feature", - "hostStatusResult": { - "$rpc": "null" - }, - "refreshing": false, - "scope": "workspace", - "screenState": { - "kind": "error", - "message": "Unknown method" - } - }, - "73bcd4456e97": { - "name": "status.get#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "status.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "$rpc": "null" - } - } - } - }, - "7b0d9b0d2428": { - "name": "status.get#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "status.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-1", - "ok": true, - "result": { - "error": "refused" - } - } - } - }, "815d2d808393": { "activeWorktreePath": "/repo/feature", "hostStatusResult": { @@ -357,9 +236,9 @@ "kind": "unsupported" } }, - "82f760e2c99f": { + "824e7d747f2a": { "name": "status.get#1", - "ordinal": 1, + "ordinal": 2, "args": [ { "name": "method", @@ -385,32 +264,59 @@ "value": { "error": { "code": "refused", - "message": "" + "message": "outer refused" }, - "id": "frame-1", + "id": "frame-2", "ok": false } } }, - "97bd894604ee": { + "887a32dd2b5e": { "name": "aiVault.listSessions#1", "ordinal": 4, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}" - }, - "a358eff43f4a": { - "activeWorktreePath": "/repo/feature", - "hostStatusResult": { - "error": "refused" - }, - "refreshing": false, - "scope": "workspace", - "screenState": { - "kind": "unsupported" + "args": [ + { + "name": "method", + "value": "aiVault.listSessions" + }, + { + "name": "params", + "value": { + "force": false, + "limit": 500, + "scopePaths": ["/repo/feature"] + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "issues": [], + "sessions": [ + { + "agent": "claude", + "cwd": "/repo/feature", + "id": "s1" + } + ] + } + } } }, - "b799ba7b0c33": { + "9cfa291517f9": { "name": "status.get#1", - "ordinal": 1, + "ordinal": 2, "args": [ { "name": "method", @@ -440,6 +346,125 @@ } } }, + "9efd8381401a": { + "name": "status.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "a358eff43f4a": { + "activeWorktreePath": "/repo/feature", + "hostStatusResult": { + "error": "refused" + }, + "refreshing": false, + "scope": "workspace", + "screenState": { + "kind": "unsupported" + } + }, + "a5e3c1935fd2": { + "name": "status.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-2", + "ok": false + } + } + }, + "acf501fa9958": { + "name": "status.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "capabilities": ["aiVault.v1"] + } + } + } + }, + "b6a16cc83e18": { + "name": "aiVault.listSessions#1", + "ordinal": 5, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}" + }, "c9c67b3f0119": { "activeWorktreePath": "/repo/feature", "hostStatusResult": { @@ -471,38 +496,9 @@ ] } }, - "d08f74d65ee6": { + "d302f4eada82": { "name": "status.get#1", "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" - }, - "d86e3b3b7ca7": { - "activeWorktreePath": "/repo/feature", - "hostStatusResult": { - "$rpc": "null" - }, - "refreshing": false, - "scope": "workspace", - "screenState": { - "kind": "error", - "message": "transport failure" - } - }, - "e8d512f74641": { - "activeWorktreePath": "/repo/feature", - "hostStatusResult": { - "$rpc": "null" - }, - "refreshing": false, - "scope": "workspace", - "screenState": { - "kind": "error", - "message": "" - } - }, - "e90520ab92af": { - "name": "status.get#1", - "ordinal": 1, "args": [ { "name": "method", @@ -526,15 +522,46 @@ "startedAt": 0, "settledAt": 0, "value": { - "error": { - "code": "method_not_found", - "message": "Unknown method" - }, - "id": "frame-1", - "ok": false + "id": "frame-2", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } } } }, + "d86e3b3b7ca7": { + "activeWorktreePath": "/repo/feature", + "hostStatusResult": { + "$rpc": "null" + }, + "refreshing": false, + "scope": "workspace", + "screenState": { + "kind": "error", + "message": "transport failure" + } + }, + "e28c488ce6b2": { + "name": "agentSession.subscribeStatus#1", + "ordinal": 1, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"agentSession.subscribeStatus\",\"params\":{}}" + }, + "e8d512f74641": { + "activeWorktreePath": "/repo/feature", + "hostStatusResult": { + "$rpc": "null" + }, + "refreshing": false, + "scope": "workspace", + "screenState": { + "kind": "error", + "message": "" + } + }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -574,8 +601,8 @@ { "id": "aivault-history-scan-fulfilled.normal:ready", "observation": { - "sender": ["4a76a58cbdac", "3719c54df702"], - "payloads": ["d08f74d65ee6", "97bd894604ee"], + "sender": ["acf501fa9958", "887a32dd2b5e"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], "settlements": { "mount": "eb79a9b3682a" }, @@ -583,11 +610,23 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.normal:cleanup", + "observation": { + "sender": ["acf501fa9958", "887a32dd2b5e"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2", "b6a16cc83e18"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "cd739a80b7a8", + "effects": ["758b3fde1948"] + } + }, { "id": "aivault-history-scan-fulfilled.result-absent:ready", "observation": { - "sender": ["0ccee4def5e0"], - "payloads": ["d08f74d65ee6"], + "sender": ["59290155f27a"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], "settlements": { "mount": "eb79a9b3682a" }, @@ -595,11 +634,23 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.result-absent:cleanup", + "observation": { + "sender": ["59290155f27a"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "f79de442ffa2", + "effects": ["111e9a5009ba"] + } + }, { "id": "aivault-history-scan-fulfilled.result-null:ready", "observation": { - "sender": ["73bcd4456e97"], - "payloads": ["d08f74d65ee6"], + "sender": ["471bd606315a"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], "settlements": { "mount": "eb79a9b3682a" }, @@ -607,11 +658,23 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.result-null:cleanup", + "observation": { + "sender": ["471bd606315a"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "f79de442ffa2", + "effects": ["111e9a5009ba"] + } + }, { "id": "aivault-history-scan-fulfilled.inner-ok-missing:ready", "observation": { - "sender": ["7b0d9b0d2428"], - "payloads": ["d08f74d65ee6"], + "sender": ["9efd8381401a"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], "settlements": { "mount": "eb79a9b3682a" }, @@ -619,11 +682,23 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.inner-ok-missing:cleanup", + "observation": { + "sender": ["9efd8381401a"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "a358eff43f4a", + "effects": ["111e9a5009ba"] + } + }, { "id": "aivault-history-scan-fulfilled.inner-false-string-error:ready", "observation": { - "sender": ["0474a1603b70"], - "payloads": ["d08f74d65ee6"], + "sender": ["7b715c94cd95"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], "settlements": { "mount": "eb79a9b3682a" }, @@ -631,11 +706,23 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.inner-false-string-error:cleanup", + "observation": { + "sender": ["7b715c94cd95"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "815d2d808393", + "effects": ["111e9a5009ba"] + } + }, { "id": "aivault-history-scan-fulfilled.inner-false-object-error:ready", "observation": { - "sender": ["41e8e39cc47e"], - "payloads": ["d08f74d65ee6"], + "sender": ["d302f4eada82"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], "settlements": { "mount": "eb79a9b3682a" }, @@ -643,11 +730,23 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.inner-false-object-error:cleanup", + "observation": { + "sender": ["d302f4eada82"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "11509bbb0b2a", + "effects": ["111e9a5009ba"] + } + }, { "id": "aivault-history-scan-fulfilled.outer-refused:ready", "observation": { - "sender": ["16a71e555cb2"], - "payloads": ["d08f74d65ee6"], + "sender": ["824e7d747f2a"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], "settlements": { "mount": "eb79a9b3682a" }, @@ -655,11 +754,23 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.outer-refused:cleanup", + "observation": { + "sender": ["824e7d747f2a"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "c9c67b3f0119", + "effects": ["111e9a5009ba"] + } + }, { "id": "aivault-history-scan-fulfilled.outer-refused-no-message:ready", "observation": { - "sender": ["82f760e2c99f"], - "payloads": ["d08f74d65ee6"], + "sender": ["a5e3c1935fd2"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], "settlements": { "mount": "eb79a9b3682a" }, @@ -667,11 +778,23 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.outer-refused-no-message:cleanup", + "observation": { + "sender": ["a5e3c1935fd2"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "fd9ea98fae8e", + "effects": ["111e9a5009ba"] + } + }, { "id": "aivault-history-scan-fulfilled.method-not-found:ready", "observation": { - "sender": ["e90520ab92af"], - "payloads": ["d08f74d65ee6"], + "sender": ["1675b1df2d81"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], "settlements": { "mount": "eb79a9b3682a" }, @@ -679,11 +802,23 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.method-not-found:cleanup", + "observation": { + "sender": ["1675b1df2d81"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "698f848e6967", + "effects": ["111e9a5009ba"] + } + }, { "id": "aivault-history-scan-fulfilled.transport-rejection:ready", "observation": { - "sender": ["2b8c44a85130"], - "payloads": ["d08f74d65ee6"], + "sender": ["51e670a85520"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], "settlements": { "mount": "eb79a9b3682a" }, @@ -691,17 +826,41 @@ "effects": [] } }, + { + "id": "aivault-history-scan-fulfilled.transport-rejection:cleanup", + "observation": { + "sender": ["51e670a85520"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "d86e3b3b7ca7", + "effects": ["111e9a5009ba"] + } + }, { "id": "aivault-history-scan-fulfilled.transport-rejection-no-message:ready", "observation": { - "sender": ["b799ba7b0c33"], - "payloads": ["d08f74d65ee6"], + "sender": ["9cfa291517f9"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], "settlements": { "mount": "eb79a9b3682a" }, "state": "e8d512f74641", "effects": [] } + }, + { + "id": "aivault-history-scan-fulfilled.transport-rejection-no-message:cleanup", + "observation": { + "sender": ["9cfa291517f9"], + "payloads": ["e28c488ce6b2", "44a27dbf05e2"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "e8d512f74641", + "effects": ["111e9a5009ba"] + } } ] } diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx index 13267760f52..9ebd8ee79b5 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -72,6 +72,18 @@ function MobileAgentSessionHistoryPanelContent({ ) const worktreeLabel = getWorktreeLabel(name, worktreeId) + const { + scope, + screenState, + refreshing, + hostStatusResult, + activeWorktreePath, + scopeFilterPaths, + onSelectScope, + onRefresh, + retry + } = useMobileAgentHistoryState({ hostId, worktreeId, worktrees, worktreesLoaded }) + // Worktree list seeds host-local scopePaths and current-worktree badge. useEffect(() => { if (!client || connState !== 'connected') { @@ -105,18 +117,6 @@ function MobileAgentSessionHistoryPanelContent({ } }, [client, connState]) - const { - scope, - screenState, - refreshing, - hostStatusResult, - activeWorktreePath, - scopeFilterPaths, - onSelectScope, - onRefresh, - retry - } = useMobileAgentHistoryState({ hostId, worktreeId, worktrees, worktreesLoaded }) - const sessions = screenState.kind === 'ready' ? screenState.sessions : EMPTY_SESSIONS const issues = screenState.kind === 'ready' ? screenState.issues : EMPTY_ISSUES const sessionsById = useMemo( diff --git a/mobile/src/agent-history/agent-history-association.test.tsx b/mobile/src/agent-history/agent-history-association.test.tsx new file mode 100644 index 00000000000..c49b5f27868 --- /dev/null +++ b/mobile/src/agent-history/agent-history-association.test.tsx @@ -0,0 +1,240 @@ +// @vitest-environment happy-dom +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState, RpcResponse } from '../transport/types' +import type { Worktree } from '../worktree/workspace-list-types' +import { useMobileAgentHistoryState } from './use-mobile-agent-history-state' + +const connection = vi.hoisted((): { client: RpcClient | null; state: ConnectionState } => ({ + client: null, + state: 'connected' +})) +vi.mock('../transport/use-workspace-client', () => ({ useWorkspaceClient: () => connection })) +vi.mock('../transport/host-client-hooks', () => ({ useForceReconnect: () => null })) +function fakeClient(sendRequest: RpcClient['sendRequest']): RpcClient { + return { + sendRequest, + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => connection.state, + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } +} + +it('refreshes a mounted provider row on current-client association changes with bounded work', async () => { + let emit = (_frame: unknown): void => {} + const manualName = 'Budget plan' + const native = { + sessionId: 'native', + workspaceId: 'workspace', + agent: 'pi', + latestPrompt: '', + status: null, + updatedAt: 1, + providerSession: { key: 'session_id', id: '/provider' }, + conversationName: manualName + } + const row = { + id: 'provider', + agent: 'pi', + sessionId: 'provider', + title: manualName, + executionHostId: 'local', + filePath: '/provider', + resumeCommand: 'pi --session /provider', + cwd: null, + modifiedAt: '', + previewMessages: [], + messageCount: 1, + structuredSession: { sessionId: 'native', workspaceId: 'workspace' } + } + let detached = false + let hold = false + let finish = (_reply: RpcResponse): void => {} + const send = vi.fn(async (method) => { + if (method === 'status.get') { + return { id: 'status', ok: true, result: { capabilities: ['aiVault.v1'] } } + } + const reply: RpcResponse = { + id: 'scan', + ok: true, + result: { + issues: [], + sessions: [ + detached ? { ...row, title: 'Provider title', structuredSession: undefined } : row + ] + } + } + return hold + ? new Promise((resolve) => { + finish = resolve + }) + : reply + }) + const subscribe = vi.fn((_method, _args, listener) => { + emit = listener + return () => {} + }) + let clientGeneration = 0 + connection.client = { ...fakeClient(send), subscribe, getGeneration: () => clientGeneration } + connection.state = 'connected' + const worktrees: Worktree[] = [] + let hostId = 'phone-host' + let current: ReturnType | null = null + function Probe() { + current = useMobileAgentHistoryState({ + hostId, + worktreeId: 'workspace', + worktrees, + worktreesLoaded: true + }) + return null + } + const root = createRoot(document.createElement('div')) + await act(async () => root.render(createElement(Probe))) + const read = () => { + if (!current) { + throw new Error('not mounted') + } + return current + } + try { + await act(async () => emit({ type: 'snapshot', sessions: [native] })) + const baseline = send.mock.calls.length + for (const n of [10, 100]) { + await act(async () => { + for (let i = 0; i < n; i++) { + emit({ type: 'status', session: { ...native, updatedAt: i, toolName: `${i}` } }) + emit({ + type: 'snapshot', + sessions: [], + namingActiveSessionIds: [], + conversationNames: [{ sessionId: 'native', conversationName: manualName }] + }) + } + }) + expect(send).toHaveBeenCalledTimes(baseline) + } + hold = true + let pending: Promise | undefined + await act(async () => { + pending = read().onRefresh() + }) + const { providerSession: _provider, ...cleared } = native + detached = true + await act(async () => { + for (const id of ['/next', '/third']) { + emit({ type: 'status', session: { ...native, providerSession: { key: 'session_id', id } } }) + } + emit({ type: 'status', session: cleared }) + }) + expect(read().screenState).toMatchObject({ + kind: 'ready', + sessions: [{ title: manualName, structuredSession: { sessionId: 'native' } }] + }) + hold = false + await act(async () => { + finish({ id: 'old', ok: true, result: { sessions: [row], issues: [] } }) + await pending + }) + expect(read().screenState).toMatchObject({ + kind: 'ready', + sessions: [ + { + title: 'Provider title', + structuredSession: undefined, + executionHostId: 'local', + filePath: '/provider', + resumeCommand: 'pi --session /provider' + } + ] + }) + expect(send.mock.calls.slice(baseline).map(([method]) => method)).toEqual([ + 'status.get', + 'aiVault.listSessions', + 'status.get', + 'aiVault.listSessions' + ]) + expect(subscribe).toHaveBeenCalledOnce() + hold = true + await act(async () => { + pending = read().onRefresh() + }) + const generationReply = finish + hold = false + await act(async () => { + clientGeneration += 1 + root.render(createElement(Probe)) + }) + await act(async () => { + generationReply({ + id: 'retired-generation', + ok: true, + result: { sessions: [row], issues: [] } + }) + await pending + }) + expect(read().screenState).toMatchObject({ + kind: 'ready', + sessions: [ + { + title: 'Provider title', + structuredSession: undefined, + executionHostId: 'local', + sessionId: 'provider', + resumeCommand: 'pi --session /provider' + } + ] + }) + expect(subscribe).toHaveBeenCalledOnce() + send.mockRejectedValueOnce(new Error('connected failure')) + await act(async () => + emit({ type: 'status', session: { ...cleared, providerSession: native.providerSession } }) + ) + expect(read().screenState).toEqual({ kind: 'error', message: 'connected failure' }) + hold = true + await act(async () => { + pending = read().onRefresh() + }) + hold = false + await act(async () => read().onSelectScope('all')) + const retiredEmit = emit + const nextSend = vi.fn(async (method) => ({ + id: 'new-host', + ok: true, + result: + method === 'status.get' + ? { capabilities: ['aiVault.v1'] } + : { + sessions: [{ ...row, title: 'Current host', structuredSession: undefined }], + issues: [] + } + })) + connection.client = { ...fakeClient(nextSend), subscribe } + hostId = 'other-host' + await act(async () => root.render(createElement(Probe))) + await act(async () => { + finish({ id: 'retired', ok: true, result: { sessions: [row], issues: [] } }) + await pending + retiredEmit({ type: 'status', session: native }) + }) + expect(read().scope).toBe('all') + expect(read().screenState).toMatchObject({ + kind: 'ready', + sessions: [{ title: 'Current host', structuredSession: undefined }] + }) + expect(nextSend).toHaveBeenCalledTimes(2) + } finally { + await act(async () => root.unmount()) + } + const activeSend = vi.mocked(connection.client!.sendRequest) + const calls = activeSend.mock.calls.length + await act(async () => emit({ type: 'status', session: native })) + expect(activeSend).toHaveBeenCalledTimes(calls) +}) diff --git a/mobile/src/agent-history/agent-history-current-host-capability.test.tsx b/mobile/src/agent-history/agent-history-current-host-capability.test.tsx index c35fa7af497..a8b272edb58 100644 --- a/mobile/src/agent-history/agent-history-current-host-capability.test.tsx +++ b/mobile/src/agent-history/agent-history-current-host-capability.test.tsx @@ -5,6 +5,7 @@ import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState, RpcResponse } from '../transport/types' import type { Worktree } from '../worktree/workspace-list-types' import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from '../../../src/shared/qoder-terminal-create-capability' +import { buildMobileAgentHistoryCard } from './agent-history-session-card' const connection = vi.hoisted((): { client: RpcClient | null; state: ConnectionState } => ({ client: null, @@ -39,6 +40,95 @@ function fakeClient(sendRequest: RpcClient['sendRequest'], generation?: () => nu } } +it('updates a mounted History card from its client naming feed without another query', async () => { + let emit = (_value: unknown): void => {} + const send = vi.fn(async (method) => ({ + id: 'reply', + ok: true, + result: + method === 'status.get' + ? { capabilities: ['aiVault.v1'] } + : { + issues: [], + sessions: [ + { + id: 'row', + agent: 'codex', + sessionId: 'provider-id', + title: 'Before', + cwd: null, + modifiedAt: '2026-10-21', + previewMessages: [], + messageCount: 1, + structuredSession: { sessionId: 'host-session', workspaceId: 'workspace' } + } + ] + } + })) + const subscribe = vi.fn((_method, _params, listener) => { + emit = listener + return () => {} + }) + connection.client = { ...fakeClient(send), subscribe } + connection.state = 'connected' + const worktrees: Worktree[] = [] + let hostId = 'host' + function Card() { + const { screenState } = useMobileAgentHistoryState({ + hostId, + worktreeId: 'workspace', + worktrees, + worktreesLoaded: true + }) + const session = screenState.kind === 'ready' ? screenState.sessions[0] : null + return createElement( + 'span', + null, + session ? buildMobileAgentHistoryCard(session, null, 0).title : '' + ) + } + let tree: ReactTestRenderer | undefined + await act(async () => { + tree = create(createElement(Card)) + }) + try { + expect(JSON.stringify(tree?.toJSON())).toContain('Before') + await act(async () => { + emit({ + type: 'snapshot', + sessions: [], + namingActiveSessionIds: [], + conversationNames: [{ sessionId: 'host-session', conversationName: 'After' }] + }) + }) + expect(JSON.stringify(tree?.toJSON())).toContain('After') + expect(send).toHaveBeenCalledTimes(2) + expect(subscribe).toHaveBeenCalledOnce() + connection.client = { + ...fakeClient(async (method) => + method === 'status.get' + ? { id: 'status-b', ok: true, result: { capabilities: ['aiVault.v1'] } } + : new Promise(() => {}) + ), + subscribe + } + hostId = 'host-b' + await act(async () => tree?.update(createElement(Card))) + await act(async () => + emit({ + type: 'snapshot', + sessions: [], + namingActiveSessionIds: [], + conversationNames: [{ sessionId: 'host-session', conversationName: 'Other host' }] + }) + ) + expect(JSON.stringify(tree?.toJSON())).toContain('Before') + expect(JSON.stringify(tree?.toJSON())).not.toContain('Other host') + } finally { + await act(async () => tree?.unmount()) + } +}) + it.each([ 'host/client replacement', 'host replacement', diff --git a/mobile/src/agent-history/use-mobile-agent-history-state.ts b/mobile/src/agent-history/use-mobile-agent-history-state.ts index 013b6654a82..23c52d34e8c 100644 --- a/mobile/src/agent-history/use-mobile-agent-history-state.ts +++ b/mobile/src/agent-history/use-mobile-agent-history-state.ts @@ -1,4 +1,14 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore +} from 'react' +import { mobileStructuredSessionStatusFeed } from '../session/mobile-structured-session-status-feed' +import type { AgentSessionConversationNames } from '../../../src/shared/agent-session-conversation-names' import { useForceReconnect } from '../transport/host-client-hooks' import { useWorkspaceClient } from '../transport/use-workspace-client' import type { RpcClient } from '../transport/rpc-client' @@ -21,12 +31,23 @@ export { MOBILE_AI_VAULT_CAPABILITY } // Match desktop SESSION_LIMIT=500 for recency only; host separately caps in-scope union, so large responses still need virtualization/lazy previews. const MOBILE_AI_VAULT_SESSION_LIMIT = 500 +const EMPTY_NAMES: AgentSessionConversationNames = new Map() +const noSubscription = () => () => {} +const noNames = () => EMPTY_NAMES export type AgentHistoryScreenState = | { kind: 'loading' } | { kind: 'unsupported' } | { kind: 'error'; message: string } - | { kind: 'ready'; sessions: AiVaultSession[]; issues: AiVaultScanIssue[] } + | { + kind: 'ready' + sessions: AiVaultSession[] + issues: AiVaultScanIssue[] + } + +type FetchedHistoryState = + | Exclude + | (Extract & { hostId: string; client: RpcClient }) export type MobileAgentHistoryStateParams = { hostId: string @@ -41,7 +62,7 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams const { client, state: connState } = useWorkspaceClient(hostId) const forceReconnect = useForceReconnect() const [scope, setScope] = useState('workspace') - const [screenState, setScreenState] = useState({ kind: 'loading' }) + const [screenState, setScreenState] = useState({ kind: 'loading' }) const clientGeneration = client?.getGeneration?.() ?? 0 const [hostStatusProbe, setHostStatusProbe] = useState<{ hostId: string @@ -78,12 +99,43 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams [scope, activeWorktree, worktrees] ) + const feed = useMemo(() => (client ? mobileStructuredSessionStatusFeed(client) : null), [client]) + const names = useSyncExternalStore( + feed?.subscribe ?? noSubscription, + feed?.getNamingSnapshot ?? noNames, + noNames + ) + + const identity = useMemo( + () => ({ + hostId, + client, + clientGeneration, + connState, + worktreeId, + worktrees, + worktreesLoaded, + scope + }), + [hostId, client, clientGeneration, connState, worktreeId, worktrees, worktreesLoaded, scope] + ) + const identityRef = useRef(identity) + useLayoutEffect(() => { + identityRef.current = identity + }, [identity]) + const activeLoads = useRef(0) + const pendingAssociationLoad = useRef<(() => void) | null>(null) + const loadSessions = useCallback( async (options: { scope: AiVaultScope; force: boolean }): Promise => { + const requestIdentity = identityRef.current + const associationRevision = feed?.getAssociationRevision() const generation = generationRef.current + 1 generationRef.current = generation const isCurrent = () => mountedRef.current && + identityRef.current === requestIdentity && + associationRevision === feed?.getAssociationRevision() && generationRef.current === generation && (client?.getGeneration?.() ?? 0) === clientGeneration @@ -99,6 +151,7 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams } setScreenState((prev) => (prev.kind === 'ready' ? prev : { kind: 'loading' })) + activeLoads.current += 1 try { // Gate capability before calling RPC absent on older hosts. const statusReply = await agentHistoryHostStatusRead.request(client) @@ -138,6 +191,8 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams ) setScreenState({ kind: 'ready', + hostId, + client, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reader checks both containers; the rows stay the host's own records because `agent` is a vocabulary this client echoes back on resume. aivault-history-screen-listed `normal` records a full row: every member the cards and the resume path read unguarded. sessions: result.sessions as AiVaultSession[], // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: same reader, same container check; the issue rows are the host's AiVaultScanIssue and are only counted, never read member-wise (MobileAgentSessionHistoryPanel.tsx:335). @@ -150,23 +205,50 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams const message = err instanceof Error ? err.message : 'Unable to load agent sessions' setHostStatusProbe(null) setScreenState({ kind: 'error', message }) + } finally { + activeLoads.current -= 1 + if (activeLoads.current === 0) { + const followUp = pendingAssociationLoad.current + pendingAssociationLoad.current = null + followUp?.() + } } }, - [activeWorktree, client, clientGeneration, connState, hostId, worktrees, worktreesLoaded] + [activeWorktree, client, clientGeneration, connState, feed, hostId, worktrees, worktreesLoaded] ) // Initial/reconnect/scope loads reuse host’s 15s cache; only explicit refresh bypasses it. useEffect(() => { void loadSessions({ scope, force: false }) - }, [loadSessions]) + }, [loadSessions, scope]) - const onSelectScope = useCallback( - (nextScope: AiVaultScope) => { - setScope(nextScope) - void loadSessions({ scope: nextScope, force: false }) - }, - [loadSessions] - ) + useEffect(() => { + if (!feed) { + return + } + let revision = feed.getAssociationRevision() + const stop = feed.subscribe(() => { + const next = feed.getAssociationRevision() + if (next === revision) { + return + } + revision = next + const reload = () => void loadSessions({ scope, force: false }) + if (activeLoads.current > 0) { + pendingAssociationLoad.current = reload + } else { + reload() + } + }) + return () => { + stop() + pendingAssociationLoad.current = null + } + }, [feed, loadSessions, scope]) + + const onSelectScope = useCallback((nextScope: AiVaultScope) => { + setScope(nextScope) + }, []) const onRefresh = useCallback(async () => { setRefreshing(true) @@ -191,10 +273,28 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams [connState, forceReconnect, hostId, loadSessions, scope] ) + const namedScreenState = useMemo(() => { + if (screenState.kind !== 'ready') { + return screenState + } + const current = screenState.hostId === hostId && screenState.client === client + return { + kind: 'ready', + issues: screenState.issues, + sessions: current + ? screenState.sessions.map((session) => { + const id = session.structuredSession?.sessionId + const title = id ? names.get(id)?.conversationName : null + return title ? { ...session, title } : session + }) + : screenState.sessions + } + }, [screenState, names, hostId, client]) + return { connState, scope, - screenState, + screenState: namedScreenState, refreshing, hostStatusResult, activeWorktreePath, diff --git a/mobile/src/session/mobile-structured-session-status-feed.ts b/mobile/src/session/mobile-structured-session-status-feed.ts index d9cd5f46e9e..3279ec90e32 100644 --- a/mobile/src/session/mobile-structured-session-status-feed.ts +++ b/mobile/src/session/mobile-structured-session-status-feed.ts @@ -1,17 +1,24 @@ // Share one status stream per client lifetime because mobile cannot end individual host subscriptions; reconnect snapshots replace revoked liveness. import { + AgentSessionAssociationObservation, foldAgentSessionStatusEvent, revokeAgentSessionStatusLive, type AgentSessionStatusSnapshot } from '../../../src/shared/agent-session-status-snapshot-fold' import type { AgentSessionStatusEvent } from '../../../src/shared/agent-session-wire' +import { + foldAgentSessionConversationNames, + type AgentSessionConversationNames +} from '../../../src/shared/agent-session-conversation-names' import { isMobileMethodUnavailableError } from '../transport/mobile-method-unavailable' import type { RpcClient } from '../transport/rpc-client' export type MobileStructuredSessionStatusFeed = { subscribe: (listener: () => void) => () => void getSnapshot: () => AgentSessionStatusSnapshot + getNamingSnapshot: () => AgentSessionConversationNames + getAssociationRevision: () => number } const feeds = new WeakMap() @@ -45,6 +52,8 @@ function streamRefusal(value: unknown): { code?: string; message?: string } | nu function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { let snapshot: AgentSessionStatusSnapshot = new Map() + let names: AgentSessionConversationNames = new Map() + const associations = new AgentSessionAssociationObservation() // `unavailable`: the host refused the method to phones; asked again only on a new connection. let stream: 'closed' | 'open' | 'unavailable' = 'closed' // Release ended streams so the client cannot replay them into later sessions. @@ -55,11 +64,16 @@ function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { pending() } const listeners = new Set<() => void>() - const setSnapshot = (next: AgentSessionStatusSnapshot): void => { - if (next === snapshot) { + const setSnapshot = ( + next: AgentSessionStatusSnapshot, + nextNames = names, + associationChanged = false + ): void => { + if (!associationChanged && next === snapshot && nextNames === names) { return } snapshot = next + names = nextNames for (const listener of listeners) { listener() } @@ -73,7 +87,12 @@ function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { revokeLive() return } - setSnapshot(foldAgentSessionStatusEvent(snapshot, raw)) + const associationChanged = associations.observe(raw) + setSnapshot( + foldAgentSessionStatusEvent(snapshot, raw), + foldAgentSessionConversationNames(names, raw), + associationChanged + ) return } const refusal = streamRefusal(raw) @@ -88,6 +107,7 @@ function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { } const open = (): void => { if (stream === 'closed' && listeners.size > 0) { + associations.renew() stream = 'open' const dispose = client.subscribe('agentSession.subscribeStatus', {}, onFrame) // A stream that ended inside `subscribe` is released at once. @@ -108,6 +128,7 @@ function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { if (stream === 'unavailable') { stream = 'closed' } + associations.renew() revokeLive() }) return { @@ -118,7 +139,9 @@ function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { listeners.delete(listener) } }, - getSnapshot: () => snapshot + getSnapshot: () => snapshot, + getNamingSnapshot: () => names, + getAssociationRevision: () => associations.revision } } diff --git a/mobile/src/test-support/bridged-parity/absent-result-evidence.ts b/mobile/src/test-support/bridged-parity/absent-result-evidence.ts new file mode 100644 index 00000000000..70832acbf99 --- /dev/null +++ b/mobile/src/test-support/bridged-parity/absent-result-evidence.ts @@ -0,0 +1,188 @@ +import { BridgeReplyRefusedError } from '../../mobile-web-shell/bridge/bridge-rpc-client' +import { readBridgeHostMessage } from '../../mobile-web-shell/bridge/bridge-envelope' +import type { BridgePortPair } from '../../mobile-web-shell/bridge/bridge-port-pair-test-harness' +import type { RpcClient } from '../../transport/rpc-client' +import { projectMobileRpcRequestParams } from '../../transport/mobile-rpc-request-projection' +import { + checkpointKeys, + checkpointListDifference, + recordingDifferences +} from '../rpc-recording/golden-difference' +import type { Recording, RecordingScenario } from '../rpc-recording/recording-scenario' +import { captureError, captureValue } from '../rpc-recording/recording-values' +import { ordinalField } from './divergence-classes' +import { + isKeyedObject, + sameParams, + postedRequestParams, + withoutRpcMeta +} from './divergence-evidence' + +/** Only a matched injected request and its observed refusal can explain a recorded residual. */ +export function absentResultExplainsResiduals( + expected: Recording, + actual: Recording, + scenarios: readonly RecordingScenario[], + pairs: readonly Pick, 'toShell' | 'toPage' | 'diagnostics'>[] +): boolean { + const list = checkpointListDifference(expected, actual) + if ( + expected.scenario !== actual.scenario || + list.missing.length || + list.extra.length || + list.reordered + ) { + return false + } + const keys = checkpointKeys(expected) + const actualKeys = checkpointKeys(actual) + const residuals = recordingDifferences(expected, actual, true).filter( + ({ field, path }) => !ordinalField(`${field}${path}`) + ) + return ( + residuals.length > 0 && + residuals.every((difference) => { + const before = expected.checkpoints[keys.indexOf(difference.checkpoint)]!.observation + const after = actual.checkpoints[actualKeys.indexOf(difference.checkpoint)]!.observation + return scenarios.some((scenario, scenarioIndex) => { + const pair = pairs[scenarioIndex] + const senders = before.sender + const bridgedSenders = after.sender + if (!pair || !Array.isArray(senders) || !Array.isArray(bridgedSenders)) { + return false + } + const prefix = scenarios.length > 1 ? `${scenario.id}:` : '' + const occurrences = new Map() + let observedAt = -1 + const aliases = new Map() + for (const [index, step] of scenario.steps.entries()) { + if ('checkpoint' in step) { + const id = `${prefix}${step.checkpoint}` + const occurrence = (occurrences.get(id) ?? 0) + 1 + occurrences.set(id, occurrence) + if ((occurrence === 1 ? id : `${id}#${occurrence}`) === difference.checkpoint) { + observedAt = index + } + } + } + if (difference.checkpoint === `${prefix}cleanup` && !occurrences.has(`${prefix}cleanup`)) { + observedAt = scenario.steps.length + } + return scenario.steps.some((step, stepIndex) => { + if ('bind' in step) { + aliases.set(step.bind, aliases.get(step.request) ?? step.request) + } + if ( + stepIndex >= observedAt || + !('complete' in step) || + step.reject || + !isKeyedObject(step.reply) || + step.reply.ok !== true || + Object.hasOwn(step.reply, 'result') + ) { + return false + } + const name = aliases.get(step.complete) ?? step.complete + const at = senders.findIndex((sender) => isKeyedObject(sender) && sender.name === name) + const sender = senders[at] + const bridged = bridgedSenders[at] + if ( + !isKeyedObject(sender) || + !isKeyedObject(bridged) || + !sameParams(sender.args, bridged.args) || + bridged.name !== name + ) { + return false + } + const native = sender.settlement + const rejected = bridged.settlement + if (!isKeyedObject(native) || native.status !== 'fulfilled' || !isKeyedObject(rejected)) { + return false + } + const payload = + Array.isArray(before.payloads) && + before.payloads.find((value) => isKeyedObject(value) && value.name === name) + if (!isKeyedObject(payload) || typeof payload.json !== 'string') { + return false + } + const physical: unknown = JSON.parse(payload.json) + if (!isKeyedObject(physical) || !sameParams(physical.params, step.params)) { + return false + } + const nativeReply = captureValue({ ...step.reply, id: physical.id }) + if (!sameParams(native.value, nativeReply)) { + return false + } + const method = name.slice(0, name.lastIndexOf('#')) + const occurrence = senders + .slice(0, at) + .filter( + (value) => + isKeyedObject(value) && + typeof value.name === 'string' && + value.name.startsWith(`${method}#`) + ).length + const request = postedRequestParams(pair.toShell, method, occurrence) + if ( + !request.found || + typeof request.id !== 'string' || + physical.method !== method || + !sameParams(projectMobileRpcRequestParams(method, request.params), step.params) + ) { + return false + } + return pair.toPage.some((json) => { + const frame: unknown = JSON.parse(json) + const read = readBridgeHostMessage(json) + if ( + read.ok || + !isKeyedObject(frame) || + frame.type !== 'reply' || + frame.id !== request.id || + !isKeyedObject(frame.payload) || + !sameParams( + withoutRpcMeta(captureValue(frame.payload)), + withoutRpcMeta(nativeReply) + ) || + !pair.diagnostics.some( + (diagnostic) => diagnostic.kind === 'refused' && diagnostic.refusal === read.refusal + ) + ) { + return false + } + const error = captureError(new BridgeReplyRefusedError(read.refusal)) + if ( + !sameParams(rejected, { + status: 'rejected', + startedAt: native.startedAt, + settledAt: native.settledAt, + error + }) + ) { + return false + } + if ( + difference.field === 'sender' && + difference.path.startsWith(`[${at}].settlement.`) + ) { + return true + } + // History copies this operation's error into the hook state and the panel's Text. + const historyPath = + (scenario.operation === 'aiVault.history-scan' && + difference.path === '.screenState.message') || + (scenario.operation === 'aiVault.history-screen' && + /^\.text\[\d+\]$/.test(difference.path)) + return ( + difference.field === 'state' && + historyPath && + ['status.get', 'aiVault.listSessions'].includes(method) && + difference.expected === `The host sent a reply this app could not read (${method})` && + difference.actual === new BridgeReplyRefusedError(read.refusal).message + ) + }) + }) + }) + }) + ) +} diff --git a/mobile/src/test-support/bridged-parity/c5-page-closure.test.ts b/mobile/src/test-support/bridged-parity/c5-page-closure.test.ts index 7c695552a28..0b703448cca 100644 --- a/mobile/src/test-support/bridged-parity/c5-page-closure.test.ts +++ b/mobile/src/test-support/bridged-parity/c5-page-closure.test.ts @@ -33,11 +33,11 @@ describe('the C5 page closure', () => { // Assert class totals beside per-id pins; a self-consistent misderived table can satisfy each pin while losing wrapped entries. it('pins how many goldens land in each class, which a per-id walk cannot see move', () => { expect(pageClosureTotals(C5_PAGE_CLOSURE)).toEqual({ - identical: 66, + identical: 61, 'result-absent-settlement': 49, 'params-undefined': 7, 'result-absent-stream-release': 3, - 'write-ordinal': 2 + 'write-ordinal': 7 }) }) @@ -60,12 +60,15 @@ describe('the C5 page closure', () => { ]) }) - it('has a byte-identical golden in every family it adds, unlike C2 s five', () => { + it('keeps byte-identical controls outside History and strict ordinal controls within it', () => { const added = Object.entries(C5_PAGE_CLOSURE).filter(([family]) => !(family in C1_PAGE_CLOSURE)) const wholly = added .filter(([, pins]) => !Object.values(pins).includes('identical')) .map(([family]) => family) - expect(wholly).toEqual([]) + expect(wholly.sort()).toEqual(['aiVault.history', 'aiVault.history-screen']) + for (const family of wholly) { + expect(Object.values(C5_PAGE_CLOSURE[family] ?? {})).toContain('write-ordinal') + } }) it('pins goldens that exist, in the family the corpus records them under', () => { @@ -92,7 +95,7 @@ describe('the C5 page closure', () => { it('excludes a closure golden only into a class that has a reason', () => { const exclusions = pageClosureExclusions(C5_PAGE_CLOSURE) - expect(exclusions.length).toBe(61) + expect(exclusions.length).toBe(66) expect(exclusions.filter(([, name]) => BRIDGED_PARITY_EXCLUSIONS[name] === undefined)).toEqual( [] ) diff --git a/mobile/src/test-support/bridged-parity/c5-page-closure.ts b/mobile/src/test-support/bridged-parity/c5-page-closure.ts index d5e5ae83427..4b66290a930 100644 --- a/mobile/src/test-support/bridged-parity/c5-page-closure.ts +++ b/mobile/src/test-support/bridged-parity/c5-page-closure.ts @@ -1,19 +1,19 @@ -// Pin agent-history closure verdicts per golden and inherit C1’s families without reclassification. +// History goldens require complete residual certification; unchanged families retain their legacy labels. // Measure the .web entry; its native sibling imports shell-only bundle operations that the page never loads. import type { PageClosurePins } from './page-closure' export const C5_PAGE_CLOSURE: PageClosurePins = { 'aiVault.history': { - 'aivault-history-scan-fulfilled': 'identical', - 'aivault-history-scan-unsupported': 'identical', - 'aivault-history-scan-worktrees-late': 'identical', + 'aivault-history-scan-fulfilled': 'write-ordinal', + 'aivault-history-scan-unsupported': 'write-ordinal', + 'aivault-history-scan-worktrees-late': 'write-ordinal', 'matrix-aivault.history-aivault.listsessions-1': 'result-absent-settlement', 'matrix-aivault.history-status.get-1': 'result-absent-settlement' }, 'aiVault.history-screen': { - 'aivault-history-screen-listed': 'identical', - 'aivault-history-screen-worktrees': 'identical', + 'aivault-history-screen-listed': 'write-ordinal', + 'aivault-history-screen-worktrees': 'write-ordinal', 'matrix-aivault.history-screen-platform-status': 'result-absent-settlement', 'matrix-aivault.history-screen-status.get-2': 'result-absent-settlement', 'matrix-aivault.history-screen-worktree.ps-1': 'result-absent-settlement' diff --git a/mobile/src/test-support/bridged-parity/divergence-classes.test.ts b/mobile/src/test-support/bridged-parity/divergence-classes.test.ts index ee118e96ef4..c04f3f98a1a 100644 --- a/mobile/src/test-support/bridged-parity/divergence-classes.test.ts +++ b/mobile/src/test-support/bridged-parity/divergence-classes.test.ts @@ -18,7 +18,7 @@ const base: BridgedParityEvidence = { fixedByReplyMeta: false, threwWhileRecording: false, divergingFields: [], - scriptsAbsentResultReply: false, + absentResultExplainsResiduals: false, paramsMismatch: null, refusalReleasedStream: false } @@ -37,7 +37,7 @@ describe('classifying one diverging golden', () => { it('names the missing field first, but only where supplying it was enough', () => { const absent = { ...base, - scriptsAbsentResultReply: true, + absentResultExplainsResiduals: true, divergingFields: ['sender[0].settlement'] } expect(classifyBridgedParity({ ...absent, fixedByReplyMeta: true })).toBe('reply-meta-required') @@ -45,7 +45,7 @@ describe('classifying one diverging golden', () => { }) it('splits the absent-result partition by what moved first', () => { - const absent = { ...base, scriptsAbsentResultReply: true } + const absent = { ...base, absentResultExplainsResiduals: true } expect( classifyBridgedParity({ ...absent, divergingFields: ['sender[0].settlement', 'effects'] }) ).toBe('result-absent-settlement') @@ -93,27 +93,41 @@ describe('classifying one diverging golden', () => { const released = { ...base, threwWhileRecording: true, - scriptsAbsentResultReply: true, + absentResultExplainsResiduals: true, refusalReleasedStream: true } expect(classifyBridgedParity(released)).toBe('result-absent-stream-release') - expect(classifyBridgedParity({ ...released, scriptsAbsentResultReply: false })).toBe( - 'unclassified' - ) expect(classifyBridgedParity({ ...released, refusalReleasedStream: false })).toBe( 'unclassified' ) }) - it('names the ordinal class ahead of the partition a matrix golden also carries', () => { + it('requires every difference to be a recorder ordinal before naming that class', () => { expect(classifyBridgedParity({ ...base, divergingFields: ['sender[0].ordinal'] })).toBe( 'write-ordinal' ) expect( classifyBridgedParity({ ...base, - scriptsAbsentResultReply: true, - divergingFields: ['payloads[0].ordinal', 'effects'] + absentResultExplainsResiduals: false, + divergingFields: ['payloads[0].ordinal', 'effects[0].value.ordinal'] + }) + ).toBe('unclassified') + expect(classifyBridgedParity({ ...base, divergingFields: ['state.ordinal'] })).toBe( + 'unclassified' + ) + }) + + it('keeps an explicit legacy membership label distinct from strict certification', () => { + const mixed = { + ...base, + divergingFields: ['sender[0].ordinal', 'payloads[1].json'] + } + expect(classifyBridgedParity(mixed)).toBe('unclassified') + expect( + classifyBridgedParity({ + ...mixed, + legacyBaseline: { firstField: 'sender[0].ordinal', scriptsAbsentResultReply: true } }) ).toBe('write-ordinal') }) diff --git a/mobile/src/test-support/bridged-parity/divergence-classes.ts b/mobile/src/test-support/bridged-parity/divergence-classes.ts index bcdc7c4dd59..1c8b30a8153 100644 --- a/mobile/src/test-support/bridged-parity/divergence-classes.ts +++ b/mobile/src/test-support/bridged-parity/divergence-classes.ts @@ -25,11 +25,13 @@ export type BridgedParityEvidence = { // Supply reply meta in a counterfactual run to isolate that cause from other divergences. fixedByReplyMeta: boolean threwWhileRecording: boolean - // Classify by the first differing field; retain all paths for diagnosis and distinguish missing checkpoints. + // Certify every differing leaf and require causal evidence for all nonordinal residuals. divergingFields: readonly string[] - scriptsAbsentResultReply: boolean + absentResultExplainsResiduals: boolean paramsMismatch: ParamsMismatchEvidence | null refusalReleasedStream: boolean + /** Unchanged corpus membership only; these legacy labels do not certify causal parity. */ + legacyBaseline?: { firstField: string | undefined; scriptsAbsentResultReply: boolean } } // Require at least one changed key and all changes to be scripted undefined; otherwise unrelated params bugs would be excluded. @@ -44,11 +46,11 @@ function settlementField(path: string): boolean { return path.endsWith('.settlement') || path.includes('.settlement.') } -function ordinalField(path: string): boolean { - return path.endsWith('.ordinal') +export function ordinalField(path: string): boolean { + return /^(sender|payloads|effects)\[\d+\]\.ordinal$/.test(path) } -// Test counterfactual causes before symptoms, then classify only evidence from the actual run. +// Certify complete residuals unless an unchanged corpus member requests its legacy label. export function classifyBridgedParity(evidence: BridgedParityEvidence): BridgedParityClass { if (evidence.fixedByReplyMeta) { return 'reply-meta-required' @@ -57,25 +59,37 @@ export function classifyBridgedParity(evidence: BridgedParityEvidence): BridgedP if (movedOnlyKeysScriptedUndefined(evidence.paramsMismatch)) { return 'params-undefined' } - return evidence.scriptsAbsentResultReply && evidence.refusalReleasedStream - ? 'result-absent-stream-release' + return evidence.refusalReleasedStream ? 'result-absent-stream-release' : 'unclassified' + } + const legacy = evidence.legacyBaseline + if (legacy !== undefined) { + if (legacy.firstField === undefined) { + return 'unclassified' + } + if (legacy.firstField.endsWith('.ordinal')) { + return 'write-ordinal' + } + return legacy.scriptsAbsentResultReply + ? settlementField(legacy.firstField) + ? 'result-absent-settlement' + : 'result-absent-observation' : 'unclassified' } - const [first] = evidence.divergingFields - if (first === undefined) { + if (evidence.divergingFields.length === 0) { return 'unclassified' } - // Ordinal-first divergence is real bridge scheduling; checking absent-result partitions first would swallow it. - if (ordinalField(first)) { + // Only ordinal residuals may receive the ordinal class. + const residuals = evidence.divergingFields.filter((path) => !ordinalField(path)) + if (residuals.length === 0) { return 'write-ordinal' } - if (evidence.scriptsAbsentResultReply) { - return settlementField(first) ? 'result-absent-settlement' : 'result-absent-observation' + if (evidence.absentResultExplainsResiduals) { + return settlementField(residuals[0]!) ? 'result-absent-settlement' : 'result-absent-observation' } return 'unclassified' } -// Only explicitly documented recorder/wire artifacts may be excluded; every other class must stay zero. +// Changed History goldens require causal evidence; unchanged corpus labels retain their legacy limits. export const BRIDGED_PARITY_EXCLUSIONS: Readonly>> = { 'result-absent-settlement': 'the recorder injects `{ ok: true }` with no `result` at the scripted sender port, below the ' + @@ -100,15 +114,15 @@ export const BRIDGED_PARITY_EXCLUSIONS: Readonly> = { - identical: 401, + identical: 396, 'reply-meta-required': 0, 'result-absent-settlement': 350, 'result-absent-observation': 3, 'result-absent-stream-release': 6, 'params-undefined': 27, - 'write-ordinal': 8, + 'write-ordinal': 13, unclassified: 0 } @@ -150,14 +164,19 @@ export const BRIDGED_PARITY_MEMBERS: Readonly< 'matrix-session.native-chat-page-nativechat.subscribe-1-1' ], 'write-ordinal': [ - 'settings-home-coalesced', - 'live-worktree-name-stream', + 'aivault-history-scan-fulfilled', + 'aivault-history-scan-unsupported', + 'aivault-history-scan-worktrees-late', + 'aivault-history-screen-listed', + 'aivault-history-screen-worktrees', 'host-worktree-refresh-stream', + 'live-worktree-name-stream', + 'matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1', + 'matrix-live-worktree-name-runtime.clientevents.subscribe-2-1', 'matrix-live-worktree-name-worktree.show-1', 'matrix-live-worktree-name-worktree.show-2', - 'matrix-live-worktree-name-runtime.clientevents.subscribe-2-1', 'matrix-live-worktree-name-worktree.show-3', - 'matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1' + 'settings-home-coalesced' ] } diff --git a/mobile/src/test-support/bridged-parity/divergence-evidence.test.ts b/mobile/src/test-support/bridged-parity/divergence-evidence.test.ts index 3b1abdd5ba0..39bfe3831e9 100644 --- a/mobile/src/test-support/bridged-parity/divergence-evidence.test.ts +++ b/mobile/src/test-support/bridged-parity/divergence-evidence.test.ts @@ -1,7 +1,18 @@ +import { absentResultExplainsResiduals } from './absent-result-evidence' import { describe, expect, it } from 'vitest' import { readBridgeHostMessage } from '../../mobile-web-shell/bridge/bridge-envelope' import type { Recording, RecordingScenario } from '../rpc-recording/recording-scenario' import type { BridgeRpcClientDiagnostic } from '../../mobile-web-shell/bridge/bridge-rpc-client' +import { recordingDifferences, valueDifferences } from '../rpc-recording/golden-difference' +import { classifyBridgedParity } from './divergence-classes' +import { strictReplayVerdict } from './replay-verdict' +import { + createBridgePortPair, + type BridgePortPair +} from '../../mobile-web-shell/bridge/bridge-port-pair-test-harness' +import type { RpcClient } from '../../transport/rpc-client' +import { ScriptedRpcTransport } from '../rpc-recording/scripted-rpc-transport' +import { agentHistoryHostStatusRead } from '../../agent-history/mobile-agent-history-operations' import { divergingFields, paramsMismatchEvidence, @@ -21,6 +32,23 @@ const scenario = (steps: RecordingScenario['steps']): RecordingScenario => ({ steps }) +function classify( + expected: Recording, + actual: Recording, + scenarios: RecordingScenario[] = [], + pairs: Parameters[3] = [] +) { + const explained = absentResultExplainsResiduals(expected, actual, scenarios, pairs) + return classifyBridgedParity({ + fixedByReplyMeta: false, + threwWhileRecording: false, + divergingFields: divergingFields(expected, actual), + absentResultExplainsResiduals: explained, + paramsMismatch: null, + refusalReleasedStream: false + }) +} + const recording = ( observation: Partial ): Recording => ({ @@ -102,6 +130,225 @@ describe('undoing the counterfactual', () => { }) describe('the fields that diverged', () => { + it('requires independent original and reply-meta evidence at the replay admission seam', () => { + const expected = recording({ + sender: [{ ordinal: 1 }], + payloads: [{ json: '{"id":"frame-1","method":"status.get"}' }] + }) + const ordinal = structuredClone(expected) + ordinal.checkpoints[0]!.observation.sender = [{ ordinal: 2 }] + const run = (value: Recording | null, thrown: unknown = null) => ({ + recording: value, + thrown, + pairs: [] + }) + expect(strictReplayVerdict(expected, [], run(ordinal), run(ordinal)).name).toBe('write-ordinal') + for (const original of [ + run(null, new Error('Original failed')), + run(expected, new Error('Late failure')) + ]) { + const result = strictReplayVerdict(expected, [], original, run(ordinal)) + expect(result.original.name).toBe('unclassified') + expect(result.replyMeta.name).toBe('write-ordinal') + expect(result.name).toBe('unclassified') + } + for (const change of [ + { state: { corrupted: true } }, + { payloads: [{ json: '{"id":"frame-2","method":"status.get"}' }] } + ]) { + const corrupt = structuredClone(ordinal) + Object.assign(corrupt.checkpoints[0]!.observation, change) + const result = strictReplayVerdict(expected, [], run(corrupt), run(ordinal)) + expect(result.original.name).toBe('unclassified') + expect(result.replyMeta.name).toBe('write-ordinal') + expect(result.name).toBe('unclassified') + expect(strictReplayVerdict(expected, [], run(ordinal), run(corrupt)).name).toBe( + 'unclassified' + ) + } + const meta = structuredClone(ordinal) + meta.checkpoints[0]!.observation.settlements = { + _meta: { runtimeId: 'counterfactual-runtime' } + } + expect(strictReplayVerdict(expected, [], run(ordinal), run(meta)).name).toBe('write-ordinal') + meta.checkpoints[0]!.observation.settlements = { _meta: { runtimeId: 'unexpected-runtime' } } + expect(strictReplayVerdict(expected, [], run(ordinal), run(meta)).name).toBe('unclassified') + }) + + it('keeps later wire differences and occurrence-aligned shared checkpoints despite list changes', () => { + const expected = recording({ + sender: [{ ordinal: 1, settlement: { value: { id: 'frame-2' } } }], + payloads: [{ ordinal: 1, json: 'subscribe' }, { json: 'frame-2' }] + }) + const actual = recording({ + sender: [{ ordinal: 2, settlement: { value: { id: 'frame-1' } } }], + payloads: [{ ordinal: 2, json: 'worktree' }, { json: 'frame-1' }] + }) + expect(divergingFields(expected, actual)[0]).toBe('sender[0].ordinal') + expect(classify(expected, actual)).toBe('unclassified') + expected.checkpoints.push(...structuredClone(expected.checkpoints)) + actual.checkpoints.unshift({ ...structuredClone(actual.checkpoints[0]!), id: 'extra' }) + actual.checkpoints.push(...structuredClone(actual.checkpoints.slice(1))) + expect( + recordingDifferences(expected, actual, true).map( + ({ checkpoint, field, path }) => `${checkpoint}:${field}${path}` + ) + ).toEqual( + ['only', 'only#2'].flatMap((key) => + [ + 'sender[0].ordinal', + 'sender[0].settlement.value.id', + 'payloads[0].json', + 'payloads[0].ordinal', + 'payloads[1].json' + ].map((path) => `${key}:${path}`) + ) + ) + const fields = divergingFields(expected, actual) + expect(fields[0]).toBe('checkpoints') + expect(classify(expected, actual)).toBe('unclassified') + expect( + valueDifferences({ a: null, b: { $rpc: 'undefined' }, c: [1, 2], d: {} }, { c: [3], d: [] }) + ).toMatchObject([ + { path: '.a', expected: null, expectedPresent: true, actualPresent: false }, + { path: '.b', expected: { $rpc: 'undefined' }, actualPresent: false }, + { path: '.c.length', expected: 2, actual: 1 }, + { path: '.c[0]', expected: 1, actual: 3 }, + { path: '.c[1]', expectedPresent: true, actualPresent: false }, + { path: '.d', expected: {}, actual: [] } + ]) + expect(divergingFields(recording({ state: Array(1) }), recording({ state: [] }))).toEqual([ + 'state.length' + ]) + }) + + it('attributes a real malformed exchange without excusing valid wire changes beside it', async () => { + const pairs: BridgePortPair[] = [] + const native = new ScriptedRpcTransport() + const makeBridge = (rewriteToPage?: (json: string) => string) => + new ScriptedRpcTransport( + () => 0, + undefined, + (rpc) => { + const pair = createBridgePortPair({ rpc, rewriteToPage }) + pairs.push(pair) + for (let round = 0; round < 4; round += 1) { + pair.drainNow() + } + return pair.client + } + ) + const bridge = makeBridge() + const metaBridge = makeBridge(withReplyMeta) + const pair = pairs[0]! + const metaPair = pairs[1]! + try { + const nativeReply = native.client.sendRequest('status.get', {}) + const nativeWorktrees = native.client.sendRequest('worktree.ps', { limit: 10000 }) + const bridgeReply = bridge.client + .sendRequest('status.get', {}) + .catch((error: unknown) => error) + const bridgeWorktrees = bridge.client + .sendRequest('worktree.ps', { limit: 10000 }) + .catch((error: unknown) => error) + const metaReply = metaBridge.client + .sendRequest('status.get', {}) + .catch((error: unknown) => error) + const metaWorktrees = metaBridge.client + .sendRequest('worktree.ps', { limit: 10000 }) + .catch((error: unknown) => error) + await Promise.all(pairs.map((entry) => entry.flush())) + native.complete('status.get#1', {}, { ok: true }) + bridge.complete('status.get#1', {}, { ok: true }) + metaBridge.complete('status.get#1', {}, { ok: true }) + const worktreeParams = { limit: 10000, supportsWorktreeVisibilitySourceDefaults: true } + native.complete('worktree.ps#1', worktreeParams, { ok: true }) + bridge.complete('worktree.ps#1', worktreeParams, { ok: true }) + metaBridge.complete('worktree.ps#1', worktreeParams, { ok: true }) + await Promise.all([nativeWorktrees, bridgeWorktrees, metaWorktrees]) + const [reply, error, metaError] = await Promise.all([nativeReply, bridgeReply, metaReply]) + await Promise.all(pairs.map((entry) => entry.flush())) + const message = 'The host sent a reply this app could not read (status.get)' + expect(() => agentHistoryHostStatusRead.interpret(reply)).toThrow(message) + if (!(error instanceof Error) || !(metaError instanceof Error)) { + throw new Error('missing actual bridge refusal') + } + const snapshot = (transport: ScriptedRpcTransport, text: string) => + recording({ + sender: structuredClone(transport.requests), + payloads: structuredClone(transport.payloads), + state: { screenState: { kind: 'error', message: text } } + }) + const expected = snapshot(native, message) + const actual = snapshot(bridge, error.message) + const injected = { + ...scenario([ + { complete: 'status.get#1', params: {}, reply: { ok: true } }, + { complete: 'worktree.ps#1', params: worktreeParams, reply: { ok: true } }, + { checkpoint: 'only' } + ]), + operation: 'aiVault.history-scan' + } + const original = { recording: actual, thrown: null, pairs: [pair] } + const replyMeta = { + recording: snapshot(metaBridge, metaError.message), + thrown: null, + pairs: [metaPair] + } + expect(strictReplayVerdict(expected, [injected], original, replyMeta).name).toBe( + 'result-absent-settlement' + ) + expect( + strictReplayVerdict(expected, [injected], { ...original, pairs: [] }, replyMeta).name + ).toBe('unclassified') + expect( + strictReplayVerdict(expected, [injected], original, { ...replyMeta, pairs: [] }).name + ).toBe('unclassified') + expect(classify(expected, actual, [injected], [pair])).toBe('result-absent-settlement') + const changed = structuredClone(actual) + changed.checkpoints[0]!.observation.payloads = [ + { name: 'status.get#1', ordinal: 2, json: '{"id":"frame-1","method":"wrong"}' } + ] + expect(classify(expected, changed, [injected], [pair])).toBe('unclassified') + expect( + strictReplayVerdict(expected, [injected], { ...original, recording: changed }, replyMeta) + .name + ).toBe('unclassified') + const sibling = scenario([{ checkpoint: 'only' }]) + sibling.id = 'normal' + const combined = (injection: Recording, normal: Recording): Recording => ({ + scenario: 'matrix', + checkpoints: [ + { ...normal.checkpoints[0]!, id: 'normal:only' }, + { ...injection.checkpoints[0]!, id: 's:only' } + ] + }) + expect( + classify( + combined(expected, expected), + combined(actual, changed), + [sibling, injected], + [pair, pair] + ) + ).toBe('unclassified') + expect( + classify( + expected, + actual, + [{ ...injected, steps: [{ checkpoint: 'only' }, injected.steps[0]!] }], + [pair] + ) + ).toBe('unclassified') + } finally { + for (const entry of pairs) { + entry.client.close() + entry.host.dispose() + } + native.dispose() + bridge.dispose() + metaBridge.dispose() + } + }) it('reports every one, not the first', () => { const expected = recording({ sender: [{ ordinal: 1 }], effects: [{ name: 'a' }] }) const actual = recording({ sender: [{ ordinal: 2 }], effects: [{ name: 'b' }] }) diff --git a/mobile/src/test-support/bridged-parity/divergence-evidence.ts b/mobile/src/test-support/bridged-parity/divergence-evidence.ts index e41873d3bbd..5d04fc62443 100644 --- a/mobile/src/test-support/bridged-parity/divergence-evidence.ts +++ b/mobile/src/test-support/bridged-parity/divergence-evidence.ts @@ -1,7 +1,6 @@ import type { BridgeRpcClientDiagnostic } from '../../mobile-web-shell/bridge/bridge-rpc-client' import { readBridgeHostMessage } from '../../mobile-web-shell/bridge/bridge-envelope' -import { firstDifference } from '../rpc-recording/golden-difference' -import { canonicalJson, OBSERVATION_FIELDS } from '../rpc-recording/golden-value-pool' +import { checkpointListDifference, recordingDifferences } from '../rpc-recording/golden-difference' import type { Observation, Recording, RecordingScenario } from '../rpc-recording/recording-scenario' import { captureValue, type RecordedValue } from '../rpc-recording/recording-values' import type { ParamsMismatchEvidence } from './divergence-classes' @@ -31,55 +30,50 @@ export function withReplyMeta(json: string): string { }) } -// Remove added meta before comparing runs; this corpus never otherwise records that key. -export function withoutRpcMeta(value: RecordedValue): RecordedValue { +// Remove only counterfactual meta when requested; preserve recorded meta evidence. +export function withoutRpcMeta(value: RecordedValue, injectedOnly = false): RecordedValue { if (Array.isArray(value)) { - return value.map(withoutRpcMeta) + return value.map((entry) => withoutRpcMeta(entry, injectedOnly)) } if (typeof value !== 'object' || value === null) { return value } return Object.fromEntries( Object.entries(value) - .filter(([key]) => key !== '_meta') - .map(([key, entry]) => [key, withoutRpcMeta(entry)]) + .filter( + ([key, entry]) => + key !== '_meta' || + (injectedOnly && + (!isKeyedObject(entry) || + Object.keys(entry).length !== 1 || + entry.runtimeId !== 'counterfactual-runtime')) + ) + .map(([key, entry]) => [key, withoutRpcMeta(entry, injectedOnly)]) ) } -export function recordingWithoutRpcMeta(recording: Recording): Recording { +export function recordingWithoutRpcMeta(recording: Recording, injectedOnly = false): Recording { return { scenario: recording.scenario, checkpoints: recording.checkpoints.map((checkpoint) => ({ id: checkpoint.id, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `withoutRpcMeta` preserves shape, so an observation maps to an observation. - observation: withoutRpcMeta(checkpoint.observation) as Observation + observation: withoutRpcMeta(checkpoint.observation, injectedOnly) as Observation })) } } // Collect every differing field; proving all changes are ordinals requires more than the first difference. -export function divergingFields(expected: Recording, actual: Recording): string[] { - const expectedIds = expected.checkpoints.map((checkpoint) => checkpoint.id) - const actualIds = actual.checkpoints.map((checkpoint) => checkpoint.id) - if (JSON.stringify(expectedIds) !== JSON.stringify(actualIds)) { +export function divergingFields(expected: Recording, actual: Recording, complete = true): string[] { + const list = checkpointListDifference(expected, actual) + if (!complete && (list.missing.length || list.extra.length || list.reordered)) { return ['checkpoints'] } - const fields: string[] = [] - for (const [index, checkpoint] of expected.checkpoints.entries()) { - const found = actual.checkpoints[index] - if (found === undefined) { - return ['checkpoints'] - } - for (const field of OBSERVATION_FIELDS) { - const mine = checkpoint.observation[field] - const theirs = found.observation[field] - if (canonicalJson(mine) === canonicalJson(theirs)) { - continue - } - fields.push(`${field}${firstDifference(mine, theirs).path}`) - } - } - return fields + return [ + ...(expected.scenario === actual.scenario ? [] : ['scenario']), + ...(list.missing.length || list.extra.length || list.reordered ? ['checkpoints'] : []), + ...recordingDifferences(expected, actual, complete).map(({ field, path }) => `${field}${path}`) + ] } export function scriptsAbsentResultReply(scenario: RecordingScenario): boolean { @@ -101,12 +95,12 @@ export function refusalReleasedStream(diagnostics: readonly BridgeRpcClientDiagn const REQUEST_PARAMS_MISMATCH = /^Request params mismatch: (?.+)#(?\d+)$/ -function isKeyedObject(value: unknown): value is Record { +export function isKeyedObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } // Use the transport’s own captureValue comparison so mismatch evidence matches its semantics. -function sameParams(left: unknown, right: unknown): boolean { +export function sameParams(left: unknown, right: unknown): boolean { return JSON.stringify(captureValue(left)) === JSON.stringify(captureValue(right)) } @@ -146,11 +140,11 @@ function differingParamPaths(scripted: unknown, arrived: unknown, path = ''): st return [path] } -function postedRequestParams( +export function postedRequestParams( posted: readonly string[], method: string, index: number -): { found: true; params: unknown } | { found: false } { +): { found: true; params: unknown; id: unknown } | { found: false } { const frames = posted .map((json): unknown => JSON.parse(json)) .filter((frame) => isKeyedObject(frame) && frame.type === 'request' && frame.method === method) @@ -158,7 +152,7 @@ function postedRequestParams( if (!isKeyedObject(frame)) { return { found: false } } - return { found: true, params: frame.params } + return { found: true, params: frame.params, id: frame.id } } // Unalignable mismatch evidence stays unclassified and loud instead of receiving a silent exclusion. diff --git a/mobile/src/test-support/bridged-parity/replay-verdict.ts b/mobile/src/test-support/bridged-parity/replay-verdict.ts new file mode 100644 index 00000000000..4e0a00a1822 --- /dev/null +++ b/mobile/src/test-support/bridged-parity/replay-verdict.ts @@ -0,0 +1,65 @@ +import type { BridgePortPair } from '../../mobile-web-shell/bridge/bridge-port-pair-test-harness' +import type { RpcClient } from '../../transport/rpc-client' +import type { Recording, RecordingScenario } from '../rpc-recording/recording-scenario' +import { absentResultExplainsResiduals } from './absent-result-evidence' +import { classifyBridgedParity } from './divergence-classes' +import { divergingFields, recordingWithoutRpcMeta } from './divergence-evidence' + +export type BridgedReplay = { + recording: Recording | null + thrown: unknown + pairs: BridgePortPair[] +} + +function certifyReplay( + expected: Recording, + scenarios: readonly RecordingScenario[], + run: BridgedReplay, + replyMeta: boolean +) { + const actual = + run.recording && (replyMeta ? recordingWithoutRpcMeta(run.recording, true) : run.recording) + const fields = actual ? divergingFields(expected, actual) : [] + if (!actual || run.thrown !== null) { + return { name: 'unclassified' as const, fields } + } + if (fields.length === 0) { + return { name: 'identical' as const, fields } + } + return { + name: classifyBridgedParity({ + fixedByReplyMeta: false, + threwWhileRecording: false, + divergingFields: fields, + absentResultExplainsResiduals: absentResultExplainsResiduals( + expected, + actual, + scenarios, + run.pairs + ), + paramsMismatch: null, + refusalReleasedStream: false + }), + fields + } +} + +/** Each replay must explain its own complete residuals before the counterfactual labels the pair. */ +export function strictReplayVerdict( + expected: Recording, + scenarios: readonly RecordingScenario[], + run: BridgedReplay, + asIf: BridgedReplay +) { + const original = certifyReplay(expected, scenarios, run, false) + const replyMeta = certifyReplay(expected, scenarios, asIf, true) + const name = + original.name === 'unclassified' || replyMeta.name === 'unclassified' + ? 'unclassified' + : replyMeta.name === 'identical' + ? original.name === 'identical' + ? 'identical' + : 'reply-meta-required' + : replyMeta.name + return { name, original, replyMeta } as const +} diff --git a/mobile/src/test-support/rpc-recording/adapters/agent-history-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/agent-history-mount-adapters.ts index e0673ccc6f1..36980411a4f 100644 --- a/mobile/src/test-support/rpc-recording/adapters/agent-history-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/agent-history-mount-adapters.ts @@ -1,7 +1,9 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import type { OperationExposure, operationModuleLoader } from '../operation-module-loader' +import { hostClientContextExposure, loadHostClientContext } from '../host-client-context-exposure' import type { MountAdapter, MountContext } from '../recording-scenario' +import type { RpcClientContextValue } from '../../../transport/rpc-client-context-contract' const HOST_ID = 'host-1' const WORKTREE_ID = 'worktree-1' @@ -14,11 +16,12 @@ const WORKTREES = [ const UNLOADED_WORKTREES: typeof WORKTREES = [] // Expose the real context provider so recordings observe acquisition/reconnect policy rather than reimplementing useHostClient. -export const agentHistoryMountExposures: readonly OperationExposure[] = [ - ['transport/client-context.tsx', '\nexports.RecordingHostClientContext = Ctx;'] -] +export const agentHistoryMountExposures: readonly OperationExposure[] = [hostClientContextExposure] -function hostClientContext(client: MountContext['client'], effect: MountContext['effect']) { +function hostClientContext( + client: MountContext['client'], + effect: MountContext['effect'] +): RpcClientContextValue { return { acquire: () => client, release: () => {}, @@ -55,9 +58,7 @@ export function agentHistoryMountAdapters( const useHistory = modules.load< typeof import('../../../agent-history/use-mobile-agent-history-state') >('mobile/src/agent-history/use-mobile-agent-history-state.ts').useMobileAgentHistoryState - const { RecordingHostClientContext } = modules.load<{ - RecordingHostClientContext: React.Context - }>('mobile/src/transport/client-context.tsx') + const RecordingHostClientContext = loadHostClientContext(modules) const context = hostClientContext(client, effect) // Use a holder because the component cannot assign an external bare binding. const observed: { history?: ReturnType } = {} diff --git a/mobile/src/test-support/rpc-recording/golden-difference.ts b/mobile/src/test-support/rpc-recording/golden-difference.ts index 6a3ec8e1370..caf04845780 100644 --- a/mobile/src/test-support/rpc-recording/golden-difference.ts +++ b/mobile/src/test-support/rpc-recording/golden-difference.ts @@ -21,6 +21,8 @@ export type RecordingDifference = { path: string expected: RecordedValue actual: RecordedValue + expectedPresent?: boolean + actualPresent?: boolean } export type GroupedDifference = Omit & { checkpoints: string[] } @@ -100,7 +102,7 @@ export function excerpt(value: RecordedValue, limit = 600): string { } // Key repeated checkpoint ids by occurrence so later instances remain distinguishable. -function checkpointKeys(recording: Recording): string[] { +export function checkpointKeys(recording: Recording): string[] { const seen = new Map() return recording.checkpoints.map((checkpoint) => { const occurrence = (seen.get(checkpoint.id) ?? 0) + 1 @@ -126,9 +128,54 @@ export function checkpointListDifference( } } +// Preserve absent members and array lengths as separate difference evidence. +export function valueDifferences( + expected: RecordedValue, + actual: RecordedValue, + path = '' +): Omit[] { + if (expected === actual) { + return [] + } + if ( + expected === null || + actual === null || + typeof expected !== 'object' || + typeof actual !== 'object' || + Array.isArray(expected) !== Array.isArray(actual) + ) { + return [{ path, expected, actual }] + } + const differences: Omit[] = [] + if (Array.isArray(expected) && Array.isArray(actual) && expected.length !== actual.length) { + differences.push({ path: `${path}.length`, expected: expected.length, actual: actual.length }) + } + const left = new Map(Object.entries(expected)) + const right = new Map(Object.entries(actual)) + for (const key of [...new Set([...left.keys(), ...right.keys()])].sort()) { + const next = Array.isArray(expected) ? `${path}[${key}]` : `${path}.${key}` + const mine = left.get(key) + const theirs = right.get(key) + if (!left.has(key) || !right.has(key)) { + differences.push({ + path: next, + expected: left.has(key) ? mine! : { $rpc: 'absent' }, + actual: right.has(key) ? theirs! : { $rpc: 'absent' }, + expectedPresent: left.has(key), + actualPresent: right.has(key) + }) + } else { + differences.push(...valueDifferences(mine!, theirs!, next)) + } + } + return differences +} + +// Align shared checkpoints by identity and occurrence even when lists differ. export function recordingDifferences( expected: Recording, - actual: Recording + actual: Recording, + complete = false ): RecordingDifference[] { const actualKeys = checkpointKeys(actual) const actualByKey = new Map( @@ -144,17 +191,12 @@ export function recordingDifferences( for (const field of OBSERVATION_FIELDS) { const left = checkpoint.observation[field] const right = found.observation[field] - if (canonicalJson(left) === canonicalJson(right)) { + if (!complete && canonicalJson(left) === canonicalJson(right)) { continue } - const at = firstDifference(left, right) - differences.push({ - checkpoint: key, - field, - path: at.path, - expected: at.expected, - actual: at.actual - }) + for (const at of complete ? valueDifferences(left, right) : [firstDifference(left, right)]) { + differences.push({ checkpoint: key, field, ...at }) + } } } return differences @@ -168,7 +210,9 @@ export function groupDifferences(differences: RecordingDifference[]): GroupedDif difference.field, difference.path, canonicalJson(difference.expected), - canonicalJson(difference.actual) + canonicalJson(difference.actual), + difference.expectedPresent, + difference.actualPresent ].join('\0') const group = groups.get(key) if (group) { diff --git a/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts b/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts index 620d261a421..b4877620867 100644 --- a/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts +++ b/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts @@ -9,7 +9,7 @@ import { operationModuleLoader } from './operation-module-loader' // Use synthetic trees so loader properties do not depend on current product imports. const roots: string[] = [] function sourceTree(files: Record): string { - const root = mkdtempSync(join(tmpdir(), 'rpc-loader-')) + const root = mkdtempSync(join(tmpdir(), 'pr-31602-rpc-loader-')) roots.push(root) for (const [name, source] of Object.entries(files)) { mkdirSync(join(root, 'mobile/src'), { recursive: true }) @@ -93,10 +93,10 @@ describe('the mounted module loader', () => { const root = sourceTree({ 'value.ts': source }) const path = 'mobile/src/value.ts' const baseline = operationModuleLoader(root).load<{ read: () => number }>(path) - const mutation = { name: 'value', file: 'value.ts', before: '= 1', after: '= 2' } + const mutation = { name: 'value', file: 'mobile\\src\\value.ts', before: '= 1', after: '= 2' } for (let index = 0; index < 2; index++) { const mutated = operationModuleLoader(root, mutation, [ - ['value.ts', '\nexports.exposed = () => value + 10'] + ['mobile\\src\\value.ts', '\nexports.exposed = () => value + 10'] ]) const result = mutated.load<{ read: () => number; exposed: () => number }>(path) expect(result.read()).toBe(2) diff --git a/mobile/src/test-support/rpc-recording/operation-module-loader.ts b/mobile/src/test-support/rpc-recording/operation-module-loader.ts index 740990159b5..9f117abb668 100644 --- a/mobile/src/test-support/rpc-recording/operation-module-loader.ts +++ b/mobile/src/test-support/rpc-recording/operation-module-loader.ts @@ -141,12 +141,13 @@ export function operationModuleLoader( return cached } let source = readFileSync(file, 'utf8') + const sourceIdentity = file.replaceAll('\\', '/') if (/mobile-tasks-(dependencies|legacy-foundation)\.tsx?$/.test(file)) { const result = barrel(file, source) cache.set(file, result) return result } - if (mutation && file.endsWith(mutation.file)) { + if (mutation && sourceIdentity.endsWith(mutation.file.replaceAll('\\', '/'))) { // Require exactly one anchor occurrence; replace alone would silently ignore duplicates. const occurrences = source.split(mutation.before).length - 1 if (occurrences !== 1) { @@ -157,10 +158,11 @@ export function operationModuleLoader( } const exports: OperationModule = {} cache.set(file, exports) - const exposure = exposures.find(([suffix]) => file.endsWith(suffix))?.[1] ?? '' + const exposure = + exposures.find(([suffix]) => sourceIdentity.endsWith(suffix.replaceAll('\\', '/')))?.[1] ?? '' const evaluate = compiledOperationModule(file, source, exposure) evaluate((name: string) => imported(file, name), exports) - observeSalvagedReads(file, exports) + observeSalvagedReads(sourceIdentity, exports) return exports } return { diff --git a/mobile/src/test-support/rpc-recording/rpc-recording-through-bridge.test.ts b/mobile/src/test-support/rpc-recording/rpc-recording-through-bridge.test.ts index 11657d58773..ca4efa599ad 100644 --- a/mobile/src/test-support/rpc-recording/rpc-recording-through-bridge.test.ts +++ b/mobile/src/test-support/rpc-recording/rpc-recording-through-bridge.test.ts @@ -1,3 +1,4 @@ +import { strictReplayVerdict, type BridgedReplay } from '../bridged-parity/replay-verdict' import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { readBridgeHostMessage } from '../../mobile-web-shell/bridge/bridge-envelope' @@ -34,8 +35,8 @@ import { divergingFields, paramsMismatchEvidence, recordingWithoutRpcMeta, - refusalReleasedStream, refusedFrames, + refusalReleasedStream, scriptsAbsentResultReply, withReplyMeta } from '../bridged-parity/divergence-evidence' @@ -48,11 +49,9 @@ import type { ScriptedClientWrapper } from './scripted-rpc-transport' import { runRecording } from './run-recording' import { vitestRecordingScheduler } from './vitest-recording-scheduler' -// Replay every golden through the page bridge without rewriting native evidence; RPC_FOUNDATION_BRIDGE=0 skips locally. -// Compare identical runs in full and pin divergence totals, small-class membership and each page closure independently. -// Exclusions cover recorder-injected absent results, undefined params lost above serialization, and logical write ordinals. -// Stream-release differences stem from cancelling refused injected frames, not a different native wire contract. -// The reader accepts the transport’s reply shapes; retain zero-pinned counterfactual-meta replay to detect future narrowing. +// Replay every golden through the page bridge; RPC_FOUNDATION_BRIDGE=0 skips locally. +// Certify complete residuals for changed History goldens; unchanged corpus members retain legacy labels. +// Preserve raw recordings and reply-meta counterfactuals; pin per-id verdicts, closure membership and totals. const root = resolve(import.meta.dirname, '../../../..') const input = readScenarios( @@ -67,11 +66,7 @@ const BUILD_ID = 'recording-build' /** `ready` out, `init` back: two deliveries, and a round to see that the session landed. */ const HANDSHAKE_ROUNDS = 4 -type Replay = { - recording: Recording | null - thrown: unknown - pairs: BridgePortPair[] -} +type Replay = BridgedReplay const counts: Record = { 'reply-meta-required': 0, @@ -178,8 +173,11 @@ async function verdict( observed.set(id, { family, verdict: name }) } const expected = readGolden(directory, id) + const certified = + Object.hasOwn(C5_PAGE_CLOSURE['aiVault.history'] ?? {}, id) || + Object.hasOwn(C5_PAGE_CLOSURE['aiVault.history-screen'] ?? {}, id) const fields = run.recording === null ? [] : divergingFields(expected.recording, run.recording) - if (run.recording !== null && fields.length === 0) { + if (!certified && run.recording !== null && fields.length === 0) { // Not redundant with the field walk: this one also pins the encoding. compareGolden(expected, { ...expected, recording: run.recording }) identical += 1 @@ -187,19 +185,49 @@ async function verdict( return } const asIf = await replay(id, scenarios, withReplyMeta) + if (certified) { + const result = strictReplayVerdict(expected.recording, scenarios, run, asIf) + if (result.name === 'unclassified') { + throw new Error( + `Unclassified bridged divergence: ${id}\noriginal (${result.original.name}):\n${explain(result.original.fields, run)}\nwith \`_meta\` supplied (${result.replyMeta.name}):\n${explain(result.replyMeta.fields, asIf)}` + ) + } + if (result.name === 'identical') { + compareGolden(expected, { ...expected, recording: run.recording! }) + identical += 1 + record('identical') + return + } + counts[result.name] += 1 + record(result.name) + members.set(result.name, [...(members.get(result.name) ?? []), id]) + if (!samples.has(result.name)) { + samples.set(result.name, `${id}\n${explain(fields, run)}`) + } + return + } const asIfFields = asIf.recording === null ? [] : divergingFields(expected.recording, recordingWithoutRpcMeta(asIf.recording)) // A scenario is handed a pair as it starts, so the last pair belongs to the one that threw. const failing = scenarios[asIf.pairs.length - 1] + const scriptsAbsent = scenarios.some(scriptsAbsentResultReply) const evidence: BridgedParityEvidence = { fixedByReplyMeta: asIf.recording !== null && asIfFields.length === 0, threwWhileRecording: asIf.recording === null, divergingFields: asIfFields, - scriptsAbsentResultReply: scenarios.some(scriptsAbsentResultReply), + absentResultExplainsResiduals: false, paramsMismatch: paramsMismatchEvidence(asIf.thrown, failing, asIf.pairs.at(-1)?.toShell ?? []), - refusalReleasedStream: asIf.pairs.some((pair) => refusalReleasedStream(pair.diagnostics)) + refusalReleasedStream: + scriptsAbsent && asIf.pairs.some((pair) => refusalReleasedStream(pair.diagnostics)), + legacyBaseline: { + firstField: + asIf.recording === null + ? undefined + : divergingFields(expected.recording, recordingWithoutRpcMeta(asIf.recording), false)[0], + scriptsAbsentResultReply: scriptsAbsent + } } const name = classifyBridgedParity(evidence) counts[name] += 1 @@ -216,7 +244,7 @@ async function verdict( } describe.skipIf(process.env[BRIDGED_PARITY_FLAG] === BRIDGED_PARITY_OFF)( - 'every golden replays through the page bridge, byte-identically or in a named class', + 'History parity certification and unchanged corpus baseline membership', () => { for (const pilot of pilotGoldens(input.scenarios)) { it(`${pilot.id}: bridged parity`, async () => { @@ -238,7 +266,7 @@ describe.skipIf(process.env[BRIDGED_PARITY_FLAG] === BRIDGED_PARITY_OFF)( golden.timeoutMs ) } - it('replays every golden byte for byte or in a class a predicate excludes by name', () => { + it('preserves measured strict and legacy membership without unexplained changed History residuals', () => { const table = [ `identical ${identical}`, ...Object.entries(counts).map(([name, count]) => { @@ -253,8 +281,12 @@ describe.skipIf(process.env[BRIDGED_PARITY_FLAG] === BRIDGED_PARITY_OFF)( ([name]) => BRIDGED_PARITY_EXCLUSIONS[asClass(name)] !== undefined ) const excludedCount = total(Object.fromEntries(excluded)) - process.stdout.write(`\nbridged parity over ${corpus} goldens\n${table}\n`) - process.stdout.write(`\nexcluded ${excludedCount} of ${corpus}, each with the reason it is\n`) + process.stdout.write( + `\nHistory certification and legacy membership over ${corpus} goldens\n${table}\n` + ) + process.stdout.write( + `\nlabelled recorder limitations ${excludedCount} of ${corpus}; unchanged labels are baseline only\n` + ) for (const [name, count] of excluded) { process.stdout.write(` ${name} ${count}: ${BRIDGED_PARITY_EXCLUSIONS[asClass(name)]}\n`) } diff --git a/src/main/ai-vault/structured-session-ownership-acp-and-pi.test.ts b/src/main/ai-vault/structured-session-ownership-acp-and-pi.test.ts index 3282b73c810..04f30d91e5d 100644 --- a/src/main/ai-vault/structured-session-ownership-acp-and-pi.test.ts +++ b/src/main/ai-vault/structured-session-ownership-acp-and-pi.test.ts @@ -1,4 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { readAcpFixture } from '../acp/acp-timeline-fixture.test-support' import type { AgentSessionProviderHandle } from '../../shared/agent-session-provider-handle' import type { AiVaultAgent } from '../../shared/ai-vault-types' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' @@ -42,7 +44,12 @@ const refused = (command: string) => 'agent_session_conflict' ) -function ownedRow(agent: AiVaultAgent, sessionId: string, filePath: string) { +function ownedRow( + agent: AiVaultAgent, + sessionId: string, + filePath: string, + title = 'First prompt' +) { const result = listResult() result.sessions = [ { @@ -50,7 +57,7 @@ function ownedRow(agent: AiVaultAgent, sessionId: string, filePath: string) { agent, sessionId, filePath, - title: 'First prompt' + title } ] return projectStructuredAiVaultSessions(result, OPENS_EVERY_STRUCTURED_CHAT).sessions[0] @@ -104,11 +111,14 @@ describe('Session History rows owned by OpenCode, Grok, OMP and Pi chats', () => filePath: PI_FILE } ])( - 'owns the $rowAgent row of a $handle.agent chat under its CLI title and refuses preparing its resume', + 'owns the $rowAgent row of a $handle.agent chat under the chat name and refuses preparing its resume', ({ handle, rowAgent, sessionId, filePath }) => { + // An unnamed chat's row keeps the CLI's title; once named, the row shows the chat's name. + installOwnership({ handle }) + expect(ownedRow(rowAgent, sessionId, filePath)).toMatchObject({ title: 'First prompt' }) installOwnership({ handle, conversationName: 'Quokka harbor chat' }) expect(ownedRow(rowAgent, sessionId, filePath)).toMatchObject({ - title: 'First prompt', + title: 'Quokka harbor chat', structuredSession: { sessionId: 'session-alpha', workspaceId: 'workspace-1' @@ -135,6 +145,38 @@ describe('Session History rows owned by OpenCode, Grok, OMP and Pi chats', () => }) }) +it('shows the saved native name over a recorded OMP provider title, retaining that title while unnamed', async () => { + // Scrubbed OMP 17.0.5 ACP output already replayed by acp-structured-omp-recordings.test.ts. + const frames = await readAcpFixture('omp-v17-reply-after-thought') + const info = frames.flatMap((frame) => { + const parsed = z + .object({ + sessionId: z.string(), + update: z.object({ sessionUpdate: z.literal('session_info_update'), title: z.string() }) + }) + .safeParse(frame.message.params) + return frame.direction === 'in' && frame.message.method === 'session/update' && parsed.success + ? [parsed.data] + : [] + })[0] + if (!info) { + throw new Error('Recorded OMP session title missing') + } + const handle = acp('omp', info.sessionId) + const row = () => + ownedRow('omp', info.sessionId, '/recording/omp-session.jsonl', info.update.title) + installOwnership({ handle }) + expect(row()).toMatchObject({ + title: info.update.title, + structuredSession: { sessionId: 'session-alpha' } + }) + installOwnership({ handle, conversationName: 'My saved OMP name' }) + expect(row()).toMatchObject({ + title: 'My saved OMP name', + structuredSession: { sessionId: 'session-alpha' } + }) +}) + describe('terminal resumes of a conversation an OpenCode chat owns', () => { it.each([ `opencode --session ${OPENCODE_ID}`, diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index 4f5688298b6..7316b3573ac 100644 --- a/src/main/ai-vault/structured-session-ownership.ts +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -2,7 +2,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication' import { defaultAgentChatLabel } from '../../shared/agent-session/agent-session-chat-label' import type { AiVaultListResult } from '../../shared/ai-vault-types' -import { historyRowShowsChatName } from '../../shared/structured-chat-row-name' +import { historyRowFallsBackToChatLabel } from '../../shared/structured-chat-row-name' import type { AiVaultSearchResponse } from '../../shared/ai-vault-search-types' import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' @@ -105,12 +105,10 @@ function ownedRow(rowAgent: string, ownership: StructuredProviderSessionOwnershi sessionId: ownership.sessionId, workspaceId: ownership.workspaceId } - return historyRowShowsChatName(rowAgent) - ? { - title: ownership.conversationName ?? defaultAgentChatLabel(ownership.provider), - structuredSession - } - : { structuredSession } + const title = + ownership.conversationName ?? + (historyRowFallsBackToChatLabel(rowAgent) ? defaultAgentChatLabel(ownership.provider) : null) + return title === null ? { structuredSession } : { title, structuredSession } } // Build one lookup per projection to avoid scanning all chat records for every row. diff --git a/src/main/ipc/ai-vault-host-leg-cache.ts b/src/main/ipc/ai-vault-host-leg-cache.ts index 6c1eb575db8..12c43c14752 100644 --- a/src/main/ipc/ai-vault-host-leg-cache.ts +++ b/src/main/ipc/ai-vault-host-leg-cache.ts @@ -1,3 +1,4 @@ +import type { AiVaultProjectionDependency } from './ai-vault-projection-dependency' import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' import type { AiVaultListResult } from '../../shared/ai-vault-types' import { @@ -9,6 +10,8 @@ import { export const AI_VAULT_CACHE_TTL_MS = 15_000 type CachedHostLeg = { + dependency?: AiVaultProjectionDependency + release?: () => void depth: AiVaultSessionDepth result: AiVaultListResult expiresAt: number @@ -21,7 +24,12 @@ let cacheGeneration = 0 // Retain rows behind unchanged replies for readers without fingerprints. const lastFullHostLegs = new Map< string, - { depth: AiVaultSessionDepth; result: AiVaultListResult } + { + depth: AiVaultSessionDepth + result: AiVaultListResult + dependency?: AiVaultProjectionDependency + release?: () => void + } >() const LAST_FULL_HOST_LEGS_MAX = 8 let expiryTimer: ReturnType | null = null @@ -45,9 +53,12 @@ function scheduleHostLegExpiry(): void { expiryTimer.unref?.() } -/** Cache only healthy legs so failing hosts retry without rescanning their neighbors. */ +/** Serves one host's leg from the per-host TTL cache, and stores it only when + * that host answered without a host issue — a failing host is retried on the + * next open while its healthy neighbours stay cached. */ export async function scanHostLegWithCache(args: { cacheKey: string + dependency?: AiVaultProjectionDependency depth: AiVaultSessionDepth scopePaths?: readonly string[] force: boolean @@ -60,6 +71,8 @@ export async function scanHostLegWithCache(args: { if ( !args.force && cached && + cached.dependency?.key === args.dependency?.key && + (cached.dependency?.isCurrent() ?? true) && cached.expiresAt > now && aiVaultCachedDepthServes(cached.depth, args.depth, args.scopePaths) && (!cached.result.unchanged || @@ -68,14 +81,15 @@ export async function scanHostLegWithCache(args: { return truncateAiVaultListResult(cached.result, args.depth, args.scopePaths) } const result = await args.scan() - // A pre-delete scan must not repopulate the invalidated cache. - if (startGeneration !== cacheGeneration) { + // A delete's invalidation landed while this leg was scanning; caching a + // pre-delete result would resurrect the deleted session for the TTL. + if (startGeneration !== cacheGeneration || args.dependency?.isCurrent() === false) { return result } if (result.issues.some((issue) => issue.kind === 'host')) { const current = cachedHostLegs.get(args.cacheKey) if (!current || current.expiresAt <= Date.now()) { - cachedHostLegs.delete(args.cacheKey) + dropEntry(cachedHostLegs, args.cacheKey) } return result } @@ -84,6 +98,8 @@ export async function scanHostLegWithCache(args: { if ( !args.force && current && + current.dependency?.key === args.dependency?.key && + (current.dependency?.isCurrent() ?? true) && current.expiresAt > Date.now() && aiVaultCachedDepthServes(current.depth, args.depth, args.scopePaths) && (!current.result.unchanged || @@ -91,36 +107,44 @@ export async function scanHostLegWithCache(args: { ) { return result } + dropEntry(cachedHostLegs, args.cacheKey) cachedHostLegs.set(args.cacheKey, { + dependency: args.dependency, + release: args.dependency?.retain(), depth: args.depth, - result: rememberFullHostLeg(args.cacheKey, args.depth, result), + result: rememberFullHostLeg(args.cacheKey, args.depth, result, args.dependency), expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS }) scheduleHostLegExpiry() return result } +/** Returns the full list an `unchanged` reply stands for when one is known. */ function rememberFullHostLeg( cacheKey: string, depth: AiVaultSessionDepth, - result: AiVaultListResult + result: AiVaultListResult, + dependency?: AiVaultProjectionDependency ): AiVaultListResult { if (!result.fingerprint) { return result } const previous = lastFullHostLegs.get(cacheKey) if (result.unchanged) { - return previous?.depth === depth && previous.result.fingerprint === result.fingerprint + return previous?.depth === depth && + previous.dependency?.key === dependency?.key && + (previous.dependency?.isCurrent() ?? true) && + previous.result.fingerprint === result.fingerprint ? { ...previous.result, scannedAt: result.scannedAt } : result } - lastFullHostLegs.delete(cacheKey) - lastFullHostLegs.set(cacheKey, { depth, result }) + dropEntry(lastFullHostLegs, cacheKey) + lastFullHostLegs.set(cacheKey, { depth, result, dependency, release: dependency?.retain() }) for (const oldest of lastFullHostLegs.keys()) { if (lastFullHostLegs.size <= LAST_FULL_HOST_LEGS_MAX) { break } - lastFullHostLegs.delete(oldest) + dropEntry(lastFullHostLegs, oldest) } return result } @@ -128,20 +152,36 @@ function rememberFullHostLeg( function pruneExpiredHostLegs(now: number): void { for (const [cacheKey, entry] of cachedHostLegs) { if (entry.expiresAt <= now) { - cachedHostLegs.delete(cacheKey) + dropEntry(cachedHostLegs, cacheKey) } } } -// Drop cached results after deletion; disk discovery already excludes the deleted file. -export function invalidateAiVaultHostLegCache(): void { - cacheGeneration++ - cachedHostLegs.clear() - lastFullHostLegs.clear() +// Drops every cached host leg after a session is deleted so a non-force list +// call within the TTL can't still serve the trashed session. Scan discovery +// walks disk first, so a deleted file is never rediscovered — this only guards +// the cached RESULT. +function dropEntry void }>(entries: Map, key: string): void { + entries.get(key)?.release?.() + entries.delete(key) +} + +export function invalidateAiVaultHostLegCache(environmentId?: string): void { + if (environmentId === undefined) { + cacheGeneration++ + } + for (const entries of [cachedHostLegs, lastFullHostLegs]) { + for (const [key, entry] of entries) { + if (environmentId === undefined || entry.dependency?.hosts.includes(environmentId)) { + dropEntry(entries, key) + } + } + } if (expiryTimer !== null) { clearTimeout(expiryTimer) } expiryTimer = null + scheduleHostLegExpiry() } export function resetAiVaultHostLegCacheForTests(): void { diff --git a/src/main/ipc/ai-vault-projection-dependency.ts b/src/main/ipc/ai-vault-projection-dependency.ts new file mode 100644 index 00000000000..745107a70d3 --- /dev/null +++ b/src/main/ipc/ai-vault-projection-dependency.ts @@ -0,0 +1,112 @@ +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { AgentSessionAssociationObservation } from '../../shared/agent-session-status-snapshot-fold' +import { getRuntimeEnvironmentTransportGeneration } from './runtime-environments/runtime-environment-transport-generation' + +type Authority = { identity: string; revision: number; references: number } +const authorities = new Map() +let sequence = 0 +export type AiVaultProjectionDependency = { + key: string + hosts: readonly string[] + isCurrent: () => boolean + retain: () => () => void + release: () => void + forHost: (host: string) => AiVaultProjectionDependency +} + +/** Only subscriptions, pending requests and retained projected answers hold authority tokens. */ +export function captureAiVaultProjectionDependency( + hosts: readonly string[], + identity: (host: string) => string +): AiVaultProjectionDependency { + const qualified = hosts.map((host) => ({ host, hostIdentity: identity(host) })) + const held = qualified.map(({ host, hostIdentity }) => { + let authority = authorities.get(host) + if (!authority || authority.identity !== hostIdentity) { + authority = { identity: hostIdentity, revision: ++sequence, references: 0 } + authorities.set(host, authority) + } + return { + host, + authority, + revision: authority.revision, + transport: getRuntimeEnvironmentTransportGeneration(host) + } + }) + const select = (selected: typeof held, owned: boolean): AiVaultProjectionDependency => { + const retain = () => { + selected.forEach(({ authority }) => authority.references++) + let released = false + return () => { + if (released) { + return + } + released = true + for (const { host, authority } of selected) { + if (--authority.references === 0 && authorities.get(host) === authority) { + authorities.delete(host) + } + } + } + } + return { + hosts: selected.map(({ host }) => host), + key: JSON.stringify( + selected.map(({ host, authority, revision, transport }) => [ + host, + authority.identity, + revision, + transport + ]) + ), + isCurrent: () => + selected.every( + ({ host, authority, revision, transport }) => + authorities.get(host) === authority && + authority.revision === revision && + getRuntimeEnvironmentTransportGeneration(host) === transport + ), + retain, + release: owned ? retain() : () => {}, + forHost: (host) => + select( + selected.filter((entry) => entry.host === host), + false + ) + } + } + return select(held, true) +} + +export function createAiVaultAssociationObserver( + environmentId: string, + identity: string, + invalidate: (environmentId: string) => void +) { + const dependency = captureAiVaultProjectionDependency([environmentId], () => identity) + const authority = authorities.get(environmentId)! + const observation = new AgentSessionAssociationObservation() + return { + observe: (frame: unknown) => { + if (authorities.get(environmentId) === authority && observation.observe(frame)) { + authority.revision = ++sequence + invalidate(environmentId) + } + }, + dispose: dependency.release + } +} + +export function captureRuntimeAiVaultProjectionDependency( + hosts: readonly string[], + getUserDataPath?: () => string +): AiVaultProjectionDependency { + return captureAiVaultProjectionDependency(hosts, (environmentId) => { + const path = getUserDataPath?.() + const environment = path ? resolveEnvironment(path, environmentId) : undefined + return JSON.stringify([ + environment?.runtimeId, + environment?.pairingRevision ?? environment?.createdAt + ]) + }) +} diff --git a/src/main/ipc/ai-vault-scan-coalescing.test.ts b/src/main/ipc/ai-vault-scan-coalescing.test.ts index cb810d60821..8bfbcd2eb30 100644 --- a/src/main/ipc/ai-vault-scan-coalescing.test.ts +++ b/src/main/ipc/ai-vault-scan-coalescing.test.ts @@ -1,17 +1,18 @@ import { EventEmitter } from 'node:events' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AiVaultListResult } from '../../shared/ai-vault-types' const mocks = vi.hoisted(() => ({ scanAiVaultSessionsInService: vi.fn(), resolveAiVaultSessionTitlesInService: vi.fn(), scanRuntimeAiVaultSessions: vi.fn(), - ipcHandle: vi.fn() + ipcHandle: vi.fn(), + subscribe: vi.fn() })) vi.mock('electron', () => ({ app: { on: vi.fn() }, - ipcMain: { handle: mocks.ipcHandle }, + ipcMain: { handle: mocks.ipcHandle, on: vi.fn() }, BrowserWindow: { getAllWindows: () => [] } })) vi.mock('../ai-vault/session-scanner-service-spawn', async (importOriginal) => ({ @@ -19,6 +20,22 @@ vi.mock('../ai-vault/session-scanner-service-spawn', async (importOriginal) => ( scanAiVaultSessionsInService: mocks.scanAiVaultSessionsInService, resolveAiVaultSessionTitlesInService: mocks.resolveAiVaultSessionTitlesInService })) +vi.mock('./runtime-environments/runtime-environment-transport-routing', () => ({ + subscribeRuntimeEnvironment: mocks.subscribe +})) +vi.mock('../../shared/runtime-environment-store', () => ({ + resolveEnvironment: (_path: string, id: string) => ({ + id, + runtimeId: id, + pairingRevision: 1, + createdAt: 1 + }) +})) +import { createAiVaultAssociationObserver } from './ai-vault-projection-dependency' +import { invalidateAiVaultHostLegCache } from './ai-vault-host-leg-cache' +import { registerRuntimeEnvironmentSubscriptionHandlers } from './runtime-environments/runtime-environment-subscription-handlers' +import { advanceRuntimeEnvironmentTransportGeneration } from './runtime-environments/runtime-environment-transport-generation' +import type { subscribeRuntimeEnvironment } from './runtime-environments/runtime-environment-transport-routing' vi.mock('../wsl', () => ({ listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]), hasCachedWslDistros: vi.fn(() => false) @@ -138,3 +155,200 @@ function ipcHandler(channel: string): (...args: unknown[]) => unknown { } return registration[1] } + +afterEach(() => { + _internals.resetAiVaultCacheForTests() + vi.useRealTimers() +}) + +it('orders forwarded association edges before new remote/All admissions and fences other-window work', async () => { + vi.useFakeTimers() + const retained = new Map() + const pending = new Map() + const forwarding: (() => Promise)[] = [] + const sender = Object.assign(new EventEmitter(), { + id: 91, + isDestroyed: () => false, + send: vi.fn(() => { + forwarding.push(() => list('runtime:a')) + }) + }) + let callbacks: Parameters[5] | undefined + mocks.subscribe.mockImplementation( + async (...args: Parameters) => { + callbacks = args[5] + return { requestId: 'status', sendBinary: () => true, close: () => {} } + } + ) + let observerThrows = false + const recordLayoutFrame = vi.fn() + registerRuntimeEnvironmentSubscriptionHandlers({ + getUserDataPath: () => '/profile', + remoteRuntimeSubscriptions: retained, + pendingSubscriptions: pending, + createAssociationObserver: (host, identity) => { + const observer = createAiVaultAssociationObserver( + host, + identity, + invalidateAiVaultHostLegCache + ) + return { + ...observer, + observe: (frame) => { + if (observerThrows) { + throw new Error('observer') + } + observer.observe(frame) + } + } + }, + recordLayoutFrame + }) + registerAiVaultHandlers({ + getUserDataPath: () => '/profile', + getActiveRuntimeAiVaultHostInfos: () => [ + { environmentId: 'a', executionHostId: 'runtime:a' }, + { environmentId: 'b', executionHostId: 'runtime:b' } + ], + scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions + }) + const list = (executionHostScope: 'runtime:a' | 'runtime:b' | 'all', scopePaths: string[] = []) => + _internals.listAiVaultSessions({ executionHostScope, scopePaths, limit: 250 }) + await ipcHandler('runtimeEnvironments:subscribe')( + { sender }, + { selector: 'a', method: 'agentSession.subscribeStatus', subscriptionId: 'association' } + ) + const summary = { + sessionId: 'native', + workspaceId: 'workspace', + agent: 'pi', + providerSession: { key: 'session_id', id: '/old-provider' } + } + const frame = (result: unknown) => + callbacks?.onEvent({ + type: 'response', + response: { id: 'status', ok: true, result, _meta: { runtimeId: 'a' } } + }) + frame({ type: 'snapshot', sessions: [summary] }) + forwarding.length = 0 + let detached = false + const answer = (host: string): AiVaultListResult => ({ + ...EMPTY_RESULT, + fingerprint: detached ? 'detached' : 'owned', + sessions: [ + { + id: `runtime:${host}:codex:host-session:/host/transcript.jsonl`, + executionHostId: `runtime:${host}`, + agent: 'codex', + sessionId: 'host-session', + cwd: '/host/folder', + branch: null, + model: null, + filePath: '/host/transcript.jsonl', + codexHome: '/host/codex', + createdAt: null, + updatedAt: null, + modifiedAt: '', + messageCount: 2, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'host-resume-command', + subagent: null, + title: detached ? 'Provider title' : 'Manual name', + ...(detached + ? {} + : { structuredSession: { sessionId: 'native', workspaceId: 'workspace' } }) + } + ] + }) + mocks.scanRuntimeAiVaultSessions.mockImplementation(async (host) => answer(host)) + await list('runtime:a') + await list('runtime:b') + await list('all') + const baseline = mocks.scanRuntimeAiVaultSessions.mock.calls.length + for (const n of [10, 100]) { + for (let i = 0; i < n; i++) { + frame({ type: 'status', session: { ...summary, updatedAt: i, conversationName: `${i}` } }) + frame({ type: 'snapshot', sessions: [], namingActiveSessionIds: [], conversationNames: [] }) + frame({ type: 'snapshot', sessions: [] }) + } + await list('runtime:a') + await list('runtime:b') + await list('all') + expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledTimes(baseline) + } + const held: (() => void)[] = [] + mocks.scanRuntimeAiVaultSessions.mockImplementation((host, args) => { + const result = answer(host) + return args.scopePaths?.includes('/race') && !detached + ? new Promise((resolve) => held.push(() => resolve(result))) + : Promise.resolve(result) + }) + const oldA = list('runtime:a', ['/race']) + const oldAll = list('all', ['/race']) + const oldB = list('runtime:b', ['/race']) + await vi.waitFor(() => expect(held).toHaveLength(4)) + const warmRaceScannerStarts = mocks.scanAiVaultSessionsInService.mock.calls.length + forwarding.length = 0 + detached = true + frame({ type: 'status', session: { ...summary, providerSession: undefined } }) + // invoke admits these after the forwarding callback has returned, as Electron does. + expect((await forwarding[0]!()).sessions[0]?.structuredSession).toBeUndefined() + const currentA = await list('runtime:a', ['/race']) + const currentAll = await list('all', ['/race']) + expect(currentA.sessions[0]?.title).toBe('Provider title') + expect(currentAll.sessions.every((row) => row.structuredSession === undefined)).toBe(true) + const bCalls = mocks.scanRuntimeAiVaultSessions.mock.calls.filter(([host]) => host === 'b').length + const bWaiter = list('runtime:b', ['/race']) + held.forEach((finish) => finish()) + await Promise.all([oldA, oldAll, oldB, bWaiter]) + expect(mocks.scanRuntimeAiVaultSessions.mock.calls.filter(([host]) => host === 'b')).toHaveLength( + bCalls + ) + expect((await list('runtime:b', ['/race'])).sessions[0]?.title).toBe('Manual name') + expect((await list('runtime:a', ['/race'])).sessions[0]?.title).toBe('Provider title') + expect((await list('all', ['/race'])).sessions).toEqual(currentAll.sessions) + // Expired projected caches must not resurrect the pre-edge last-full answer. + await vi.advanceTimersByTimeAsync(15_000) + mocks.scanRuntimeAiVaultSessions.mockResolvedValueOnce({ + ...EMPTY_RESULT, + fingerprint: 'owned', + unchanged: true + }) + expect((await list('runtime:a')).sessions).toEqual([]) + expect(mocks.scanAiVaultSessionsInService).toHaveBeenCalledTimes(warmRaceScannerStarts) + expect(mocks.scanRuntimeAiVaultSessions.mock.calls.every(([, args]) => args.force !== true)).toBe( + true + ) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + observerThrows = true + frame({ type: 'snapshot', sessions: [] }) + expect(warning).toHaveBeenCalled() + await forwarding.at(-1)!() + warning.mockRestore() + const sends = sender.send.mock.calls.length + advanceRuntimeEnvironmentTransportGeneration('a') + frame({ type: 'snapshot', sessions: [] }) + expect(sender.send).toHaveBeenCalledTimes(sends) + await ipcHandler('runtimeEnvironments:unsubscribe')({ sender }, { subscriptionId: 'association' }) + expect(retained.size).toBe(0) + await ipcHandler('runtimeEnvironments:subscribe')( + { sender }, + { selector: 'a', method: 'agentSession.subscribeStatus', subscriptionId: 'renewed' } + ) + observerThrows = false + await list('runtime:a') + frame({ type: 'status', session: { ...summary, providerSession: undefined } }) + await list('runtime:a') + const beforeRenewal = mocks.scanRuntimeAiVaultSessions.mock.calls.length + frame({ type: 'snapshot', sessions: [] }) + await list('runtime:a') + expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledTimes(beforeRenewal + 1) + await ipcHandler('runtimeEnvironments:unsubscribe')({ sender }, { subscriptionId: 'renewed' }) + const disposedSends = sender.send.mock.calls.length + frame({ type: 'status', session: summary }) + expect(sender.send).toHaveBeenCalledTimes(disposedSends) + expect(retained.size).toBe(0) +}) diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 9c222bbcf7a..5a73496a65a 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -1,3 +1,7 @@ +import { + captureRuntimeAiVaultProjectionDependency, + type AiVaultProjectionDependency +} from './ai-vault-projection-dependency' import { app, ipcMain } from 'electron' import { configureAiVaultSessionSources, @@ -32,7 +36,7 @@ import { parseRoutableExecutionHostId, requestedExecutionHostScope, toRuntimeExecutionHostId, - type ExecutionHostScope + type ExecutionHostId } from '../../shared/execution-host' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' import { discoverAiVaultHosts, type AiVaultHostDiscoveryResult } from './ai-vault-host-discovery' @@ -64,11 +68,16 @@ import { registerAiVaultScanProgressBroadcast } from './ai-vault-scan-progress-b type AiVaultHandlerOptions = AiVaultSessionSources & AiVaultResumeHandlerOptions & { + getUserDataPath?: () => string getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[] scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner resolveRuntimeAiVaultSessionTitles?: RuntimeAiVaultSessionTitleResolver } +type AiVaultScanHostScope = + | { scope: ExecutionHostId } + | { scope: 'all'; runtimeHosts: AiVaultHostDiscoveryResult } + let scanCoordinator = new AiVaultScanCoordinator() let handlerOptions: AiVaultHandlerOptions = {} const listCancellations = createSenderScopedRequestCancellations() @@ -87,6 +96,19 @@ async function listAiVaultSessions( options: { signal?: AbortSignal } = {} ): Promise { const executionHostScope = requestedExecutionHostScope(args?.executionHostScope) + const hostScope: AiVaultScanHostScope = + executionHostScope === 'all' + ? { scope: executionHostScope, runtimeHosts: getActiveRuntimeAiVaultHostInfosResult() } + : { scope: executionHostScope } + const parsedHost = parseRoutableExecutionHostId(executionHostScope) + const dependency = captureRuntimeAiVaultProjectionDependency( + hostScope.scope === 'all' + ? hostScope.runtimeHosts.hostInfos.map((host) => host.environmentId) + : parsedHost?.kind === 'runtime' + ? [parsedHost.environmentId] + : [], + handlerOptions.getUserDataPath + ) // Canonicalize bounded workspace sets so equivalent scopes share a scan. const scopePaths = args?.scopePaths ?? [] const key = JSON.stringify({ @@ -102,47 +124,55 @@ async function listAiVaultSessions( parseRoutableExecutionHostId(executionHostScope)?.kind === 'runtime' ? args?.afterFingerprint : undefined - // Coalesced scans abort only after every same-key caller cancels. - return scanCoordinator.run({ - key: JSON.stringify({ key, depth, afterFingerprint }), - force: args?.force, - signal: options.signal, - start: (scanSignal) => { - const scan = () => scanAiVaultSessionsByHostScope(args, executionHostScope, scanSignal, key) - if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { - return scan() + // Why: every renderer request carries its own cancellation signal, so + // coalescing has to survive them — the coordinator hands all same-key callers + // one scan and only aborts it once every one of them has cancelled. + return scanCoordinator + .run({ + key: JSON.stringify({ key, depth, afterFingerprint, projection: dependency.key }), + force: args?.force, + signal: options.signal, + start: (scanSignal) => { + const scan = () => + scanAiVaultSessionsByHostScope(args, hostScope, scanSignal, key, dependency) + if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { + return scan() + } + return scanHostLegWithCache({ + cacheKey: key, + dependency, + depth, + scopePaths, + force: args?.force === true, + afterFingerprint, + scan + }) } - return scanHostLegWithCache({ - cacheKey: key, - depth, - scopePaths, - force: args?.force === true, - afterFingerprint, - scan - }) - } - }) + }) + .finally(dependency.release) } async function scanAiVaultSessionsByHostScope( args: AiVaultListArgs | undefined, - executionHostScope: ExecutionHostScope, + hostScope: AiVaultScanHostScope, signal?: AbortSignal, - cacheKey = '' + cacheKey = '', + dependency?: AiVaultProjectionDependency ): Promise { const depth = requestedAiVaultSessionDepth(args) const scopePaths = args?.scopePaths ?? [] - if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { + if (hostScope.scope === LOCAL_EXECUTION_HOST_ID) { return scanLocalAiVaultSessionsAsIssue(args, signal) } - if (executionHostScope === 'all') { - const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult() + if (hostScope.scope === 'all') { + const { runtimeHosts } = hostScope const runtimeResults = runtimeHosts.issue ? [runtimeHosts.issue] : [] const scannedResults = await Promise.all([ scanLocalAiVaultSessionsAsIssue(args, signal), ...runtimeHosts.hostInfos.map((hostInfo) => scanHostLegWithCache({ cacheKey: `${cacheKey}|${hostInfo.executionHostId}`, + dependency: dependency?.forHost(hostInfo.environmentId), depth, scopePaths, force: args?.force === true, @@ -167,6 +197,7 @@ async function scanAiVaultSessionsByHostScope( ) } + const executionHostScope = hostScope.scope const parsed = parseRoutableExecutionHostId(executionHostScope) if (parsed?.kind === 'runtime') { return scanRuntimeAiVaultSessions({ @@ -200,7 +231,7 @@ async function scanLocalAiVaultSessionsAsIssue( signal: AbortSignal | undefined ): Promise { try { - return await scanLocalAiVaultSessions(args, signal) + return await listCachedLocalAiVaultSessions(args, { signal }) } catch (error) { if (isAiVaultScanCancelledError(error)) { throw error @@ -217,14 +248,6 @@ async function scanLocalAiVaultSessionsAsIssue( } } -async function scanLocalAiVaultSessions( - args?: AiVaultListArgs, - signal?: AbortSignal -): Promise { - // Share runtime/mobile cache and Codex/WSL source injection with desktop IPC. - return listCachedLocalAiVaultSessions(args, { signal }) -} - export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void { handlerOptions = options // Configure the same source/cache instance used by runtime RPC and headless serve. diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index a7bb19fea14..c0169e4dccb 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -269,6 +269,7 @@ export function registerCoreHandlers( ensureStructuredSessionOwnership }) registerAiVaultHandlers({ + getUserDataPath: () => app.getPath('userData'), ensureStructuredSessionOwnership, getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume, diff --git a/src/main/ipc/runtime-environments/runtime-environment-subscription-handlers.ts b/src/main/ipc/runtime-environments/runtime-environment-subscription-handlers.ts index 0f802f8a09b..c6921d5c83a 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-subscription-handlers.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-subscription-handlers.ts @@ -25,14 +25,23 @@ export function registerRuntimeEnvironmentSubscriptionHandlers(args: { getUserDataPath: () => string remoteRuntimeSubscriptions: Map pendingSubscriptions: Map + createAssociationObserver?: ( + environmentId: string, + identity: string + ) => { observe: (frame: unknown) => void; dispose: () => void } recordLayoutFrame: ( environmentId: string, method: string, response: RuntimeRpcResponse ) => void }): void { - const { getUserDataPath, remoteRuntimeSubscriptions, pendingSubscriptions, recordLayoutFrame } = - args + const { + getUserDataPath, + remoteRuntimeSubscriptions, + pendingSubscriptions, + recordLayoutFrame, + createAssociationObserver + } = args ipcMain.handle( 'runtimeEnvironments:subscribe', async ( @@ -80,10 +89,21 @@ export function registerRuntimeEnvironmentSubscriptionHandlers(args: { const sender = event.sender const ownerWebContentsId = sender.id const setupController = new AbortController() + const associationObserver = + args.method === 'agentSession.subscribeStatus' + ? createAssociationObserver?.( + environment.id, + JSON.stringify([ + environment.runtimeId, + environment.pairingRevision ?? environment.createdAt + ]) + ) + : undefined let rendererGone = sender.isDestroyed() let subscription: RemoteRuntimeSubscription | null = null const rendererLifetime = abortWhenRendererGone(sender) const disposeRendererLifetime = (): void => { + associationObserver?.dispose() rendererLifetime.dispose() rendererLifetime.signal.removeEventListener('abort', closeSubscription) } @@ -151,7 +171,14 @@ export function registerRuntimeEnvironmentSubscriptionHandlers(args: { subscriptionId, ...payload }) - // Record after sending so snapshots cannot delay window delivery. + if (payload.type === 'response' && payload.response.ok) { + try { + associationObserver?.observe(payload.response.result) + } catch (error) { + console.warn('[ai-vault] association observation failed', error) + } + } + // After the send, so recording never delays or changes what the window gets. if (payload.type === 'response') { recordLayoutFrame(environment.id, args.method, payload.response) } diff --git a/src/main/ipc/runtime-environments/runtime-environments.ts b/src/main/ipc/runtime-environments/runtime-environments.ts index 424ee32b68f..a2d1a19f72e 100644 --- a/src/main/ipc/runtime-environments/runtime-environments.ts +++ b/src/main/ipc/runtime-environments/runtime-environments.ts @@ -1,3 +1,5 @@ +import { createAiVaultAssociationObserver } from '../ai-vault-projection-dependency' +import { invalidateAiVaultHostLegCache } from '../ai-vault-host-leg-cache' import { registerRuntimeEnvironmentSubscriptionHandlers, type RetainedRemoteRuntimeSubscription, @@ -175,6 +177,8 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { store }) registerRuntimeEnvironmentSubscriptionHandlers({ + createAssociationObserver: (environmentId, identity) => + createAiVaultAssociationObserver(environmentId, identity, invalidateAiVaultHostLegCache), getUserDataPath, remoteRuntimeSubscriptions, pendingSubscriptions, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts index 4ec556c74d9..586666be7e6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts @@ -22,7 +22,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { now: () => number currentWork: StructuredAgentSessionSubagentRowsDeps['currentWork'] deps: () => { - store: Pick + store: Pick logger: StructuredAgentSessionLogger onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] statusSink?: StructuredAgentSessionStatusSink @@ -59,6 +59,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { sessions: args.sessions, starting: args.starting, getRecord: (sessionId) => args.deps().store.getRecord(sessionId), + listRecords: () => args.deps().store.listRecords(), replacedRuntime: (sessionId) => args.deps().store.replacedRuntime(sessionId), now: args.now, logger: deferredStructuredAgentSessionLogger(() => args.deps().logger), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index bd82eeadcfa..b8d2b41d2d6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -10,6 +10,7 @@ import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionUserNameWrite } from '../../runtime/agent-session-record-conversation-name' import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import type { AgentSessionSpawnTokenScan } from '../../runtime/agent-session/ownership/agent-session-spawn-token-process-scan' import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' @@ -161,6 +162,8 @@ export type StructuredAgentSessionHostDeps = { conversationNaming?: StructuredAgentSessionConversationNaming } +export type SetUserConversationName = StructuredAgentSessionConversationNaming['setUserName'] + /** Published only while the chat is still named forName. */ export type StructuredChatNamingFailure = AgentSessionNamingError & { forName: string } @@ -171,4 +174,11 @@ export type StructuredAgentSessionConversationNaming = { failureOutput: (sessionId: string) => string | null /** Start or join naming; false if the host has no such chat. */ regenerate: (sessionId: string) => boolean + /** Saves the user's name for the chat; null when the host has no such chat. `name` is what the + * chat is called afterwards, which differs from the request when a legacy alias was refused. */ + setUserName: ( + sessionId: string, + name: string, + write: AgentSessionUserNameWrite + ) => Promise<{ name: string | null; applied: boolean } | null> } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index abf5e003a87..ce3c2535a38 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -31,7 +31,7 @@ import { StructuredAgentSessionConversations } from './structured-agent-session- import * as resume from './structured-agent-session-restart-resume-host' import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' import * as conversation from './structured-agent-session-host-delivery' -import { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring' +import { wireStructuredAgentSessionQueuedMessages as wireQueuedMessages } from './structured-agent-session-queued-wiring' import * as sessionLogger from './structured-agent-session-logger' import { sleepStructuredAgentSessionWorkspace } from './structured-agent-session-workspace-sleep' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -53,9 +53,7 @@ export class StructuredAgentSessionHost { onOpened: (sessionId) => this.queued.onOpened(sessionId), now: () => this.now() }) - private readonly queued = wireStructuredAgentSessionQueuedMessages(this.sessions, () => - this.mutationContext() - ) + private readonly queued = wireQueuedMessages(this.sessions, () => this.mutationContext()) // Journal publication renews idle activity. private readonly clientDelivery = new StructuredAgentSessionClientDelivery( this.sessions, @@ -341,6 +339,8 @@ export class StructuredAgentSessionHost { publishConversationName = this.clientDelivery.publishConversationName regenerateConversationName = (sessionId: string): boolean => this.deps.conversationNaming?.regenerate(sessionId) ?? false + setUserConversationName: HostTypes.SetUserConversationName = (...args) => + this.deps.conversationNaming?.setUserName(...args) ?? Promise.resolve(null) /** Live-only completion/prompt events; missed events are never replayed. */ subscribeTurnCompletions = this.clientDelivery.subscribeTurnCompletions diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts index 220e1b21728..13c08796921 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts @@ -34,12 +34,12 @@ async function namedFeed() { stateDirectory: directory, now: () => 100 }) - let record: AgentSessionRecord = agentSessionRecordFixture() + let record: AgentSessionRecord = { ...agentSessionRecordFixture(), sessionId: 'session' } let failure: StructuredChatNamingFailure | undefined const sessions = new Map([['session', indexedStatusFeedSession({ journal })]]) const feed = new StructuredAgentSessionStatusFeed({ sessions, - getRecord: () => record, + getRecord: (id) => (id === record.sessionId ? record : null), namingFailure: () => failure, now: () => 100, logger: createStructuredAgentSessionLogger() @@ -107,6 +107,33 @@ it('publishes naming activity without needing a chat summary', async () => { expect(events.at(-1)).toEqual({ type: 'snapshot', sessions: [], namingActiveSessionIds: [] }) }) +it('publishes cold names from durable records across a host restart without a status row', () => { + let record = { ...agentSessionRecordFixture(), conversationName: 'Before' } + const open = () => + new StructuredAgentSessionStatusFeed({ + sessions: new Map(), + getRecord: () => record, + listRecords: () => [record], + now: () => 100, + logger: createStructuredAgentSessionLogger() + }) + const feed = open() + const events: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'existing', emit: (event) => events.push(event) }) + record = { ...record, conversationName: 'After' } + feed.publishConversationName(record.sessionId) + expect(events.at(-1)).toMatchObject({ + type: 'snapshot', + sessions: [], + conversationNames: [{ sessionId: record.sessionId, conversationName: 'After' }] + }) + expect(feed.readPublished(record.sessionId)).toBeUndefined() + const restarted: AgentSessionStatusEvent[] = [] + open().subscribe({ id: 'reconnect', emit: (event) => restarted.push(event) }) + expect(restarted[0]).toMatchObject({ conversationNames: [{ conversationName: 'After' }] }) + expect(restarted.at(-1)).toMatchObject({ sessions: [] }) +}) + it('shows a closed chat its naming failure only while it keeps the name that failed', async () => { const { feed, events, sessions, name, fail, sleep } = await namedFeed() name('Explain the parser') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-deps.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-deps.ts index e3d4e62e09c..a40fdc63299 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-deps.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-deps.ts @@ -13,6 +13,7 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo import type { StructuredAgentSessionChildWorkProducer } from './structured-agent-session-subagent-rows' import type { StructuredAgentSessionStatusObserverOptions } from './structured-agent-session-status-observation' import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-ownership' +import { structuredSummaryConversationNaming } from './structured-agent-session-status-summary' export type StatusFeedSession = { journal: AgentSessionJournal @@ -27,6 +28,7 @@ export type StructuredAgentSessionStatusFeedDeps = { sessions: ReadonlyMap starting?: (sessionId: string) => boolean getRecord: (sessionId: string) => AgentSessionRecord | null + listRecords?: () => AgentSessionRecord[] /** Absent means no replaced runtime. */ replacedRuntime?: (sessionId: string) => { fence: number } | undefined now: () => number @@ -59,14 +61,23 @@ export type StatusPublication = { firstInputSubmissionKey: string | null } -/** Naming snapshots need neither readable journals nor retained status rows. */ +/** Naming activity does not depend on any readable journal or retained summary. */ export function structuredAgentSessionNamingSnapshot( - deps: StructuredAgentSessionStatusFeedDeps + deps: StructuredAgentSessionStatusFeedDeps, + sessionId?: string ): AgentSessionStatusEvent { + const record = sessionId === undefined ? null : deps.getRecord(sessionId) + const records = sessionId === undefined ? (deps.listRecords?.() ?? []) : record ? [record] : [] + const conversationNames = records.map((entry) => ({ + sessionId: entry.sessionId, + conversationName: entry.conversationName ?? null, + ...structuredSummaryConversationNaming(entry, deps.namingFailure?.(entry.sessionId)) + })) return { type: 'snapshot', sessions: [], - namingActiveSessionIds: deps.namingActiveSessionIds?.() ?? [] + namingActiveSessionIds: deps.namingActiveSessionIds?.() ?? [], + ...(conversationNames.length ? { conversationNames } : {}) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 8bd2549c079..2e274026317 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -19,6 +19,7 @@ import { } from './structured-agent-session-status-feed-deps' import { renamedStructuredStatusSummary, + retainedStructuredStatusSummaries, structuredAgentSessionStatusSummary } from './structured-agent-session-status-summary' import { structuredStatusChildWork } from './structured-agent-session-status-child-work' @@ -48,7 +49,7 @@ export class StructuredAgentSessionStatusFeed { this.deps.statusSink?.() ) private readonly subscribers = new Map() - // Retain closed-chat names for naming jobs and AI Vault. + // Never evicted: chats are named only while in here, and AI Vault reads closed ones' names here. private readonly published = new Map() private readonly acceptedSends = new Map() private readonly projections = new StructuredAgentSessionJournalProjections((sessionId) => @@ -61,9 +62,10 @@ export class StructuredAgentSessionStatusFeed { this.deps.logger.warn(message, { scope, sessionId, error }) } + /** Opens with every session this host has projected, live ones re-read, then only changes. */ subscribe(subscriber: StructuredAgentSessionStatusSubscriber): () => void { - // Naming must stay observable even without a readable journal. - if (this.deps.namingActiveSessionIds) { + // Naming completion must be observable even if a held journal cannot be read. + if (this.deps.namingActiveSessionIds || this.deps.listRecords) { this.emit(subscriber, structuredAgentSessionNamingSnapshot(this.deps)) } // Reproject before registering: existing readers get changes; the new reader gets one snapshot. @@ -73,7 +75,7 @@ export class StructuredAgentSessionStatusFeed { this.subscribers.set(subscriber.id, subscriber) this.emit(subscriber, { type: 'snapshot', - sessions: [...this.published.values()].map(({ summary }) => summary) + sessions: retainedStructuredStatusSummaries(this.published.values(), this.deps) }) return () => this.unsubscribe(subscriber.id) } @@ -140,8 +142,9 @@ export class StructuredAgentSessionStatusFeed { this.broadcast({ type: 'status', session: retained }) } + /** Naming activity is published even when the chat has no retained status row. */ publishConversationName(sessionId: string): void { - this.broadcast(structuredAgentSessionNamingSnapshot(this.deps)) + this.broadcast(structuredAgentSessionNamingSnapshot(this.deps, sessionId)) this.publishRecordNotes(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts index d5b632dcd55..015052ecdae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts @@ -12,7 +12,10 @@ import type { import { structuredAgentSessionProviderSessionMetadata } from './structured-agent-session-history-result' import { agentSessionPinnedLaunchDirectory } from '../../runtime/agent-session-record-launch-directory' import { isAgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' -import type { StructuredAgentSessionStatusFeedDeps } from './structured-agent-session-status-feed-deps' +import type { + StructuredAgentSessionStatusFeedDeps, + StatusPublication +} from './structured-agent-session-status-feed-deps' import { structuredStatusSummariesEqual } from './structured-agent-session-status-summary-equality' export function structuredAgentSessionStatusSummary({ @@ -77,7 +80,8 @@ export function structuredAgentSessionStatusSummary({ } } -function structuredSummaryConversationNaming( +/** The record's name, and the failure to replace it only while that is still the name. */ +export function structuredSummaryConversationNaming( record: AgentSessionRecord | null, failure: StructuredChatNamingFailure | undefined ): Pick { @@ -107,3 +111,12 @@ export function renamedStructuredStatusSummary( } return structuredStatusSummariesEqual(previous, summary) ? null : summary } + +export function retainedStructuredStatusSummaries( + publications: Iterable, + deps: StructuredAgentSessionStatusFeedDeps +): AgentSessionStatusSummary[] { + return [...publications].map( + ({ summary }) => renamedStructuredStatusSummary(summary, deps, summary.sessionId) ?? summary + ) +} diff --git a/src/main/native-chat/structured-chat-naming.test.ts b/src/main/native-chat/structured-chat-naming.test.ts index c8bde9a8a12..aa9bebba34c 100644 --- a/src/main/native-chat/structured-chat-naming.test.ts +++ b/src/main/native-chat/structured-chat-naming.test.ts @@ -7,7 +7,11 @@ import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' import { STRUCTURED_CHAT_NAME_PROMPT_LIMIT } from '../../shared/structured-agent-session-first-prompt' -import { setAgentSessionRecordConversationName } from '../runtime/agent-session-record-conversation-name' +import { + type AgentSessionUserNameWrite, + compareAndSetAgentSessionRecordName, + setAgentSessionRecordUserName +} from '../runtime/agent-session-record-conversation-name' import { createStructuredChatNamingHandler, type StructuredChatNameGeneration, @@ -44,20 +48,34 @@ async function settle() { function rig(provider: 'claude' | 'codex') { let record: AgentSessionRecord | null = { ...agentSessionRecordFixture(), provider } const getRecord = vi.fn(() => record) - const setConversationName = vi.fn( - async (_id: string, name: string | null, expected: string | null) => { - if (!record) { - throw new Error('Missing record') - } - if ((record.conversationName ?? null) !== expected) { - return null - } - record = setAgentSessionRecordConversationName(record, name, Date.now()) - return record + // The store's own rules, over this rig's one record. + const mutate = async (apply: (current: AgentSessionRecord) => AgentSessionRecord) => { + if (!record) { + throw new Error('Missing record') + } + record = apply(record) + return record + } + const setConversationName = vi.fn((_id: string, name: string | null, expected: string | null) => + compareAndSetAgentSessionRecordName(mutate, name, expected) + ) + const setUserConversationName = vi.fn( + async (_id: string, name: string, write: AgentSessionUserNameWrite) => { + let applied = false + const next = await mutate((current) => { + const result = setAgentSessionRecordUserName(current, name, write, Date.now()) + applied = result.applied + return result.record + }) + return { record: next, applied } } ) const deps: StructuredChatNamingDeps = { - getStore: () => ({ getRecord, compareAndSetConversationName: setConversationName }), + getStore: () => ({ + getRecord, + compareAndSetConversationName: setConversationName, + setUserConversationName + }), getSettings: () => ({}), now: () => 100, workerTaskTitle: vi.fn(() => null), @@ -81,6 +99,7 @@ function rig(provider: 'claude' | 'codex') { summary, getRecord, setConversationName, + setUserConversationName, handle: createStructuredChatNamingHandler(deps), read: () => record, replace: (next: AgentSessionRecord | null) => { @@ -90,6 +109,24 @@ function rig(provider: 'claude' | 'codex') { } describe.each(['claude', 'codex'] as const)('structured %s chat naming', (provider) => { + it('still names the first message after a manual rename fails to save', async () => { + const state = rig(provider) + state.setUserConversationName.mockRejectedValueOnce(new Error('Save failed')) + await expect( + state.handle.setUserName(state.summary.sessionId, 'Manual name', 'rename') + ).rejects.toThrow('Save failed') + expect(state.read()?.conversationName).toBeUndefined() + state.handle(state.summary, { replay: false, firstInputSubmissionKey: 'first-message' }) + await settle() + expect(state.setConversationName).toHaveBeenCalledWith( + state.summary.sessionId, + 'Please repair the login flow', + null + ) + expect(state.deps.generate).toHaveBeenCalledTimes(1) + expect(state.read()?.conversationName).toBe('auth/login') + }) + it('names the chat from its first prompt at once, then takes the generated name', async () => { const state = rig(provider) const generation = deferred() @@ -285,7 +322,7 @@ describe.each(['claude', 'codex'] as const)('structured %s chat naming', (provid state.deps.generate = vi.fn(async () => ({ name: 'New name' })) state.handle.regenerate(id) state.handle.regenerate(id) - expect(events.at(-1)).toEqual({ + expect(events.at(-1)).toMatchObject({ type: 'snapshot', sessions: [], namingActiveSessionIds: [id] @@ -293,7 +330,11 @@ describe.each(['claude', 'codex'] as const)('structured %s chat naming', (provid prompt.resolve('Repair login') await settle() expect(state.deps.readJournalFirstPrompt).toHaveBeenCalledTimes(1) - expect(events.at(-1)).toEqual({ type: 'snapshot', sessions: [], namingActiveSessionIds: [] }) + expect(events.at(-1)).toMatchObject({ + type: 'snapshot', + sessions: [], + namingActiveSessionIds: [] + }) expect(state.handle.failure(id)).toBe(failure) state.deps.readJournalFirstPrompt = async () => '' state.handle.regenerate(id) @@ -348,6 +389,42 @@ describe.each(['claude', 'codex'] as const)('structured %s chat naming', (provid expect(state.deps.generate).not.toHaveBeenCalled() }) + it.each([{ name: 'Late agent name' }, { error: 'Generation failed' }])( + 'keeps a same-text user rename after generation settles with %j', + async (outcome) => { + const state = rig(provider) + const generation = deferred() + state.deps.generate = vi.fn(() => generation.promise) + state.handle(state.summary, { replay: false }) + await settle() + const id = state.summary.sessionId + const seed = 'Please repair the login flow' + await state.handle.setUserName(id, 'Login work', 'rename') + await state.handle.setUserName(id, seed, 'rename') + generation.resolve(outcome) + await settle() + expect(state.read()).toMatchObject({ conversationName: seed, conversationNameSource: 'user' }) + expect(state.handle.failure(id)).toBeUndefined() + } + ) + + it("takes a window's pre-upgrade tab name only over a name Orca chose", async () => { + const state = rig(provider) + const id = state.summary.sessionId + await state.handle(state.summary, { replay: false }) + await settle() + expect(await state.handle.setUserName(id, 'Old tab name', 'legacy-alias')).toEqual({ + name: 'Old tab name', + applied: true + }) + await state.handle.setUserName(id, 'Newer rename', 'rename') + // A second window's, or a replayed, upload of an older name never replaces the newer rename. + expect(await state.handle.setUserName(id, 'Old tab name', 'legacy-alias')).toEqual({ + name: 'Newer rename', + applied: false + }) + }) + it('coalesces concurrent edges before the first journal read completes', async () => { const state = rig(provider) const prompt = deferred() diff --git a/src/main/native-chat/structured-chat-naming.ts b/src/main/native-chat/structured-chat-naming.ts index 9c21e56bcfc..072b17b4ca2 100644 --- a/src/main/native-chat/structured-chat-naming.ts +++ b/src/main/native-chat/structured-chat-naming.ts @@ -31,7 +31,7 @@ export type StructuredChatNameGeneration = export type StructuredChatNamingDeps = { getStore: () => Pick< AgentSessionRecordStore, - 'getRecord' | 'compareAndSetConversationName' + 'getRecord' | 'compareAndSetConversationName' | 'setUserConversationName' > | null getSettings: () => Pick readFirstPrompt: (sessionId: string, hostStartedAt: number) => Promise @@ -164,6 +164,15 @@ export function createStructuredChatNamingHandler(deps: StructuredChatNamingDeps } } + const generationStillCurrent = (store: NamingStore, record: AgentSessionRecord) => { + const current = store.getRecord(record.sessionId) + return ( + current !== null && + current.conversationNameSource !== 'user' && + current.conversationName === record.conversationName + ) + } + async function name(summary: AgentSessionStatusSummary) { const store = deps.getStore() const record = store?.getRecord(summary.sessionId) @@ -195,8 +204,7 @@ export function createStructuredChatNamingHandler(deps: StructuredChatNamingDeps } refresh(seeded.location.workspaceId, seeded.sessionId) const seedName = seeded.conversationName - const stillSeeded = () => - !namingOff() && store.getRecord(seeded.sessionId)?.conversationName === seedName + const stillSeeded = () => !namingOff() && generationStillCurrent(store, seeded) const chatName = await generateName( seeded, firstPrompt, @@ -211,8 +219,7 @@ export function createStructuredChatNamingHandler(deps: StructuredChatNamingDeps async function rename(store: NamingStore, record: AgentSessionRecord) { const current = record.conversationName ?? '' - const stillCurrent = () => - (store.getRecord(record.sessionId)?.conversationName ?? '') === current + const stillCurrent = () => generationStillCurrent(store, record) const firstPrompt = clampConversationNameFirstPrompt( await deps.readJournalFirstPrompt(record.sessionId) ) @@ -257,6 +264,20 @@ export function createStructuredChatNamingHandler(deps: StructuredChatNamingDeps attempted.add(sessionId) run(sessionId, record.location.workspaceId, () => rename(store, record)) return true + }, + // Not a job: a pending generation must not swallow the user's rename. The record's 'user' + // source is what makes that generation's later write refuse. + setUserName: async (sessionId, name, write) => { + const store = deps.getStore() + if (!store?.getRecord(sessionId)) { + return null + } + const { record, applied } = await store.setUserConversationName(sessionId, name, write) + if (applied) { + forget(sessionId) + } + refresh(record.location.workspaceId, sessionId) + return { name: record.conversationName ?? null, applied } } } return Object.assign(observe, naming) diff --git a/src/main/runtime/agent-session-record-conversation-name.ts b/src/main/runtime/agent-session-record-conversation-name.ts index da9d0b66a31..eface430f6c 100644 --- a/src/main/runtime/agent-session-record-conversation-name.ts +++ b/src/main/runtime/agent-session-record-conversation-name.ts @@ -20,6 +20,57 @@ export function setAgentSessionRecordConversationName( return next } +/** How a user's name write lands: `rename` always wins; `legacy-alias` (a window's tab rename + * from before names were saved here) only replaces a name Orca chose, so a late or replayed + * upload never overwrites a rename the user made since. */ +export type AgentSessionUserNameWrite = 'rename' | 'legacy-alias' + +export function setAgentSessionRecordUserName( + record: AgentSessionRecord, + name: string, + write: AgentSessionUserNameWrite, + now: number +): { record: AgentSessionRecord; applied: boolean } { + const normalized = normalizeAgentSessionConversationName(name) + if ( + normalized === null || + (write === 'legacy-alias' && record.conversationNameSource === 'user') + ) { + return { record, applied: false } + } + if (record.conversationName === normalized && record.conversationNameSource === 'user') { + return { record, applied: true } + } + return { + record: { + ...record, + conversationName: normalized, + conversationNameSource: 'user', + updatedAt: now + }, + applied: true + } +} + +export async function setAgentSessionRecordUserNameThrough( + mutate: ( + apply: (record: AgentSessionRecord) => AgentSessionRecord + ) => Promise, + name: string, + write: AgentSessionUserNameWrite +): Promise<{ record: AgentSessionRecord; applied: boolean }> { + let applied = false + const record = await mutate((current) => { + const result = setAgentSessionRecordUserName(current, name, write, Date.now()) + applied = result.applied + return result.record + }) + return { record, applied } +} + +/** Automatic naming's write: applies only while the name is still `expected` and the user has not + * named the conversation, so a stale generation never replaces a rename — even one back to the + * same text. */ export async function compareAndSetAgentSessionRecordName( mutate: ( apply: (record: AgentSessionRecord) => AgentSessionRecord @@ -29,7 +80,8 @@ export async function compareAndSetAgentSessionRecordName( ): Promise { let matched = false const record = await mutate((current) => { - matched = (current.conversationName ?? null) === expected + matched = + current.conversationNameSource !== 'user' && (current.conversationName ?? null) === expected return matched ? setAgentSessionRecordConversationName(current, name, Date.now()) : current }) return matched ? record : null diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 37f1d50ccd6..a51576cb7ee 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -75,8 +75,10 @@ import { AgentSessionStoreTransactions } from './agent-session-store-transaction import { createAgentSessionFoundingStore } from './agent-session-founding-store' import { compareAndSetAgentSessionRecordName, + type AgentSessionUserNameWrite, type CompareAndSetConversationName, - setAgentSessionRecordConversationName + setAgentSessionRecordConversationName, + setAgentSessionRecordUserNameThrough } from './agent-session-record-conversation-name' export const AGENT_SESSION_LEASE_TTL_MS = 30_000, @@ -160,6 +162,10 @@ export class AgentSessionRecordStore { setAgentSessionRecordConversationName(record, name, Date.now()) ) + /** The user's name for the conversation; see `AgentSessionUserNameWrite`. */ + setUserConversationName = (sessionId: string, name: string, write: AgentSessionUserNameWrite) => + setAgentSessionRecordUserNameThrough((apply) => this.mutate(sessionId, apply), name, write) + compareAndSetConversationName: CompareAndSetConversationName = (sessionId, name, expected) => compareAndSetAgentSessionRecordName((apply) => this.mutate(sessionId, apply), name, expected) diff --git a/src/main/runtime/renderer-owned-tab-properties.test.ts b/src/main/runtime/renderer-owned-tab-properties.test.ts index b2b84ed4cab..29f704a00ec 100644 --- a/src/main/runtime/renderer-owned-tab-properties.test.ts +++ b/src/main/runtime/renderer-owned-tab-properties.test.ts @@ -1,5 +1,24 @@ import './orca-runtime-test-lifecycle.spec' import { describe, expect, it, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { closeTestJournalHostDatabases } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredChatNamingHandler } from '../native-chat/structured-chat-naming' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { + readPersistedTestAgentSessionStore, + seedTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' +import type { AgentSessionRecordStore } from './agent-session-record-store' import { OrcaRuntimeService, electronMocks } from './orca-runtime-test-mocks.spec' import { TEST_WORKTREE_ID, @@ -10,8 +29,7 @@ import type { RuntimeNotifier } from './runtime-notifier-contract' import { RpcDispatcher } from './rpc/dispatcher' import { SESSION_TAB_MUTATION_METHODS } from './rpc/methods/session-tab-mutation-methods' -function fixture() { - const session = makeWorkspaceSessionWithHeadlessTerminal() +function fixture(session = makeWorkspaceSessionWithHeadlessTerminal()) { const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService(runtimeStore) const notifier = { @@ -82,3 +100,126 @@ it.each([' Renamed ', null])( expect(notifier.renameTerminal).toHaveBeenCalledWith('host-tab', customTitle?.trim() ?? null) } ) + +it('refuses a combined native chat edit before saving its name, while accepting a title alone', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-native-tab-props-')) + const record = { + ...agentSessionRecordFixture( + agentSessionLeaseFixture({ + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released' + }) + ), + conversationName: 'Existing saved name' + } + const sessionId = record.sessionId + const tabId = `agent-session:${sessionId}` + const alias = 'Existing tab alias' + const { runtime, dispatcher, getSession, notifier } = fixture( + makeWorkspaceSessionWithHeadlessTerminal({ + unifiedTabs: { + [TEST_WORKTREE_ID]: [ + { + id: `structured-agent-session-${sessionId}`, + entityId: sessionId, + groupId: 'group', + worktreeId: TEST_WORKTREE_ID, + contentType: 'agent-session', + agentSessionAgent: 'claude', + label: 'Claude Chat', + customLabel: alias, + color: null, + sortOrder: 1, + createdAt: 1 + } + ] + } + }) + ) + try { + await seedTestAgentSessionRecordStore(directory, { + records: [record] + }) + let recordStore: AgentSessionRecordStore | null = null + const { logger } = recordingStructuredAgentSessionLogger() + const host = await ensureStructuredAgentSessionHost({ + logger, + stateDirectory: directory, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => directory, + resolveEnvironment: async () => ({}), + resolveLaunchArgs: () => [], + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), + conversationNaming: createStructuredChatNamingHandler({ + getStore: () => recordStore, + getSettings: () => ({}), + workerTaskTitle: () => null, + readFirstPrompt: async () => '', + readJournalFirstPrompt: async () => '', + generate: async () => null, + onNamed: () => {}, + logger + }) + }) + recordStore = host.deps.store + vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockResolvedValue(undefined) + runtime['storeMobileSessionSnapshot'](TEST_WORKTREE_ID, { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'native-tab-props', + snapshotVersion: 1, + activeGroupId: 'group', + activeTabId: tabId, + activeTabType: 'agent-session', + tabs: [ + { + type: 'agent-session', + id: tabId, + sessionId, + agent: 'claude', + title: 'Existing saved name', + isActive: true + } + ] + }) + const before = await readPersistedTestAgentSessionStore(directory) + expect(before.records[sessionId]?.conversationName).toBe('Existing saved name') + const params = { + worktree: `id:${TEST_WORKTREE_ID}`, + tabId, + customTitle: ' Renamed through tab props ' + } + expect( + await dispatcher.dispatch({ + id: 'native-combined', + authToken: 'token', + method: 'session.tabs.setTabProps', + params: { ...params, isPinned: true } + }) + ).toMatchObject({ ok: false, error: { code: 'capability_unsupported' } }) + expect({ + saved: await readPersistedTestAgentSessionStore(directory), + alias: getSession().unifiedTabs?.[TEST_WORKTREE_ID]?.[0]?.customLabel + }).toEqual({ saved: before, alias }) + expect(notifier.renameTerminal).not.toHaveBeenCalled() + + expect( + await dispatcher.dispatch({ + id: 'native-title', + authToken: 'token', + method: 'session.tabs.setTabProps', + params + }) + ).toMatchObject({ ok: true, result: { updated: true } }) + expect( + (await readPersistedTestAgentSessionStore(directory)).records[sessionId]?.conversationName + ).toBe('Renamed through tab props') + expect(getSession().unifiedTabs?.[TEST_WORKTREE_ID]?.[0]?.customLabel).toBe(alias) + expect(notifier.renameTerminal).not.toHaveBeenCalled() + } finally { + await stopStructuredAgentSessionRuntime() + closeTestJournalHostDatabases() + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index 7c0451b0444..b84870ec853 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -100,5 +100,6 @@ export const WORK_METHODS = [ { method: 'agentSession.hold', params: { sessionId: SESSION, holderId: 'surface-1' } }, { method: 'agentSession.reveal', params: { sessionId: SESSION } }, { method: 'agentSession.regenerateName', params: { sessionId: SESSION } }, + { method: 'agentSession.rename', params: { sessionId: SESSION, name: 'Release notes' } }, { method: 'agentSession.subscribeStatus', params: null } ] as const diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rename.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-rename.test.ts new file mode 100644 index 00000000000..10317a8dbf5 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-rename.test.ts @@ -0,0 +1,42 @@ +import { afterEach, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionStatusSubscriber } from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { agentSessionRecordFixture } from '../../../../shared/agent-session-record.test-fixture' +import { + call, + clearStructuredHostStub, + hostCalls, + installStructuredHostStub, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +afterEach(clearStructuredHostStub) + +it('filters naming notes by the same record audience as status rows', async () => { + installStructuredHostStub() + const host = getStructuredAgentSessionHost() + if (!host) { + throw new Error('Missing test host') + } + vi.spyOn(host.deps.store, 'getRecord').mockImplementation((sessionId: string) => + sessionId === 'missing' + ? null + : { ...agentSessionRecordFixture(), provider: sessionId === 'pi' ? 'pi' : 'codex' } + ) + hostCalls.subscribeStatus.mockImplementation( + (subscriber: StructuredAgentSessionStatusSubscriber) => { + subscriber.emit({ + type: 'snapshot', + sessions: [], + conversationNames: ['codex', 'pi', 'missing'].map((sessionId) => ({ + sessionId, + conversationName: 'Name' + })) + }) + return () => {} + } + ) + expect(await call('agentSession.subscribeStatus', null, STRUCTURED_CLIENT)).toMatchObject({ + result: { conversationNames: [{ sessionId: 'codex', conversationName: 'Name' }] } + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rename.ts b/src/main/runtime/rpc/methods/structured-agent-session-rename.ts new file mode 100644 index 00000000000..6c1418511bd --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-rename.ts @@ -0,0 +1,26 @@ +// `agentSession.rename` — the user names a chat. The host saves it as the chat's one name, the +// field Session History, tabs and phones read, and the status feed carries it to every client. + +import { defineMethod } from '../core' +import { requireInstalledStructuredHost } from './structured-agent-session-gate' +import { RenameParams } from './structured-agent-session-schemas' + +export const STRUCTURED_AGENT_SESSION_RENAME_METHODS = [ + defineMethod({ + name: 'agentSession.rename', + permission: 'workspace', + params: RenameParams, + handler: async (params, ctx) => { + const host = await requireInstalledStructuredHost(ctx, params.sessionId) + const result = await host.setUserConversationName( + params.sessionId, + params.name, + params.legacyAlias ? 'legacy-alias' : 'rename' + ) + if (!result) { + throw new Error('agent_session_identity_required') + } + return result + } + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 094baf6c83b..992bf35dc92 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -33,6 +33,7 @@ export { UnsubscribeParams } from '../../../../shared/rpc-contract/structured-agent-session-params' export { ContinueInterruptedParams } from '../../../../shared/rpc-contract/structured-agent-session-continue-params' +export { RenameParams } from '../../../../shared/rpc-contract/structured-agent-session-rename-params' export { QueuedMessageUpdateParams, QueuedMessageEditHoldParams diff --git a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts index 8181c4a157a..61cd8b6bdf5 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts @@ -66,6 +66,14 @@ export const STRUCTURED_AGENT_SESSION_STATUS_METHODS = [ if (event.type === 'snapshot') { emit({ ...event, + ...(event.conversationNames + ? { + conversationNames: event.conversationNames.filter((note) => { + const record = host.deps.store.getRecord(note.sessionId) + return record && clientReadsStructuredSessionAgent(ctx, record.provider) + }) + } + : {}), ...(event.namingActiveSessionIds ? { namingActiveSessionIds: event.namingActiveSessionIds.filter((sessionId) => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 112c57310f2..6bd67b3499f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -169,7 +169,7 @@ describe('capability gating', () => { expect(names).toContain(method.name) } // Keep protocol stable: structured capability already hides additive session methods from older clients. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(40) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(41) }) it('hides the surface from a declared client that did not advertise it', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index b7e062ea7be..bac50adbfe3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -35,6 +35,7 @@ import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-sessio import { STRUCTURED_AGENT_SESSION_PROMPT_RESPONSE_METHODS } from './structured-agent-session-prompt-response' import { STRUCTURED_AGENT_SESSION_REVEAL_METHODS } from './structured-agent-session-reveal' import { STRUCTURED_AGENT_SESSION_REGENERATE_NAME_METHODS } from './structured-agent-session-regenerate-name' +import { STRUCTURED_AGENT_SESSION_RENAME_METHODS } from './structured-agent-session-rename' import { STRUCTURED_AGENT_SESSION_QUEUED_METHODS } from './structured-agent-session-queued-methods' import { STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS } from './structured-agent-session-restart-resume' import { @@ -295,6 +296,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ ...STRUCTURED_AGENT_SESSION_HOLD_METHODS, ...STRUCTURED_AGENT_SESSION_REVEAL_METHODS, ...STRUCTURED_AGENT_SESSION_REGENERATE_NAME_METHODS, + ...STRUCTURED_AGENT_SESSION_RENAME_METHODS, ...STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS, ...STRUCTURED_AGENT_SESSION_STATUS_METHODS, ...STRUCTURED_AGENT_SESSION_TURN_COMPLETION_METHODS, diff --git a/src/main/runtime/runtime-desktop-pane-title-forwarding.test.ts b/src/main/runtime/runtime-desktop-pane-title-forwarding.test.ts index 41a4c4f6c0f..f62a89fcd0c 100644 --- a/src/main/runtime/runtime-desktop-pane-title-forwarding.test.ts +++ b/src/main/runtime/runtime-desktop-pane-title-forwarding.test.ts @@ -9,6 +9,14 @@ import { } from './orca-runtime-test-fixtures.spec' import { electronMocks, OrcaRuntimeService } from './orca-runtime-test-mocks.spec' import { createMobileCreateTestNotifier } from './orca-runtime-test-scenario-builders.spec' +import { RpcDispatcher } from './rpc/dispatcher' +import { SESSION_TAB_METHODS } from './rpc/methods/session-tabs' +import { + clearStructuredHostStub, + hostCalls, + installStructuredHostStub +} from './rpc/methods/structured-agent-session-rpc.test-fixture' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-session-contracts' import type { TerminalPaneLayoutNode } from '../../shared/terminal-tab-types' const SPLIT_ROOT: TerminalPaneLayoutNode = { @@ -44,7 +52,7 @@ function desktopHostRuntime() { ...(titlesByLeafId ? { titlesByLeafId } : {}), renamedLeafIds }) - return { setPaneTitle, renameTerminal, renamePanes, savedLayout, before } + return { runtime, setPaneTitle, renameTerminal, renamePanes, savedLayout, before } } describe('client pane names on a host with its own desktop window', () => { @@ -105,3 +113,70 @@ describe('client pane names on a host with its own desktop window', () => { expect(renameTerminal).not.toHaveBeenCalled() }) }) + +it('routes old-client chat tab renames to the owning record with or without an attached window', async () => { + installStructuredHostStub() + hostCalls.setUserConversationName = vi.fn(async () => ({ + name: 'Saved through tab props', + applied: true + })) + try { + for (const attached of [true, false]) { + const desktop = desktopHostRuntime() + const runtime = attached ? desktop.runtime : new OrcaRuntimeService(null) + vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockResolvedValue(undefined) + vi.spyOn(runtime, 'getClientSettings').mockReturnValue({ + ...desktop.runtime.getClientSettings(), + experimentalNativeChat: true + }) + const snapshot: RuntimeMobileSessionTabsSnapshot = { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'chat-rename', + snapshotVersion: 1, + activeGroupId: 'group', + activeTabId: 'agent-session:session-alpha', + activeTabType: 'agent-session', + tabs: [ + { + type: 'agent-session', + id: 'agent-session:session-alpha', + sessionId: 'session-alpha', + agent: 'codex', + title: 'Codex Chat', + isActive: true + } + ] + } + runtime['storeMobileSessionSnapshot'](TEST_WORKTREE_ID, snapshot) + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const replies: string[] = [] + await dispatcher.dispatchStreaming( + { + id: 'rename', + authToken: 'token', + method: 'session.tabs.setTabProps', + params: { + worktree: `id:${TEST_WORKTREE_ID}`, + tabId: snapshot.activeTabId, + customTitle: 'Saved through tab props' + } + }, + (reply) => replies.push(reply), + { clientKind: 'mobile', clientCapabilities: [] } + ) + expect(JSON.parse(replies[0]), replies[0]).toMatchObject({ + ok: true, + result: { updated: true } + }) + expect(hostCalls.setUserConversationName).toHaveBeenLastCalledWith( + 'session-alpha', + 'Saved through tab props', + 'rename' + ) + expect(desktop.renameTerminal).not.toHaveBeenCalled() + } + expect(hostCalls.setUserConversationName).toHaveBeenCalledTimes(2) + } finally { + clearStructuredHostStub() + } +}) diff --git a/src/main/runtime/session-tabs/headless-tab-properties.ts b/src/main/runtime/session-tabs/headless-tab-properties.ts index 4bc89c615ab..91ace71f21b 100644 --- a/src/main/runtime/session-tabs/headless-tab-properties.ts +++ b/src/main/runtime/session-tabs/headless-tab-properties.ts @@ -1,3 +1,4 @@ +import { renameStructuredSessionTab, type SessionTabProps } from './structured-session-tab-rename' import { OrcaRuntimeWithCloseHeadlessMobileTerminalTab } from './headless-terminal-tab-close' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-session-contracts' @@ -35,37 +36,39 @@ export abstract class RuntimeHeadlessTabProperties extends OrcaRuntimeWithCloseH // Remote tab color/pin need persistence and live publication or the next snapshot reverts them. async setMobileSessionTabProps( worktreeSelector: string, - args: { - tabId: string - color?: string | null - isPinned?: boolean - viewMode?: 'terminal' | 'chat' - customTitle?: string | null - } + args: SessionTabProps ): Promise<{ updated: true }> { const explicitWorktreeId = this.getExplicitSessionWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) // Attached renderers own and republish props; headless writes would be overwritten. const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const hostTabId = snapshot ? (this.resolveMobileSessionHostTabId(snapshot, args.tabId) ?? args.tabId) : args.tabId - // Sleeping/starting panes lack handles, so title edits must address the tab (#29975). - const customTitle = - args.customTitle === undefined ? undefined : args.customTitle?.trim() || null if (ORCA_LAYOUT_OWNER_IS_MAIN && (await this.setMainTabProps(worktreeId, hostTabId, args))) { return { updated: true } } - if (this.getAvailableAuthoritativeWindow()) { - if (args.color !== undefined || args.isPinned !== undefined || args.viewMode !== undefined) { - throw Object.assign( - new Error( - 'Tab color, pin, and view mode edits are unavailable while a desktop window owns the layout' - ), - { code: 'capability_unsupported' } - ) - } + const authoritativeWindow = this.getAvailableAuthoritativeWindow() + if ( + authoritativeWindow && + (args.color !== undefined || args.isPinned !== undefined || args.viewMode !== undefined) + ) { + throw Object.assign( + new Error( + 'Tab color, pin, and view mode edits are unavailable while a desktop window owns the layout' + ), + { code: 'capability_unsupported' } + ) + } + args = await renameStructuredSessionTab(snapshot, hostTabId, args, () => + this.ensureStructuredAgentSessionHost() + ) + // Sleeping/starting panes lack handles, so title edits must address the tab (#29975). + const customTitle = + args.customTitle === undefined ? undefined : args.customTitle?.trim() || null + if (authoritativeWindow) { if (customTitle !== undefined) { this.notifier?.renameTerminal(hostTabId, customTitle) } diff --git a/src/main/runtime/session-tabs/structured-session-tab-rename.ts b/src/main/runtime/session-tabs/structured-session-tab-rename.ts new file mode 100644 index 00000000000..ffc8a6a1597 --- /dev/null +++ b/src/main/runtime/session-tabs/structured-session-tab-rename.ts @@ -0,0 +1,34 @@ +import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-session-contracts' + +export type SessionTabProps = { + tabId: string + color?: string | null + isPinned?: boolean + viewMode?: 'terminal' | 'chat' + customTitle?: string | null +} + +export async function renameStructuredSessionTab( + snapshot: RuntimeMobileSessionTabsSnapshot | undefined, + tabId: string, + props: Props, + installHost: () => Promise +): Promise { + const tab = snapshot?.tabs.find( + (candidate) => candidate.id === tabId && candidate.type === 'agent-session' + ) + if (props.customTitle === undefined || tab?.type !== 'agent-session') { + return props + } + await installHost() + const result = await getStructuredAgentSessionHost()?.setUserConversationName( + tab.sessionId, + props.customTitle ?? '', + 'rename' + ) + if (!result?.applied) { + throw new Error('agent_session_rename_failed') + } + return { ...props, customTitle: undefined } +} diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index b16a2ce7e14..abf84b435e7 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -17,6 +17,7 @@ import { useOsc52ClipboardDefaultOnNotice } from '../components/terminal-pane/os import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync/use-web-session-tabs-sync' import { useLocalStructuredSessionTabsSync } from '../runtime/local-structured-session-tabs-sync' import { useHostStructuredAgentsSync } from '../runtime/host-structured-agents-sync' +import { useLegacyChatRenameMigration } from '../runtime/legacy-chat-rename-migration' import { useHostModelCatalogSnapshotsSync } from '../runtime/host-model-catalog-snapshots-sync' import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime-recovery-triggers' import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-publication' @@ -41,6 +42,7 @@ export function useAppShellServices(): void { useWebSessionTabsSync() useLocalStructuredSessionTabsSync() useHostStructuredAgentsSync() + useLegacyChatRenameMigration() useHostModelCatalogSnapshotsSync() useIpcEvents() useProfileStateSaveDelayNotice() diff --git a/src/renderer/src/components/activity/ActivityPrototypePage-test-fixtures.ts b/src/renderer/src/components/activity/ActivityPrototypePage-test-fixtures.ts index 86426972782..8656da9aff3 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage-test-fixtures.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage-test-fixtures.ts @@ -151,6 +151,7 @@ export function makeActivityResult(args: { const tab = args.tab ?? makeTab() return buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: args.entries ?? {}, retainedAgentsByPaneKey: args.retained ?? {}, tabsByWorktree: { diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts index b21b76d9dae..de3dc2d8f0a 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts @@ -67,6 +67,7 @@ import { folderWorkspaceKey } from '../../../../shared/workspace-scope' } const { events, liveAgentByPaneKey } = buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: entries, retainedAgentsByPaneKey: {}, tabsByWorktree: { [worktree.id]: tabs }, @@ -222,6 +223,7 @@ import { folderWorkspaceKey } from '../../../../shared/workspace-scope' it('creates a thread for a repo-less floating terminal agent', () => { const tab = makeTabWithIds('tab-1', FLOATING_TERMINAL_WORKTREE_ID, 'Claude') const result = buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: { [PANE_KEY]: makeWorkingEntryWithoutHistory() }, @@ -415,6 +417,7 @@ import { folderWorkspaceKey } from '../../../../shared/workspace-scope' const blockedTab = { ...makeTab(), id: 'tab-2', ptyId: 'pty-2' } const doneTab = { ...makeTab(), id: 'tab-3', ptyId: 'pty-3' } const result = buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: { [PANE_KEY]: makeWorkingEntryWithoutHistory(), [PANE_KEY_2]: { @@ -466,6 +469,7 @@ import { folderWorkspaceKey } from '../../../../shared/workspace-scope' const tab1 = makeTabWithIds('tab-1', worktree.id) const tab2 = makeTabWithIds('tab-2', worktree.id) const result = buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: { [PANE_KEY]: makeWorkingEntryWithoutHistory(), [PANE_KEY_2]: { @@ -535,6 +539,7 @@ import { folderWorkspaceKey } from '../../../../shared/workspace-scope' ) const result = buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: { [PANE_KEY]: doneEntry('builder') }, retainedAgentsByPaneKey: {}, tabsByWorktree: { [localWorktree.id]: [tab] }, @@ -571,6 +576,7 @@ import { folderWorkspaceKey } from '../../../../shared/workspace-scope' ) const result = buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: { [PANE_KEY]: doneEntry(null) }, retainedAgentsByPaneKey: {}, tabsByWorktree: { [localWorktree.id]: [tab] }, @@ -618,6 +624,7 @@ import { folderWorkspaceKey } from '../../../../shared/workspace-scope' retained.entry = doneEntry(null) const result = buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: {}, retainedAgentsByPaneKey: { [PANE_KEY]: retained }, tabsByWorktree: {}, @@ -641,6 +648,7 @@ import { folderWorkspaceKey } from '../../../../shared/workspace-scope' const tab = makeTab() const result = buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: {}, retainedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: { @@ -683,6 +691,7 @@ import { folderWorkspaceKey } from '../../../../shared/workspace-scope' ) const result = buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: {}, retainedAgentsByPaneKey: { [PANE_KEY]: retained }, tabsByWorktree: {}, diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts index c305be47bff..fe8909d8d37 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts @@ -94,6 +94,7 @@ describe('activity thread grouping', () => { agentType: 'claude' } const { events, liveAgentByPaneKey } = buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: { [PANE_KEY]: { ...sharedDone, @@ -132,6 +133,7 @@ describe('activity thread grouping', () => { const worktree = makeWorktreeWithId('wt-unknown', 'missing-repo', 'unknown-wt') const tab = makeTabWithIds('tab-unknown', worktree.id) const { events, liveAgentByPaneKey } = buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: { [UNKNOWN_PANE_KEY]: { state: 'done', @@ -183,6 +185,7 @@ describe('activity thread grouping', () => { const tabB1 = makeTabWithIds('tab-b1', wtB.id) const tabA2 = makeTabWithIds('tab-a2', wtA.id) const { events, liveAgentByPaneKey } = buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: { [PANE_KEY_A1]: { state: 'done', diff --git a/src/renderer/src/components/activity/activity-answered-ask-timeline.test.tsx b/src/renderer/src/components/activity/activity-answered-ask-timeline.test.tsx index 8da2633eb42..dcc2a3b3dbd 100644 --- a/src/renderer/src/components/activity/activity-answered-ask-timeline.test.tsx +++ b/src/renderer/src/components/activity/activity-answered-ask-timeline.test.tsx @@ -15,6 +15,10 @@ import { activityThreadRowCopy, activityThreadStatusId } from './activity-thread import { clearActivityThread } from './activity-clear-completed' import { countActivityUnread } from './useActivityUnreadCount' import { useAgentPaneThreads } from './use-agent-pane-threads' +import { useAppStore } from '@/store' +import { makeTabGroup } from '@/store/slices/store-test-helpers' +import { tabActivationPatch } from '@/store/slices/tabs/tabs-focus-actions' +import { getStructuredChatTabNames } from '@/runtime/structured-chat-name-observation' type TestStore = { getState: () => AppState @@ -157,6 +161,82 @@ describe("an answered subagent ask on a settled parent's Activity row", () => { resetStructuredAgentSessionStatusFeedsForTests() }) + it('keeps the saved native name across focus and follows naming-only updates', async () => { + const unrelated = { ...structuredTab, id: 'other-tab', entityId: 'other-session' } + const terminal: Tab = { ...structuredTab, id: 'terminal', contentType: 'terminal' } + useAppStore.setState({ + unifiedTabsByWorktree: { 'wt-1': [structuredTab, unrelated, terminal] }, + groupsByWorktree: { + 'wt-1': [ + makeTabGroup({ + id: 'group-1', + worktreeId: 'wt-1', + tabOrder: [structuredTab.id, unrelated.id, terminal.id], + activeTabId: structuredTab.id + }) + ] + } + }) + const emit = await connect() + emit({ + type: 'snapshot', + sessions: [ + summary({ latestPrompt: 'Implement the requested change' }), + summary({ sessionId: unrelated.entityId }) + ], + conversationNames: [{ sessionId: structuredTab.entityId, conversationName: 'Saved name' }] + }) + const stop = useAppStore.subscribe(() => getStructuredChatTabNames()) + try { + const activity = renderActivity() + const row = (id = structuredTab.id) => { + const found = activity.result.current.allThreads.find((item) => item.tab.id === id) + if (!found) { + throw new Error('Missing Activity row') + } + return found + } + const otherRow = row(unrelated.id) + expect(activityThreadRowCopy(row()).taskTitle).toBe('Saved name') + const focus = (current: Tab) => + act(() => { + const state = useAppStore.getState() + useAppStore.setState(tabActivationPatch(state, current, 'wt-1', undefined, false)) + }) + for (let i = 0; i < 2; i += 1) { + const before = useAppStore.getState().unifiedTabsByWorktree['wt-1'][0] + focus(before) + const current = useAppStore.getState().unifiedTabsByWorktree['wt-1'][0] + expect(current).not.toBe(before) + expect(getStructuredChatTabNames().has(current)).toBe(true) + expect(activityThreadRowCopy(row()).taskTitle).toBe('Saved name') + expect(activity.result.current.storeData.activityTabs['wt-1'][0]).toBe(current) + expect(row(unrelated.id)).toBe(otherRow) + } + const projectedTerminal = activity.result.current.storeData.activityTabs['wt-1'][2] + focus(terminal) + expect(activity.result.current.storeData.activityTabs['wt-1'][2]).toBe(projectedTerminal) + const threads = activity.result.current.allThreads + activity.rerender() + expect(activity.result.current.allThreads).toBe(threads) + const state = useAppStore.getState() + for (const name of ['Renamed on host', null]) { + emit({ + type: 'snapshot', + sessions: [], + conversationNames: [{ sessionId: structuredTab.entityId, conversationName: name }] + }) + expect(activityThreadRowCopy(row()).taskTitle).toBe( + name ?? 'Implement the requested change' + ) + expect(useAppStore.getState().unifiedTabsByWorktree).toBe(state.unifiedTabsByWorktree) + expect(useAppStore.getState().agentStatusByPaneKey).toBe(state.agentStatusByPaneKey) + } + } finally { + stop() + } + }) + it('reads done, lists Blocked then done, and leaves the answer read', async () => { const emit = await connect() emit({ type: 'snapshot', sessions: [summary()] }) diff --git a/src/renderer/src/components/activity/activity-event-builder-context.ts b/src/renderer/src/components/activity/activity-event-builder-context.ts index ab2f664900e..de12e00313d 100644 --- a/src/renderer/src/components/activity/activity-event-builder-context.ts +++ b/src/renderer/src/components/activity/activity-event-builder-context.ts @@ -11,6 +11,10 @@ import type { Repo } from '../../../../shared/repo-types' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { + projectedStructuredChatTabSavedName, + type StructuredChatTabNames +} from '@/runtime/structured-chat-tab-name' import type { Worktree } from '../../../../shared/worktree/types' import { effectiveWorktreeAgentRowStartedAt, @@ -23,33 +27,36 @@ export type ActivityTabContext = { worktreeId: string; tab: TerminalTab } export type ActivityEventOwner = { worktree: Worktree; repo: Repo | null; knownWorktree: boolean } export type ActivityTabHostIndex = Map> -// Cache derived tab context by stable source identity so agent-session/missing-tab rows can reuse pane builds. -const agentSessionTerminalTabs = new WeakMap() +// Key derived tab context by original native tab and saved name so unchanged pane builds retain identity. +const agentSessionTerminalTabs = new WeakMap() const attributedTabContexts = new WeakMap() -function terminalTabFromAgentSessionTab(tab: Tab): TerminalTab { +function terminalTabFromAgentSessionTab(tab: Tab, names: StructuredChatTabNames): TerminalTab { + // The chat's name lives on its host, so a rename changes it without changing the tab. + const name = projectedStructuredChatTabSavedName(tab, names) const cached = agentSessionTerminalTabs.get(tab) - if (cached) { - return cached + if (cached && cached.name === name) { + return cached.tab } const derived: TerminalTab = { id: tab.id, ptyId: null, worktreeId: tab.worktreeId, - title: tab.customLabel ?? tab.generatedLabel ?? tab.label, - customTitle: tab.customLabel, + title: name ?? tab.generatedLabel ?? tab.label, + customTitle: name, color: tab.color, isPinned: tab.isPinned, sortOrder: tab.sortOrder, createdAt: tab.createdAt } - agentSessionTerminalTabs.set(tab, derived) + agentSessionTerminalTabs.set(tab, { name, tab: derived }) return derived } export function buildActivityTabContext( tabsByWorktree: Record, - unifiedTabsByWorktree?: Record + unifiedTabsByWorktree: Record | undefined, + names: StructuredChatTabNames ): Map { const contexts = new Map() for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { @@ -62,7 +69,7 @@ export function buildActivityTabContext( if (tab.contentType !== 'agent-session' || contexts.has(tab.id)) { continue } - contexts.set(tab.id, { worktreeId, tab: terminalTabFromAgentSessionTab(tab) }) + contexts.set(tab.id, { worktreeId, tab: terminalTabFromAgentSessionTab(tab, names) }) } } return contexts diff --git a/src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts b/src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts index 6915a69bb4f..d8a87ab9bed 100644 --- a/src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts +++ b/src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts @@ -50,6 +50,7 @@ import type { Tab } from '../../../../shared/tab-types' const repo = makeRepo() const worktree = makeWorktree() return { + chatNames: new Map(), agentStatusByPaneKey: { [PANE_A]: entry(PANE_A), [PANE_B]: entry(PANE_B, { state: 'working', stateStartedAt: NOW - 1_000 }) @@ -298,6 +299,7 @@ import type { Tab } from '../../../../shared/tab-types' const worktree = makeWorktree() const tab = makeTab() return buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: args.entries ?? {}, retainedAgentsByPaneKey: {}, tabsByWorktree: { [worktree.id]: [tab] }, @@ -422,6 +424,7 @@ import type { Tab } from '../../../../shared/tab-types' }) ) const result = buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: entries, retainedAgentsByPaneKey: {}, tabsByWorktree: { [worktree.id]: tabs }, @@ -451,6 +454,7 @@ import type { Tab } from '../../../../shared/tab-types' const repo = makeRepo() const worktree = makeWorktree() return buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: { [PANE_KEY]: args.entry }, retainedAgentsByPaneKey: {}, tabsByWorktree: { [worktree.id]: [] }, @@ -539,6 +543,7 @@ import type { Tab } from '../../../../shared/tab-types' ) const result = buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: { [PANE_KEY]: { ...makeWorkingEntryWithoutHistory(), connectionId: null } }, @@ -576,6 +581,7 @@ import type { Tab } from '../../../../shared/tab-types' ) const result = buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: { [PANE_KEY]: { ...makeWorkingEntryWithoutHistory(), diff --git a/src/renderer/src/components/activity/activity-event-builder.ts b/src/renderer/src/components/activity/activity-event-builder.ts index cc18b1d72c5..66bfbe28b79 100644 --- a/src/renderer/src/components/activity/activity-event-builder.ts +++ b/src/renderer/src/components/activity/activity-event-builder.ts @@ -6,6 +6,7 @@ import type { MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { StructuredChatTabNames } from '@/runtime/structured-chat-tab-name' import type { Repo } from '../../../../shared/repo-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' import type { Tab } from '../../../../shared/tab-types' @@ -31,6 +32,7 @@ import { export { createActivityEventBuildCache, type ActivityEventBuildCache, newestActivityHistoryEntries } export type BuildActivityEventsArgs = { + chatNames: StructuredChatTabNames agentStatusByPaneKey: Record runtimeAgentOrchestrationByPaneKey?: Record migrationUnsupportedByPtyId?: Record @@ -58,7 +60,11 @@ export function buildActivityEvents( const events: ActivityEvent[] = [] const paneEntryByPaneKey: Record = {} const seenEventIds = new Set() - const tabContext = buildActivityTabContext(args.tabsByWorktree, args.unifiedTabsByWorktree) + const tabContext = buildActivityTabContext( + args.tabsByWorktree, + args.unifiedTabsByWorktree, + args.chatNames + ) const tabHostIndex = buildActivityTabHostIndex(args.unifiedTabsByWorktree) const ownerCache = new Map() const liveAgentByPaneKey: Record = {} diff --git a/src/renderer/src/components/activity/activity-tab-projection.ts b/src/renderer/src/components/activity/activity-tab-projection.ts index 028d474d519..8d479445d30 100644 --- a/src/renderer/src/components/activity/activity-tab-projection.ts +++ b/src/renderer/src/components/activity/activity-tab-projection.ts @@ -3,10 +3,8 @@ import type { Tab } from '../../../../shared/tab-types' /** * Stable view of the unified tab map for the activity pipeline. * - * Why: the store rewrites the focused tab object (lastFocusedAt) on every focus, which would - * rebuild every activity thread even though nothing the pipeline reads changed. Keep only the - * tab kinds the pipeline consults and reuse prior tab objects when their relevant fields match, - * so downstream identity-keyed caches keep hitting. + * Terminal tabs reuse prior objects across focus-only writes. Native chats keep current + * originals because the shared host-name projection uses those objects as keys. */ export type ActivityTabProjection = Record @@ -46,7 +44,11 @@ export function projectActivityTabs( let reusable = prior !== undefined && prior.length === relevant.length const projected = relevant.map((tab, index) => { const priorTab = prior?.[index] - if (priorTab && activityTabFieldsEqual(priorTab, tab)) { + if ( + priorTab && + (priorTab === tab || + (tab.contentType === 'terminal' && activityTabFieldsEqual(priorTab, tab))) + ) { return priorTab } reusable = false diff --git a/src/renderer/src/components/activity/activity-thread-grouping.status-order.test.ts b/src/renderer/src/components/activity/activity-thread-grouping.status-order.test.ts index 966e74abb19..40529e55446 100644 --- a/src/renderer/src/components/activity/activity-thread-grouping.status-order.test.ts +++ b/src/renderer/src/components/activity/activity-thread-grouping.status-order.test.ts @@ -61,6 +61,7 @@ import { formatShortTimeAgo } from '@/lib/short-time-ago' ) return makeThreads( buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey, retainedAgentsByPaneKey: {}, tabsByWorktree: { [worktree.id]: tabs }, @@ -248,6 +249,7 @@ import { formatShortTimeAgo } from '@/lib/short-time-ago' const repo = makeRepo() const worktree = makeWorktree() const { events, liveAgentByPaneKey } = buildActivityEvents({ + chatNames: new Map(), agentStatusByPaneKey: { [PANE_KEY]: doneEntry(PANE_KEY, newer, 3_000), [PANE_KEY_2]: doneEntry(PANE_KEY_2, older, 2_000) diff --git a/src/renderer/src/components/activity/use-agent-pane-threads.ts b/src/renderer/src/components/activity/use-agent-pane-threads.ts index 2581852356e..43a32d6ad13 100644 --- a/src/renderer/src/components/activity/use-agent-pane-threads.ts +++ b/src/renderer/src/components/activity/use-agent-pane-threads.ts @@ -1,6 +1,7 @@ import { useDeferredValue, useMemo, useRef } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' +import { useStructuredChatTabNames } from '@/runtime/structured-chat-name-observation' import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' import type { AppState } from '@/store/types' import { @@ -53,7 +54,7 @@ export type AgentPaneThreadsStoreData = Pick< | 'acknowledgeAgents' | 'unacknowledgeAgents' > & { - /** Terminal and agent-session tabs only, identity-stable across focus writes. */ + /** Current native-chat originals and terminal tabs with semantic identity reuse. */ activityTabs: ActivityTabProjection worktreeMap: ReturnType repoMap: ReturnType @@ -102,7 +103,7 @@ export function useAgentPaneThreads(args: { : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, [runtimeEnvironments, runtimeStatusByEnvironmentId] ) - // Project fields the pipeline reads so unrelated tab-focus rewrites preserve tab/list identities. + // Keep original native tabs for owner-qualified names; preserve terminal projections across focus writes. const tabProjectionRef = useRef<{ raw: AppState['unifiedTabsByWorktree'] | null projected: ActivityTabProjection | null @@ -141,6 +142,7 @@ export function useAgentPaneThreads(args: { ) // Why: agentStatusEpoch is a dep (not used in the body) so the memo recomputes when freshness boundaries expire even without new PTY data. const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) + const chatNames = useStructuredChatTabNames() // Per-hook caches preserve unchanged pane/event/thread identities across status writes, including repeated StrictMode/deferred builds. const eventBuildCacheRef = useRef>(undefined!) @@ -156,6 +158,7 @@ export function useAgentPaneThreads(args: { () => buildActivityEvents( { + chatNames, agentStatusByPaneKey: storeData.agentStatusByPaneKey, runtimeAgentOrchestrationByPaneKey: storeData.runtimeAgentOrchestrationByPaneKey, migrationUnsupportedByPtyId: storeData.migrationUnsupportedByPtyId, @@ -174,7 +177,7 @@ export function useAgentPaneThreads(args: { eventBuildCacheRef.current ), // eslint-disable-next-line react-hooks/exhaustive-deps - [storeData, agentStatusEpoch] + [storeData, agentStatusEpoch, chatNames] ) const allThreads = useMemo( diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts index 6c670934cb5..a7b326e8286 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts @@ -6,7 +6,10 @@ import type { ProjectGroup } from '../../../../shared/project-group-types' import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' +import { + buildDashboardSnapshot, + type DashboardSnapshotInput as DashboardSnapshotState +} from './build-dashboard-snapshot' import { buildDashboardBucketCounts } from './build-dashboard-bucket-counts' const NOW = 2_000_000_000 @@ -79,6 +82,7 @@ function entry(): AgentStatusEntry { function state(): DashboardSnapshotState { return { + chatNames: new Map(), repos: [], worktreesByRepo: {}, folderWorkspaces: [folderWorkspace()], @@ -100,7 +104,7 @@ function state(): DashboardSnapshotState { runtimePaneTitlesByTabId: {}, acknowledgedAgentsByPaneKey: {}, settings: null - } as unknown as DashboardSnapshotState + } } describe('buildDashboardSnapshot folder workspaces', () => { diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts index 97697f5f8a4..de7e435243e 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts @@ -15,7 +15,10 @@ vi.mock('../sidebar/worktree-agent-orchestration-batch', () => ({ selectRuntimeAgentOrchestrationBatch: batchMocks.select })) -import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' +import { + buildDashboardSnapshot, + type DashboardSnapshotInput as DashboardSnapshotState +} from './build-dashboard-snapshot' function worktree(index: number): Worktree { return { @@ -58,7 +61,8 @@ function state( ): DashboardSnapshotState { const worktrees = Array.from({ length: worktreeCount }, (_, index) => worktree(index)) return { - repos: [{ id: 'r1', path: '/r1', displayName: 'Repo One', badgeColor: '#000' }], + chatNames: new Map(), + repos: [{ id: 'r1', path: '/r1', displayName: 'Repo One', badgeColor: '#000', addedAt: 1 }], worktreesByRepo: { r1: worktrees }, tabsByWorktree: Object.fromEntries( worktrees.map((currentWorktree, index) => [currentWorktree.id, [tab(index)]]) @@ -70,8 +74,9 @@ function state( terminalLayoutsByTabId: {}, ptyIdsByTabId: {}, runtimePaneTitlesByTabId: {}, - acknowledgedAgentsByPaneKey: {} - } as unknown as DashboardSnapshotState + acknowledgedAgentsByPaneKey: {}, + settings: null + } } describe('buildDashboardSnapshot orchestration routing', () => { diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts index 9e6b30f2e31..2b29e47720d 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts @@ -4,7 +4,10 @@ import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { Worktree } from '../../../../shared/worktree/types' import type { RetainedAgentEntry } from '@/store/slices/agent-status' -import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' +import { + buildDashboardSnapshot, + type DashboardSnapshotInput as DashboardSnapshotState +} from './build-dashboard-snapshot' import { createWorktreeAgentRowsCache } from './worktree-agent-rows-cache' const NOW = 1_000_000_000 @@ -64,6 +67,7 @@ function entry(paneKey: string, tabId: string, worktreeId: string): AgentStatusE function baseState(): DashboardSnapshotState { return { + chatNames: new Map(), repos: [{ id: 'r1', path: '/r1', displayName: 'Repo One', badgeColor: '#000', addedAt: 1 }], worktreesByRepo: { r1: [worktree('w1'), worktree('w2')] }, tabsByWorktree: { w1: [tab('tab1', 'w1')], w2: [tab('tab2', 'w2')] }, diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts index 91eb43d35b5..e0c7ed93bf5 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' +import { + buildDashboardSnapshot, + type DashboardSnapshotInput as DashboardSnapshotState +} from './build-dashboard-snapshot' import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry, @@ -106,7 +109,8 @@ function worktree(id = 'w1', displayName = 'wt-one'): Worktree { function baseState(overrides: Partial): DashboardSnapshotState { return { - repos: [{ id: 'r1', path: '/r1', displayName: 'Repo One', badgeColor: '#000' }], + chatNames: new Map(), + repos: [{ id: 'r1', path: '/r1', displayName: 'Repo One', badgeColor: '#000', addedAt: NOW }], worktreesByRepo: { r1: [worktree()] }, tabsByWorktree: { w1: [tab()] }, agentStatusByPaneKey: {}, @@ -117,14 +121,16 @@ function baseState(overrides: Partial): DashboardSnapsho [TAB_ID]: { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: LEAF_ID, + expandedLeafId: null, ptyIdsByLeafId: { [LEAF_ID]: 'pty1' } } }, ptyIdsByTabId: { [TAB_ID]: ['pty1'] }, runtimePaneTitlesByTabId: {}, acknowledgedAgentsByPaneKey: {}, + settings: null, ...overrides - } as unknown as DashboardSnapshotState + } } describe('buildDashboardSnapshot', () => { diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index ad94fde3f9b..ce4aaeb1d45 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -1,3 +1,4 @@ +import type { StructuredChatTabNames } from '@/runtime/structured-chat-tab-name' import type { AgentLaunchRouteView } from '@/lib/agent-launch-route-input' import type { AppState } from '@/store/types' import { @@ -71,9 +72,11 @@ export type DashboardSnapshotState = Pick< Pick > +export type DashboardSnapshotInput = DashboardSnapshotState & { chatNames: StructuredChatTabNames } + /** Reuse sidebar row/index derivation for serializable cards; fold terminal-less subagents into their spawning card. */ export function buildDashboardSnapshot( - state: DashboardSnapshotState, + state: DashboardSnapshotInput, now: number, options: { launchRouteStore?: AgentLaunchRouteView @@ -239,7 +242,9 @@ export function buildDashboardSnapshot( row, generatedTitlesEnabled, terminalLayoutsByTabId[row.tab.id], - paneTitlesByTabId[row.tab.id] + paneTitlesByTabId[row.tab.id], + state.chatNames, + state.unifiedTabsByWorktree ) ), ...(terminalInput ? { terminalInput } : {}) diff --git a/src/renderer/src/components/dashboard/dashboard-card-labels.test.ts b/src/renderer/src/components/dashboard/dashboard-card-labels.test.ts index 7d08ee7f9d8..723c040a38e 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-labels.test.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-labels.test.ts @@ -49,11 +49,11 @@ describe('rowConversationName', () => { it('publishes a provider title only for the split-pane session that owns it', () => { const paneTitles = { 1: '\u2733 Linear work log', 2: '\u2733 Redis cache strategy' } - expect(rowConversationName(row(LEAF_A, 'session-a'), false, LAYOUT, paneTitles)).toBe( - 'Provider title' - ) - expect(rowConversationName(row(LEAF_B, 'session-b'), false, LAYOUT, paneTitles)).toBe( - 'Redis cache strategy' - ) + expect( + rowConversationName(row(LEAF_A, 'session-a'), false, LAYOUT, paneTitles, new Map(), undefined) + ).toBe('Provider title') + expect( + rowConversationName(row(LEAF_B, 'session-b'), false, LAYOUT, paneTitles, new Map(), undefined) + ).toBe('Redis cache strategy') }) }) diff --git a/src/renderer/src/components/dashboard/dashboard-card-labels.ts b/src/renderer/src/components/dashboard/dashboard-card-labels.ts index a3b2bb1e9e9..d4ed0e7ffb9 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-labels.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-labels.ts @@ -4,6 +4,12 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { resolveAgentRowPaneLiveTitle } from './agent-row-pane-live-title' import type { DashboardAgentRow } from './useDashboardData' +import type { AppState } from '@/store/types' +import { structuredChatTabById } from '@/lib/structured-chat-tab-index' +import { + projectedStructuredChatTabName, + type StructuredChatTabNames +} from '@/runtime/structured-chat-tab-name' export function rowTask(row: DashboardAgentRow): string { return (row.entry.orchestration?.taskTitle ?? '').trim() || (row.entry.prompt ?? '').trim() @@ -30,17 +36,24 @@ export function rowConversationName( row: DashboardAgentRow, generatedTitlesEnabled: boolean, layout: TerminalLayoutSnapshot | undefined, - paneTitles: Record | undefined + paneTitles: Record | undefined, + names: StructuredChatTabNames, + tabs: AppState['unifiedTabsByWorktree'] | undefined ): string | undefined { const parentPaneKey = row.entry.orchestration?.parentPaneKey // Why: a child row rendered on its parent's tab does not own that tab's name. if ( - row.lineage?.depth === 1 && - parentPaneKey !== undefined && - parsePaneKey(parentPaneKey)?.tabId === row.tab.id + row.rowSource === 'subagent' || + (row.lineage?.depth === 1 && + parentPaneKey !== undefined && + parsePaneKey(parentPaneKey)?.tabId === row.tab.id) ) { return undefined } + const chat = structuredChatTabById(tabs, row.tab.worktreeId, row.tab.id) + if (chat) { + return projectedStructuredChatTabName(chat, names) + } const paneLiveTitle = resolveAgentRowPaneLiveTitle( layout, paneTitles, diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx index 876873f83ea..7bf660a702a 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx @@ -37,6 +37,11 @@ vi.mock('@/store', () => ({ } })) +vi.mock('@/runtime/structured-chat-name-observation', () => ({ + subscribeStructuredChatTabNames: () => () => {}, + getStructuredChatTabNames: () => new Map() +})) + vi.mock('@/lib/activate-tab-and-focus-pane', () => ({ activateTabAndFocusPane: mocks.activateTabAndFocusPane })) diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index 689812500f6..70a0603be7a 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -1,6 +1,10 @@ import { createDashboardChatProgramHolds } from './dashboard-chat-program-acquisition' import { useEffect } from 'react' import { useAppStore, type AppState } from '@/store' +import { + subscribeStructuredChatTabNames, + getStructuredChatTabNames +} from '@/runtime/structured-chat-name-observation' import { subscribeChatProgramFacts } from '@/lib/host-chat-program' import { type AgentLaunchRouteView, @@ -80,6 +84,7 @@ export function dashboardSnapshotInputsChanged( /** Keep subscription outside the effect to avoid react-doctor’s cleanup false positive; callers must invoke the returned disposer. */ export function watchSnapshotInputs(onChanged: () => void): () => void { + const stopNames = subscribeStructuredChatTabNames(onChanged) const unsubscribeStore = useAppStore.subscribe((state, previousState) => { // Why: unrelated high-frequency store writes must not rebuild a cross-worktree snapshot. if (dashboardSnapshotInputsChanged(state, previousState)) { @@ -89,6 +94,7 @@ export function watchSnapshotInputs(onChanged: () => void): () => void { // Launch options also read host chat facts held outside the store. const unsubscribeChatFacts = subscribeChatProgramFacts(onChanged) return () => { + stopNames() unsubscribeStore() unsubscribeChatFacts() } @@ -133,11 +139,15 @@ export function useDashboardPopoutBridge(enabled: boolean): void { lastPublishAt = Date.now() const state = useAppStore.getState() chatOwners.reconcile(state) - const snapshot = buildDashboardSnapshot(state, lastPublishAt, { - launchRouteStore: selectAgentLaunchRouteView(state), - rowsCache, - rowsGeneration: state.agentStatusEpoch - }) + const snapshot = buildDashboardSnapshot( + { ...state, chatNames: getStructuredChatTabNames() }, + lastPublishAt, + { + launchRouteStore: selectAgentLaunchRouteView(state), + rowsCache, + rowsGeneration: state.agentStatusEpoch + } + ) const icons = snapshot.repoIconsByRepoId ?? {} if (!withIcons && repoIconsUnchanged(icons, lastPublishedRepoIcons)) { const { repoIconsByRepoId: _omitted, ...withoutIcons } = snapshot diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts index 3e88de03b4c..347adab3b8d 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts @@ -2,6 +2,7 @@ import { watchSnapshotInputs } from './useDashboardPopoutBridge' import { createDashboardChatProgramHolds } from './dashboard-chat-program-acquisition' import { useEffect, useMemo, useRef } from 'react' import { useAppStore } from '@/store' +import { useStructuredChatTabNames } from '@/runtime/structured-chat-name-observation' import type { DashboardSnapshot } from '../../../../shared/dashboard-snapshot' import { buildDashboardSnapshot } from './build-dashboard-snapshot' import { useChatProgramFactsVersion } from '@/lib/host-chat-program-acquisition' @@ -57,6 +58,7 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { const runtimeDetectedAgentIds = useAppStore((s) => s.runtimeDetectedAgentIds) // Freshness can change buckets without map writes; the epoch invalidates stale-decay memos. const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) + const chatNames = useStructuredChatTabNames() // Why: launch options read only this view, so any route input change rebuilds them. const launchRouteStore = useAppStore(selectAgentLaunchRouteView) useEffect(() => { @@ -76,6 +78,7 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { () => buildDashboardSnapshot( { + chatNames, repos, worktreesByRepo, tabsByWorktree, @@ -147,6 +150,7 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { detectedAgentIds, remoteDetectedAgentIds, runtimeDetectedAgentIds, + chatNames, chatProgramFactsVersion, launchRouteStore, agentStatusEpoch diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx index 6b80cd80037..57dddc62109 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx @@ -81,6 +81,11 @@ vi.mock('zustand/react/shallow', () => ({ useShallow: (selector: unknown) => selector })) +vi.mock('@/runtime/structured-chat-name-observation', () => ({ + useStructuredChatTabNames: () => new Map(), + getStructuredChatTabNames: () => new Map() +})) + vi.mock('@/lib/active-agent-note-send', () => ({ activeAgentNotesSendFailureMessage: ( status: string, diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index 91842c0c80a..641c7b8b587 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -2,6 +2,10 @@ import React, { useCallback, useMemo } from 'react' import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' +import { + useStructuredChatTabNames, + getStructuredChatTabNames +} from '@/runtime/structured-chat-name-observation' import { QuickLaunchAgentMenuItems } from '@/components/tab-bar/QuickLaunchButton' import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot' import { AgentTypeIcon } from '@/lib/agent-type-icon' @@ -66,6 +70,7 @@ export function ReviewNotesSendMenuContent({ const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) const agentRows = useWorktreeAgentRows(worktreeId) const now = useNow(30_000) + const chatNames = useStructuredChatTabNames() const sendTargets = useMemo(() => { void agentStatusEpoch return deriveNotesSendAgentTargets( @@ -77,11 +82,13 @@ export function ReviewNotesSendMenuContent({ ptyIdsByTabId, runtimePaneTitlesByTabId }, - worktreeId + worktreeId, + chatNames ) }, [ // Freshness timers can change eligibility through this epoch without replacing the status map. agentStatusEpoch, + chatNames, agentStatusByPaneKey, tabsByWorktree, unifiedTabsByWorktree, @@ -219,9 +226,11 @@ function resolveCurrentSendTargetEligibility( ? 'Terminal is no longer available' : 'Chat is no longer open' const state = useAppStore.getState() - const currentTarget = deriveNotesSendAgentTargets(state, worktreeId).find( - (candidate) => candidate.paneKey === target.paneKey - ) + const currentTarget = deriveNotesSendAgentTargets( + state, + worktreeId, + getStructuredChatTabNames() + ).find((candidate) => candidate.paneKey === target.paneKey) if (currentTarget) { return currentTarget.status === 'eligible' ? { status: 'eligible' } diff --git a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx index 0885734b969..cb3e9525949 100644 --- a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx @@ -3,7 +3,14 @@ import '@testing-library/jest-dom/vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' -import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' +import type { + AgentSessionConversationName, + AgentSessionStatusSummary +} from '../../../../shared/agent-session-wire' +import { + foldAgentSessionConversationNames, + type AgentSessionConversationNames +} from '../../../../shared/agent-session-conversation-names' import type { AgentMessageSource } from '../../../../shared/agent-session/agent-session-message-source' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { testOrcaSessionId } from '../../../../shared/orca-session-address-test-fixture' @@ -16,6 +23,7 @@ const feeds = vi.hoisted( string, { snapshot: ReadonlyMap + names: AgentSessionConversationNames listeners: Set<() => void> live: boolean activations: number @@ -33,6 +41,7 @@ vi.mock('@/runtime/structured-agent-session-status-feed', () => ({ } }, getSnapshot: () => feed.snapshot, + getNamingSnapshot: () => feed.names, getSessionObservation: () => (feed.live ? 'live' : 'unverifiable'), subscribe: (listener: () => void) => { feed.listeners.add(listener) @@ -94,9 +103,20 @@ function source(terminal = false): AgentMessageSource { ] } } -function publish(host: string, summaries: AgentSessionStatusSummary[]): void { +function publish( + host: string, + summaries?: AgentSessionStatusSummary[], + conversationNames: AgentSessionConversationName[] = [] +): void { const feed = feeds.get(host)! - feed.snapshot = new Map(summaries.map((row) => [row.sessionId, row])) + if (summaries) { + feed.snapshot = new Map(summaries.map((row) => [row.sessionId, row])) + } + feed.names = foldAgentSessionConversationNames(feed.names, { + type: 'snapshot', + sessions: summaries ?? [], + conversationNames + }) for (const listener of feed.listeners) { listener() } @@ -121,11 +141,18 @@ beforeEach(() => { openAgentMessageSender.mockClear() feeds.set('local', { snapshot: new Map([[ROOT, summary(ROOT)]]), + names: new Map(), + listeners: new Set(), + live: true, + activations: 0 + }) + feeds.set('remote', { + snapshot: new Map(), + names: new Map(), listeners: new Set(), live: true, activations: 0 }) - feeds.set('remote', { snapshot: new Map(), listeners: new Set(), live: true, activations: 0 }) useAppStore.setState( { ...initial, activeWorktreeId: RECIPIENT, activeWorkspaceExecutionHostId: 'local' }, true @@ -151,9 +178,18 @@ it('uses the recipient host when identical session text is published by two host }) it('shows a chat sender by its current rename', () => { - useAppStore.setState({ unifiedTabsByWorktree: { [SENDER]: [tab(ROOT, 'Renamed sender')] } }) + useAppStore.setState({ unifiedTabsByWorktree: { [SENDER]: [tab(ROOT, '')] } }) + const feed = feeds.get('local')! + const status = feed.snapshot + expect(feed.names.has(ROOT)).toBe(false) render() + expect(screen.getByRole('button', { name: 'Original sender' })).toBeInTheDocument() + act(() => publish('local', undefined, [{ sessionId: ROOT, conversationName: null }])) + expect(screen.getByRole('button', { name: 'Original sender' })).toBeInTheDocument() + act(() => publish('local', undefined, [{ sessionId: ROOT, conversationName: 'Renamed sender' }])) expect(screen.getByRole('button', { name: 'Renamed sender' })).toBeInTheDocument() + expect(feed.snapshot).toBe(status) + expect(status.get(ROOT)?.conversationName).toBeUndefined() }) it.each([false, true])( diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx index b6890b1d0ec..09b650b533a 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx @@ -211,22 +211,12 @@ describe('native chat names in Vault rows', () => { ) it.each(['opencode', 'opencode2', 'grok', 'omp', 'pi'] as const)( - 'keeps the CLI title on a %s chat row, as its terminal conversations do', + 'shows a %s chat row its CLI title until the chat is named, then the chat name', async (agent) => { - useAppStore.setState({ - unifiedTabsByWorktree: { - 'folder-workspace': [ - { - ...tab('Manual name'), - agentSessionAgent: agent === 'opencode2' ? 'opencode' : agent - } - ] - } - }) await renderRow({ ...row('local', 'Fix the parser'), agent }) - publish('local', 'Explain the parser') expect(screen.getByText('Fix the parser')).toBeTruthy() - expect(screen.queryByText('Manual name')).toBeNull() + publish('local', 'Explain the parser') + expect(screen.getByText('Explain the parser')).toBeTruthy() } ) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-association-observation.ts b/src/renderer/src/components/right-sidebar/ai-vault-association-observation.ts new file mode 100644 index 00000000000..82bd74e4f1d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-association-observation.ts @@ -0,0 +1,65 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' +import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' +import { + type ExecutionHostScope, + toRuntimeExecutionHostId +} from '../../../../shared/execution-host' +import { invalidateAiVaultSessionResults } from './ai-vault-session-result-cache' + +/** List and search activate the same host feeds, independently of open chat tabs. */ +export function useAiVaultAssociationObservation(scope: ExecutionHostScope | null) { + const environments = useAppStore((s) => s.runtimeEnvironments) ?? [] + const addressed = environments.find((e) => toRuntimeExecutionHostId(e.id) === scope) + const identity = JSON.stringify( + scope === 'all' + ? [ + 'local', + ...environments.map((e) => [ + toRuntimeExecutionHostId(e.id), + e.runtimeId, + e.pairingRevision ?? e.createdAt + ]) + ] + : [scope, addressed?.runtimeId, addressed?.pairingRevision ?? addressed?.createdAt] + ) + return useMemo(() => { + const hosts: ExecutionHostScope[] = + scope === 'all' + ? ['local', ...environments.map((e) => toRuntimeExecutionHostId(e.id))] + : scope + ? [scope] + : [] + const feeds = hosts.flatMap((host) => { + const target = structuredAgentSessionTargetForHost(host) + return target ? [{ host, feed: getStructuredAgentSessionStatusFeed(target) }] : [] + }) + return { + getSnapshot: () => + JSON.stringify([identity, ...feeds.map(({ feed }) => feed.getAssociationRevision())]), + subscribe: (listener: () => void) => { + const stops = feeds.map(({ host, feed }) => { + let revision = feed.getAssociationRevision() + const stop = feed.subscribe(() => { + const next = feed.getAssociationRevision() + if (revision === next) { + return + } + revision = next + invalidateAiVaultSessionResults(host) + listener() + }) + const deactivate = feed.activate() + return () => { + stop() + deactivate() + } + }) + return () => stops.forEach((stop) => stop()) + } + } + // The addressed identity, rather than unrelated catalog metadata, owns this observation. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [identity]) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-forced-rescan-budget.ts b/src/renderer/src/components/right-sidebar/ai-vault-forced-rescan-budget.ts new file mode 100644 index 00000000000..1ba53075aa9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-forced-rescan-budget.ts @@ -0,0 +1,24 @@ +// In-app session creation bypasses the cache so the new session appears promptly. +// Keep the budget at module scope so tab remounts cannot amplify full scans. +const FORCED_RESCAN_MIN_INTERVAL_MS = 30_000 +let lastForcedRescanAt = 0 + +/** Takes the shared forced-rescan budget when it is free. Every caller outside the panel that forces + * a scan must take it too, or it would amplify full scans the same way. A user's explicit request + * takes it even when spent, as the panel's Refresh does, so no automatic rescan follows it. */ +export function claimAiVaultForcedRescan(userRequested = false): boolean { + const now = Date.now() + if (!userRequested && lastForcedRescanAt + FORCED_RESCAN_MIN_INTERVAL_MS > now) { + return false + } + lastForcedRescanAt = now + return true +} + +export function aiVaultForcedRescanDelay(): number { + return lastForcedRescanAt + FORCED_RESCAN_MIN_INTERVAL_MS - Date.now() +} + +export function resetAiVaultForcedRescanBudgetForTest(): void { + lastForcedRescanAt = 0 +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-conditional-refresh-hook.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-conditional-refresh-hook.test.tsx index ef655fa8d02..5f60f3a0a5e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-conditional-refresh-hook.test.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-conditional-refresh-hook.test.tsx @@ -16,6 +16,18 @@ import { useAiVaultSessionRefresh } from './ai-vault-session-refresh' +import type { AgentSessionStatusEvent } from '../../../../shared/agent-session-wire' +import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' +const statusFrames = vi.hoisted(() => ({ emit: (_event: AgentSessionStatusEvent): void => {} })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: async ( + _target: unknown, + emit: typeof statusFrames.emit + ) => { + statusFrames.emit = emit + return { unsubscribe: () => {} } + } +})) vi.mock('@tanstack/react-virtual', () => ({ useVirtualizer: ({ count }: { count: number }) => ({ getVirtualItems: () => @@ -160,6 +172,7 @@ async function refresh(force = false): Promise { beforeEach(() => { vi.useFakeTimers() resetInputQuietSchedulerForTest() + resetStructuredAgentSessionStatusFeedsForTests() pendingRenders.length = 0 resume.mockReset() resetAiVaultForcedRescanThrottleForTest() @@ -181,6 +194,7 @@ beforeEach(() => { }) afterEach(() => { act(() => root?.unmount()) + resetStructuredAgentSessionStatusFeedsForTests() root = null latest = null document.body.replaceChildren() @@ -350,3 +364,86 @@ describe('conditional panel publication', () => { expect(latest?.scanResult).toBe(original) }) }) + +it('cancels queued pre-clear publication while the current nonforced projection is pending', async () => { + const summary = { + sessionId: 'native', + workspaceId: 'workspace', + agent: 'codex' as const, + latestPrompt: '', + status: null, + updatedAt: 1, + providerSession: { key: 'session_id' as const, id: 'session' } + } + const owned = { + ...session, + title: 'Manual name', + structuredSession: { sessionId: 'native', workspaceId: 'workspace' } + } + list.mockResolvedValue({ ...first, sessions: [owned] }) + await render('local') + await act(async () => statusFrames.emit({ type: 'snapshot', sessions: [summary] })) + await settle() + markInputQuietSchedulerInput() + list.mockResolvedValueOnce({ + ...first, + sessions: [{ ...owned, title: 'Obsolete queued result' }] + }) + await act(async () => latest?.refresh({ background: true })) + expect(latest?.sessions[0]?.title).toBe('Manual name') + let deliver = (_result: AiVaultListResult): void => {} + list.mockImplementationOnce( + () => + new Promise((resolve) => { + deliver = resolve + }) + ) + const baseline = list.mock.calls.length + const { providerSession: _provider, ...detached } = summary + await act(async () => statusFrames.emit({ type: 'status', session: detached })) + await act(async () => { + for (const id of ['next', 'third']) { + statusFrames.emit({ + type: 'status', + session: { + ...summary, + providerSession: { key: 'session_id', id } + } + }) + } + statusFrames.emit({ type: 'status', session: detached }) + }) + await settle() + expect(latest?.sessions[0]?.title).toBe('Manual name') + list.mockResolvedValueOnce({ ...first, sessions: [{ ...session, title: 'Provider title' }] }) + await act(async () => deliver({ ...first, sessions: [{ ...session, title: 'Provider title' }] })) + await settle() + expect(latest?.sessions[0]).toMatchObject({ title: 'Provider title' }) + expect(latest?.sessions[0]?.structuredSession).toBeUndefined() + expect(list).toHaveBeenCalledTimes(baseline + 2) + expect( + list.mock.calls + .slice(baseline) + .every( + ([args]) => + typeof args === 'object' && args !== null && 'force' in args && args.force === false + ) + ).toBe(true) + list.mockImplementationOnce( + () => + new Promise((resolve) => { + deliver = resolve + }) + ) + await act(async () => { + statusFrames.emit({ type: 'status', session: summary }) + statusFrames.emit({ type: 'status', session: detached }) + }) + await act(async () => { + root?.unmount() + root = null + }) + await act(async () => deliver(first)) + await settle() + expect(list).toHaveBeenCalledTimes(baseline + 3) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts index a10439fa2e9..ce832c055c9 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -274,52 +274,39 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { expect(lastCallArgs()).toMatchObject({ limit: 250, scopePaths: ['/repo'] }) }) - it('reuses a loaded larger depth when lowering and raising within its coverage', async () => { + it('shows cached coverage while revalidating each selected depth', async () => { const loaded = { ...EMPTY_RESULT, sessions: Array.from({ length: 600 }, (_, index) => makeVaultSession(index)) } - listSessionsMock.mockResolvedValueOnce(loaded) + listSessionsMock.mockImplementation(async (args) => ({ + ...loaded, + sessions: loaded.sessions.slice(0, args.limit) + })) await renderHook([], 'ssh:dev-box', 1000) await flushMicrotasks() - await rerenderHook([], 'ssh:dev-box', 250) await flushMicrotasks() expect(latest?.sessions).toHaveLength(250) - await rerenderHook([], 'ssh:dev-box', 500) await flushMicrotasks() expect(latest?.sessions).toHaveLength(500) - expect(listSessionsMock).toHaveBeenCalledTimes(1) + expect(listSessionsMock).toHaveBeenCalledTimes(3) + expect(listSessionsMock.mock.calls.every(([args]) => args.force === false)).toBe(true) }) - it('reuses the rendered result after the panel remounts on a tab switch', async () => { - const loaded = { ...EMPTY_RESULT, sessions: [makeVaultSession(1)] } - listSessionsMock.mockResolvedValueOnce(loaded) - await renderHook(['/repo'], 'ssh:dev-box', 250) - await flushMicrotasks() - - roots.splice(0).forEach((root) => act(() => root.unmount())) - await renderHook(['/repo'], 'ssh:dev-box', 250) - await flushMicrotasks() - - expect(listSessionsMock).toHaveBeenCalledTimes(1) - expect(latest?.sessions).toEqual(loaded.sessions) - }) - - it('reuses each workspace result when switching back across tabs', async () => { - listSessionsMock - .mockResolvedValueOnce({ ...EMPTY_RESULT, sessions: [makeVaultSession(1)] }) - .mockResolvedValueOnce({ ...EMPTY_RESULT, sessions: [makeVaultSession(2)] }) + it('revalidates each workspace when switching back across tabs', async () => { + listSessionsMock.mockImplementation(async (args) => ({ + ...EMPTY_RESULT, + sessions: [makeVaultSession(args.scopePaths?.[0] === '/repo-a' ? 1 : 2)] + })) await renderHook(['/repo-a'], 'ssh:dev-box', 250) await flushMicrotasks() - await rerenderHook(['/repo-b'], 'ssh:dev-box', 250) await flushMicrotasks() await rerenderHook(['/repo-a'], 'ssh:dev-box', 250) await flushMicrotasks() - - expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(listSessionsMock).toHaveBeenCalledTimes(3) expect(latest?.sessions[0]?.id).toBe('session-1') }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index 8ec7dbc0bbd..d4610439879 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -5,11 +5,7 @@ import { type AiVaultSession } from '../../../../shared/ai-vault-types' import { describeAiVaultScanError } from '../../../../shared/ai-vault-scan-error-message' -import { - ALL_EXECUTION_HOSTS_SCOPE, - requestedExecutionHostScope, - type ExecutionHostScope -} from '../../../../shared/execution-host' +import type { ExecutionHostScope } from '../../../../shared/execution-host' import { useAppStore } from '@/store' import type { AiVaultSessionLimit } from './ai-vault-session-limit' import { AiVaultSessionPublicationGate } from './ai-vault-session-publication-gate' @@ -30,24 +26,19 @@ import { getAgentSessionIdsKey, resetAgentSessionIdsRefreshKeys } from './ai-vault-agent-session-refresh-key' +import { useAiVaultAssociationObservation } from './ai-vault-association-observation' import { createBrowserUuid } from '@/lib/browser-uuid' // Share the forced-rescan budget across remounts so session creation cannot amplify full scans. -const FORCED_RESCAN_MIN_INTERVAL_MS = 30_000 -let lastForcedRescanAt = 0 - -/** All forced callers share this budget; explicit requests consume it even when spent to suppress automatic follow-ups. */ -export function claimAiVaultForcedRescan(userRequested = false): boolean { - const now = Date.now() - if (!userRequested && lastForcedRescanAt + FORCED_RESCAN_MIN_INTERVAL_MS > now) { - return false - } - lastForcedRescanAt = now - return true -} +import { + claimAiVaultForcedRescan, + aiVaultForcedRescanDelay, + resetAiVaultForcedRescanBudgetForTest +} from './ai-vault-forced-rescan-budget' +export { claimAiVaultForcedRescan } from './ai-vault-forced-rescan-budget' export function resetAiVaultForcedRescanThrottleForTest(): void { - lastForcedRescanAt = 0 + resetAiVaultForcedRescanBudgetForTest() resetAgentSessionIdsRefreshKeys() resetAiVaultSessionResultCacheForTest() } @@ -57,11 +48,6 @@ export const isAiVaultScanCancellation = isAiVaultScanCancelledError type AiVaultRefreshArgs = { force?: boolean; background?: boolean; reuseLoadedDepth?: boolean } -// Use desktop merge semantics even if web drops scope: redundant reconciliation is safer than trusting a merged stamp. -function isMergedAiVaultHostScope(scope: ExecutionHostScope): boolean { - return requestedExecutionHostScope(scope) === ALL_EXECUTION_HOSTS_SCOPE -} - export function useAiVaultSessionRefresh( scopePaths: readonly string[], executionHostScope: ExecutionHostScope, @@ -77,6 +63,8 @@ export function useAiVaultSessionRefresh( loadedSessionLimit: AiVaultSessionLimit | null } { const { scan, applyScan } = useAppliedAiVaultScan() + const associations = useAiVaultAssociationObservation(executionHostScope) + const associationsRef = useRef(associations) const scanResult = scan?.result ?? null const sessions = scanResult?.sessions ?? EMPTY_AI_VAULT_SESSIONS const [loading, setLoading] = useState(false) @@ -89,7 +77,6 @@ export function useAiVaultSessionRefresh( const pendingRefreshRef = useRef(false) const pendingForceRef = useRef(false) const pendingBackgroundRef = useRef(true) - const lastAppliedScanRef = useRef<{ scopeKey: string; scannedAt: string } | null>(null) const conditionalSnapshotRef = useRef<(AiVaultConditionalSnapshot & { scopeKey: string }) | null>( null ) @@ -107,7 +94,8 @@ export function useAiVaultSessionRefresh( // Keep render pure for React Doctor; layout effect still lands before refresh effects. useLayoutEffect(() => { sessionLimitRef.current = sessionLimit - }, [sessionLimit]) + associationsRef.current = associations + }, [associations, sessionLimit]) const currentScanScopeKey = useCallback( () => `${aiVaultSessionResultCacheKey( @@ -118,6 +106,10 @@ export function useAiVaultSessionRefresh( ) const refresh = useCallback( async (args: AiVaultRefreshArgs = {}): Promise => { + const observation = associationsRef.current + const associationRevision = observation.getSnapshot() + const associationCurrent = () => + observation === associationsRef.current && associationRevision === observation.getSnapshot() const hostScope = executionHostScopeRef.current const selectedLimit = sessionLimitRef.current const baseKey = aiVaultSessionResultCacheKey(hostScope, scopePathsRef.current) @@ -131,10 +123,12 @@ export function useAiVaultSessionRefresh( : null if (cachedResult) { const scanKey = `${baseKey}\n${selectedLimit}` - lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: cachedResult.scannedAt } setFailure(null) setTerminalScopeKey(null) publicationGateRef.current.publish(cachedResult, (published) => { + if (!mountedRef.current || scanKey !== currentScanScopeKey() || !associationCurrent()) { + return + } applyScan(published, selectedLimit, scanKey, (applied) => { conditionalSnapshotRef.current = published.fingerprint ? { scopeKey: scanKey, fingerprint: published.fingerprint, result: applied } @@ -142,13 +136,12 @@ export function useAiVaultSessionRefresh( }) }) setLoading(false) - return } // Queue scope changes during an in-flight scan so the new view still refreshes. if (refreshInFlightRef.current) { pendingRefreshRef.current = true pendingForceRef.current ||= args.force === true - pendingBackgroundRef.current &&= args.background === true + pendingBackgroundRef.current &&= args.background === true || cachedResult !== null if (args.force === true && args.background !== true) { void window.api.aiVault .cancelListSessions({ requestToken: requestTokenRef.current }) @@ -162,14 +155,19 @@ export function useAiVaultSessionRefresh( refreshIdRef.current = refreshId // Count manual refresh against the throttle to prevent an immediate automatic full scan. if (args.force === true) { - lastForcedRescanAt = Date.now() + claimAiVaultForcedRescan(true) } // Background cache hits must not flash a loading spinner on every refocus. - if (args.background !== true) { + if (args.background !== true && !cachedResult) { setLoading(true) } setTerminalScopeKey(null) const scanKey = `${baseKey}\n${selectedLimit}` + const requestCurrent = () => + mountedRef.current && + refreshIdRef.current === refreshId && + scanKey === currentScanScopeKey() && + associationCurrent() const request = { scopePaths: scopePathsRef.current, executionHostScope: hostScope, @@ -182,12 +180,8 @@ export function useAiVaultSessionRefresh( { force: args.force, requestToken: requestTokenRef.current }, previous?.scopeKey === scanKey ? previous : undefined ) - // Superseded scans resolve cancelled to keep main logs clean; never paint their empty results. - if (!mountedRef.current || refreshIdRef.current !== refreshId) { - return - } - // Reject old-scope results before the queued scan so the wrong history never flashes. - if (scanKey !== currentScanScopeKey()) { + // Reject superseded or old-scope replies before they can repaint History. + if (!requestCurrent()) { return } if (result.cancelled) { @@ -195,19 +189,9 @@ export function useAiVaultSessionRefresh( return } setFailure(null) - // Equal single-host stamps skip replayed snapshots; merged stamps can hide lagging-host changes and require structural comparison. - if ( - !isMergedAiVaultHostScope(hostScope) && - lastAppliedScanRef.current?.scopeKey === scanKey && - lastAppliedScanRef.current.scannedAt === result.scannedAt && - result.fingerprint === undefined - ) { - return - } - lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: result.scannedAt } cacheAiVaultSessionList(request, result, { replaceHostEntries: args.force === true }) publicationGateRef.current.publish(result, (published) => { - if (mountedRef.current && scanKey === currentScanScopeKey()) { + if (requestCurrent()) { applyScan(published, selectedLimit, scanKey, (applied) => { conditionalSnapshotRef.current = published.fingerprint ? { scopeKey: scanKey, fingerprint: published.fingerprint, result: applied } @@ -217,11 +201,7 @@ export function useAiVaultSessionRefresh( }) } catch (err) { // Cancellation means another shared scan superseded this one; do not replace its incoming list with an error. - if ( - mountedRef.current && - refreshIdRef.current === refreshId && - scanKey === currentScanScopeKey() - ) { + if (requestCurrent()) { setTerminalScopeKey(scanKey) if (!isAiVaultScanCancellation(err)) { setFailure({ @@ -257,7 +237,7 @@ export function useAiVaultSessionRefresh( void refresh({ background: true, force: true }) return } - const waitMs = lastForcedRescanAt + FORCED_RESCAN_MIN_INTERVAL_MS - Date.now() + const waitMs = aiVaultForcedRescanDelay() if (forcedRescanTimerRef.current !== null) { return } @@ -274,6 +254,9 @@ export function useAiVaultSessionRefresh( return () => { mountedRef.current = false publicationGate.cancel() + pendingRefreshRef.current = false + pendingForceRef.current = false + pendingBackgroundRef.current = true refreshIdRef.current += 1 refreshInFlightRef.current = false void window.api.aiVault.cancelListSessions({ @@ -289,13 +272,26 @@ export function useAiVaultSessionRefresh( // Panel entry reuses the renderer result first, then the host scan cache. useEffect(() => { publicationGateRef.current.cancel() + pendingRefreshRef.current = false + pendingForceRef.current = false + pendingBackgroundRef.current = true if (refreshInFlightRef.current) { void window.api.aiVault.cancelListSessions({ requestToken: requestTokenRef.current }) } void refresh({ force: false, reuseLoadedDepth: true }) - }, [executionHostScope, refresh, scanScopeKey]) + }, [associations, executionHostScope, refresh, scanScopeKey]) + + useEffect( + () => + associations.subscribe(() => { + publicationGateRef.current.cancel() + conditionalSnapshotRef.current = null + void refresh({ background: true, force: false }) + }), + [associations, refresh] + ) // Retry prior relay-readiness errors on host events; otherwise startup/reconnect failures can persist indefinitely. useAiVaultSessionHostRefreshEvents(error, refresh) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-result-cache.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-result-cache.ts index 81ebb7289fc..47558de7298 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-result-cache.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-result-cache.ts @@ -52,11 +52,6 @@ export function cacheAiVaultSessionResult(args: { cachedSessionResults.delete(key) } } - } else { - const cached = cachedSessionResults.get(args.key) - if (cached && aiVaultCachedDepthServes(cached.limit, args.limit, args.scopePaths)) { - return - } } cachedSessionResults.delete(args.key) cachedSessionResults.set(args.key, { @@ -76,3 +71,11 @@ export function cacheAiVaultSessionResult(args: { export function resetAiVaultSessionResultCacheForTest(): void { cachedSessionResults.clear() } + +export function invalidateAiVaultSessionResults(host: ExecutionHostScope): void { + for (const [key, entry] of cachedSessionResults) { + if (entry.executionHostScope === host || entry.executionHostScope === 'all') { + cachedSessionResults.delete(key) + } + } +} diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx b/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx index e4d0c986fd8..abe879cdd4d 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx @@ -8,12 +8,26 @@ import type { } from '../../../../shared/ai-vault-search-types' import type { ExecutionHostId, ExecutionHostScope } from '../../../../shared/execution-host' import { searchHit, searchResults } from '../../../../shared/ai-vault-search-test-fixture' +import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' +import type { AgentSessionStatusEvent } from '../../../../shared/agent-session-wire' import { useAiVaultPanelSearch, useAiVaultSearch } from './use-ai-vault-search' const mockSettings: { aiVaultSearch?: { enabled: boolean } } = {} vi.mock('@/store', () => ({ - useAppStore: (select: (state: { settings: typeof mockSettings }) => unknown) => - select({ settings: mockSettings }) + useAppStore: ( + select: (state: { settings: typeof mockSettings; runtimeEnvironments: never[] }) => unknown + ) => select({ settings: mockSettings, runtimeEnvironments: [] }) +})) + +const feeds = vi.hoisted(() => ({ + subscribe: vi.fn(), + emit: new Map void>() +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: feeds.subscribe +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + runtimeEnvironmentSupportsCapability: async () => true })) const ALL_AGENTS = ['codex' as const] @@ -31,6 +45,12 @@ const empty: AiVaultSearchResponse = { truncated: { candidates: false, snippets: 0, query: false, freshness: false } } beforeEach(() => { + resetStructuredAgentSessionStatusFeedsForTests() + feeds.emit.clear() + feeds.subscribe.mockImplementation(async (target, emit) => { + feeds.emit.set(target.environmentId ?? 'local', emit) + return { unsubscribe: vi.fn() } + }) vi.useFakeTimers() Object.defineProperty(window, 'api', { configurable: true, @@ -39,7 +59,10 @@ beforeEach(() => { searchSessions.mockReset().mockResolvedValue(empty) delete mockSettings.aiVaultSearch }) -afterEach(() => vi.useRealTimers()) +afterEach(() => { + resetStructuredAgentSessionStatusFeedsForTests() + vi.useRealTimers() +}) async function debounce() { await act(async () => { await vi.advanceTimersByTimeAsync(250) @@ -367,3 +390,91 @@ it('keeps full-text search for a host that is merely not ready', async () => { expect(result.current.titleOnly).toBe(false) unmount() }) + +it('revalidates active search without losing hits, accepting old pages, or touching another host', async () => { + const request = { query: 'inventory' } + const owned = { + ...searchHit(), + title: 'Budget plan', + structuredSession: { sessionId: 'native', workspaceId: 'workspace' } + } + searchSessions.mockResolvedValue({ + ...searchResults(), + hits: [owned], + page: { cursor: 'old-page', hasMore: true } + }) + const a = renderHook(() => useAiVaultSearch(request, 'runtime:a', '')) + const b = renderHook(() => useAiVaultSearch(request, 'runtime:b', '')) + const summary = { + sessionId: 'native', + workspaceId: 'workspace', + agent: 'pi' as const, + latestPrompt: '', + status: null, + updatedAt: 1, + providerSession: { key: 'session_id' as const, id: '/provider' } + } + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + await act(async () => feeds.emit.get('a')?.({ type: 'snapshot', sessions: [summary] })) + await debounce() + const baseline = searchSessions.mock.calls.length + for (const n of [10, 100]) { + await act(async () => { + for (let i = 0; i < n; i++) { + feeds.emit.get('a')?.({ + type: 'status', + session: { ...summary, updatedAt: i, toolName: `${i}` } + }) + feeds.emit.get('a')?.({ + type: 'snapshot', + sessions: [], + namingActiveSessionIds: [], + conversationNames: [{ sessionId: 'native', conversationName: `${i}` }] + }) + } + }) + expect(searchSessions).toHaveBeenCalledTimes(baseline) + } + let settlePage = (_response: AiVaultSearchResponse): void => {} + searchSessions.mockImplementationOnce( + () => + new Promise((resolve) => { + settlePage = resolve + }) + ) + act(() => a.result.current.loadMore()) + const { providerSession: _provider, ...detached } = summary + await act(async () => { + for (const id of ['/next', '/third']) { + feeds.emit.get('a')?.({ + type: 'status', + session: { + ...summary, + providerSession: { key: 'session_id', id } + } + }) + } + feeds.emit.get('a')?.({ type: 'status', session: detached }) + }) + expect(a.result.current.hits).toEqual([owned]) + const recovered = { ...searchHit(), title: 'Check inventory' } + searchSessions.mockResolvedValueOnce({ ...searchResults(), hits: [recovered] }) + await act(async () => + settlePage({ ...searchResults(), hits: [{ ...searchHit(), title: 'obsolete' }] }) + ) + expect(a.result.current.hits).toEqual([recovered]) + expect(b.result.current.hits).toEqual([owned]) + expect( + searchSessions.mock.calls.slice(baseline).map(([args, scope]) => [args.cursor, scope]) + ).toEqual([ + ['old-page', 'runtime:a'], + [undefined, 'runtime:a'] + ]) + a.unmount() + b.unmount() + await act(async () => feeds.emit.get('a')?.({ type: 'snapshot', sessions: [] })) + expect(searchSessions).toHaveBeenCalledTimes(baseline + 2) +}) diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts index b878e30ade4..f8dcb2a1ad7 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts @@ -20,6 +20,7 @@ import type { AiVaultSearchScopeIdentity } from '../../../../shared/ai-vault-sea import { resolveAiVaultSearchSettings } from '../../../../shared/ai-vault-search-settings' import { isWebClientLocation } from '@/lib/web-client-location' import { useAppStore } from '@/store' +import { useAiVaultAssociationObservation } from './ai-vault-association-observation' import { aiVaultSearchHitToSession } from './ai-vault-search-session' type SearchIdentity = { @@ -42,6 +43,7 @@ export function useAiVaultSearch( scope: ExecutionHostScope | null, policyKey: string ) { + const associations = useAiVaultAssociationObservation(request ? scope : null) const [page, setPage] = useState(null) const [revision, setRevision] = useState(0) const loadPage = useRef<((cursor: string) => void) | null>(null) @@ -57,14 +59,19 @@ export function useAiVaultSearch( } let cancelled = false let pending = false - async function run(cursor?: string) { + let followUp = false + let started = false + async function run(cursor?: string, revalidate = false) { if (pending || cancelled || !request || !scope) { return } pending = true + started = true + const revision = associations.getSnapshot() + const isCurrent = () => !cancelled && revision === associations.getSnapshot() setPage((previous) => ({ identity, - hits: cursor && previous?.identity === identity ? previous.hits : [], + hits: (cursor || revalidate) && previous?.identity === identity ? previous.hits : [], response: null, error: false, loading: true @@ -72,14 +79,14 @@ export function useAiVaultSearch( try { let response = await window.api.aiVault.searchSessions({ ...request, cursor }, scope) let append = Boolean(cursor) - if (cancelled) { + if (!isCurrent()) { return } if (response.kind === 'stale-cursor') { append = false response = await window.api.aiVault.searchSessions(request, scope) } - if (cancelled) { + if (!isCurrent()) { return } setPage((previous) => ({ @@ -96,21 +103,37 @@ export function useAiVaultSearch( loading: false })) } catch { - if (!cancelled) { + if (isCurrent()) { setPage({ identity, hits: [], response: null, error: true, loading: false }) } } finally { pending = false + if (followUp && !cancelled) { + followUp = false + void run(undefined, true) + } } } loadPage.current = (cursor) => void run(cursor) - const timer = setTimeout(() => void run(), 250) + const stop = associations.subscribe(() => { + if (!started) { + return + } + if (pending) { + followUp = true + } else { + void run(undefined, true) + } + }) + const timer = setTimeout(() => void run(undefined, true), 250) return () => { cancelled = true + followUp = false + stop() loadPage.current = null clearTimeout(timer) } - }, [identity]) + }, [identity, associations]) const current = page?.identity === identity ? page : null return { diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-session-display-title.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-session-display-title.ts index fc10bc1b879..fa5afa8555f 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-session-display-title.ts +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-session-display-title.ts @@ -1,15 +1,12 @@ import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import { - historyRowShowsChatName, - structuredChatDisplayName -} from '../../../../shared/structured-chat-row-name' +import { structuredChatDisplayName } from '../../../../shared/structured-chat-row-name' import { useAppStore } from '@/store' import { structuredChatTabBySessionId } from '@/lib/structured-chat-tab-index' import { useStructuredConversationName } from '@/runtime/structured-conversation-name' -/** Match Claude/Codex tab names, falling back to host titles for unpublished chats; other agents retain CLI titles. */ +/** Native rows use live saved names, falling back to authoritative host titles across closed tabs and restarts. */ export function useAiVaultSessionDisplayTitle(session: AiVaultSession): string { - const owner = historyRowShowsChatName(session.agent) ? session.structuredSession : undefined + const owner = session.structuredSession const customLabel = useAppStore((state) => { const tab = owner ? structuredChatTabBySessionId( diff --git a/src/renderer/src/components/sidebar/WorkspaceAttachmentOrigins.test.tsx b/src/renderer/src/components/sidebar/WorkspaceAttachmentOrigins.test.tsx index 799c1586c61..f6d63716a01 100644 --- a/src/renderer/src/components/sidebar/WorkspaceAttachmentOrigins.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceAttachmentOrigins.test.tsx @@ -12,26 +12,18 @@ import { TooltipProvider } from '@/components/ui/tooltip' import { WorktreeLinkedItemRow } from './WorktreeLinkedItemRow' import { makeWorktree, makeTerminalTab } from '../../store/slices/worktrees-slice-test-fixtures' import { WorkspaceAttachmentOrigins } from './WorkspaceAttachmentOrigins' +import { useAppStore } from '@/store' const mocks = vi.hoisted(() => ({ - state: { - unifiedTabsByWorktree: {}, - terminalLayoutsByTabId: {}, - tabsByWorktree: {}, - agentStatusByPaneKey: {}, - setActiveWorktree: vi.fn(() => true), - activateTab: vi.fn() - }, + setActiveWorktree: vi.fn(() => true), + activateTab: vi.fn(), activateTabAndFocusPane: vi.fn() })) vi.mock('@/lib/activate-tab-and-focus-pane', () => ({ activateTabAndFocusPane: mocks.activateTabAndFocusPane })) -vi.mock('@/store', () => ({ - useAppStore: Object.assign( - (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state), - { getState: () => mocks.state } - ) +vi.mock('@/store', async () => ({ + useAppStore: (await import('@/store/slices/store-test-helpers')).createTestStore() })) vi.mock('@/attention/notification-subject-owner', () => ({ resolveNotificationTabOwner: (_state: unknown, tab: Tab) => ({ @@ -55,18 +47,30 @@ const tab = (id: string): Tab => ({ createdAt: 0, agentSessionAgent: 'codex' }) +beforeEach(() => { + useAppStore.setState( + { + ...useAppStore.getInitialState(), + setActiveWorktree: mocks.setActiveWorktree, + activateTab: mocks.activateTab + }, + true + ) +}) afterEach(() => { cleanup() vi.clearAllMocks() }) function setup() { - mocks.state.unifiedTabsByWorktree = { - [workspace.id]: [ - tab('Session A'), - tab('Session B'), - { ...tab('Foreign session'), executionHostId: 'ssh:other' } - ] - } + useAppStore.setState({ + unifiedTabsByWorktree: { + [workspace.id]: [ + tab('Session A'), + tab('Session B'), + { ...tab('Foreign session'), executionHostId: 'ssh:other' } + ] + } + }) } describe('reference session associations', () => { it('reveals automatic observations without checks selection or manual assignment controls', () => { @@ -102,7 +106,7 @@ describe('reference session associations', () => { expect(screen.queryByText('Linked to Session A')).toBeNull() expect(screen.queryByRole('checkbox')).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Seen in Session B' })) - expect(mocks.state.activateTab).toHaveBeenCalledWith('Session B', { worktreeId: workspace.id }) + expect(mocks.activateTab).toHaveBeenCalledWith('Session B', { worktreeId: workspace.id }) fireEvent.click(screen.getByRole('button', { name: 'Unlink PR #23' })) expect(onRemove).toHaveBeenCalledOnce() }) @@ -141,8 +145,8 @@ describe('reference session associations', () => { /> ) fireEvent.click(screen.getByRole('button', { name: 'Seen in Session A' })) - expect(mocks.state.setActiveWorktree).toHaveBeenCalledWith(workspace.id, 'local') - expect(mocks.state.activateTab).toHaveBeenCalledWith('Session A', { worktreeId: workspace.id }) + expect(mocks.setActiveWorktree).toHaveBeenCalledWith(workspace.id, 'local') + expect(mocks.activateTab).toHaveBeenCalledWith('Session A', { worktreeId: workspace.id }) }) it.each([true, false])( @@ -151,26 +155,28 @@ describe('reference session associations', () => { setup() const leafId = '11111111-1111-4111-8111-111111111111' const otherLeaf = '22222222-2222-4222-8222-222222222222' - mocks.state.unifiedTabsByWorktree = { - [workspace.id]: [{ ...tab('Terminal'), contentType: 'terminal' }] - } - mocks.state.tabsByWorktree = { - [workspace.id]: [makeTerminalTab({ id: 'Terminal', worktreeId: workspace.id })] - } - mocks.state.terminalLayoutsByTabId = { - Terminal: { - ...singlePaneLayoutSnapshot(otherLeaf), - root: present - ? { - type: 'split', - direction: 'horizontal', - ratio: 0.5, - first: { type: 'leaf', leafId }, - second: { type: 'leaf', leafId: otherLeaf } - } - : { type: 'leaf', leafId: otherLeaf } + useAppStore.setState({ + unifiedTabsByWorktree: { + [workspace.id]: [{ ...tab('Terminal'), contentType: 'terminal' }] + }, + tabsByWorktree: { + [workspace.id]: [makeTerminalTab({ id: 'Terminal', worktreeId: workspace.id })] + }, + terminalLayoutsByTabId: { + Terminal: { + ...singlePaneLayoutSnapshot(otherLeaf), + root: present + ? { + type: 'split', + direction: 'horizontal', + ratio: 0.5, + first: { type: 'leaf', leafId }, + second: { type: 'leaf', leafId: otherLeaf } + } + : { type: 'leaf', leafId: otherLeaf } + } } - } + }) render( { /> ) fireEvent.click(screen.getByRole('button', { name: 'Seen in Terminal' })) - expect(mocks.state.activateTab).toHaveBeenCalledWith('Terminal', { worktreeId: workspace.id }) + expect(mocks.activateTab).toHaveBeenCalledWith('Terminal', { worktreeId: workspace.id }) if (present) { expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith('Terminal', leafId) } else { @@ -207,7 +213,7 @@ const foreignOrigin: WorkspaceAttachmentOrigin = { } describe('session association host and keyboard regressions', () => { beforeEach(() => { - mocks.state.unifiedTabsByWorktree = { [workspace.id]: [localTab] } + useAppStore.setState({ unifiedTabsByWorktree: { [workspace.id]: [localTab] } }) }) it('keeps another host observation unavailable even when a live tab has the same id', () => { render( diff --git a/src/renderer/src/components/sidebar/WorkspaceAttachmentOrigins.tsx b/src/renderer/src/components/sidebar/WorkspaceAttachmentOrigins.tsx index 89119b903cc..61b8267a50a 100644 --- a/src/renderer/src/components/sidebar/WorkspaceAttachmentOrigins.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceAttachmentOrigins.tsx @@ -11,6 +11,8 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' import type { WorkspaceAttachment, Worktree } from '../../../../shared/worktree/types' import type { Tab } from '../../../../shared/tab-types' import { getWorkspaceAttachmentOriginKey } from '../../../../shared/workspace-attachment-origins' +import { useStructuredChatTabNames } from '@/runtime/structured-chat-name-observation' +import { projectedStructuredChatTabName } from '@/runtime/structured-chat-tab-name' const EMPTY_TABS: Tab[] = [] @@ -53,6 +55,7 @@ export function WorkspaceAttachmentOrigins({ workspace: Worktree compact?: boolean }): React.JSX.Element | null { + const names = useStructuredChatTabNames() const tabs = useReferenceTabs(workspace) const origins = item.origins?.filter((origin) => origin.kind === 'observed') ?? [] if (!origins.length) { @@ -69,6 +72,9 @@ export function WorkspaceAttachmentOrigins({ (origin.hostId ?? 'local') === (workspace.hostId ?? 'local') ) const label = + (tab?.contentType === 'agent-session' + ? projectedStructuredChatTabName(tab, names) + : null) || tab?.customLabel || tab?.label || origin.label || diff --git a/src/renderer/src/components/tab-bar/TabBarItemRow.tsx b/src/renderer/src/components/tab-bar/TabBarItemRow.tsx index f0cc8f47500..af1e3002e91 100644 --- a/src/renderer/src/components/tab-bar/TabBarItemRow.tsx +++ b/src/renderer/src/components/tab-bar/TabBarItemRow.tsx @@ -1,4 +1,5 @@ import { memo } from 'react' +import { useAppStore } from '@/store' import { useTranslation } from 'react-i18next' import { shallow } from 'zustand/shallow' import type { GitFileStatus } from '../../../../shared/git-status-types' @@ -76,7 +77,7 @@ function TabBarItemRow({ unifiedTabId: item.unifiedTabId, visibleTabId: item.id, tabType: item.type, - label: getTabDragLabel(item, generatedTabTitlesEnabled), + label: getTabDragLabel(item, generatedTabTitlesEnabled, useAppStore.getState()), iconPath: item.type === 'editor' ? item.data.filePath : undefined, color: item.type === 'terminal' ? (item.data.color ?? null) : null } diff --git a/src/renderer/src/components/tab-bar/recent-tab-switching.test.ts b/src/renderer/src/components/tab-bar/recent-tab-switching.test.ts index fc114f3e62f..f67568d4762 100644 --- a/src/renderer/src/components/tab-bar/recent-tab-switching.test.ts +++ b/src/renderer/src/components/tab-bar/recent-tab-switching.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { createTestStore } from '@/store/slices/store-test-helpers' import type { AppState } from '../../store/types' import type { Tab } from '../../../../shared/tab-types' import { @@ -30,20 +31,7 @@ function stateWithTabs( recentTabIds: string[], activeTabId: string, hydratingTabIds: string[] = [] -): Pick< - AppState, - | 'activeBrowserTabId' - | 'activeFileId' - | 'activeGroupIdByWorktree' - | 'activeTabId' - | 'activeTabType' - | 'browserTabsByWorktree' - | 'groupsByWorktree' - | 'openFiles' - | 'tabBarOrderByWorktree' - | 'tabsByWorktree' - | 'unifiedTabsByWorktree' -> { +): AppState { // Include only actual group tabs; extra tabs absent from tabOrder would be appended by strip reconciliation. const tabs = [ tab('tab-a', 'file-a', 'A'), @@ -51,6 +39,7 @@ function stateWithTabs( tab('tab-c', 'file-c', 'C') ].filter((entry) => tabOrder.includes(entry.id) || hydratingTabIds.includes(entry.id)) return { + ...createTestStore().getState(), activeBrowserTabId: null, activeFileId: tabs.find((entry) => entry.id === activeTabId)?.entityId ?? null, activeGroupIdByWorktree: { [WT]: GROUP }, diff --git a/src/renderer/src/components/tab-bar/recent-tab-switching.ts b/src/renderer/src/components/tab-bar/recent-tab-switching.ts index a2b5fba9fab..fe210bd88b1 100644 --- a/src/renderer/src/components/tab-bar/recent-tab-switching.ts +++ b/src/renderer/src/components/tab-bar/recent-tab-switching.ts @@ -1,5 +1,6 @@ import type { CtrlTabOrderMode, Tab, TabContentType, TabGroup } from '../../../../shared/tab-types' import { resolveUnifiedTabLabel } from '../../../../shared/tab-title-resolution' +import { readStructuredChatTabName } from '@/runtime/structured-chat-tab-name' import type { AppState } from '../../store/types' import { sanitizeRecentTabIds } from '../../store/slices/tab-group-state' import { getActiveTabNavOrder, type VisibleTabRef } from './group-tab-order' @@ -17,21 +18,6 @@ export type RecentTabSwitcherModel = { activeIndex: number } -type RecentTabSwitchingState = Pick< - AppState, - | 'activeBrowserTabId' - | 'activeFileId' - | 'activeGroupIdByWorktree' - | 'activeTabId' - | 'activeTabType' - | 'browserTabsByWorktree' - | 'groupsByWorktree' - | 'openFiles' - | 'tabBarOrderByWorktree' - | 'tabsByWorktree' - | 'unifiedTabsByWorktree' -> & { settings?: AppState['settings'] } - export function normalizeCtrlTabOrderMode( value: CtrlTabOrderMode | null | undefined ): CtrlTabOrderMode { @@ -88,22 +74,26 @@ function getActiveVisibleTabKey( function getTabLabel( tab: Tab | undefined, generatedTitlesEnabled: boolean, - fallback: string + fallback: string, + state: AppState ): string { - return resolveUnifiedTabLabel(tab, generatedTitlesEnabled, fallback) + return tab?.contentType === 'agent-session' + ? readStructuredChatTabName(tab, state) + : resolveUnifiedTabLabel(tab, generatedTitlesEnabled, fallback) } function toSwitcherItem( entry: VisibleTabRef, tabById: ReadonlyMap, dirtyFileIds: ReadonlySet, - generatedTitlesEnabled: boolean + generatedTitlesEnabled: boolean, + state: AppState ): RecentTabSwitcherItem { const backingTab = entry.tabId ? tabById.get(entry.tabId) : undefined return { ...entry, key: getVisibleTabKey(entry), - label: getTabLabel(backingTab, generatedTitlesEnabled, entry.id), + label: getTabLabel(backingTab, generatedTitlesEnabled, entry.id, state), contentType: backingTab?.contentType ?? (entry.type === 'editor' ? 'editor' : entry.type), isDirty: entry.type === 'editor' && dirtyFileIds.has(entry.id) } @@ -150,7 +140,7 @@ function orderByMru( } export function buildRecentTabSwitcherModel( - state: RecentTabSwitchingState, + state: AppState, worktreeId: string, mode: CtrlTabOrderMode ): RecentTabSwitcherModel | null { @@ -170,7 +160,7 @@ export function buildRecentTabSwitcherModel( const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true const itemByKey = new Map( visibleEntries.map((entry) => { - const item = toSwitcherItem(entry, tabById, dirtyFileIds, generatedTitlesEnabled) + const item = toSwitcherItem(entry, tabById, dirtyFileIds, generatedTitlesEnabled, state) return [item.key, item] as const }) ) diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-label.ts b/src/renderer/src/components/tab-bar/tab-bar-item-label.ts new file mode 100644 index 00000000000..1e6de916a2f --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-bar-item-label.ts @@ -0,0 +1,24 @@ +import type { TabBarItem } from './tab-bar-item-model' +import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' +import { getEditorDisplayLabel } from '@/components/editor/editor-labels' +import { getBrowserTabLabel } from './BrowserTab' + +export function tabItemLabel( + item: TabBarItem, + generatedTitlesEnabled: boolean, + savedName: string | null +): string { + if (item.type === 'terminal') { + return resolveTerminalTabTitle(item.data, generatedTitlesEnabled, item.data.title) + } + if (item.type === 'browser') { + return getBrowserTabLabel(item.data) + } + if (item.type === 'agent-session') { + return savedName ?? item.data.label + } + if (item.type === 'simulator') { + return item.data.label || 'Mobile Emulator' + } + return getEditorDisplayLabel(item.data) +} diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-model.ts b/src/renderer/src/components/tab-bar/tab-bar-item-model.ts index cb89dff890e..04d57963ffc 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-item-model.ts +++ b/src/renderer/src/components/tab-bar/tab-bar-item-model.ts @@ -1,13 +1,18 @@ +import { + readStructuredChatTabSavedName, + projectedStructuredChatTabSavedName, + type StructuredChatTabNames +} from '@/runtime/structured-chat-tab-name' +import type { AppState } from '@/store/types' import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' import type { GitFileStatus } from '../../../../shared/git-status-types' import type { Tab, WorkspaceVisibleTabType } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' import type { OpenFile } from '../../store/slices/editor' -import { getEditorDisplayLabel } from '@/components/editor/editor-labels' import { normalizeRelativePath } from '@/lib/path' import { isVirtualEditorFile } from '@/store/slices/editor/tabs/editor-tab-content-type' -import { getBrowserTabLabel } from './BrowserTab' +import { tabItemLabel } from './tab-bar-item-label' import type { DropIndicator } from './drop-indicator' import { reconcileTabOrder } from './reconcile-order' import { resolveTabIndicatorEdges } from '../tab-group/tab-insertion' @@ -69,17 +74,16 @@ export function resolveEditorTabGitStatus( return statusByRelativePath.get(normalizeRelativePath(file.relativePath)) ?? null } -export function getTabDragLabel(item: TabBarItem, generatedTitlesEnabled: boolean): string { - if (item.type === 'terminal') { - return resolveTerminalTabTitle(item.data, generatedTitlesEnabled, item.data.title) - } - if (item.type === 'browser') { - return getBrowserTabLabel(item.data) - } - if (item.type === 'simulator' || item.type === 'agent-session') { - return item.data.label || 'Mobile Emulator' - } - return getEditorDisplayLabel(item.data) +export function getTabDragLabel( + item: TabBarItem, + generatedTitlesEnabled: boolean, + state: AppState +): string { + return tabItemLabel( + item, + generatedTitlesEnabled, + item.type === 'agent-session' ? readStructuredChatTabSavedName(item.data, state) : null + ) } export function getTabLayoutSignature( @@ -87,14 +91,20 @@ export function getTabLayoutSignature( { generatedTitlesEnabled, isExpanded, - status + status, + chatNames }: { generatedTitlesEnabled: boolean isExpanded: boolean status?: string | null + chatNames: StructuredChatTabNames } ): string { - const label = getTabDragLabel(item, generatedTitlesEnabled) + const label = tabItemLabel( + item, + generatedTitlesEnabled, + item.type === 'agent-session' ? projectedStructuredChatTabSavedName(item.data, chatNames) : null + ) if (item.type === 'terminal') { return `${item.type}:${item.id}:${item.isPinned}:${isExpanded}:${Boolean(item.data.color)}:${label}` } @@ -270,12 +280,14 @@ export function buildTabStripLayoutKey( items: readonly TabBarItem[], generatedTitlesEnabled: boolean, expandedPaneByTabId: Record, - statusByRelativePath: Map + statusByRelativePath: Map, + chatNames: StructuredChatTabNames ): string { return items .map((item) => getTabLayoutSignature(item, { generatedTitlesEnabled, + chatNames, isExpanded: expandedPaneByTabId[item.id] === true, status: item.type === 'editor' diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts b/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts index 8a2f5a58157..6bb6a727342 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts @@ -1,3 +1,4 @@ +import { useStructuredChatTabNames } from '@/runtime/structured-chat-name-observation' import { useMemo, useState } from 'react' import type { GitFileStatus } from '../../../../shared/git-status-types' import type { Tab } from '../../../../shared/tab-types' @@ -138,15 +139,17 @@ export function useTabBarItemProjection({ orderedItems ] ) + const chatNames = useStructuredChatTabNames() const tabStripLayoutKey = useMemo( () => buildTabStripLayoutKey( orderedItems, generatedTabTitlesEnabled, expandedPaneByTabId, - statusByRelativePath + statusByRelativePath, + chatNames ), - [expandedPaneByTabId, generatedTabTitlesEnabled, orderedItems, statusByRelativePath] + [expandedPaneByTabId, generatedTabTitlesEnabled, orderedItems, statusByRelativePath, chatNames] ) return { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c950adc085e..32a4a3b8fcb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19773,6 +19773,10 @@ "editors": "Close all editor tabs?", "description": "The selected tabs will be closed. Pinned tabs will stay open.", "confirm": "Close tabs" + }, + "chatRename": { + "empty": "A chat name can't be empty", + "failed": "Couldn't rename this chat" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a0b335d6c25..465bbf4fb70 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -19425,6 +19425,10 @@ "editors": "¿Cerrar todas las pestañas del editor?", "description": "Se cerrarán las pestañas seleccionadas. Las pestañas fijadas permanecerán abiertas.", "confirm": "Cerrar pestañas" + }, + "chatRename": { + "empty": "El nombre del chat no puede estar vacío", + "failed": "No se pudo cambiar el nombre de este chat" } } }, diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 5a23825e193..6baea5927b9 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -19565,6 +19565,10 @@ "editors": "Fermer tous les onglets de l’éditeur ?", "description": "Les onglets sélectionnés seront fermés. Les onglets épinglés resteront ouverts.", "confirm": "Fermer les onglets" + }, + "chatRename": { + "empty": "Le nom du chat ne peut pas être vide", + "failed": "Impossible de renommer ce chat" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a1d4c57b63a..f9ecbf5276a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -19459,6 +19459,10 @@ "editors": "すべてのエディタータブを閉じますか?", "description": "選択したタブを閉じます。ピン留めしたタブは開いたままになります。", "confirm": "タブを閉じる" + }, + "chatRename": { + "empty": "チャット名を空にすることはできません", + "failed": "このチャットの名前を変更できませんでした" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a3f567bb6e9..e3ebf1b2d63 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -19459,6 +19459,10 @@ "editors": "모든 편집기 탭을 닫을까요?", "description": "선택한 탭을 닫습니다. 고정된 탭은 열린 상태로 유지됩니다.", "confirm": "탭 닫기" + }, + "chatRename": { + "empty": "채팅 이름은 비워 둘 수 없습니다", + "failed": "이 채팅의 이름을 바꾸지 못했습니다" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 83c9acb5ddd..281f4718c6f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -19424,6 +19424,10 @@ "editors": "关闭所有编辑器标签页?", "description": "选中的标签页将关闭。固定的标签页将保持打开。", "confirm": "关闭标签页" + }, + "chatRename": { + "empty": "聊天名称不能为空", + "failed": "无法重命名此聊天" } } }, diff --git a/src/renderer/src/lib/notes-send-agent-targets.test.ts b/src/renderer/src/lib/notes-send-agent-targets.test.ts index 1931dfe1747..2df2d5b8cc1 100644 --- a/src/renderer/src/lib/notes-send-agent-targets.test.ts +++ b/src/renderer/src/lib/notes-send-agent-targets.test.ts @@ -122,6 +122,7 @@ describe('notes send agent targets', () => { terminalLayoutsByTabId: { [STATUS_TAB_ID]: leafLayout(LEAF_A, 'pty-a') } }), WORKTREE_ID, + new Map(), NOW ) @@ -146,6 +147,7 @@ describe('notes send agent targets', () => { terminalLayoutsByTabId: { [STATUS_TAB_ID]: leafLayout(LEAF_A, 'pty-a') } }), WORKTREE_ID, + new Map(), NOW ) @@ -168,6 +170,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [STATUS_TAB_ID]: { 1: 'Codex - action required' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -190,6 +193,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex ready' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -215,6 +219,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [MANUAL_TAB_ID]: { 1: 'Codex ready' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -240,6 +245,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [MANUAL_TAB_ID]: { 1: 'zsh' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -256,6 +262,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [MANUAL_TAB_ID]: { 1: 'Codex - action required' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -278,6 +285,7 @@ describe('notes send agent targets', () => { terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') } }), WORKTREE_ID, + new Map(), NOW ) @@ -294,6 +302,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -310,6 +319,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex - action required' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -333,6 +343,7 @@ describe('notes send agent targets', () => { terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') } }), WORKTREE_ID, + new Map(), NOW ) @@ -355,6 +366,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'zsh' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -371,6 +383,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -388,6 +401,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex ready' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -408,6 +422,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 2: 'Codex' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -433,6 +448,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [MANUAL_TAB_ID]: { 2: 'Codex ready' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -460,6 +476,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex ready' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -491,6 +508,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex ready' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -516,6 +534,7 @@ describe('notes send agent targets', () => { terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') } }), WORKTREE_ID, + new Map(), NOW ) @@ -542,6 +561,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex - action required' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -571,6 +591,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 2: 'Codex ready' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -603,6 +624,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'zsh' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -632,6 +654,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -645,6 +668,7 @@ describe('notes send agent targets', () => { terminalLayoutsByTabId: { [MANUAL_TAB_ID]: leafLayout(LEAF_B, 'pty-b') } }), WORKTREE_ID, + new Map(), NOW ) @@ -666,6 +690,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [MANUAL_TAB_ID]: { 1: 'OC | Ad hoc build' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -695,6 +720,7 @@ describe('notes send agent targets', () => { runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'OC | Ad hoc build' } } }), WORKTREE_ID, + new Map(), NOW ) @@ -726,6 +752,7 @@ describe('notes send agent targets', () => { } }), WORKTREE_ID, + new Map(), NOW ) diff --git a/src/renderer/src/lib/notes-send-agent-targets.ts b/src/renderer/src/lib/notes-send-agent-targets.ts index 78732983c03..3af02e89c7e 100644 --- a/src/renderer/src/lib/notes-send-agent-targets.ts +++ b/src/renderer/src/lib/notes-send-agent-targets.ts @@ -1,3 +1,8 @@ +import { + projectedStructuredChatTabName, + type StructuredChatTabNames +} from '@/runtime/structured-chat-tab-name' +import { structuredChatTabById } from './structured-chat-tab-index' import type { AgentType } from '../../../shared/agent-status-types' import type { AppState } from '@/store/types' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' @@ -55,12 +60,20 @@ function detectTitleHintPaneEvidence( export function deriveNotesSendAgentTargets( state: NotesSendAgentTargetState, worktreeId: string, + chatNames: StructuredChatTabNames, now = Date.now() ): NotesSendAgentTarget[] { const targets: NotesSendAgentTarget[] = [ ...deriveRunningAgentSendTargets(state, worktreeId, now), ...deriveStatuslessStructuredAgentSendTargets(state, worktreeId) - ].map(toNotesSendAgentTarget) + ].map((target) => { + const tab = structuredChatTabById(state.unifiedTabsByWorktree, worktreeId, target.tabId) + const title = + target.kind === 'structured-session' && tab?.entityId === target.sessionId + ? projectedStructuredChatTabName(tab, chatNames) + : undefined + return toNotesSendAgentTarget(target, title) + }) for (const tab of state.tabsByWorktree[worktreeId] ?? []) { const titleHintTarget = deriveTitleHintAgentTarget(state, tab) @@ -79,7 +92,10 @@ export function deriveNotesSendAgentTargets( return targets } -function toNotesSendAgentTarget(target: RunningAgentSendTarget): NotesSendAgentTarget { +function toNotesSendAgentTarget( + target: RunningAgentSendTarget, + title?: string +): NotesSendAgentTarget { return { paneKey: target.paneKey, tabId: target.tabId, @@ -88,7 +104,7 @@ function toNotesSendAgentTarget(target: RunningAgentSendTarget): NotesSendAgentT target.kind === 'terminal' ? resolveNotesTargetAgentType(target.entry.agentType, target.tab.launchAgent) : target.agentType, - tabTitle: target.kind === 'terminal' ? target.tab.title : target.title, + tabTitle: target.kind === 'terminal' ? target.tab.title : (title ?? target.title), status: target.status, ...(target.disabledReason ? { disabledReason: target.disabledReason } : {}) } diff --git a/src/renderer/src/lib/running-agent-targets.ts b/src/renderer/src/lib/running-agent-targets.ts index 59562616f29..3069a099df9 100644 --- a/src/renderer/src/lib/running-agent-targets.ts +++ b/src/renderer/src/lib/running-agent-targets.ts @@ -3,7 +3,6 @@ import type { AgentStatusEntry, AgentType } from '../../../shared/agent-status-t import type { TerminalTab } from '../../../shared/terminal-tab-types' import { parsePaneKey } from '../../../shared/stable-pane-id' import { structuredAgentSessionPaneKey } from '../../../shared/structured-agent-session-projection' -import { resolveUnifiedTabLabel } from '../../../shared/tab-title-resolution' import { isStructuredTab } from '../../../shared/structured-agent-session-tab' import { resolvePaneAgentActivity } from '@/lib/pane-agent-evidence' import type { AgentMessageTarget } from './agent-message-target' @@ -182,8 +181,7 @@ function deriveStructuredAgentSendTargets( tabId: tab.id, sessionId: tab.entityId, agentType: tab.agentSessionAgent, - // Why: false matches the tab strip, which labels a chat tab customLabel ?? label. - title: resolveUnifiedTabLabel(tab, false), + title: tab.customLabel?.trim() || tab.label, entry, status: disabledReason ? 'disabled' : 'eligible', ...(disabledReason ? { disabledReason } : {}) diff --git a/src/renderer/src/lib/structured-agent-session-action-launch.ts b/src/renderer/src/lib/structured-agent-session-action-launch.ts index 7507846b359..3f257efe5e5 100644 --- a/src/renderer/src/lib/structured-agent-session-action-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-action-launch.ts @@ -1,5 +1,6 @@ import { structuredAgentLabel } from './structured-agent-session-launch-label' import { translate } from '@/i18n/i18n' +import { readStructuredChatTabSavedName } from '@/runtime/structured-chat-tab-name' import { getStructuredLaunchStateBySessionId, notifyStructuredLaunchListeners @@ -157,10 +158,9 @@ export async function openTerminalForDeclinedChat( placed.activeWorktreeId === worktreeId && placed.activeGroupIdByWorktree[worktreeId] === current.groupId && group.activeTabId === current.id - if (current.customLabel !== null) { - placed.setTabCustomTitle(replacement.entityId, current.customLabel, { - recordInteraction: false - }) + const chatName = readStructuredChatTabSavedName(current, placed) + if (chatName !== null) { + placed.setTabCustomTitle(replacement.entityId, chatName, { recordInteraction: false }) } if (current.color !== null) { placed.setTabColor(replacement.entityId, current.color, { recordInteraction: false }) diff --git a/src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts b/src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts index ad019b4cf58..c8ad015a7ae 100644 --- a/src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts +++ b/src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts @@ -8,6 +8,8 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' import { fetchWorkspaceSessionWithRuntimeHostOwners } from './workspace-session-host-hydration' import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { makeUnifiedTab } from '@/store/slices/store-test-helpers' +import { buildWorkspaceSessionHostSnapshots } from './workspace-session-host-persistence' const TARGET_ID = 'target-1' const SSH_HOST_ID: ExecutionHostId = `ssh:${TARGET_ID}` @@ -537,19 +539,62 @@ describe('ssh host partition adoption on a contested bare id', () => { it('still fills a gap on a contested id', async () => { // Declining to replace is not declining to repair: a row no claimant answered is still adopted. const partitions = contestedPartitions() + partitions.local = session({ tabsByWorktree: {} }) + const sourceTab = makeUnifiedTab({ + id: 'chat-source', + entityId: 'session-1', + groupId: 'chat-group', + contentType: 'agent-session', + worktreeId: WORKTREE_ID, + executionHostId: 'runtime:chat' + }) + const shadowTab = { ...sourceTab, executionHostId: 'local' as const } partitions[SSH_HOST_ID] = session({ ...partitions[SSH_HOST_ID], + unifiedTabs: { [WORKTREE_ID]: [sourceTab] }, activeFileIdByWorktree: { [WORKTREE_ID]: '/remote/checkout/feature/src/other.ts' } }) const read = await fetchWorkspaceSessionWithRuntimeHostOwners( - partitionedApi(partitions), - contestedRepos + partitionedApi({ + ...partitions, + 'runtime:r2': session({ unifiedTabs: { [WORKTREE_ID]: [shadowTab], 'folder:other': [] } }) + }), + contestedRepos, + ['runtime:r1', 'runtime:r2'] ) expect(read.session.activeFileIdByWorktree?.[WORKTREE_ID]).toBe( '/remote/checkout/feature/src/other.ts' ) + expect(read.contestedPrimaryHostBySessionKey[WORKTREE_ID]).toBe(RUNTIME_HOST_ID) + expect(read.session.unifiedTabs?.[WORKTREE_ID]?.[0]).toBe(sourceTab) + expect(read.contestedHostWorkspaceSessions['runtime:r2']?.unifiedTabs?.[WORKTREE_ID]?.[0]).toBe( + shadowTab + ) + const saved = buildWorkspaceSessionHostSnapshots(read.session, { + repos: contestedRepos, + worktreesByRepo: {}, + contestedPrimaryHostBySessionKey: read.contestedPrimaryHostBySessionKey, + contestedHostWorkspaceSessions: read.contestedHostWorkspaceSessions + }) + expect( + saved.find((slice) => slice.hostId === RUNTIME_HOST_ID)?.state.unifiedTabs?.[WORKTREE_ID]?.[0] + ).toEqual(sourceTab) + expect( + saved.find((slice) => slice.hostId === 'runtime:r2')?.state.unifiedTabs?.[WORKTREE_ID]?.[0] + ).toEqual(shadowTab) + const reloaded = await fetchWorkspaceSessionWithRuntimeHostOwners( + partitionedApi( + Object.fromEntries(saved.map((slice) => [slice.hostId ?? 'local', slice.state])) + ), + contestedRepos, + ['runtime:r1', 'runtime:r2'] + ) + expect(reloaded.session.unifiedTabs?.[WORKTREE_ID]?.[0]).toEqual(sourceTab) + expect( + reloaded.contestedHostWorkspaceSessions['runtime:r2']?.unifiedTabs?.[WORKTREE_ID]?.[0] + ).toEqual(shadowTab) }) it('does not let a legacy bare recency key move another host workspace of the same id', async () => { diff --git a/src/renderer/src/runtime/legacy-chat-rename-migration.test.tsx b/src/renderer/src/runtime/legacy-chat-rename-migration.test.tsx new file mode 100644 index 00000000000..18ee4cded61 --- /dev/null +++ b/src/renderer/src/runtime/legacy-chat-rename-migration.test.tsx @@ -0,0 +1,639 @@ +// @vitest-environment happy-dom +import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { Tab } from '../../../shared/tab-types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { createGlobalSettingsFixture } from '../../../shared/global-settings-test-fixture' +import { brandEphemeralSetupTerminalWorktreeId } from '../../../shared/ephemeral-setup-terminal-worktree-id' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type * as LayoutRouting from '@/store/terminals/tab-change-through-layout-owner' +import type * as LocalRuntimeCapabilities from './local-runtime-capabilities' +import { + addWorkspace, + emptySession +} from '../../../shared/workspace-layout/workspace-layout-session.test-fixture' +import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' +import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' +import { renameTab } from '../../../shared/workspace-layout/workspace-layout-tab-commands' +import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save' +import { makeWorktree, makeUnifiedTab, TEST_REPO } from '@/store/slices/store-test-helpers' +import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures' +import { WorkspaceAttachmentOrigins } from '@/components/sidebar/WorkspaceAttachmentOrigins' +import { useAppStore } from '@/store' +import { installLegacyChatRenameMigration } from './legacy-chat-rename-migration' +import { uploadLegacyStructuredChatRename } from './structured-chat-rename' +import { readStructuredChatTabName } from './structured-chat-tab-name' +import { guardPinnedTabClose, resolvePinnedTabLabel } from '@/store/pinned-tab-close-guard' +import { + subscribeStructuredChatTabNames, + getStructuredChatTabNames +} from './structured-chat-name-observation' +import { useLiveDashboardSnapshot } from '@/components/dashboard/useLiveDashboardSnapshot' +import { useAgentPaneThreads } from '@/components/activity/use-agent-pane-threads' +import { makePaneKey } from '../../../shared/stable-pane-id' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { fetchWorkspaceSessionWithRuntimeHostOwners } from '@/lib/workspace-session-host-hydration' +import { buildPersistedUnifiedTabSessionData } from '@/lib/workspace-session-unified-tabs' +import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence' +import { sendMessageToAgent } from '@/lib/agent-message-send' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api' +import { applyWebSessionTabsStorePatch } from './web-session-tabs-sync/store-patch' +import { applyLocalStructuredSessionTabSnapshots } from './local-structured-session-tabs-sync/snapshot-apply' +import { readStructuredAgentLaunchRecord } from '@/lib/structured-agent-session-launch-persistence' + +const mock = vi.hoisted(() => ({ + rpc: vi.fn(), + layout: vi.fn(), + contacts: new Set<() => void>(), + listeners: new Set<() => void>(), + snapshot: new Map(), + paired: new Map(), + activates: vi.fn(), + deactivates: vi.fn(), + send: vi.fn() +})) +vi.mock('@/components/native-chat/structured-agent-session-message-sender', () => ({ + sendStructuredAgentSessionMessage: mock.send +})) +vi.mock('@/store', async () => ({ + useAppStore: (await import('@/store/slices/store-test-helpers')).createTestStore() +})) +vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc: mock.rpc })) +vi.mock('./structured-agent-session-client', () => ({ + supportsStructuredAgentSessionRename: async () => true +})) +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +vi.mock('@/store/terminals/tab-change-through-layout-owner', () => ({ + sendTabChangeToLayoutOwner: mock.layout +})) +vi.mock('./local-runtime-capabilities', async (importOriginal) => ({ + ...(await importOriginal()), + subscribeLocalRuntimeCapabilitiesKnown: () => () => {} +})) +vi.mock('./runtime-host-contact-regained', () => ({ + subscribeRuntimeHostContactRegained: (_id: string, listener: () => void) => { + mock.contacts.add(listener) + return () => mock.contacts.delete(listener) + } +})) +vi.mock('./structured-agent-session-status-feed', () => ({ + getStructuredAgentSessionStatusFeed: (target: RuntimeClientTarget) => ({ + getSnapshot: () => (target.kind === 'local' ? mock.snapshot : mock.paired), + getNamingSnapshot: () => (target.kind === 'local' ? mock.snapshot : mock.paired), + activate: () => { + mock.activates() + return mock.deactivates + }, + subscribe: (listener: () => void) => { + mock.listeners.add(listener) + return () => mock.listeners.delete(listener) + } + }) +})) + +const workspace = makeWorktree({ id: 'repo::/tmp/rename-migration', repoId: 'repo' }) +const chat = (extra: Partial = {}): Tab => + makeUnifiedTab({ + id: 'chat-tab', + entityId: 'session-1', + worktreeId: workspace.id, + groupId: 'group', + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + ...extra + }) +function loadChat(tab: Tab, owner: ExecutionHostId) { + const stored = addWorkspace(emptySession(), tab.worktreeId, [ + { + id: 'group', + tabs: [{ id: tab.id, entityId: tab.entityId, kind: 'agent-session', entry: tab }] + } + ]) + return loadWorkspaceLayoutWithoutReport(owner, stored, nameBasedLoadContext()) +} +function seed(tab: Tab) { + useAppStore.setState({ + workspaceSessionReady: true, + repos: [{ ...TEST_REPO, id: 'repo' }], + worktreesByRepo: { repo: [workspace] }, + unifiedTabsByWorktree: { [tab.worktreeId]: [tab] } + }) +} +function partitionedSessionApi(partitions: Record) { + return { + get: async (host?: string) => partitions[host ?? 'local'] ?? emptySession(), + set: async (session: WorkspaceSessionState, host?: string) => { + partitions[host ?? 'local'] = session + }, + patch: async () => {}, + flush: async () => {} + } +} +const stops: (() => void)[] = [] +beforeEach(() => { + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) + useAppStore.setState(useAppStore.getInitialState(), true) + mock.activates.mockClear() + mock.deactivates.mockClear() + mock.rpc.mockReset() + mock.layout.mockReset() + mock.layout.mockReturnValue(false) +}) +afterEach(() => { + cleanup() + stops.splice(0).forEach((stop) => stop()) + mock.snapshot.clear() + mock.paired.clear() + vi.restoreAllMocks() +}) + +it('migrates an inactive restored alias after reconnect and clears both saved copies only after acknowledgment', async () => { + const alias = 'x'.repeat(5000) + const tab = chat({ + worktreeId: 'folder:twin', + executionHostId: 'runtime:paired', + customLabel: alias + }) + let loaded = loadChat(tab, 'runtime:paired') + mock.layout.mockImplementation((_state, target, change, _local, answers) => { + if (!target) { + throw new Error('Missing tab') + } + const command = change(target.id) + if (command.type !== 'renameTab') { + throw new Error('Unexpected layout command') + } + const result = renameTab(loaded.layout, { ...command, workspace: target.worktreeId }) + if (!result.ok) { + throw new Error('Layout refused cleanup') + } + loaded = { ...loaded, layout: result.model } + answers?.shown?.() + return true + }) + seedTwin(tab) + mock.rpc + .mockRejectedValueOnce(new Error('Disconnected')) + .mockResolvedValue({ name: 'x'.repeat(200), applied: true }) + stops.push(installLegacyChatRenameMigration()) + await waitFor(() => expect(mock.rpc).toHaveBeenCalledTimes(1)) + expect(useAppStore.getState().unifiedTabsByWorktree[tab.worktreeId]?.[0]?.customLabel).toBe(alias) + mock.contacts.forEach((regained) => regained()) + await waitFor(() => + expect( + useAppStore.getState().unifiedTabsByWorktree[tab.worktreeId]?.[0]?.customLabel + ).toBeNull() + ) + expect(mock.rpc).toHaveBeenLastCalledWith(expect.anything(), 'agentSession.rename', { + sessionId: tab.entityId, + name: 'x'.repeat(200), + legacyAlias: true + }) + expect(saveWorkspaceLayout(loaded).unifiedTabs?.[tab.worktreeId]?.[0]?.customLabel).toBeNull() +}) + +it('retains a replacement alias when main receives an older session cleanup', async () => { + const tab = chat({ executionHostId: 'local', customLabel: 'Legacy' }) + seed(tab) + const loaded = loadChat({ ...tab, entityId: 'replacement' }, 'local') + mock.rpc.mockResolvedValue({ name: 'Legacy', applied: true }) + mock.layout.mockImplementation((_state, target, change, _local, answers) => { + if (!target) { + throw new Error('Missing tab') + } + const command = change(target.id) + if (command.type !== 'renameTab') { + throw new Error('Unexpected command') + } + expect(renameTab(loaded.layout, { ...command, workspace: target.worktreeId }).ok).toBe(false) + answers?.refused?.() + return true + }) + expect(await uploadLegacyStructuredChatRename(useAppStore.getState, tab, 'Legacy')).toBe(false) + expect(saveWorkspaceLayout(loaded).unifiedTabs?.[workspace.id]?.[0]?.customLabel).toBe('Legacy') + expect(useAppStore.getState().unifiedTabsByWorktree[workspace.id]?.[0]?.customLabel).toBe( + 'Legacy' + ) +}) + +it('transfers an unstamped alias once its single local owner is known and reloads the canonical name', async () => { + const tab = chat({ worktreeId: 'folder:unique', customLabel: 'Legacy' }) + seed(tab) + useAppStore.setState({ + repos: [], + worktreesByRepo: {}, + restoredRuntimeHostIdByWorkspaceSessionKey: { [tab.worktreeId]: 'runtime:storage-only' } + }) + mock.snapshot.set(tab.entityId, name(tab, 'History name')) + mock.rpc.mockResolvedValue({ name: 'History name', applied: false }) + stops.push(installLegacyChatRenameMigration()) + expect(await uploadLegacyStructuredChatRename(useAppStore.getState, tab, 'Legacy')).toBe(false) + expect(mock.rpc).not.toHaveBeenCalled() + const groups = saveWorkspaceLayout(loadChat(tab, 'local')).tabGroups?.[tab.worktreeId] ?? [] + useAppStore.setState({ groupsByWorktree: { [tab.worktreeId]: groups } }) + useAppStore.setState({ + folderWorkspaces: [makeFolderWorkspace({ id: 'unique', executionHostId: 'local' })] + }) + await waitFor(() => + expect(useAppStore.getState().unifiedTabsByWorktree[tab.worktreeId][0].customLabel).toBeNull() + ) + expect(mock.rpc).toHaveBeenCalledExactlyOnceWith({ kind: 'local' }, 'agentSession.rename', { + sessionId: tab.entityId, + name: 'Legacy', + legacyAlias: true + }) + const saved = { + ...emptySession(), + ...buildPersistedUnifiedTabSessionData(useAppStore.getState()) + } + const api = partitionedSessionApi({ local: emptySession() }) + await persistWorkspaceSessionByHost(api, saved, useAppStore.getState()) + const read = await fetchWorkspaceSessionWithRuntimeHostOwners(api, []) + useAppStore.getState().hydrateWorkspaceSession(read.session, read) + useAppStore.getState().hydrateTabsSession(read.session) + useAppStore.setState({ activeWorktreeId: 'elsewhere' }) + const restored = useAppStore.getState().unifiedTabsByWorktree[tab.worktreeId][0] + expect(restored.executionHostId).toBeUndefined() + expect(restored.customLabel).toBeNull() + expect(readStructuredChatTabName(restored, useAppStore.getState())).toBe('History name') +}) + +function seedTwin(tab: Tab) { + seed(tab) + const folder = makeFolderWorkspace({ id: 'twin' }) + const paneKey = makePaneKey(tab.id, '11111111-1111-4111-8111-111111111111') + useAppStore.setState({ + activeWorktreeId: tab.worktreeId, + activeWorkspaceExecutionHostId: 'runtime:paired', + folderWorkspaces: [ + { ...folder, executionHostId: 'local' }, + { ...folder, executionHostId: 'runtime:paired' } + ], + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + tabId: tab.id, + worktreeId: tab.worktreeId, + agentType: 'codex', + state: 'working', + prompt: '', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } + } + }) +} +function name(tab: Tab, conversationName: string): AgentSessionStatusSummary { + return { + sessionId: tab.entityId, + workspaceId: tab.worktreeId, + agent: 'codex', + status: null, + latestPrompt: '', + updatedAt: 1, + conversationName + } +} + +it('names a native pinned-close confirmation from the owning host after alias retirement', () => { + const tab = chat({ executionHostId: 'runtime:paired', isPinned: true, customLabel: null }) + seed(tab) + const requestPinnedTabCloseConfirm = vi.fn() + useAppStore.setState({ + settings: createGlobalSettingsFixture({ confirmClosePinnedTab: true }), + requestPinnedTabCloseConfirm + }) + mock.snapshot = new Map([[tab.entityId, name(tab, 'Other host name')]]) + mock.paired = new Map([[tab.entityId, name(tab, 'Saved native name')]]) + const onClose = vi.fn() + guardPinnedTabClose({ + isPinned: tab.isPinned === true, + tabLabel: resolvePinnedTabLabel(useAppStore.getState(), tab.worktreeId, tab.id), + onClose + }) + expect(requestPinnedTabCloseConfirm).toHaveBeenCalledWith({ + tabLabel: 'Saved native name', + onConfirm: onClose + }) +}) + +it('shares qualified current names with live dashboard and attachments without activating plain reads', () => { + const tab = chat({ worktreeId: 'folder:twin' }) + seedTwin(tab) + mock.snapshot = new Map([[tab.entityId, name(tab, 'Same')]]) + mock.paired = new Map([[tab.entityId, name(tab, 'Same')]]) + const attached = chat({ id: 'attached-tab', executionHostId: 'runtime:paired' }) + useAppStore.setState((state) => ({ + unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [workspace.id]: [attached] } + })) + const before = getStructuredChatTabNames() + expect(getStructuredChatTabNames()).toBe(before) + expect(before.get(tab)).toMatchObject({ owner: 'runtime:paired', savedName: 'Same' }) + expect(mock.activates).not.toHaveBeenCalled() + stops.push(useAppStore.subscribe(() => getStructuredChatTabNames())) + const changed = vi.fn() + const stop = subscribeStructuredChatTabNames(changed) + const view = render( + + ) + const dashboard = renderHook(useLiveDashboardSnapshot) + const activity = renderHook(() => + useAgentPaneThreads({ + query: '', + readFilter: 'all', + groupBy: 'none', + selectedPaneKey: null + }) + ) + const activityTitle = () => + activity.result.current.allThreads.find((row) => row.tab.id === tab.id)?.paneTitle + const shown = () => + dashboard.result.current.cards.find((card) => card.tabId === tab.id)?.conversationName + expect(shown()).toBe('Same') + expect(activityTitle()).toBe('Same') + expect(view.container.textContent).toContain('Same') + expect(mock.activates).toHaveBeenCalledTimes(1) + act(() => useAppStore.setState({ agentStatusEpoch: 1 })) + expect(getStructuredChatTabNames()).toBe(before) + act(() => useAppStore.setState({ activeWorkspaceExecutionHostId: 'local' })) + expect(getStructuredChatTabNames()).not.toBe(before) + expect(getStructuredChatTabNames().get(tab)?.owner).toBe('local') + expect(activityTitle()).toBe('Same') + act(() => { + mock.snapshot = new Map([[tab.entityId, name(tab, 'Local renamed')]]) + mock.listeners.forEach((notify) => notify()) + }) + expect(shown()).toBe('Local renamed') + expect(activityTitle()).toBe('Local renamed') + const local = getStructuredChatTabNames() + act(() => { + mock.paired = new Map([[tab.entityId, name(tab, 'Unrelated')]]) + mock.listeners.forEach((notify) => notify()) + }) + expect(getStructuredChatTabNames().get(tab)).toEqual(local.get(tab)) + expect(view.container.textContent).toContain('Unrelated') + expect(shown()).toBe('Local renamed') + expect(activityTitle()).toBe('Local renamed') + act(() => useAppStore.setState({ activeWorktreeId: 'elsewhere' })) + expect(getStructuredChatTabNames().get(tab)?.owner).toBeNull() + expect(shown()).toBe('Codex Chat') + expect(activityTitle()).toBe('Codex Chat') + const replacement = { ...tab } + act(() => useAppStore.setState({ unifiedTabsByWorktree: { [tab.worktreeId]: [replacement] } })) + expect(getStructuredChatTabNames().has(tab)).toBe(false) + expect(getStructuredChatTabNames().has(replacement)).toBe(true) + dashboard.unmount() + activity.unmount() + view.unmount() + expect(mock.deactivates).toHaveBeenCalledTimes(2) + stop() + mock.paired = new Map([[tab.entityId, name(tab, 'Dormant rename')]]) + useAppStore.setState({ + activeWorktreeId: tab.worktreeId, + activeWorkspaceExecutionHostId: 'runtime:paired' + }) + expect(getStructuredChatTabNames().get(replacement)?.savedName).toBe('Dormant rename') + expect(readStructuredChatTabName(replacement, useAppStore.getState())).toBe('Dormant rename') + expect(mock.activates).toHaveBeenCalledTimes(2) +}) + +it('updates an unstamped setup chat name when settings replace its fallback owner', () => { + const tab = chat({ worktreeId: brandEphemeralSetupTerminalWorktreeId('rename-observer') }) + seed(tab) + const settings = createGlobalSettingsFixture({ activeRuntimeEnvironmentId: null }) + useAppStore.setState({ + settings, + runtimeEnvironments: [ + { + id: 'paired', + name: 'paired', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [{ id: 'ws-paired', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://x' }], + preferredEndpointId: 'ws-paired' + } + ] + }) + mock.snapshot.set(tab.entityId, name(tab, 'Local name')) + mock.paired.set(tab.entityId, name(tab, 'Paired name')) + expect(getStructuredChatTabNames().get(tab)).toMatchObject({ + owner: 'local', + savedName: 'Local name' + }) + const changed = vi.fn() + stops.push(subscribeStructuredChatTabNames(changed)) + + useAppStore.setState({ settings: { ...settings, activeRuntimeEnvironmentId: 'paired' } }) + + expect(changed).toHaveBeenCalledTimes(1) + expect(getStructuredChatTabNames().get(tab)).toMatchObject({ + owner: 'runtime:paired', + savedName: 'Paired name' + }) + expect(settings.activeRuntimeEnvironmentId).toBeNull() + expect(mock.rpc).not.toHaveBeenCalled() +}) + +it('traces legacy runtime storage movement, action routing and switch-away before inventory', async () => { + const tab = chat({ worktreeId: 'folder:twin', customLabel: 'Legacy paired' }) + seedTwin(tab) + useAppStore.setState({ workspaceSessionReady: false }) + const local = { + ...emptySession(), + activeWorktreeId: tab.worktreeId, + activeWorkspaceKey: 'folder:twin' as const, + activeWorkspaceExecutionHostId: 'runtime:b' as const + } + const partitions: Record = { + local, + 'runtime:b': { + ...emptySession(), + unifiedTabs: { [tab.worktreeId]: [tab] }, + tabGroups: { + [tab.worktreeId]: [ + { id: tab.groupId, worktreeId: tab.worktreeId, activeTabId: tab.id, tabOrder: [tab.id] } + ] + } + } + } + const api = partitionedSessionApi(partitions) + const restored = await fetchWorkspaceSessionWithRuntimeHostOwners( + api, + [], + ['runtime:a', 'runtime:b'] + ) + useAppStore.getState().hydrateWorkspaceSession(restored.session, restored) + useAppStore.getState().hydrateTabsSession(restored.session) + expect(useAppStore.getState().restoredRuntimeHostIdByWorkspaceSessionKey[tab.worktreeId]).toBe( + 'runtime:b' + ) + const current = () => useAppStore.getState().unifiedTabsByWorktree[tab.worktreeId][0] + expect(current().executionHostId).toBeUndefined() + const folder = makeFolderWorkspace({ id: 'twin' }) + useAppStore.setState({ + activeWorkspaceExecutionHostId: 'runtime:b', + folderWorkspaces: [ + { ...folder, executionHostId: 'runtime:a' }, + { ...folder, executionHostId: 'runtime:b' } + ] + }) + const payload = { + ...restored.session, + ...buildPersistedUnifiedTabSessionData(useAppStore.getState()) + } + expect(payload.unifiedTabs?.[tab.worktreeId]?.[0]).toBe(current()) + await persistWorkspaceSessionByHost(api, payload, useAppStore.getState()) + expect(partitions['runtime:a'].unifiedTabs?.[tab.worktreeId]?.[0]).toBe(current()) + const reloaded = await fetchWorkspaceSessionWithRuntimeHostOwners( + api, + [], + ['runtime:a', 'runtime:b'] + ) + expect(reloaded.contestedPrimaryHostBySessionKey[tab.worktreeId]).toBe('runtime:a') + useAppStore.getState().hydrateWorkspaceSession(reloaded.session, reloaded) + useAppStore.getState().hydrateTabsSession(reloaded.session) + expect(current().executionHostId).toBeUndefined() + useAppStore.setState({ + activeWorktreeId: tab.worktreeId, + activeWorkspaceExecutionHostId: 'runtime:a' + }) + mock.send.mockReturnValue({ outcome: Promise.resolve('recorded') }) + expect( + await sendMessageToAgent({ + worktreeId: tab.worktreeId, + target: { kind: 'structured-session', sessionId: tab.entityId }, + prompt: 'hello' + }) + ).toEqual({ status: 'sent' }) + expect(mock.send).toHaveBeenCalledWith( + expect.objectContaining({ target: { kind: 'environment', environmentId: 'a' } }) + ) + mock.paired = new Map([[tab.entityId, name(tab, 'Legacy paired')]]) + mock.rpc.mockResolvedValue({ name: 'Legacy paired', applied: true }) + await useAppStore.getState().reconnectPersistedTerminals() + expect(useAppStore.getState().workspaceSessionReady).toBe(true) + stops.push(installLegacyChatRenameMigration()) + expect( + await uploadLegacyStructuredChatRename(useAppStore.getState, current(), 'Legacy paired') + ).toBe(false) + expect(mock.rpc).not.toHaveBeenCalled() + expect(current().customLabel).toBe('Legacy paired') + expect(readStructuredChatTabName(current(), useAppStore.getState())).toBe('Legacy paired') + useAppStore.setState({ activeWorktreeId: 'elsewhere' }) + expect(mock.rpc).not.toHaveBeenCalled() + expect(readStructuredChatTabName(current(), useAppStore.getState())).toBe('Legacy paired') + useAppStore.setState({ + activeWorktreeId: tab.worktreeId, + activeWorkspaceExecutionHostId: 'runtime:b' + }) + expect(mock.rpc).not.toHaveBeenCalled() + mock.paired.set(tab.entityId, name(tab, 'Explicit name')) + useAppStore.getState().setTabCustomTitle(current().id, ' Explicit name ') + // Navigation happens before the dynamic rename import resumes, with inventory still absent. + useAppStore.setState({ + activeWorktreeId: 'elsewhere', + activeWorkspaceExecutionHostId: 'runtime:a' + }) + await waitFor(() => expect(current().customLabel).toBe('Explicit name')) + expect(mock.rpc).toHaveBeenCalledExactlyOnceWith( + { kind: 'environment', environmentId: 'b' }, + 'agentSession.rename', + { + sessionId: tab.entityId, + name: 'Explicit name' + } + ) + expect(current().executionHostId).toBeUndefined() + expect(readStructuredChatTabName(current(), useAppStore.getState())).toBe('Explicit name') + await persistWorkspaceSessionByHost( + api, + { ...reloaded.session, ...buildPersistedUnifiedTabSessionData(useAppStore.getState()) }, + useAppStore.getState() + ) + const renamed = await fetchWorkspaceSessionWithRuntimeHostOwners( + api, + [], + ['runtime:a', 'runtime:b'] + ) + useAppStore.getState().hydrateWorkspaceSession(renamed.session, renamed) + useAppStore.getState().hydrateTabsSession(renamed.session) + useAppStore.setState({ activeWorktreeId: 'elsewhere' }) + expect(current().executionHostId).toBeUndefined() + expect(readStructuredChatTabName(current(), useAppStore.getState())).toBe('Explicit name') + expect(mock.rpc).toHaveBeenCalledTimes(1) + // The existing migration retires the copy once catalog ownership is proved. + useAppStore.setState({ folderWorkspaces: [{ ...folder, executionHostId: 'runtime:b' }] }) + await waitFor(() => expect(current().customLabel).toBeNull()) + expect(mock.rpc).toHaveBeenLastCalledWith( + { kind: 'environment', environmentId: 'b' }, + 'agentSession.rename', + { sessionId: tab.entityId, name: 'Explicit name', legacyAlias: true } + ) + expect(readStructuredChatTabName(current(), useAppStore.getState())).toBe('Explicit name') +}) + +it('traces a local legacy alias through foreign same-ID inventory and authoritative omission', () => { + const tab = chat({ customLabel: 'Legacy local' }) + seed(tab) + expect(readStructuredAgentLaunchRecord(tab.entityId)).toBeUndefined() + const snapshot: RuntimeMobileSessionTabsResult = { + worktree: tab.worktreeId, + publicationEpoch: 'identity-evidence', + snapshotVersion: 1, + activeTabId: tab.id, + activeTabType: 'agent-session', + activeGroupId: tab.groupId, + tabs: [ + { + type: 'agent-session', + id: tab.id, + sessionId: tab.entityId, + agent: 'codex', + title: 'Codex Chat', + isActive: true + } + ] + } + const current = () => useAppStore.getState().unifiedTabsByWorktree[tab.worktreeId][0] + applyWebSessionTabsStorePatch( + (state) => applyLocalStructuredSessionTabSnapshots(state, [snapshot]), + { frames: [] } + )() + expect(current()).toMatchObject({ + id: tab.id, + customLabel: 'Legacy local', + executionHostId: 'local', + createdAt: tab.createdAt + }) + const localOccurrence = current() + applyWebSessionTabsStorePatch((state) => applyWebSessionTabsSnapshot(state, snapshot, 'a'), { + frames: [] + })() + expect(current()).not.toBe(localOccurrence) + expect(current()).toMatchObject({ + id: tab.id, + customLabel: 'Legacy local', + executionHostId: 'runtime:a', + createdAt: tab.createdAt + }) + applyWebSessionTabsStorePatch( + (state) => + applyWebSessionTabsSnapshot(state, { ...snapshot, snapshotVersion: 2, tabs: [] }, 'a'), + { frames: [] } + )() + expect(useAppStore.getState().unifiedTabsByWorktree[tab.worktreeId] ?? []).toEqual([]) +}) diff --git a/src/renderer/src/runtime/legacy-chat-rename-migration.ts b/src/renderer/src/runtime/legacy-chat-rename-migration.ts new file mode 100644 index 00000000000..f76c896587f --- /dev/null +++ b/src/renderer/src/runtime/legacy-chat-rename-migration.ts @@ -0,0 +1,98 @@ +import { useEffect } from 'react' +import { useAppStore } from '@/store' +import { shallow } from 'zustand/shallow' +import { getStructuredAgentSessionTabs } from '@/components/native-chat/structured-agent-session-tabs' +import { subscribeLocalRuntimeCapabilitiesKnown } from './local-runtime-capabilities' +import { subscribeRuntimeHostContactRegained } from './runtime-host-contact-regained' +import { + legacyStructuredChatRenameOwner, + structuredAgentSessionCatalogOwnerInputs, + structuredAgentSessionTargetForHost +} from './structured-agent-session-owner' +import { uploadLegacyStructuredChatRename } from './structured-chat-rename' + +/** Remaining saved aliases are the migration debt; only active requests need bookkeeping. */ +export function installLegacyChatRenameMigration(): () => void { + const inFlight = new Map>() + const contacts = new Map void>() + let previousCandidates = '' + let disposed = false + const contactRegained = () => { + void Promise.allSettled(inFlight.values()).then(() => reconcile(true)) + } + const reconcile = (retryPending = false) => { + if (disposed) { + return + } + const state = useAppStore.getState() + const pending = state.workspaceSessionReady + ? getStructuredAgentSessionTabs(state.unifiedTabsByWorktree).flatMap((tab) => { + const alias = tab.customLabel + const owner = legacyStructuredChatRenameOwner(state, tab) + const target = structuredAgentSessionTargetForHost(owner) + return alias && target + ? [ + { + tab, + alias, + target, + key: JSON.stringify([owner, tab.worktreeId, tab.id, tab.entityId, alias]) + } + ] + : [] + }) + : [] + const environments = new Set( + pending.flatMap(({ target }) => (target.kind === 'environment' ? [target.environmentId] : [])) + ) + for (const [id, stop] of contacts) { + if (!environments.has(id)) { + stop() + contacts.delete(id) + } + } + for (const id of environments) { + if (!contacts.has(id)) { + contacts.set(id, subscribeRuntimeHostContactRegained(id, contactRegained)) + } + } + const candidates = JSON.stringify(pending.map(({ key }) => key)) + if (!retryPending && candidates === previousCandidates) { + return + } + previousCandidates = candidates + for (const { tab, alias, key } of pending) { + if (inFlight.has(key)) { + continue + } + const request = uploadLegacyStructuredChatRename(useAppStore.getState, tab, alias) + .catch((error: unknown) => console.warn('[structured-chat-rename] migration failed', error)) + .finally(() => inFlight.delete(key)) + inFlight.set(key, request) + } + } + const stopStore = useAppStore.subscribe((state, previous) => { + if ( + state.workspaceSessionReady !== previous.workspaceSessionReady || + state.unifiedTabsByWorktree !== previous.unifiedTabsByWorktree || + !shallow( + structuredAgentSessionCatalogOwnerInputs(state), + structuredAgentSessionCatalogOwnerInputs(previous) + ) + ) { + reconcile() + } + }) + const stopLocal = subscribeLocalRuntimeCapabilitiesKnown(contactRegained) + reconcile() + return () => { + disposed = true + stopStore() + stopLocal() + contacts.forEach((stop) => stop()) + } +} + +export function useLegacyChatRenameMigration(): void { + useEffect(() => installLegacyChatRenameMigration(), []) +} diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts index 7ea835fdc5b..0eefce1723c 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -19,6 +19,7 @@ import { AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REGENERATE_NAME_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RENAME_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../shared/protocol-version' import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../shared/agent-session-create-capabilities' @@ -221,6 +222,16 @@ export function supportsStructuredAgentSessionRegenerateName( ) } +/** Whether the owning host saves a chat's rename; an unproven host keeps it on the tab. */ +export function supportsStructuredAgentSessionRename( + target: RuntimeClientTarget +): Promise { + return structuredAgentSessionHostSupports( + target, + STRUCTURED_AGENT_SESSION_RENAME_RUNTIME_CAPABILITY + ) +} + /** Asks the owning host to name the chat again; the status feed carries the outcome. */ export async function regenerateStructuredAgentSessionName( target: RuntimeClientTarget, diff --git a/src/renderer/src/runtime/structured-agent-session-owner.ts b/src/renderer/src/runtime/structured-agent-session-owner.ts index 7c7f2db1a66..4fac831d58c 100644 --- a/src/renderer/src/runtime/structured-agent-session-owner.ts +++ b/src/renderer/src/runtime/structured-agent-session-owner.ts @@ -7,7 +7,14 @@ import { import type { Tab } from '../../../shared/tab-types' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state' -import { isTwinWorkspace, namedHostForOwnerRef } from '@/lib/resolve-owner' +import type { AppState } from '@/store/types' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' +import { isTwinWorkspace, namedHostForOwnerRef, resolveOwner } from '@/lib/resolve-owner' +import { authorityKey } from '../../../shared/host-authority' import { LOCAL_STRUCTURED_SESSION_OWNER } from './local-structured-session-owner' import { runtimeRpcTargetForAuthorityKey } from './runtime-client-target' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' @@ -63,6 +70,52 @@ export function structuredAgentSessionOwnerForTab( return tab.executionHostId ?? resolveStructuredAgentSessionOwner(state, tab.worktreeId) } +/** Automatic transfer cannot treat a selected twin or the routing default as ownership evidence. */ +export function legacyStructuredChatRenameOwner( + state: WorktreeRuntimeOwnerState, + tab: Pick +): ExecutionHostId | null { + if (tab.executionHostId) { + return tab.executionHostId + } + const owner = resolveOwner( + { + ...state, + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null, + restoredRuntimeHostIdByWorkspaceSessionKey: undefined + }, + { workspaceId: tab.worktreeId } + ) + const host = owner.kind === 'resolved' ? parseExecutionHostId(authorityKey(owner.owner)) : null + return host && structuredAgentSessionTargetForHost(host.id) ? host.id : null +} + +/** Catalog inputs independent of the user's current workspace selection. */ +export function structuredAgentSessionCatalogOwnerInputs(state: AppState): unknown[] { + return [ + state.repos, + selectWorktreesByRepo(state), + selectDetectedWorktreesByRepo(state), + selectFolderWorkspaces(state), + state.projectGroups, + state.runtimeEnvironments, + state.runtimeEnvironmentCatalogHydrated + ] +} + +/** Inputs to the legacy workspace-owner resolver, compared before scanning chat tabs. */ +export function structuredAgentSessionOwnerInputs(state: AppState): unknown[] { + return [ + ...structuredAgentSessionCatalogOwnerInputs(state), + state.settings, + state.restoredRuntimeHostIdByWorkspaceSessionKey, + state.removedRuntimeEnvironmentIds, + state.activeWorktreeId, + state.activeWorkspaceExecutionHostId + ] +} + /** The host a runtime target serves, for bookkeeping keyed by host. */ export function executionHostIdForStructuredTarget(target: RuntimeClientTarget): ExecutionHostId { return target.kind === 'local' diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts index 04c408c699a..b16ed0f21a8 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts @@ -51,6 +51,68 @@ function hostEmit(index = 0): (event: AgentSessionStatusEvent) => void { } describe('structured agent session status feed', () => { + it('orders naming observations without changing retained turns, Sleep or confirmation', async () => { + const feed = getStructuredAgentSessionStatusFeed(LOCAL) + const stop = feed.activate() + await vi.advanceTimersByTimeAsync(0) + const retained = { + ...summary('one'), + turnOutcome: 'interruption' as const, + permissionMode: 'plan', + model: 'model', + statusStartedAt: 2, + sleptAt: 3 + } + hostEmit()({ type: 'status', session: retained }) + const status = feed.getSnapshot() + const metadata = (conversationName: string) => ({ + type: 'snapshot' as const, + sessions: [], + namingActiveSessionIds: [], + conversationNames: [{ sessionId: 'one', conversationName }] + }) + hostEmit()(metadata('Before')) + expect(feed.getSnapshot()).toBe(status) + expect(feed.getSessionSleepState('one')).toBe(3) + expect(feed.getSessionSleepState('cold')).toBeUndefined() + expect(feed.getSessionObservation('cold')).toBe('unverifiable') + hostEmit()({ + ...metadata('Before'), + conversationNames: [ + { + sessionId: 'one', + conversationName: 'Before', + namingError: { message: 'Failed', failedAt: 1 } + } + ] + }) + hostEmit()(metadata('Before')) + expect(feed.getNamingSnapshot().get('one')).not.toHaveProperty('namingError') + hostEmit()({ + type: 'snapshot', + sessions: [retained], + conversationNames: JSON.parse('[null, {"sessionId":"one","conversationName":5}]') + }) + expect(feed.getNamingSnapshot().get('one')?.conversationName).toBe('Before') + hostEmit()({ ...metadata('After'), sessions: [{ ...retained, conversationName: 'Stale' }] }) + expect(feed.getNamingSnapshot().get('one')?.conversationName).toBe('After') + hostEmit()({ type: 'end' }) + expect(feed.getSessionObservation('one')).toBe('unverifiable') + expect(feed.getNamingSnapshot().get('one')?.conversationName).toBe('After') + await vi.advanceTimersByTimeAsync(300) + hostEmit(1)({ + ...metadata('Restarted'), + conversationNames: [ + { sessionId: 'one', conversationName: 'Restarted' }, + { sessionId: 'cold', conversationName: 'Cold' } + ] + }) + expect(feed.getSessionObservation('one')).toBe('unverifiable') + expect(feed.getSessionObservation('cold')).toBe('unverifiable') + expect(feed.getSnapshot().get('one')).toMatchObject(retained) + stop() + expect(feed.getNamingSnapshot().get('one')?.conversationName).toBe('Restarted') + }) beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.ts index 0269462c784..e0fe68a28e2 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.ts @@ -3,6 +3,11 @@ import { AGENT_SESSION_WORKSPACE_SLEEP_MARK_RUNTIME_CAPABILITY } from '../../../shared/agent-session/agent-session-workspace-sleep' import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' import { + foldAgentSessionConversationNames, + type AgentSessionConversationNames +} from '../../../shared/agent-session-conversation-names' +import { + AgentSessionAssociationObservation, foldAgentSessionStatusEvent, revokeAgentSessionStatusLive, type AgentSessionStatusSnapshot @@ -17,6 +22,8 @@ export type StructuredAgentSessionStatusSnapshot = AgentSessionStatusSnapshot export type StructuredAgentSessionStatusFeedOwner = { activate: () => () => void getSnapshot: () => StructuredAgentSessionStatusSnapshot + getNamingSnapshot: () => AgentSessionConversationNames + getAssociationRevision: () => number getSessionSleepState: (sessionId: string) => number | null | undefined getNamingActive: (sessionId: string) => boolean | null getSessionObservation: (sessionId: string) => 'live' | 'unverifiable' @@ -36,6 +43,8 @@ export function structuredAgentSessionStatusFeedKey(target: RuntimeClientTarget) function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { let snapshot: StructuredAgentSessionStatusSnapshot = new Map() + let names: AgentSessionConversationNames = new Map() + const associations = new AgentSessionAssociationObservation() let namingActiveSessionIds: Set | null = null const confirmedSessions = new Set() // Disconnect revokes live execution, but each chat's last Sleep answer still holds. @@ -81,6 +90,8 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { } else { return } + associations.observe(event) + names = foldAgentSessionConversationNames(names, event) setSnapshot(foldAgentSessionStatusEvent(snapshot, event)) } const active = (candidate: number): boolean => activations.size > 0 && candidate === generation @@ -162,6 +173,7 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { } open = (): void => { const candidate = ++generation + associations.renew() dropHandle() if (target.kind !== 'environment') { // A local host is this build; only a remote one can predate the method. @@ -222,6 +234,8 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { } }, getSnapshot: () => snapshot, + getNamingSnapshot: () => names, + getAssociationRevision: () => associations.revision, getSessionSleepState: (sessionId) => { const known = knownSleep.get(sessionId) return known !== undefined ? known : sleepResolved ? null : undefined diff --git a/src/renderer/src/runtime/structured-chat-name-observation.ts b/src/renderer/src/runtime/structured-chat-name-observation.ts new file mode 100644 index 00000000000..9e9bda2ac8e --- /dev/null +++ b/src/renderer/src/runtime/structured-chat-name-observation.ts @@ -0,0 +1,147 @@ +import { useSyncExternalStore } from 'react' +import { shallow } from 'zustand/shallow' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { useAppStore } from '@/store' +import { getStructuredAgentSessionTabs } from '@/components/native-chat/structured-agent-session-tabs' +import { + structuredAgentSessionOwnerInputs, + structuredAgentSessionTargetForHost +} from './structured-agent-session-owner' +import { getStructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' +import { + resolveStructuredChatTabName, + type StructuredChatTabNames +} from './structured-chat-tab-name' + +const listeners = new Set<() => void>() +let snapshot: StructuredChatTabNames = new Map() +let inputs: unknown[] = [] +let nameMaps = new Map() +let stopObserving: (() => void) | undefined +export function getStructuredChatTabNames(): StructuredChatTabNames { + const state = useAppStore.getState() + const nextInputs = [state.unifiedTabsByWorktree, ...structuredAgentSessionOwnerInputs(state)] + const nextMaps = new Map() + for (const owner of nameMaps.keys()) { + const target = structuredAgentSessionTargetForHost(owner) + if (target) { + nextMaps.set(owner, getStructuredAgentSessionStatusFeed(target).getNamingSnapshot()) + } + } + if (shallow(inputs, nextInputs) && shallow(nameMaps, nextMaps)) { + return snapshot + } + const next = new Map( + getStructuredAgentSessionTabs(state.unifiedTabsByWorktree).map((tab) => { + const value = resolveStructuredChatTabName(tab, state) + const target = structuredAgentSessionTargetForHost(value.owner) + if (value.owner && target) { + nextMaps.set(value.owner, getStructuredAgentSessionStatusFeed(target).getNamingSnapshot()) + } + return [tab, value] as const + }) + ) + const owners = new Set([...next.values()].map((value) => value.owner)) + for (const owner of nextMaps.keys()) { + if (!owners.has(owner)) { + nextMaps.delete(owner) + } + } + inputs = nextInputs + nameMaps = nextMaps + if ( + next.size !== snapshot.size || + [...next].some(([tab, value]) => { + const previous = snapshot.get(tab) + return ( + !previous || + previous.owner !== value.owner || + previous.sessionId !== value.sessionId || + previous.savedName !== value.savedName + ) + }) + ) { + snapshot = next + } + return snapshot +} + +function observe(): () => void { + const feeds = new Map void>() + let published = getStructuredChatTabNames() + const notice = () => { + const next = getStructuredChatTabNames() + if (published !== next) { + published = next + listeners.forEach((listener) => listener()) + } + } + const reconcile = () => { + const owners = new Set([...getStructuredChatTabNames().values()].map((value) => value.owner)) + for (const [owner, stop] of feeds) { + if (!owners.has(owner)) { + stop() + feeds.delete(owner) + } + } + for (const owner of owners) { + const target = structuredAgentSessionTargetForHost(owner) + if (!owner || !target || feeds.has(owner)) { + continue + } + const feed = getStructuredAgentSessionStatusFeed(target) + let names = feed.getNamingSnapshot() + const stop = feed.subscribe(() => { + const next = feed.getNamingSnapshot() + if (names !== next) { + names = next + notice() + } + }) + const deactivate = feed.activate() + feeds.set(owner, () => { + stop() + deactivate() + }) + } + notice() + } + const stopStore = useAppStore.subscribe((state, previous) => { + if ( + state.unifiedTabsByWorktree !== previous.unifiedTabsByWorktree || + !shallow( + structuredAgentSessionOwnerInputs(state), + structuredAgentSessionOwnerInputs(previous) + ) + ) { + reconcile() + } + }) + reconcile() + return () => { + stopStore() + feeds.forEach((stop) => stop()) + } +} + +export function subscribeStructuredChatTabNames(listener: () => void): () => void { + listeners.add(listener) + if (listeners.size === 1) { + stopObserving = observe() + } + return () => { + listeners.delete(listener) + if (listeners.size === 0) { + stopObserving?.() + stopObserving = undefined + } + } +} + +export function useStructuredChatTabNames(): StructuredChatTabNames { + return useSyncExternalStore( + subscribeStructuredChatTabNames, + getStructuredChatTabNames, + getStructuredChatTabNames + ) +} diff --git a/src/renderer/src/runtime/structured-chat-rename-action.ts b/src/renderer/src/runtime/structured-chat-rename-action.ts new file mode 100644 index 00000000000..23b8ef88403 --- /dev/null +++ b/src/renderer/src/runtime/structured-chat-rename-action.ts @@ -0,0 +1,53 @@ +import type { AppState } from '@/store/types' +import type { Tab } from '../../../shared/tab-types' +import { + executionHostIdForStructuredTarget, + legacyStructuredChatRenameOwner, + structuredAgentSessionTargetForTab +} from './structured-agent-session-owner' + +const intents = new WeakMap<() => AppState, Map>() + +/** Capture the submitted occurrence and address before the rename module loads or navigation moves. */ +export function captureStructuredChatRename(get: () => AppState, tab: Tab) { + const state = get() + const target = structuredAgentSessionTargetForTab(state, tab) + const retainAlias = legacyStructuredChatRenameOwner(state, tab) === null + const owner = target ? executionHostIdForStructuredTarget(target) : tab.executionHostId + const alias = tab.customLabel ?? null + const key = JSON.stringify([tab.worktreeId, tab.id]) + let pending = intents.get(get) + if (!pending) { + pending = new Map() + intents.set(get, pending) + } + const slots = pending + const intent = {} + slots.set(key, intent) + const currentTab = () => + get().unifiedTabsByWorktree[tab.worktreeId]?.find((entry) => entry.id === tab.id) + const current = () => { + const entry = currentTab() + return ( + slots.get(key) === intent && + entry?.worktreeId === tab.worktreeId && + entry.contentType === tab.contentType && + entry.entityId === tab.entityId && + entry.agentSessionAgent === tab.agentSessionAgent && + entry.createdAt === tab.createdAt && + (!entry.executionHostId || entry.executionHostId === owner) + ) + } + return { + target, + retainAlias, + current, + // Alias retirement or another local edit must not cancel the addressed host command. + aliasCurrent: () => current() && (currentTab()?.customLabel ?? null) === alias, + finish: () => { + if (slots.get(key) === intent) { + slots.delete(key) + } + } + } +} diff --git a/src/renderer/src/runtime/structured-chat-rename.ts b/src/renderer/src/runtime/structured-chat-rename.ts new file mode 100644 index 00000000000..8171686355e --- /dev/null +++ b/src/renderer/src/runtime/structured-chat-rename.ts @@ -0,0 +1,201 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { AppState } from '@/store/types' +import type { Tab } from '../../../shared/tab-types' +import { normalizeAgentSessionConversationName } from '../../../shared/agent-session/agent-session-conversation-name' +import { callRuntimeRpc } from './runtime-rpc-client' +import { supportsStructuredAgentSessionRename } from './structured-agent-session-client' +import { + structuredAgentSessionTargetForHost, + structuredAgentSessionOwnerForTab, + legacyStructuredChatRenameOwner, + executionHostIdForStructuredTarget +} from './structured-agent-session-owner' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import { sendTabChangeToLayoutOwner } from '@/store/terminals/tab-change-through-layout-owner' +import { captureStructuredChatRename } from './structured-chat-rename-action' + +type RenameResult = { name: string | null; applied: boolean } + +/** + * Names a chat on the host that owns it: the one name Session History, tabs, sidebars and phones + * read, so it outlives the tab. A host too old to store it keeps the name on this window's tab, as + * before names were saved there. + */ +export async function renameStructuredChatTab( + get: () => AppState, + tab: Tab, + title: string | null, + opts: { recordInteraction?: boolean } | undefined, + keepOnTab: () => void, + action = captureStructuredChatRename(get, tab) +): Promise { + try { + await submitRename(get, tab, title, opts, keepOnTab, action) + } finally { + action.finish() + } +} + +async function submitRename( + get: () => AppState, + tab: Tab, + title: string | null, + opts: { recordInteraction?: boolean } | undefined, + keepOnTab: () => void, + action: ReturnType +): Promise { + if (!action.current()) { + return + } + const { target } = action + if (!target || !(await supportsStructuredAgentSessionRename(target))) { + if (action.aliasCurrent()) { + keepOnTab() + } + return + } + if (!action.current()) { + return + } + const name = normalizeAgentSessionConversationName(title) + if (name === null) { + // Clearing would leave the chat with no name for good: naming runs once, on the first message. + toast.error(translate('components.tab.bar.chatRename.empty', "A chat name can't be empty")) + return + } + if (opts?.recordInteraction !== false) { + get().recordFeatureInteraction?.('terminal-tabs') + } + try { + await callRuntimeRpc(target, 'agentSession.rename', { + sessionId: tab.entityId, + name + }) + } catch (error) { + console.warn('[structured-chat-rename] rename failed', error) + if (action.current()) { + toast.error(translate('components.tab.bar.chatRename.failed', "Couldn't rename this chat")) + } + return + } + if (!action.current()) { + return + } + if (action.retainAlias) { + if (action.aliasCurrent()) { + get().setTabCustomLabel(tab.id, name, { recordInteraction: false }) + } + return + } + await dropLegacyAlias( + get, + tab, + tab.customLabel ?? null, + executionHostIdForStructuredTarget(target), + structuredAgentSessionOwnerForTab, + action.current + ) +} + +/** + * Uploads a tab rename this window saved before names lived on the host. The host takes it only + * while the chat still has Orca's own name, so a later rename from any window wins; either way the + * host's name is the chat's name from then on and the tab stops carrying its own. + */ +export async function uploadLegacyStructuredChatRename( + get: () => AppState, + tab: Tab, + alias: string +): Promise { + const target = structuredAgentSessionTargetForHost(legacyStructuredChatRenameOwner(get(), tab)) + if (!target || !(await supportsStructuredAgentSessionRename(target))) { + return false + } + try { + await callRuntimeRpc(target, 'agentSession.rename', { + sessionId: tab.entityId, + name: normalizeAgentSessionConversationName(alias) ?? '', + legacyAlias: true + }) + } catch (error) { + console.warn('[structured-chat-rename] legacy rename upload failed', error) + return false + } + return dropLegacyAlias( + get, + tab, + alias, + executionHostIdForStructuredTarget(target), + legacyStructuredChatRenameOwner + ) +} + +/** Clears the tab's own name only if it is still the one the host just answered for. */ +async function dropLegacyAlias( + get: () => AppState, + tab: Tab, + alias: string | null, + owner: string, + ownerForTab = structuredAgentSessionOwnerForTab, + isCurrent = () => true +): Promise { + if (alias === null) { + return true + } + const state = get() + const matches = () => { + const latest = get() + const current = latest.unifiedTabsByWorktree[tab.worktreeId]?.find( + (entry) => entry.id === tab.id + ) + return ( + isCurrent() && + current?.contentType === 'agent-session' && + current.entityId === tab.entityId && + current.customLabel === alias && + ownerForTab(latest, current) === owner + ) + } + if (!matches()) { + return true + } + const clear = () => { + if (matches()) { + get().setTabCustomLabel(tab.id, null, { recordInteraction: false }) + } + } + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + clear() + return true + } + return new Promise((resolve) => { + if ( + !sendTabChangeToLayoutOwner( + state, + tab, + (id) => ({ + type: 'renameTab', + tabId: id, + kind: 'custom', + title: null, + expected: { entityId: tab.entityId, executionHostId: owner, customTitle: alias } + }), + () => { + clear() + resolve(true) + }, + { + shown: () => { + clear() + resolve(true) + }, + refused: () => resolve(false) + } + ) + ) { + clear() + resolve(true) + } + }) +} diff --git a/src/renderer/src/runtime/structured-chat-tab-name.ts b/src/renderer/src/runtime/structured-chat-tab-name.ts new file mode 100644 index 00000000000..c9fd8348a48 --- /dev/null +++ b/src/renderer/src/runtime/structured-chat-tab-name.ts @@ -0,0 +1,62 @@ +import type { Tab } from '../../../shared/tab-types' +import { isAgentSessionConversationName } from '../../../shared/agent-session/agent-session-conversation-name' +import type { AppState } from '@/store/types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { + structuredAgentSessionOwnerForTab, + structuredAgentSessionTargetForHost +} from './structured-agent-session-owner' +import { getStructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' + +type ChatTabNameSource = Pick< + Tab, + 'entityId' | 'worktreeId' | 'executionHostId' | 'customLabel' | 'label' +> + +/** A chat tab's name, read once outside React (labels built in plain functions): the same order + * the tab strip uses, from the host's feed as it stands now. Null while the chat has no name. + * Legacy tabs resolve their owner from the workspace just as the reactive tab reader does. */ +export function resolveStructuredChatTabName( + tab: ChatTabNameSource, + state: AppState +): StructuredChatTabName { + const owner = structuredAgentSessionOwnerForTab(state, tab) + const target = structuredAgentSessionTargetForHost(owner) + const name = target + ? getStructuredAgentSessionStatusFeed(target).getNamingSnapshot().get(tab.entityId) + ?.conversationName + : undefined + return { + owner, + sessionId: tab.entityId, + savedName: tab.customLabel?.trim() || (isAgentSessionConversationName(name) ? name : null) + } +} + +export type StructuredChatTabName = Readonly<{ + owner: ExecutionHostId | null + sessionId: string + savedName: string | null +}> +export type StructuredChatTabNames = ReadonlyMap + +export function projectedStructuredChatTabSavedName( + tab: Tab, + names: StructuredChatTabNames +): string | null { + return names.get(tab)?.savedName ?? (tab.customLabel?.trim() || null) +} +export function projectedStructuredChatTabName(tab: Tab, names: StructuredChatTabNames): string { + return projectedStructuredChatTabSavedName(tab, names) ?? tab.label +} +export function readStructuredChatTabSavedName( + tab: ChatTabNameSource, + state: AppState +): string | null { + return resolveStructuredChatTabName(tab, state).savedName +} + +/** The chat tab's shown label: its name, else the tab's default label. */ +export function readStructuredChatTabName(tab: ChatTabNameSource, state: AppState): string { + return readStructuredChatTabSavedName(tab, state) ?? tab.label +} diff --git a/src/renderer/src/runtime/structured-conversation-name.ts b/src/renderer/src/runtime/structured-conversation-name.ts index 40da8a6851b..26c21f8e6b4 100644 --- a/src/renderer/src/runtime/structured-conversation-name.ts +++ b/src/renderer/src/runtime/structured-conversation-name.ts @@ -3,7 +3,7 @@ import type { Tab } from '../../../shared/tab-types' import { isAgentSessionConversationName } from '../../../shared/agent-session/agent-session-conversation-name' import type { AgentSessionNamingError, - AgentSessionStatusSummary + AgentSessionConversationName } from '../../../shared/agent-session-wire' import { useAppStore } from '@/store' import { @@ -52,7 +52,7 @@ export function useStructuredConversationNamingError( function useStructuredSessionSummaryField( executionHostId: string | null | undefined, sessionId: string | null | undefined, - read: (summary: AgentSessionStatusSummary | undefined) => T | null + read: (summary: AgentSessionConversationName | undefined) => T | null ): T | null { const feed = useMemo(() => { const target = structuredAgentSessionTargetForHost(executionHostId) @@ -62,7 +62,7 @@ function useStructuredSessionSummaryField( useEffect(() => watched?.activate(), [watched]) return useSyncExternalStore( watched?.subscribe ?? noSubscription, - () => read(sessionId ? watched?.getSnapshot().get(sessionId) : undefined), + () => read(sessionId ? watched?.getNamingSnapshot().get(sessionId) : undefined), () => null ) } diff --git a/src/renderer/src/runtime/workspace-layout-frame-tab-props.test.ts b/src/renderer/src/runtime/workspace-layout-frame-tab-props.test.ts index f8e6b064329..5cdd7e72d54 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-tab-props.test.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-tab-props.test.ts @@ -190,7 +190,8 @@ const CASES: Case[] = [ mainShows: { [`${GIT_KEY} terminal term-agent phone and CLI title`]: 'Terminal 2' } }, { - name: 'rename a chat', + // A chat's name lives on its host; only a host too old to save it keeps the name on the tab. + name: 'rename a chat whose host cannot save names', command: { type: 'renameTab', workspace: GIT_KEY, @@ -198,7 +199,7 @@ const CASES: Case[] = [ kind: 'custom', title: 'Review' }, - windowAction: (state) => state.setTabCustomTitle('agent-session-1', 'Review') + windowAction: (state) => state.setTabCustomLabel('agent-session-1', 'Review') }, { name: 'color a terminal', diff --git a/src/renderer/src/store/pinned-tab-close-guard.ts b/src/renderer/src/store/pinned-tab-close-guard.ts index f5de9620137..41820ba209e 100644 --- a/src/renderer/src/store/pinned-tab-close-guard.ts +++ b/src/renderer/src/store/pinned-tab-close-guard.ts @@ -1,5 +1,6 @@ import { useAppStore } from '@/store' import { resolveUnifiedTabLabel } from '../../../shared/tab-title-resolution' +import { readStructuredChatTabName } from '@/runtime/structured-chat-tab-name' import type { AppState } from './types' export function resolvePinnedTabLabel( @@ -10,7 +11,9 @@ export function resolvePinnedTabLabel( const tab = (state.unifiedTabsByWorktree?.[worktreeId] ?? []).find( (candidate) => candidate.id === visibleId || candidate.entityId === visibleId ) - return resolveUnifiedTabLabel(tab, state.settings?.tabAutoGenerateTitle === true) + return tab?.contentType === 'agent-session' + ? readStructuredChatTabName(tab, state) + : resolveUnifiedTabLabel(tab, state.settings?.tabAutoGenerateTitle === true) } /** Check unified id/entityId pin state before running-process confirmation. */ diff --git a/src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts b/src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts index 513abbcfb09..39475dbf0a3 100644 --- a/src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts +++ b/src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts @@ -1,14 +1,40 @@ -import { describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { createTerminalTabAppearanceActions } from './terminal-tab-appearance' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Tab } from '../../../../shared/tab-types' import { createTestStore, makeWorktree, seedStore } from '../slices/store-test-helpers' import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import type * as SessionClient from '@/runtime/structured-agent-session-client' +import { routeTerminalTabChangesThroughLayoutOwner } from './terminal-tab-layout-owner-actions' +import { makeFolderWorkspace } from '../slices/worktrees-slice-test-fixtures' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' -vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => - ( +const { hostRename, hostSavesNames } = vi.hoisted(() => ({ + hostRename: vi.fn(async (_params: unknown, _target?: RuntimeClientTarget) => ({ + name: 'Flaky retry test', + applied: true + })), + hostSavesNames: vi.fn(async () => true) +})) + +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => { + const client = ( await import('@/runtime/layout-apply-test-transport.test-fixture') ).runtimeRpcClientWithLayoutApply(await importOriginal()) -) + return { + ...client, + callRuntimeRpc: (...args: Parameters) => + args[1] === 'agentSession.rename' + ? hostRename(args[2], args[0]) + : client.callRuntimeRpc(...args) + } +}) + +vi.mock('@/runtime/structured-agent-session-client', async (importOriginal) => ({ + ...(await importOriginal()), + supportsStructuredAgentSessionRename: hostSavesNames +})) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } @@ -124,23 +150,239 @@ describe('recoloring a structured chat tab', () => { }) describe('renaming a structured chat tab', () => { - it('clears the custom label when the rename is emptied', async () => { + beforeEach(() => { + vi.mocked(toast.error).mockClear() + hostRename.mockClear() + hostRename.mockResolvedValue({ name: 'Flaky retry test', applied: true }) + hostSavesNames.mockClear() + hostSavesNames.mockResolvedValue(true) + }) + + it("saves the name on the chat's host, where Session History reads it, not on the tab", async () => { + const store = storeWithStructuredTab() + store.getState().setTabCustomTitle(STRUCTURED_TAB_ID, ' Flaky retry test ') + await vi.waitFor(() => + expect(hostRename).toHaveBeenCalledWith( + { sessionId: 'codex-1', name: 'Flaky retry test' }, + { kind: 'local' } + ) + ) + await layoutApplyTestTransport.settle() + expect(labelOf(store)).toBeNull() + expect(layoutApplyTestTransport.sent).not.toContainEqual( + expect.objectContaining({ type: 'renameTab', tabId: STRUCTURED_TAB_ID }) + ) + store.getState().setTabCustomLabel(STRUCTURED_TAB_ID, 'Old local alias') + let answer = (_supported: boolean) => {} + hostSavesNames.mockImplementationOnce( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + store.getState().setTabCustomTitle(STRUCTURED_TAB_ID, 'Updated local name') + await vi.waitFor(() => expect(hostSavesNames).toHaveBeenCalledTimes(2)) + store.getState().setUnifiedTabColor(STRUCTURED_TAB_ID, 'red') + store.setState({ + unifiedTabsByWorktree: { + [WORKTREE]: store + .getState() + .unifiedTabsByWorktree[WORKTREE]!.map((tab) => ({ ...tab, executionHostId: 'local' })) + } + }) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + layoutApplyTestTransport.answerWith('pending') + } + answer(true) + await vi.waitFor(() => + expect(hostRename).toHaveBeenLastCalledWith( + { sessionId: 'codex-1', name: 'Updated local name' }, + { kind: 'local' } + ) + ) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await vi.waitFor(() => expect(layoutApplyTestTransport.pendingCount).toBe(1)) + store.getState().setUnifiedTabColor(STRUCTURED_TAB_ID, 'blue') + layoutApplyTestTransport.answerPending() + layoutApplyTestTransport.answerWith('accept') + } + await layoutApplyTestTransport.settle() + await vi.waitFor(() => expect(labelOf(store)).toBeNull()) + expect(colorOf(store)).toBe(ORCA_LAYOUT_OWNER_IS_MAIN ? 'blue' : 'red') + + // Existing alias retirement is bookkeeping, not a reason to discard an explicit host write. + hostSavesNames.mockImplementationOnce( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + store.getState().setTabCustomLabel(STRUCTURED_TAB_ID, 'Migrating alias') + store.getState().setTabCustomTitle(STRUCTURED_TAB_ID, 'After migration') + await vi.waitFor(() => expect(hostSavesNames).toHaveBeenCalledTimes(3)) + store.getState().setTabCustomLabel(STRUCTURED_TAB_ID, null) + answer(true) + await vi.waitFor(() => expect(hostRename).toHaveBeenCalledTimes(3)) + expect(hostRename).toHaveBeenLastCalledWith( + { sessionId: 'codex-1', name: 'After migration' }, + { kind: 'local' } + ) + }) + + it.each(['window', 'main'] as const)( + 'captures an alias-free twin before the %s entry imports, and preserves newer intent', + async (entry) => { + const store = storeWithStructuredTab() + const folder = makeFolderWorkspace({ id: 'twin' }) + const tab = { ...structuredTab(), worktreeId: folder.id } + store.setState({ + unifiedTabsByWorktree: { [folder.id]: [tab] }, + activeWorktreeId: folder.id, + activeWorkspaceExecutionHostId: 'runtime:b', + folderWorkspaces: [ + { ...folder, executionHostId: 'runtime:a' }, + { ...folder, executionHostId: 'runtime:b' } + ] + }) + const windowActions = createTerminalTabAppearanceActions(store.setState, store.getState) + const mainActions = routeTerminalTabChangesThroughLayoutOwner( + { ...store.getState(), ...windowActions }, + store.getState + ) + const actions = entry === 'main' ? mainActions : windowActions + const otherActions = entry === 'main' ? windowActions : mainActions + const current = () => store.getState().unifiedTabsByWorktree[folder.id][0]! + let complete = () => {} + hostRename.mockImplementationOnce( + () => + new Promise((resolve) => { + complete = () => resolve({ name: 'First', applied: true }) + }) + ) + actions.setTabCustomTitle(tab.id, ' First ') + store.setState({ + activeWorktreeId: 'intermediate', + activeWorkspaceExecutionHostId: 'runtime:a' + }) + await vi.waitFor(() => expect(hostRename).toHaveBeenCalledTimes(1)) + store.getState().setUnifiedTabColor(tab.id, 'red') + store.setState({ activeWorktreeId: 'elsewhere', activeWorkspaceExecutionHostId: 'runtime:a' }) + complete() + await vi.waitFor(() => expect(current().customLabel).toBe('First')) + expect(current().color).toBe('red') + expect(hostRename).toHaveBeenLastCalledWith( + { sessionId: tab.entityId, name: 'First' }, + { kind: 'environment', environmentId: 'b' } + ) + expect(current().executionHostId).toBeUndefined() + + store.setState({ activeWorktreeId: folder.id, activeWorkspaceExecutionHostId: 'runtime:b' }) + hostRename.mockImplementationOnce( + () => + new Promise((resolve) => { + complete = () => resolve({ name: 'Older', applied: true }) + }) + ) + actions.setTabCustomTitle(tab.id, 'Older') + await vi.waitFor(() => + expect(hostRename).toHaveBeenLastCalledWith( + { sessionId: tab.entityId, name: 'Older' }, + { kind: 'environment', environmentId: 'b' } + ) + ) + const error = vi.spyOn(console, 'warn').mockImplementation(() => {}) + hostRename.mockRejectedValueOnce(new Error('Save refused')) + store.getState().setUnifiedTabColor(tab.id, 'blue') + otherActions.setTabCustomTitle(tab.id, 'Not saved') + await vi.waitFor(() => expect(error).toHaveBeenCalled()) + expect(current().customLabel).toBe('First') + expect(toast.error).toHaveBeenCalledExactlyOnceWith("Couldn't rename this chat") + error.mockRestore() + complete() + await layoutApplyTestTransport.settle() + expect(current().customLabel).toBe('First') + let fail = (_error: Error) => {} + hostRename.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + fail = reject + }) + ) + actions.setTabCustomTitle(tab.id, 'Older failure') + await vi.waitFor(() => expect(hostRename).toHaveBeenCalledTimes(4)) + store.getState().setUnifiedTabColor(tab.id, 'green') + otherActions.setTabCustomTitle(tab.id, 'Newer') + await vi.waitFor(() => expect(current().customLabel).toBe('Newer')) + vi.mocked(toast.error).mockClear() + const staleError = vi.spyOn(console, 'warn').mockImplementation(() => {}) + fail(new Error('Obsolete failure')) + await vi.waitFor(() => expect(staleError).toHaveBeenCalled()) + expect(toast.error).not.toHaveBeenCalled() + staleError.mockRestore() + + hostRename.mockImplementationOnce( + () => + new Promise((resolve) => { + complete = () => resolve({ name: 'Stale', applied: true }) + }) + ) + actions.setTabCustomTitle(tab.id, 'Stale') + await vi.waitFor(() => + expect(hostRename).toHaveBeenLastCalledWith( + { sessionId: tab.entityId, name: 'Stale' }, + { kind: 'environment', environmentId: 'b' } + ) + ) + const replacement = { ...current(), customLabel: 'Replacement' } + store.setState({ unifiedTabsByWorktree: { [folder.id]: [replacement] } }) + complete() + await layoutApplyTestTransport.settle() + expect(current()).toBe(replacement) + + actions.setTabCustomTitle(tab.id, 'After replacement') + await vi.waitFor(() => expect(current().customLabel).toBe('After replacement')) + let answer = (_supported: boolean) => {} + hostSavesNames.mockImplementationOnce( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + actions.setTabCustomTitle(tab.id, 'Repointed') + await vi.waitFor(() => expect(hostSavesNames).toHaveBeenCalledTimes(8)) + const repointed = { ...current(), entityId: 'another-session', createdAt: 2 } + store.setState({ unifiedTabsByWorktree: { [folder.id]: [repointed] } }) + answer(true) + await layoutApplyTestTransport.settle() + expect(hostRename).toHaveBeenCalledTimes(7) + expect(current()).toBe(repointed) + } + ) + + it('keeps the name on the tab for a host too old to save it, and clears it when emptied', async () => { + hostSavesNames.mockResolvedValue(false) const store = storeWithStructuredTab() store.getState().setTabCustomTitle(STRUCTURED_TAB_ID, 'Flaky retry test') + await vi.waitFor(() => expect(hostSavesNames).toHaveBeenCalled()) await layoutApplyTestTransport.settle() // Guard: without the intermediate assertion this case passes on a rename // that never wrote anything, since the label starts out null too. - expectChange(() => expect(labelOf(store)).toBe('Flaky retry test'), { - type: 'renameTab', - tabId: STRUCTURED_TAB_ID, - title: 'Flaky retry test' - }) + await vi.waitFor(() => + expectChange(() => expect(labelOf(store)).toBe('Flaky retry test'), { + type: 'renameTab', + tabId: STRUCTURED_TAB_ID, + title: 'Flaky retry test' + }) + ) store.getState().setTabCustomTitle(STRUCTURED_TAB_ID, null) await layoutApplyTestTransport.settle() - expectChange(() => expect(labelOf(store)).toBeNull(), { - type: 'renameTab', - tabId: STRUCTURED_TAB_ID, - title: null - }) + await vi.waitFor(() => + expectChange(() => expect(labelOf(store)).toBeNull(), { + type: 'renameTab', + tabId: STRUCTURED_TAB_ID, + title: null + }) + ) + expect(hostRename).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/store/terminals/terminal-tab-appearance.ts b/src/renderer/src/store/terminals/terminal-tab-appearance.ts index 2a68c81ed99..3e39cce478d 100644 --- a/src/renderer/src/store/terminals/terminal-tab-appearance.ts +++ b/src/renderer/src/store/terminals/terminal-tab-appearance.ts @@ -2,6 +2,7 @@ import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' import { findRenamableUnifiedTab } from './renamable-unified-tab' +import { captureStructuredChatRename } from '@/runtime/structured-chat-rename-action' export function createTerminalTabAppearanceActions( set: TerminalStoreSet, @@ -23,7 +24,21 @@ export function createTerminalTabAppearanceActions( return { tabsByWorktree: next } }) const item = findRenamableUnifiedTab(get().unifiedTabsByWorktree, tabId) - if (item) { + if (item?.contentType === 'agent-session') { + const action = captureStructuredChatRename(get, item) + void import('@/runtime/structured-chat-rename') + .then(({ renameStructuredChatTab }) => + renameStructuredChatTab( + get, + item, + title, + opts, + () => get().setTabCustomLabel(item.id, title, opts), + action + ) + ) + .finally(action.finish) + } else if (item) { get().setTabCustomLabel(item.id, title, opts) } }, diff --git a/src/renderer/src/store/terminals/terminal-tab-layout-owner-actions.ts b/src/renderer/src/store/terminals/terminal-tab-layout-owner-actions.ts index fe8940a4be9..5e2008e4aa6 100644 --- a/src/renderer/src/store/terminals/terminal-tab-layout-owner-actions.ts +++ b/src/renderer/src/store/terminals/terminal-tab-layout-owner-actions.ts @@ -10,6 +10,7 @@ import { type TabChange } from './tab-change-through-layout-owner' import { findRenamableUnifiedTab } from './renamable-unified-tab' +import { captureStructuredChatRename } from '@/runtime/structured-chat-rename-action' import { runtimeEnvironmentIdForWorkspaceOwner } from '@/lib/resolve-owner' type RoutedActions = Pick< @@ -59,13 +60,28 @@ export function routeTerminalTabChangesThroughLayoutOwner( } } return { - setTabCustomTitle: (tabId, title, opts) => - route( - tabId, - (id) => ({ type: 'renameTab', tabId: id, kind: 'custom', title }), - () => actions.setTabCustomTitle(tabId, title, opts), - opts?.recordInteraction === false ? undefined : recordTabsInteraction - ), + setTabCustomTitle: (tabId, title, opts) => { + const renameOnTab = () => + route( + tabId, + (id) => ({ type: 'renameTab', tabId: id, kind: 'custom', title }), + () => actions.setTabCustomTitle(tabId, title, opts), + opts?.recordInteraction === false ? undefined : recordTabsInteraction + ) + // A chat's name lives on its host, not in the layout; only a host too old to save it keeps + // the name on the tab. + const chat = findRenamableUnifiedTab(get().unifiedTabsByWorktree, tabId) + if (chat?.contentType !== 'agent-session') { + renameOnTab() + return + } + const action = captureStructuredChatRename(get, chat) + void import('@/runtime/structured-chat-rename') + .then(({ renameStructuredChatTab }) => + renameStructuredChatTab(get, chat, title, opts, renameOnTab, action) + ) + .finally(action.finish) + }, setTabColor: (tabId, color, opts) => route( tabId, diff --git a/src/shared/agent-session-conversation-names.ts b/src/shared/agent-session-conversation-names.ts new file mode 100644 index 00000000000..21249669a3b --- /dev/null +++ b/src/shared/agent-session-conversation-names.ts @@ -0,0 +1,68 @@ +import { isAgentSessionConversationName } from './agent-session/agent-session-conversation-name' +import type { AgentSessionConversationName, AgentSessionStatusEvent } from './agent-session-wire' + +export type AgentSessionConversationNames = ReadonlyMap + +function isNamingNote(value: unknown): value is AgentSessionConversationName { + if (typeof value !== 'object' || value === null) { + return false + } + if ( + !('sessionId' in value) || + typeof value.sessionId !== 'string' || + !value.sessionId || + !('conversationName' in value) || + (value.conversationName !== null && !isAgentSessionConversationName(value.conversationName)) + ) { + return false + } + if (!('namingError' in value) || value.namingError === undefined) { + return true + } + const error = value.namingError + return ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' && + error.message.length > 0 && + error.message.length <= 1024 && + 'failedAt' in error && + typeof error.failedAt === 'number' && + Number.isFinite(error.failedAt) + ) +} + +/** Stream order is title order; metadata never asserts a turn or live observation. */ +export function foldAgentSessionConversationNames( + names: AgentSessionConversationNames, + event: AgentSessionStatusEvent +): AgentSessionConversationNames { + const sessions = + event.type === 'snapshot' ? event.sessions : event.type === 'status' ? [event.session] : [] + const notes = + event.type === 'snapshot' && Array.isArray(event.conversationNames) + ? event.conversationNames + : [] + let next: Map | undefined + for (const note of [...sessions, ...notes]) { + if (!isNamingNote(note)) { + continue + } + const previous = (next ?? names).get(note.sessionId) + if ( + previous?.conversationName === note.conversationName && + previous?.namingError?.message === note.namingError?.message && + previous?.namingError?.failedAt === note.namingError?.failedAt + ) { + continue + } + next ??= new Map(names) + next.set(note.sessionId, { + sessionId: note.sessionId, + conversationName: note.conversationName, + ...(note.namingError ? { namingError: note.namingError } : {}) + }) + } + return next ?? names +} diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 30257e980ae..7d28894c100 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -129,8 +129,13 @@ export type AgentSessionRecord = { permissionMode?: string rewind?: AgentSessionRewindRecord conversationCommand?: AgentSessionConversationCommandRecord - /** The name Orca gave this conversation, so a later acquisition need not name it again. */ + /** The conversation's one name: Orca's automatic name or the user's rename. Session History, + * tabs, sidebars and phones all read this field. */ conversationName?: string + /** `'user'` once the user renamed the conversation; absent means Orca named it. Automatic naming + * never replaces a user's name, and a window's pre-upgrade tab rename only fills a name Orca + * chose. */ + conversationNameSource?: 'user' /** When the user's Sleep stopped this chat; the next admitted send clears it. */ sleptAt?: number launchArgs?: AgentSessionLaunchArgs @@ -324,6 +329,7 @@ export function isPersistedAgentSessionRecord( isAgentSessionConversationCommandRecord(record.conversationCommand)) && (record.conversationName === undefined || isAgentSessionConversationName(record.conversationName)) && + (record.conversationNameSource === undefined || record.conversationNameSource === 'user') && (record.launchArgs === undefined || isAgentSessionLaunchArgs(record.launchArgs)) && !Object.hasOwn(record, 'launchEnv') && isPersistedAgentSessionLease(record.lease) && diff --git a/src/shared/agent-session-status-snapshot-fold.ts b/src/shared/agent-session-status-snapshot-fold.ts index 239a9a27cde..02714b103e4 100644 --- a/src/shared/agent-session-status-snapshot-fold.ts +++ b/src/shared/agent-session-status-snapshot-fold.ts @@ -10,6 +10,9 @@ export function foldAgentSessionStatusEvent( event: AgentSessionStatusEvent ): AgentSessionStatusSnapshot { if (event.type === 'snapshot') { + if (event.sessions.length === 0) { + return snapshot + } const next = new Map(snapshot) for (const session of event.sessions) { next.set(session.sessionId, session) @@ -53,3 +56,73 @@ export function revokeAgentSessionStatusLive( } return next ?? snapshot } + +/** A provider head is a query dependency, never a client-side ownership index. */ +export class AgentSessionAssociationObservation { + private readonly associations = new Map() + private confirmed = false + revision = 0 + + renew(): void { + this.confirmed = false + } + + observe(frame: unknown): boolean { + if (typeof frame !== 'object' || frame === null || !('type' in frame)) { + return false + } + let included: unknown[] + if (frame.type === 'snapshot' && 'sessions' in frame && Array.isArray(frame.sessions)) { + if ('namingActiveSessionIds' in frame) { + return false + } + included = frame.sessions + } else if (frame.type === 'status' && 'session' in frame) { + included = [frame.session] + } else { + return false + } + let changed = frame.type === 'snapshot' && !this.confirmed + for (const summary of included) { + if ( + typeof summary !== 'object' || + summary === null || + !('sessionId' in summary) || + typeof summary.sessionId !== 'string' || + !('agent' in summary) || + typeof summary.agent !== 'string' || + !('workspaceId' in summary) || + typeof summary.workspaceId !== 'string' + ) { + continue + } + const provider = 'providerSession' in summary ? summary.providerSession : undefined + if ( + provider !== undefined && + (typeof provider !== 'object' || + provider === null || + !('key' in provider) || + typeof provider.key !== 'string' || + !('id' in provider) || + typeof provider.id !== 'string') + ) { + continue + } + const tuple = JSON.stringify([ + summary.agent, + summary.workspaceId, + provider?.key, + provider?.id + ]) + changed ||= this.associations.get(summary.sessionId) !== tuple + this.associations.set(summary.sessionId, tuple) + } + if (frame.type === 'snapshot') { + this.confirmed = true + } + if (changed) { + this.revision += 1 + } + return changed + } +} diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 704dd5c46d7..b31db1ca6fe 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -258,9 +258,20 @@ export type AgentSessionStatusSummary = { /** `failedAt` dates the run, so a repeat failure with the same message still republishes. */ export type AgentSessionNamingError = { message: string; failedAt: number } +export type AgentSessionConversationName = { + sessionId: string + conversationName: string | null + namingError?: AgentSessionNamingError +} + /** Retain projections after child eviction; idle remains idle. Tabs decide list membership, never this feed. */ export type AgentSessionStatusEvent = - | { type: 'snapshot'; sessions: AgentSessionStatusSummary[]; namingActiveSessionIds?: string[] } + | { + type: 'snapshot' + sessions: AgentSessionStatusSummary[] + namingActiveSessionIds?: string[] + conversationNames?: AgentSessionConversationName[] + } | { type: 'status'; session: AgentSessionStatusSummary } | { type: 'end' } diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 98cd8be9dda..5b989c2cc3f 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -72,6 +72,7 @@ export { STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REGENERATE_NAME_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RENAME_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 81ad255a258..04840c9188b 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -584,6 +584,7 @@ import { QueuedMessageEditHoldParams, QueuedMessageUpdateParams } from './structured-agent-session-queued-edit-params' +import { RenameParams } from './structured-agent-session-rename-params' import { TerminalDaemonRestartSeveredParams } from './terminal-daemon-params' import { TerminalAdoptOrphans } from './terminal-orphan-params' import { TerminalQuickCommandsUpdate } from './terminal-quick-command-params' @@ -746,6 +747,7 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.readVisual': ReadVisualParams, 'agentSession.regenerateName': OptionsParams, 'agentSession.release': HoldParams, + 'agentSession.rename': RenameParams, 'agentSession.respondToApproval': RespondParams, 'agentSession.respondToQuestion': RespondToQuestionParams, 'agentSession.restartContinue': RestartResumeParams, diff --git a/src/shared/rpc-contract/structured-agent-session-rename-params.ts b/src/shared/rpc-contract/structured-agent-session-rename-params.ts new file mode 100644 index 00000000000..3d10bf0b614 --- /dev/null +++ b/src/shared/rpc-contract/structured-agent-session-rename-params.ts @@ -0,0 +1,11 @@ +// Split out of structured-agent-session-params.ts, which is at its line limit. + +import { z } from 'zod' +import { OptionsParams } from './structured-agent-session-params' + +// Why `legacyAlias`: a window uploading a tab rename it saved before names lived on the host must +// not overwrite a rename the user has made since. The host trims and bounds `name` itself. +export const RenameParams = OptionsParams.extend({ + name: z.string().min(1).max(4096), + legacyAlias: z.literal(true).optional() +}).strict() diff --git a/src/shared/rpc-contract/workspace-layout-params.ts b/src/shared/rpc-contract/workspace-layout-params.ts index af33a9c5114..c1c096fede8 100644 --- a/src/shared/rpc-contract/workspace-layout-params.ts +++ b/src/shared/rpc-contract/workspace-layout-params.ts @@ -144,7 +144,10 @@ const RenameTabCommand = z.discriminatedUnion('kind', [ type: z.literal('renameTab'), ...OnTab, kind: z.enum(['custom', 'generated']), - title: z.union([z.string(), z.null()]) + title: z.union([z.string(), z.null()]), + expected: z + .object({ entityId: z.string(), executionHostId: z.string(), customTitle: z.string() }) + .optional() }), z.object({ type: z.literal('renameTab'), diff --git a/src/shared/structured-agent-session-surface-capabilities.ts b/src/shared/structured-agent-session-surface-capabilities.ts index 831f01103f4..e5285cdb396 100644 --- a/src/shared/structured-agent-session-surface-capabilities.ts +++ b/src/shared/structured-agent-session-surface-capabilities.ts @@ -15,6 +15,9 @@ export const STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY = // Offer Regenerate name only to hosts implementing agentSession.regenerateName. export const STRUCTURED_AGENT_SESSION_REGENERATE_NAME_RUNTIME_CAPABILITY = 'agent-session.structured.regenerate-name.v1' as const +// Save names only on rename-capable hosts; older hosts retain the local tab alias. +export const STRUCTURED_AGENT_SESSION_RENAME_RUNTIME_CAPABILITY = + 'agent-session.structured.rename.v1' as const // Negotiate resumeFrom before sending: old strict create schemas reject it, while unproven acceptance could silently start a blank chat. export const STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY = 'agent-session.structured.resume-history.v1' as const @@ -44,5 +47,6 @@ export const STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES = [ STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY, AGENT_SESSION_MODEL_CATALOG_SAVED_ONLY_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_AVAILABILITY_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_REGENERATE_NAME_RUNTIME_CAPABILITY + STRUCTURED_AGENT_SESSION_REGENERATE_NAME_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RENAME_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/structured-chat-row-name.ts b/src/shared/structured-chat-row-name.ts index 5895cd478b0..25aa7041e7f 100644 --- a/src/shared/structured-chat-row-name.ts +++ b/src/shared/structured-chat-row-name.ts @@ -7,7 +7,7 @@ export function structuredChatDisplayName( return customLabel?.trim() || conversationName || fallback.trim() } -/** Only Claude/Codex history uses chat names; other agents retain CLI conversation titles. */ -export function historyRowShowsChatName(rowAgent: string): boolean { +/** Unnamed Claude/Codex rows use chat labels; other unnamed agents retain provider titles. */ +export function historyRowFallsBackToChatLabel(rowAgent: string): boolean { return rowAgent === 'claude' || rowAgent === 'codex' } diff --git a/src/shared/workspace-layout/workspace-layout-command-types.ts b/src/shared/workspace-layout/workspace-layout-command-types.ts index 12460a5b712..0e3d1e00722 100644 --- a/src/shared/workspace-layout/workspace-layout-command-types.ts +++ b/src/shared/workspace-layout/workspace-layout-command-types.ts @@ -272,7 +272,12 @@ export type NewGroupSplit = { export type TabTitle = /** A generated title replaces the tab's existing one only with `replaceExisting`: first wins. */ - | { kind: 'custom' | 'generated'; title: string | null; replaceExisting?: true } + | { + kind: 'custom' | 'generated' + title: string | null + replaceExisting?: true + expected?: { entityId: string; executionHostId: string; customTitle: string } + } | { kind: 'aiVault'; title: AiVaultSessionTitle | null } /** One tab's agent-generated title in a `renameTabs` batch. */ diff --git a/src/shared/workspace-layout/workspace-layout-tab-commands.ts b/src/shared/workspace-layout/workspace-layout-tab-commands.ts index 95eacf9dac5..3fe5b48418c 100644 --- a/src/shared/workspace-layout/workspace-layout-tab-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-tab-commands.ts @@ -175,23 +175,7 @@ function recordClosedTerminalTabs( return withWorkspace(model, key, { ...workspace, closedTerminalTabs }) } -export function renameTab(model: WorkspaceLayoutModel, command: CommandOf<'renameTab'>): Applied { - const tab = findTab(model.workspaces[command.workspace]!, command.tabId) - if (!tab) { - return refuse('tab_not_found') - } - if (command.kind === 'aiVault') { - return updateTab(model, command.workspace, { ...tab, aiVaultTitle: command.title }) - } - if (command.kind === 'custom') { - return updateTab(model, command.workspace, { ...tab, customTitle: command.title }) - } - // As the window's own generated titles: an agent's later prompt does not rename the tab. - if (tab.generatedTitle?.trim() && !command.replaceExisting) { - return refuse('generated_title_kept') - } - return updateTab(model, command.workspace, { ...tab, generatedTitle: command.title }) -} +export { renameTab } from './workspace-layout-tab-rename' export function setTabProps( model: WorkspaceLayoutModel, diff --git a/src/shared/workspace-layout/workspace-layout-tab-rename.ts b/src/shared/workspace-layout/workspace-layout-tab-rename.ts new file mode 100644 index 00000000000..9fb32bbbb46 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-tab-rename.ts @@ -0,0 +1,31 @@ +import { findTab, refuse, updateTab, type Applied } from './workspace-layout-command-steps' +import type { CommandOf } from './workspace-layout-command-types' +import type { WorkspaceLayoutModel } from './workspace-layout-model' + +export function renameTab(model: WorkspaceLayoutModel, command: CommandOf<'renameTab'>): Applied { + const tab = findTab(model.workspaces[command.workspace]!, command.tabId) + if (!tab) { + return refuse('tab_not_found') + } + if (command.kind === 'aiVault') { + return updateTab(model, command.workspace, { ...tab, aiVaultTitle: command.title }) + } + if (command.kind === 'custom') { + const expected = command.expected + if ( + expected && + (tab.kind !== 'agent-session' || + tab.entityId !== expected.entityId || + model.hostId !== expected.executionHostId || + tab.customTitle !== expected.customTitle) + ) { + return refuse('tab_not_found') + } + return updateTab(model, command.workspace, { ...tab, customTitle: command.title }) + } + // As the window's own generated titles: an agent's later prompt does not rename the tab. + if (tab.generatedTitle?.trim() && !command.replaceExisting) { + return refuse('generated_title_kept') + } + return updateTab(model, command.workspace, { ...tab, generatedTitle: command.title }) +} diff --git a/tests/e2e/ai-vault-manual-name-clear.unit.test.ts b/tests/e2e/ai-vault-manual-name-clear.unit.test.ts new file mode 100644 index 00000000000..139e8a427ad --- /dev/null +++ b/tests/e2e/ai-vault-manual-name-clear.unit.test.ts @@ -0,0 +1,345 @@ +// @vitest-environment happy-dom +import type * as StructuredSessionClient from '@/runtime/structured-agent-session-client' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { createElement, Fragment } from 'react' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../src/shared/agent-session-record.test-fixture' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../src/shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../src/shared/agent-session-mutation-envelope' +import type { AiVaultListResult, AiVaultSession } from '../../src/shared/ai-vault-types' +import { + projectStructuredAiVaultSessions, + OPENS_EVERY_STRUCTURED_CHAT +} from '../../src/main/ai-vault/structured-session-ownership' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore, + readPersistedTestAgentSessionStore +} from '../../src/main/runtime/agent-session-record-store-test-harness' +import { AgentSessionJournal } from '../../src/main/native-chat/agent-session-journal/journal-store' +import { + openTestJournalHostDatabase, + closeTestJournalHostDatabases +} from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +import { setStructuredAgentSessionHost } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { structuredAgentsDeclaringNothing } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import { recordingStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { createStructuredChatNamingHandler } from '../../src/main/native-chat/structured-chat-naming' +import { PI_RPC_AGENT } from '../../src/main/pi/rpc-agent-definition' +import type { AgentSessionStatusEvent } from '../../src/shared/agent-session-wire' +import { useAppStore } from '@/store' +import { + getStructuredAgentSessionStatusFeed, + resetStructuredAgentSessionStatusFeedsForTests +} from '@/runtime/structured-agent-session-status-feed' +import { + resetAiVaultForcedRescanThrottleForTest, + useAiVaultSessionRefresh +} from '../../src/renderer/src/components/right-sidebar/ai-vault-session-refresh' +import { aiVaultSessionRowResumeGating } from '../../src/renderer/src/components/right-sidebar/ai-vault-session-resume' +import { useAiVaultSessionDisplayTitle } from '../../src/renderer/src/components/right-sidebar/use-ai-vault-session-display-title' + +const mocks = vi.hoisted(() => ({ subscribe: vi.fn() })) +vi.mock('@/store', async () => { + const { createTestStore } = await import('@/store/slices/store-test-helpers') + return { useAppStore: createTestStore() } +}) +vi.mock('@/runtime/structured-agent-session-client', async (importOriginal) => ({ + ...(await importOriginal()), + subscribeStructuredAgentSessionStatus: mocks.subscribe +})) + +const SESSION = 'native-pi-history-clear' +const PROVIDER = '019fd622-3c4d-7e5f-a061-7c8d9e0f1a2b' +const FILE = `/sessions/2026-10-23T10-00-00-000Z_${PROVIDER}.jsonl` +const NAME = 'Budget plan' +const PATHS = ['/repo'] +const initialState = useAppStore.getInitialState() +let directory: string | null = null +let host: StructuredAgentSessionHost | null = null + +function HistoryRow({ session }: { session: AiVaultSession }) { + const title = useAiVaultSessionDisplayTitle(session) + return createElement( + 'div', + { + 'data-testid': 'history-row', + 'data-owner': session.structuredSession?.sessionId, + 'data-copy-resume': aiVaultSessionRowResumeGating(session, { blocked: false }) + .canCopyResumeCommand, + 'data-resume': session.resumeCommand + }, + title + ) +} +function History() { + const { sessions } = useAiVaultSessionRefresh(PATHS, 'local', 250) + return createElement( + Fragment, + null, + sessions.map((session) => createElement(HistoryRow, { key: session.id, session })) + ) +} + +async function disposeHistory() { + cleanup() + resetStructuredAgentSessionStatusFeedsForTests() + setStructuredAgentSessionHost(null) + await host?.flushAllStreamedEvents() + host = null + closeTestJournalHostDatabases() + if (directory) { + await rm(directory, { recursive: true, force: true }) + } + directory = null + useAppStore.setState(initialState, true) +} +afterEach(disposeHistory) + +it('detaches a mounted provider History row after clear without losing the native manual name', async () => { + for (const remount of [false, true]) { + resetAiVaultForcedRescanThrottleForTest() + resetStructuredAgentSessionStatusFeedsForTests() + directory = await mkdtemp(join(tmpdir(), 'orca-history-manual-clear-')) + const lease = agentSessionLeaseFixture({ + sessionId: SESSION, + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released' + }) + const record = { + ...agentSessionRecordFixture(lease), + provider: 'pi' as const, + accountHome: { variable: 'PI_CODING_AGENT_DIR' as const, path: directory }, + providerHandleChain: [ + { + linkId: 'pi-link', + origin: 'created' as const, + mintedAtFence: lease.runtimeFence, + observedAt: 1000, + handle: { transport: 'jsonl-rpc' as const, agent: 'pi' as const, nativeId: FILE } + } + ] + } + await seedTestAgentSessionRecordStore(directory, { records: [record] }) + const store = await openTestAgentSessionRecordStore(directory) + const database = openTestJournalHostDatabase(directory) + const journal = new AgentSessionJournal({ + database, + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'pi', + providerHandle: null + } + }) + await journal.open() + await journal.appendItem( + { provider: 'legacy', agent: 'pi', sessionId: FILE, recordId: 'completed-reply' }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Inventory checked' }] }, + { fence: lease.runtimeFence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.close() + const { logger } = recordingStructuredAgentSessionLogger() + const unexpectedLaunch = (): never => { + throw new Error('Clear must not launch a provider') + } + host = new StructuredAgentSessionHost({ + store, + journalDatabase: database, + logger, + agents: structuredAgentsDeclaringNothing([PI_RPC_AGENT]), + adapter: { + acquire: unexpectedLaunch, + open: unexpectedLaunch, + dispatch: unexpectedLaunch, + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => {}, + setOption: async () => {}, + closeSession: async () => true + }, + claimKeyId: 'key-1', + mintSpawnToken: () => 'unused', + probeOwner: async () => ({ outcome: 'pid-absent' }), + conversationNaming: createStructuredChatNamingHandler({ + getStore: () => store, + getSettings: () => ({}), + workerTaskTitle: () => null, + readFirstPrompt: async () => '', + readJournalFirstPrompt: async () => '', + generate: async () => null, + onNamed: () => host?.publishConversationName(SESSION), + logger + }) + }) + setStructuredAgentSessionHost(host) + await host.revealSession(SESSION) + expect(await host.setUserConversationName(SESSION, NAME, 'rename')).toEqual({ + name: NAME, + applied: true + }) + let emitFrame = (_event: AgentSessionStatusEvent): void => {} + const unsubscribe = vi.fn() + mocks.subscribe.mockImplementation(async (_target, emit) => { + emitFrame = emit + const stop = host?.subscribeStatus({ id: 'history-clear-test', emit }) + return { + unsubscribe: () => { + unsubscribe() + stop?.() + } + } + }) + const scanned: AiVaultListResult = { + scannedAt: 'unchanged-provider-scan', + issues: [], + sessions: [ + { + id: `local:pi:${PROVIDER}`, + executionHostId: 'local', + agent: 'pi', + sessionId: PROVIDER, + filePath: FILE, + title: 'Check inventory', + cwd: '/repo', + branch: null, + model: null, + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-10-23T10:00:00Z', + messageCount: 2, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: `pi --session ${FILE}`, + subagent: null + } + ] + } + const project = () => projectStructuredAiVaultSessions(scanned, OPENS_EVERY_STRUCTURED_CHAT) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + aiVault: { + listSessions: vi.fn(async () => project()), + cancelListSessions: vi.fn(async () => {}), + onWindowFocused: () => () => {} + } + } + }) + // History owns its feed even when no chat tab or pane projector exists. + let view = render(createElement(History)) + await waitFor(() => expect(screen.getByTestId('history-row').textContent).toBe(NAME)) + expect(screen.getByTestId('history-row').getAttribute('data-owner')).toBe(SESSION) + expect(screen.getByTestId('history-row').getAttribute('data-copy-resume')).toBe('false') + + const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' }) + const confirmed = feed.getSnapshot().get(SESSION)! + const revision = feed.getAssociationRevision() + const queries = window.api.aiVault.listSessions + const beforeChurn = vi.mocked(queries).mock.calls.length + for (const n of [10, 100]) { + await act(async () => { + for (let i = 0; i < n; i++) { + emitFrame({ + type: 'status', + session: { ...confirmed, updatedAt: i, latestPrompt: `${i}` } + }) + emitFrame({ + type: 'snapshot', + sessions: [], + namingActiveSessionIds: [], + conversationNames: [{ sessionId: SESSION, conversationName: NAME }] + }) + } + }) + expect(vi.mocked(queries).mock.calls).toHaveLength(beforeChurn) + expect(feed.getAssociationRevision()).toBe(revision) + } + const oldProjection = project() + if (remount) { + view.unmount() + expect(unsubscribe).toHaveBeenCalledOnce() + } + let settleOld = (_result: AiVaultListResult): void => {} + if (remount) { + vi.mocked(queries).mockImplementationOnce( + () => + new Promise((resolve) => { + settleOld = resolve + }) + ) + } + await act(async () => { + const command = 'clear' + const clearing = host?.conversationCommand( + { callerKey: 'desktop' }, + { + command, + envelope: { + sessionId: SESSION, + clientOperationId: `${Date.now()}-${'1'.repeat(32)}`, + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.conversationCommand', + sessionId: SESSION, + fields: { command } + }) + } + } + ) + if (remount) { + view = render(createElement(History)) + } + expect(await clearing).toMatchObject({ + ok: true, + value: { command: 'clear', state: 'completed' } + }) + }) + const beforeSettlement = vi.mocked(queries).mock.calls.length + if (remount) { + expect(screen.getByTestId('history-row').textContent).toBe(NAME) + await act(async () => settleOld(oldProjection)) + } + const saved = (await readPersistedTestAgentSessionStore(directory)).records[SESSION] + expect(saved).toMatchObject({ + conversationName: NAME, + conversationNameSource: 'user', + providerHandleChain: [] + }) + expect(project().sessions[0]).toMatchObject({ title: 'Check inventory' }) + expect(project().sessions[0]?.structuredSession).toBeUndefined() + const published = getStructuredAgentSessionStatusFeed({ kind: 'local' }) + .getSnapshot() + .get(SESSION) + expect(published).toMatchObject({ sessionId: SESSION, conversationName: NAME }) + expect(published?.providerSession).toBeUndefined() + await waitFor(() => { + expect(screen.getByTestId('history-row').textContent).toBe('Check inventory') + expect(screen.getByTestId('history-row').getAttribute('data-owner')).toBeNull() + expect(screen.getByTestId('history-row').getAttribute('data-copy-resume')).toBe('true') + expect(screen.getByTestId('history-row').getAttribute('data-resume')).toBe( + `pi --session ${FILE}` + ) + }) + expect(vi.mocked(queries).mock.calls).toHaveLength(beforeSettlement + (remount ? 1 : 0)) + expect(vi.mocked(queries).mock.calls.every(([args]) => args?.force !== true)).toBe(true) + view.unmount() + expect(unsubscribe).toHaveBeenCalledTimes(remount ? 2 : 1) + expect((await readPersistedTestAgentSessionStore(directory)).records[SESSION]).toMatchObject({ + conversationName: NAME, + conversationNameSource: 'user' + }) + await disposeHistory() + } +}) diff --git a/tests/e2e/cross-version-wire/agent-session-saved-name-old-client.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-saved-name-old-client.unit.test.ts new file mode 100644 index 00000000000..fe1c46adeef --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-saved-name-old-client.unit.test.ts @@ -0,0 +1,124 @@ +import { expect, it, vi } from 'vitest' +import { materializeReleaseCheckout, importReleaseCheckoutModule } from './release-checkout' +import { RpcDispatcher } from '../../../src/main/runtime/rpc/dispatcher' +import { AI_VAULT_METHODS } from '../../../src/main/runtime/rpc/methods/ai-vault' +import { SESSION_TAB_METHODS } from '../../../src/main/runtime/rpc/methods/session-tabs' +import { listResult } from '../../../src/main/ai-vault/structured-session-ownership.test-support' +import { + HOST_TEST_SESSION, + HOST_TEST_LOCATION +} from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { + WORKTREE, + createChat, + send, + runtime, + host, + store +} from '../../../src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture' + +// Cold CI may materialize the pinned release checkout before importing its client modules. +it('a released client reads the current saved name from tabs and each fresh History query', async () => { + const checkout = await materializeReleaseCheckout('v1.4.222') + const [tabs, history, protocol] = await Promise.all( + [ + 'mobile/src/session/mobile-session-read-operations.ts', + 'mobile/src/agent-history/mobile-agent-history-operations.ts', + 'mobile/src/transport/mobile-runtime-client-capabilities.ts' + ].map((path) => importReleaseCheckoutModule(checkout, path)) + ) + const capabilities = protocol.MOBILE_RUNTIME_CLIENT_CAPABILITIES + if ( + !Array.isArray(capabilities) || + !capabilities.every((value: unknown): value is string => typeof value === 'string') + ) { + throw new Error('Pinned released client capabilities missing') + } + expect(checkout.commit).toBe('4bb6f2072b07c1f0664b809551f77754700570e4') + // The fixture already installed the real host on its throwaway SQLite store. + vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockResolvedValue() + expect(await createChat(HOST_TEST_SESSION)).toMatchObject({ ok: true }) + expect(await send(HOST_TEST_SESSION, 'Begin the compatibility conversation')).toMatchObject({ + ok: true + }) + const providerId = store.getRecord(HOST_TEST_SESSION)?.providerHandleChain.at(-1)?.handle.nativeId + if (!providerId) { + throw new Error('Real host did not acquire provider identity') + } + const raw = listResult() + raw.sessions = [ + { + ...raw.sessions[0]!, + id: `local:codex:${providerId}`, + sessionId: providerId, + filePath: `/sessions/rollout-${providerId}.jsonl`, + resumeCommand: `codex resume '${providerId}'`, + title: 'Provider title' + } + ] + const scan = vi.spyOn(runtime, 'listAiVaultSessions').mockResolvedValue(raw) + const dispatcher = new RpcDispatcher({ + runtime, + methods: [...SESSION_TAB_METHODS, ...AI_VAULT_METHODS] + }) + const client = { + sendRequest: async (method: string, params: unknown) => + JSON.parse( + JSON.stringify( + await dispatcher.dispatch( + { id: method, authToken: 'token', method, params }, + { + clientKind: 'mobile', + clientCapabilities: capabilities + } + ) + ) + ) + } + const read = async (operation: unknown, params: unknown): Promise => { + if ( + !operation || + typeof operation !== 'object' || + !('request' in operation) || + typeof operation.request !== 'function' || + !('interpret' in operation) || + typeof operation.interpret !== 'function' + ) { + throw new Error('Released reader missing') + } + return operation.interpret(await operation.request(client, params)) + } + for (const name of ['Cold saved name', 'Renamed on serving host']) { + expect(await store.setUserConversationName(HOST_TEST_SESSION, name, 'rename')).toMatchObject({ + applied: true + }) + host.publishConversationName(HOST_TEST_SESSION) + expect(await read(tabs.sessionTabsListRead, { worktree: WORKTREE })).toMatchObject({ + tabs: [ + expect.objectContaining({ + type: 'agent-session', + sessionId: HOST_TEST_SESSION, + title: name + }) + ] + }) + expect(await read(history.agentHistorySessionScan, { scopePaths: ['/repo'] })).toMatchObject({ + sessions: [ + expect.objectContaining({ + title: name, + sessionId: providerId, + structuredSession: { + sessionId: HOST_TEST_SESSION, + workspaceId: HOST_TEST_LOCATION.workspaceId + } + }) + ] + }) + } + expect(scan).toHaveBeenCalledTimes(2) + expect(raw.sessions[0]?.title).toBe('Provider title') + expect(store.getRecord(HOST_TEST_SESSION)).toMatchObject({ + conversationName: 'Renamed on serving host', + conversationNameSource: 'user' + }) +}, 180_000) diff --git a/tests/e2e/cross-version-wire/release-checkout-publication.ts b/tests/e2e/cross-version-wire/release-checkout-publication.ts index 05e662c6a2f..684643aee89 100644 --- a/tests/e2e/cross-version-wire/release-checkout-publication.ts +++ b/tests/e2e/cross-version-wire/release-checkout-publication.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path' import { lock } from 'proper-lockfile' import { scavengeReleaseCheckoutStaging } from './release-checkout-tree.ts' -export const CHECKOUT_FORMAT = 8 +export const CHECKOUT_FORMAT = 9 export type CheckoutLockOptions = { realpath: false diff --git a/tests/e2e/cross-version-wire/release-checkout-tree.ts b/tests/e2e/cross-version-wire/release-checkout-tree.ts index 1c8a584fd60..a351431566c 100644 --- a/tests/e2e/cross-version-wire/release-checkout-tree.ts +++ b/tests/e2e/cross-version-wire/release-checkout-tree.ts @@ -22,7 +22,22 @@ const ARCHIVE_PATHS = [ 'mobile/src/worktree/agent-row-display.ts' ] // Why optional: a release may have either or both, and `git archive` refuses a missing pathspec. -const OPTIONAL_ARCHIVE_PATHS = ['src/relay', 'src/wsl-guest'] +const OPTIONAL_ARCHIVE_PATHS = [ + 'src/relay', + 'src/wsl-guest', + // Released phone read operations and their runtime decoding dependencies. + 'mobile/src/agent-history/agent-history-reply-schema.ts', + 'mobile/src/agent-history/mobile-agent-history-operations.ts', + 'mobile/src/session/mobile-session-read-operations.ts', + 'mobile/src/session/session-read-reply-schema.ts', + 'mobile/src/transport/mobile-runtime-client-capabilities.ts', + 'mobile/src/transport/request-single-flight.ts', + 'mobile/src/transport/rpc-acceptance-policies.ts', + 'mobile/src/transport/rpc-incompatible-reply-error.ts', + 'mobile/src/transport/rpc-operation-reply.ts', + 'mobile/src/transport/rpc-operation-result-reader.ts', + 'mobile/src/transport/rpc-operation.ts' +] const WORKSPACE_POLICY = 'pnpm-workspace.yaml' diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index afb7a8adbb2..b2ad9169ab8 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -77,6 +77,7 @@ export function structuredHostStub( readable: true })), regenerateConversationName: vi.fn(() => true), + setUserConversationName: vi.fn(async () => ({ name: 'Release notes', applied: true })), hold: vi.fn(async () => undefined), release: vi.fn(() => undefined), respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts index c0447fc1734..8af3c0315f0 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts @@ -164,6 +164,13 @@ export const STRUCTURED_CALLS: { hostMethod: 'regenerateConversationName', result: { accepted: true } }, + // Save the user's chat name. Clients call it only on a host advertising + // `agent-session.structured.rename.v1`; an older host answers `method_not_found`. + { + method: 'agentSession.rename', + hostMethod: 'setUserConversationName', + result: { name: 'Release notes', applied: true } + }, { method: 'agentSession.release', hostMethod: null, result: { released: true } }, { method: 'agentSession.history', diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index 40ef2a1949b..ce631c76e68 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -189,6 +189,8 @@ export function paramsFor(method: string): unknown { return { agent: 'codex', sessionId: SESSION } case 'agentSession.readVisual': return { sessionId: SESSION, file: 'usage-chart.html' } + case 'agentSession.rename': + return { sessionId: SESSION, name: 'Release notes' } case 'agentSession.hold': case 'agentSession.release': return { sessionId: SESSION, holderId: 'surface-1' }