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' }