mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+511
-363
File diff suppressed because it is too large
Load Diff
+526
-125
@@ -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.
|
||||
|
||||
@@ -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
|
||||
])
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+29
-2
@@ -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')
|
||||
|
||||
+14
-3
@@ -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>()
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
+1
@@ -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
-2
@@ -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', () => {
|
||||
|
||||
+9
-4
@@ -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' }
|
||||
|
||||
+41
-5
@@ -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])(
|
||||
|
||||
+3
-13
@@ -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
|
||||
}
|
||||
+97
@@ -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
Reference in New Issue
Block a user