Chat renames are saved as the chat's name, so Session History shows them after the tab closes (#31602)

* Chat renames are saved as the chat's name, so Session History shows them after the tab closes

Renaming a native chat tab only changed that window's tab. Session History, phones and other windows read the host's saved name, so the rename vanished from History once the tab closed or Orca restarted.

A rename now goes to the host that owns the chat (agentSession.rename) and is saved as the chat's one name, the field History, tabs, sidebars and phones already read. The record remembers that the user chose it, so automatic naming never replaces it. A window's pre-upgrade tab rename is uploaded once and only fills a name Orca chose. Hosts without the new capability keep the old tab-only rename. Every native agent's History row now shows the chat's name once it has one.

* Keep saved chat names consistent across readers and reconnects

* Carry qualified chat names into cached readers

* Reuse the history recorder context across platforms

* Use recorded chat ownership for legacy name transfer

* Transfer legacy chat names only from known catalog ownership

* Correct recorder path matching and History subscription order

* Require complete evidence for bridged parity exclusions

* Preserve sparse array lengths in complete recording comparisons

* Initialize History observation before loading workspace context

* Integrate measured History recordings and scoped parity evidence

* fix chat naming retries and certify both replay modes

* fix ambiguous chat rename labels after navigation

* preserve chat rename intent across tab updates

* type dashboard snapshot fixtures without assertions

* fix: align chat name observation with store contracts

* test: update chat name consumer fixtures

* test: distinguish sender naming metadata from status

* Preserve saved Activity chat names across tab focus

* Clarify Activity tab projection identity

* Remove package-manager lockfile churn from main merge

* fix(runtime): validate tab property edits before saving chat names

* fix: refresh History ownership after conversation clear

* fix: commit History request identity before refresh effects

* Fix History scan scope typing and integration test placement

* test: cover saved chat name reader contracts

* test: make chat naming fixtures deterministic
This commit is contained in:
Brennan Benson
2026-10-25 16:20:29 -07:00
committed by GitHub
parent cffb527b82
commit 3aec926fe5
148 changed files with 9017 additions and 2372 deletions
@@ -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"]
}
}
]
}
@@ -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"]
}
}
]
}
@@ -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"]
}
}
]
}
@@ -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"]
}
}
]
}
@@ -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"]
}
}
]
}
File diff suppressed because it is too large Load Diff
@@ -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"]
}
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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"]
}
}
]
}
@@ -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(
@@ -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<RpcClient['sendRequest']>(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<RpcClient['subscribe']>((_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<typeof useMobileAgentHistoryState> | 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<void> | 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<RpcClient['sendRequest']>(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)
})
@@ -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<RpcClient['sendRequest']>(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<RpcClient['subscribe']>((_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<RpcResponse>(() => {})
),
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',
@@ -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<AgentHistoryScreenState, { kind: 'ready' }>
| (Extract<AgentHistoryScreenState, { kind: 'ready' }> & { 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<AiVaultScope>('workspace')
const [screenState, setScreenState] = useState<AgentHistoryScreenState>({ kind: 'loading' })
const [screenState, setScreenState] = useState<FetchedHistoryState>({ 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<void> => {
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<AgentHistoryScreenState>(() => {
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,
@@ -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<RpcClient, MobileStructuredSessionStatusFeed>()
@@ -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
}
}
@@ -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<BridgePortPair<RpcClient>, '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<string, number>()
let observedAt = -1
const aliases = new Map<string, string>()
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
)
})
})
})
})
)
}
@@ -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(
[]
)
@@ -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'
@@ -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')
})
@@ -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<Partial<Record<BridgedParityClass, string>>> = {
'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<Partial<Record<BridgedParityCla
'while a same-turn `subscribe` payload publishes one delivery later'
}
// Pin exact totals in both directions, including identical; one exclusion class shrinking can otherwise hide another growing.
// Pin strict History verdicts and unchanged legacy counts in both directions.
export const BRIDGED_PARITY_BASELINE: Readonly<Record<BridgedParityClass | 'identical', number>> = {
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'
]
}
@@ -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<typeof absentResultExplainsResiduals>[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['checkpoints'][number]['observation']>
): 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<RpcClient>[] = []
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' }] })
@@ -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: (?<method>.+)#(?<occurrence>\d+)$/
function isKeyedObject(value: unknown): value is Record<string, unknown> {
export function isKeyedObject(value: unknown): value is Record<string, unknown> {
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.
@@ -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<RpcClient>[]
}
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
}
@@ -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<unknown>
}>('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<typeof useHistory> } = {}
@@ -21,6 +21,8 @@ export type RecordingDifference = {
path: string
expected: RecordedValue
actual: RecordedValue
expectedPresent?: boolean
actualPresent?: boolean
}
export type GroupedDifference = Omit<RecordingDifference, 'checkpoint'> & { 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<string, number>()
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<RecordingDifference, 'checkpoint' | 'field'>[] {
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<RecordingDifference, 'checkpoint' | 'field'>[] = []
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) {
@@ -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, string>): 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)
@@ -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 {
@@ -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<RpcClient>[]
}
type Replay = BridgedReplay
const counts: Record<BridgedParityClass, number> = {
'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`)
}
@@ -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}`,
@@ -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.
+57 -17
View File
@@ -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<typeof setTimeout> | 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<T extends { release?: () => void }>(entries: Map<string, T>, 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 {
@@ -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<string, Authority>()
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
])
})
}
+217 -3
View File
@@ -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<AiVaultListResult>)[] = []
const sender = Object.assign(new EventEmitter(), {
id: 91,
isDestroyed: () => false,
send: vi.fn(() => {
forwarding.push(() => list('runtime:a'))
})
})
let callbacks: Parameters<typeof subscribeRuntimeEnvironment>[5] | undefined
mocks.subscribe.mockImplementation(
async (...args: Parameters<typeof subscribeRuntimeEnvironment>) => {
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)
})
+57 -34
View File
@@ -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<RuntimeAiVaultHostInfo> }
let scanCoordinator = new AiVaultScanCoordinator()
let handlerOptions: AiVaultHandlerOptions = {}
const listCancellations = createSenderScopedRequestCancellations()
@@ -87,6 +96,19 @@ async function listAiVaultSessions(
options: { signal?: AbortSignal } = {}
): Promise<AiVaultListResult> {
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<AiVaultListResult> {
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<AiVaultListResult> {
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<AiVaultListResult> {
// 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.
@@ -269,6 +269,7 @@ export function registerCoreHandlers(
ensureStructuredSessionOwnership
})
registerAiVaultHandlers({
getUserDataPath: () => app.getPath('userData'),
ensureStructuredSessionOwnership,
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths,
prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume,
@@ -25,14 +25,23 @@ export function registerRuntimeEnvironmentSubscriptionHandlers(args: {
getUserDataPath: () => string
remoteRuntimeSubscriptions: Map<string, RetainedRemoteRuntimeSubscription>
pendingSubscriptions: Map<string, PendingRuntimeSubscription>
createAssociationObserver?: (
environmentId: string,
identity: string
) => { observe: (frame: unknown) => void; dispose: () => void }
recordLayoutFrame: (
environmentId: string,
method: string,
response: RuntimeRpcResponse<unknown>
) => 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)
}
@@ -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,
@@ -22,7 +22,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: {
now: () => number
currentWork: StructuredAgentSessionSubagentRowsDeps['currentWork']
deps: () => {
store: Pick<AgentSessionRecordStore, 'getRecord' | 'replacedRuntime'>
store: Pick<AgentSessionRecordStore, 'getRecord' | 'replacedRuntime' | 'listRecords'>
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),
@@ -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>
}
@@ -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
@@ -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')
@@ -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<string, StatusFeedSession>
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 } : {})
}
}
@@ -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<string, StructuredAgentSessionStatusSubscriber>()
// 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<string, StatusPublication>()
private readonly acceptedSends = new Map<string, string>()
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)
}
@@ -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<AgentSessionStatusSummary, 'conversationName' | 'namingError'> {
@@ -107,3 +111,12 @@ export function renamedStructuredStatusSummary(
}
return structuredStatusSummariesEqual(previous, summary) ? null : summary
}
export function retainedStructuredStatusSummaries(
publications: Iterable<StatusPublication>,
deps: StructuredAgentSessionStatusFeedDeps
): AgentSessionStatusSummary[] {
return [...publications].map(
({ summary }) => renamedStructuredStatusSummary(summary, deps, summary.sessionId) ?? summary
)
}
@@ -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<StructuredChatNameGeneration>()
@@ -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<StructuredChatNameGeneration>()
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<string>()
+26 -5
View File
@@ -31,7 +31,7 @@ export type StructuredChatNameGeneration =
export type StructuredChatNamingDeps = {
getStore: () => Pick<
AgentSessionRecordStore,
'getRecord' | 'compareAndSetConversationName'
'getRecord' | 'compareAndSetConversationName' | 'setUserConversationName'
> | null
getSettings: () => Pick<GlobalSettings, 'nativeChatAutoName'>
readFirstPrompt: (sessionId: string, hostStartedAt: number) => Promise<string>
@@ -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)
@@ -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<AgentSessionRecord>,
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<AgentSessionRecord | null> {
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
@@ -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)
@@ -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 })
}
})
@@ -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
@@ -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' }] }
})
})
@@ -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
}
})
]
@@ -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
@@ -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) => {
@@ -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 () => {
@@ -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,
@@ -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()
}
})
@@ -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)
}
@@ -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<Props extends { customTitle?: string | null }>(
snapshot: RuntimeMobileSessionTabsSnapshot | undefined,
tabId: string,
props: Props,
installHost: () => Promise<void>
): Promise<Props> {
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 }
}
@@ -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()
@@ -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: {
@@ -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: {},
@@ -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',
@@ -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()] })
@@ -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<string, Map<string, ExecutionHostId | null>>
// Cache derived tab context by stable source identity so agent-session/missing-tab rows can reuse pane builds.
const agentSessionTerminalTabs = new WeakMap<Tab, TerminalTab>()
// Key derived tab context by original native tab and saved name so unchanged pane builds retain identity.
const agentSessionTerminalTabs = new WeakMap<Tab, { name: string | null; tab: TerminalTab }>()
const attributedTabContexts = new WeakMap<AgentStatusEntry, ActivityTabContext | null>()
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<string, TerminalTab[]>,
unifiedTabsByWorktree?: Record<string, Tab[]>
unifiedTabsByWorktree: Record<string, Tab[]> | undefined,
names: StructuredChatTabNames
): Map<string, ActivityTabContext> {
const contexts = new Map<string, ActivityTabContext>()
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
@@ -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(),
@@ -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<string, AgentStatusEntry>
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
migrationUnsupportedByPtyId?: Record<string, MigrationUnsupportedPtyEntry>
@@ -58,7 +60,11 @@ export function buildActivityEvents(
const events: ActivityEvent[] = []
const paneEntryByPaneKey: Record<string, AgentStatusEntry> = {}
const seenEventIds = new Set<string>()
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<string, ActivityEventOwner>()
const liveAgentByPaneKey: Record<string, ActivityLiveAgentSnapshot> = {}
@@ -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<string, Tab[]>
@@ -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
@@ -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)
@@ -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<typeof getWorktreeMapFromState>
repoMap: ReturnType<typeof getRepoMapFromState>
@@ -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<ReturnType<typeof createActivityEventBuildCache>>(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(
@@ -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', () => {
@@ -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', () => {
@@ -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')] },
@@ -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>): 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<DashboardSnapshotState>): 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', () => {
@@ -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<AppState, 'runtimeEnvironments' | 'sshTargetLabels' | 'unifiedTabsByWorktree'>
>
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 } : {})
@@ -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')
})
})
@@ -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<number, string> | undefined
paneTitles: Record<number, string> | 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,
@@ -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
}))
@@ -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
@@ -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
@@ -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,
@@ -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' }
@@ -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<string, AgentSessionStatusSummary>
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(<NativeChatAgentMessageSenders from={source()} chatWorktreeId={RECIPIENT} />)
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])(
@@ -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()
}
)
@@ -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])
}
@@ -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
}
@@ -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<void> {
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)
})
@@ -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')
})
@@ -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<void> => {
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)
@@ -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)
}
}
}
@@ -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<string, (event: AgentSessionStatusEvent) => 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)
})
@@ -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<SearchPage | null>(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 {
@@ -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(
@@ -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(
<WorkspaceAttachmentOrigins
workspace={workspace}
@@ -188,7 +194,7 @@ describe('reference session associations', () => {
/>
)
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(
@@ -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 ||
@@ -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
}
@@ -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 },
@@ -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<string, Tab>,
dirtyFileIds: ReadonlySet<string>,
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
})
)
@@ -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)
}
@@ -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<string, boolean>,
statusByRelativePath: Map<string, string>
statusByRelativePath: Map<string, string>,
chatNames: StructuredChatTabNames
): string {
return items
.map((item) =>
getTabLayoutSignature(item, {
generatedTitlesEnabled,
chatNames,
isExpanded: expandedPaneByTabId[item.id] === true,
status:
item.type === 'editor'

Some files were not shown because too many files have changed in this diff Show More