@cloudcli-ai/cloudcli 1.37.2 → 1.37.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +269 -0
- package/dist/assets/{abnfDiagram-N423BO3Z-C4UAoHJb.js → abnfDiagram-N423BO3Z-Dmkwx38H.js} +1 -1
- package/dist/assets/{arc-B8jF-ja9.js → arc-DLe9AYuO.js} +1 -1
- package/dist/assets/{architectureDiagram-T3A2C74G-C_Wur_CU.js → architectureDiagram-T3A2C74G-CBbbbNUW.js} +1 -1
- package/dist/assets/{blockDiagram-VBNYF7ZC-Cq4P1i8t.js → blockDiagram-VBNYF7ZC-CaMEaWu4.js} +1 -1
- package/dist/assets/{c4Diagram-5PPSVZJV-C1wG4eUj.js → c4Diagram-5PPSVZJV-DhjMgNnR.js} +1 -1
- package/dist/assets/channel-CWrmwsir.js +1 -0
- package/dist/assets/{chunk-2GRJ4B5K-B8fnBG6E.js → chunk-2GRJ4B5K-yjl3-F-i.js} +1 -1
- package/dist/assets/{chunk-2Q5K7J3B-WdfPooPJ.js → chunk-2Q5K7J3B-DsAYaWEP.js} +1 -1
- package/dist/assets/{chunk-5RXB4S5H-DhZJuG2E.js → chunk-5RXB4S5H-Bt5qmqOX.js} +1 -1
- package/dist/assets/{chunk-5VM5RSS4-B0rjslal.js → chunk-5VM5RSS4-yWrWVvUt.js} +1 -1
- package/dist/assets/{chunk-6Q2QTUOP-CG2Oj4jL.js → chunk-6Q2QTUOP-Dt66K5xb.js} +1 -1
- package/dist/assets/{chunk-GF5L2VYU-rrl2lRxY.js → chunk-GF5L2VYU-DA0vzaj4.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-CjVbrVMA.js → chunk-JWPE2WC7-CUkbx96V.js} +1 -1
- package/dist/assets/{chunk-KBJHAD2P-CRzKgU8S.js → chunk-KBJHAD2P-DWKxx1WH.js} +1 -1
- package/dist/assets/{chunk-RYQCIY6F-DBz4GTZF.js → chunk-RYQCIY6F-BqluplN0.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-wHxalsXk.js → chunk-XXDRQBXY-DelKg3Xf.js} +1 -1
- package/dist/assets/classDiagram-JCYQIIEL-COAGCC6w.js +1 -0
- package/dist/assets/classDiagram-v2-OCEON4UE-COAGCC6w.js +1 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-s1hygY8e.js → cose-bilkent-JH36ORCC-DHV93-Em.js} +1 -1
- package/dist/assets/{cynefin-VYW2F7L2-C4LGV15i.js → cynefin-VYW2F7L2-B9HZgoGX.js} +1 -1
- package/dist/assets/{cynefinDiagram-MW4NZA55-C5uhu7B3.js → cynefinDiagram-MW4NZA55-hFjjEQg8.js} +1 -1
- package/dist/assets/{dagre-VZM6K2ZE-B-OBTewm.js → dagre-VZM6K2ZE-CpeVOC7V.js} +1 -1
- package/dist/assets/{diagram-7IWD3JNH-BjGKQxih.js → diagram-7IWD3JNH-D0H1UoRA.js} +1 -1
- package/dist/assets/{diagram-B4RE2ZJO-BMKz1X2L.js → diagram-B4RE2ZJO-6Y7BQUup.js} +1 -1
- package/dist/assets/{diagram-LBJQPF4R-DNh-GhKC.js → diagram-LBJQPF4R-C0nNOwht.js} +1 -1
- package/dist/assets/{diagram-Q27KOJAE-BaTG9jPT.js → diagram-Q27KOJAE-En6QlOVX.js} +1 -1
- package/dist/assets/{diagram-UB23O5K3-BUwo2OvJ.js → diagram-UB23O5K3-CmPiNeGD.js} +1 -1
- package/dist/assets/{ebnfDiagram-BXEA7PRR-DAZgfzX2.js → ebnfDiagram-BXEA7PRR-Bygzp1Qt.js} +1 -1
- package/dist/assets/{erDiagram-JOGREHBK-1w1CcGEb.js → erDiagram-JOGREHBK-CwScRfzl.js} +1 -1
- package/dist/assets/{flowDiagram-UKHOOZJN-D4EII8Jj.js → flowDiagram-UKHOOZJN-bKX-0xP6.js} +1 -1
- package/dist/assets/{ganttDiagram-PKOTCBZU-ChvJZAgB.js → ganttDiagram-PKOTCBZU-DtZPKXGd.js} +1 -1
- package/dist/assets/{gitGraphDiagram-DS77QQ5N-DtOn41S8.js → gitGraphDiagram-DS77QQ5N-CUOtdL1o.js} +1 -1
- package/dist/assets/index-CdjIFxM2.js +5004 -0
- package/dist/assets/index-DgafhGTF.css +32 -0
- package/dist/assets/{infoDiagram-6WML65LV-DN_PVEJZ.js → infoDiagram-6WML65LV-ciRQTS0h.js} +1 -1
- package/dist/assets/{ishikawaDiagram-WSZJBQD7-DbkF1kXb.js → ishikawaDiagram-WSZJBQD7-CgjhQZ84.js} +1 -1
- package/dist/assets/{journeyDiagram-NVQOT4AX-pJwmhddE.js → journeyDiagram-NVQOT4AX-BsbQSeIW.js} +1 -1
- package/dist/assets/{kanban-definition-27J2QSJJ-PPAPgNp5.js → kanban-definition-27J2QSJJ-CrwzY9Jw.js} +1 -1
- package/dist/assets/{linear-iPR-J9f7.js → linear-BUaCDr6l.js} +1 -1
- package/dist/assets/{mermaid.core-BEUL86Z6.js → mermaid.core-ClgdUU0j.js} +5 -5
- package/dist/assets/{mindmap-definition-FAOFIHXS-BvZoBib7.js → mindmap-definition-FAOFIHXS-BictljY1.js} +1 -1
- package/dist/assets/{pegDiagram-VL7TDLO6-DhYreaUd.js → pegDiagram-VL7TDLO6-BXdByy_U.js} +1 -1
- package/dist/assets/{pieDiagram-7S7Q4E2Y-CrOHlyD0.js → pieDiagram-7S7Q4E2Y-uZsMfkRX.js} +1 -1
- package/dist/assets/{quadrantDiagram-CIZ2JOQS-D4g5yH3t.js → quadrantDiagram-CIZ2JOQS-7lCv4QIj.js} +1 -1
- package/dist/assets/{railroadDiagram-AXF67PYL-Dj39lHlf.js → railroadDiagram-AXF67PYL-07dxIWbi.js} +1 -1
- package/dist/assets/{requirementDiagram-LRYGKXZP-1AS4Zgm_.js → requirementDiagram-LRYGKXZP-Cb31Yv1_.js} +1 -1
- package/dist/assets/{sankeyDiagram-W5VNT64P-CKlMn19P.js → sankeyDiagram-W5VNT64P-X0YPSyF9.js} +1 -1
- package/dist/assets/{sequenceDiagram-SI44F4Z6-B_j3qyvo.js → sequenceDiagram-SI44F4Z6-BdBxSIlP.js} +1 -1
- package/dist/assets/server.browser-CtX3Hdl5.js +27 -0
- package/dist/assets/{sizeCapture-X5ZJPWSS-Bp2Op16v.js → sizeCapture-X5ZJPWSS-DQM5xAyD.js} +1 -1
- package/dist/assets/{stateDiagram-OKZ733FA-C0cN6DoY.js → stateDiagram-OKZ733FA-DJ3xkLUF.js} +1 -1
- package/dist/assets/stateDiagram-v2-UEYNNEHI-vAaE4zNo.js +1 -0
- package/dist/assets/{swimlanes-SLNWSIFB-BEvXOV9b.js → swimlanes-SLNWSIFB-rjfYdW0w.js} +2 -2
- package/dist/assets/swimlanesDiagram-ULZ7WXOC-TV2IF4Nq.js +8 -0
- package/dist/assets/{timeline-definition-Z64GVDOM-zdJQinvZ.js → timeline-definition-Z64GVDOM-DZMra5do.js} +1 -1
- package/dist/assets/{vendor-codemirror-OwyKSvPE.js → vendor-codemirror-CYrSt7-U.js} +18 -18
- package/dist/assets/{vennDiagram-T6HMQDX7-DuF1uZMY.js → vennDiagram-T6HMQDX7-BevD8uu0.js} +1 -1
- package/dist/assets/{wardleyDiagram-T6FBY63Y-B9AlLQup.js → wardleyDiagram-T6FBY63Y-DsIyXB7z.js} +1 -1
- package/dist/assets/{xychartDiagram-ELKLHX3M-DTJX48Qg.js → xychartDiagram-ELKLHX3M-G3PlF3ko.js} +1 -1
- package/dist/index.html +3 -3
- package/dist-server/server/index.js +11 -4
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/modules/agent/agent.module.js +9 -2
- package/dist-server/server/modules/agent/agent.module.js.map +1 -1
- package/dist-server/server/modules/agent/agent.routes.js +177 -130
- package/dist-server/server/modules/agent/agent.routes.js.map +1 -1
- package/dist-server/server/modules/agent/tests/agent-run-registration.test.js +306 -0
- package/dist-server/server/modules/agent/tests/agent-run-registration.test.js.map +1 -0
- package/dist-server/server/modules/agent/tests/agent.routes.test.js +48 -2
- package/dist-server/server/modules/agent/tests/agent.routes.test.js.map +1 -1
- package/dist-server/server/modules/browser-use/browser-use.routes.js +39 -33
- package/dist-server/server/modules/browser-use/browser-use.routes.js.map +1 -1
- package/dist-server/server/modules/database/connection.js +1 -1
- package/dist-server/server/modules/database/connection.js.map +1 -1
- package/dist-server/server/modules/database/index.js +5 -0
- package/dist-server/server/modules/database/index.js.map +1 -1
- package/dist-server/server/modules/database/migrations.js +45 -1
- package/dist-server/server/modules/database/migrations.js.map +1 -1
- package/dist-server/server/modules/database/repositories/provider-models.js +60 -6
- package/dist-server/server/modules/database/repositories/provider-models.js.map +1 -1
- package/dist-server/server/modules/database/repositories/scheduled-messages.db.js +71 -0
- package/dist-server/server/modules/database/repositories/scheduled-messages.db.js.map +1 -0
- package/dist-server/server/modules/database/repositories/session-drafts.db.js +105 -0
- package/dist-server/server/modules/database/repositories/session-drafts.db.js.map +1 -0
- package/dist-server/server/modules/database/repositories/sessions.db.js +144 -5
- package/dist-server/server/modules/database/repositories/sessions.db.js.map +1 -1
- package/dist-server/server/modules/database/repositories/user-preferences.db.js +65 -0
- package/dist-server/server/modules/database/repositories/user-preferences.db.js.map +1 -0
- package/dist-server/server/modules/database/schema.js +99 -0
- package/dist-server/server/modules/database/schema.js.map +1 -1
- package/dist-server/server/modules/database/tests/provider-models.db.integration.test.js +99 -0
- package/dist-server/server/modules/database/tests/provider-models.db.integration.test.js.map +1 -1
- package/dist-server/server/modules/database/tests/sessions.db.integration.test.js +39 -0
- package/dist-server/server/modules/database/tests/sessions.db.integration.test.js.map +1 -1
- package/dist-server/server/modules/database/tests/user-preferences.db.integration.test.js +105 -0
- package/dist-server/server/modules/database/tests/user-preferences.db.integration.test.js.map +1 -0
- package/dist-server/server/modules/file-tree/file-tree.module.js +2 -1
- package/dist-server/server/modules/file-tree/file-tree.module.js.map +1 -1
- package/dist-server/server/modules/file-tree/file-tree.service.js +44 -7
- package/dist-server/server/modules/file-tree/file-tree.service.js.map +1 -1
- package/dist-server/server/modules/file-tree/tests/file-tree.service.test.js +126 -1
- package/dist-server/server/modules/file-tree/tests/file-tree.service.test.js.map +1 -1
- package/dist-server/server/modules/git/git-branch-diff.service.js +142 -0
- package/dist-server/server/modules/git/git-branch-diff.service.js.map +1 -0
- package/dist-server/server/modules/git/git-branch.service.js.map +1 -1
- package/dist-server/server/modules/git/git.routes.js +92 -5
- package/dist-server/server/modules/git/git.routes.js.map +1 -1
- package/dist-server/server/modules/git/tests/git-branch-diff.service.test.js +160 -0
- package/dist-server/server/modules/git/tests/git-branch-diff.service.test.js.map +1 -0
- package/dist-server/server/modules/projects/project-clone.routes.js +108 -0
- package/dist-server/server/modules/projects/project-clone.routes.js.map +1 -0
- package/dist-server/server/modules/projects/projects.routes.js +13 -66
- package/dist-server/server/modules/projects/projects.routes.js.map +1 -1
- package/dist-server/server/modules/projects/services/project-clone-request.service.js +36 -0
- package/dist-server/server/modules/projects/services/project-clone-request.service.js.map +1 -0
- package/dist-server/server/modules/projects/services/project-clone.service.js +86 -32
- package/dist-server/server/modules/projects/services/project-clone.service.js.map +1 -1
- package/dist-server/server/modules/projects/services/project-delete.service.js +12 -1
- package/dist-server/server/modules/projects/services/project-delete.service.js.map +1 -1
- package/dist-server/server/modules/projects/services/project-star.service.js +2 -2
- package/dist-server/server/modules/projects/services/project-star.service.js.map +1 -1
- package/dist-server/server/modules/projects/tests/project-clone.routes.test.js +191 -0
- package/dist-server/server/modules/projects/tests/project-clone.routes.test.js.map +1 -0
- package/dist-server/server/modules/projects/tests/project-clone.service.test.js +275 -0
- package/dist-server/server/modules/projects/tests/project-clone.service.test.js.map +1 -1
- package/dist-server/server/modules/providers/index.js +3 -0
- package/dist-server/server/modules/providers/index.js.map +1 -1
- package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js +62 -9
- package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/claude/claude-fork.provider.js +46 -0
- package/dist-server/server/modules/providers/list/claude/claude-fork.provider.js.map +1 -0
- package/dist-server/server/modules/providers/list/claude/claude-models.provider.js +31 -12
- package/dist-server/server/modules/providers/list/claude/claude-models.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js +472 -53
- package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js +38 -11
- package/dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js +1007 -60
- package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/claude/claude-skills.provider.js +4 -1
- package/dist-server/server/modules/providers/list/claude/claude-skills.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/claude/claude.provider.js +2 -0
- package/dist-server/server/modules/providers/list/claude/claude.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/codex/codex-app-server.client.js +204 -0
- package/dist-server/server/modules/providers/list/codex/codex-app-server.client.js.map +1 -0
- package/dist-server/server/modules/providers/list/codex/codex-fork.provider.js +32 -0
- package/dist-server/server/modules/providers/list/codex/codex-fork.provider.js.map +1 -0
- package/dist-server/server/modules/providers/list/codex/codex-models.provider.js +47 -0
- package/dist-server/server/modules/providers/list/codex/codex-models.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/codex/codex-runtime.provider.js +71 -53
- package/dist-server/server/modules/providers/list/codex/codex-runtime.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js +8 -0
- package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js +1621 -431
- package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/codex/codex.provider.js +2 -0
- package/dist-server/server/modules/providers/list/codex/codex.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/cursor/cursor-auth.provider.js +9 -2
- package/dist-server/server/modules/providers/list/cursor/cursor-auth.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/cursor/cursor-runtime.provider.js +16 -8
- package/dist-server/server/modules/providers/list/cursor/cursor-runtime.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +274 -9
- package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/opencode/opencode-runtime.provider.js +5 -2
- package/dist-server/server/modules/providers/list/opencode/opencode-runtime.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +19 -2
- package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -1
- package/dist-server/server/modules/providers/provider.routes.js +134 -2
- package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
- package/dist-server/server/modules/providers/services/claude-cli-liveness.service.js +100 -0
- package/dist-server/server/modules/providers/services/claude-cli-liveness.service.js.map +1 -0
- package/dist-server/server/modules/providers/services/claude-settings-permissions.service.js +97 -0
- package/dist-server/server/modules/providers/services/claude-settings-permissions.service.js.map +1 -0
- package/dist-server/server/modules/providers/services/provider-capabilities.service.js +23 -1
- package/dist-server/server/modules/providers/services/provider-capabilities.service.js.map +1 -1
- package/dist-server/server/modules/providers/services/provider-models.service.js +59 -5
- package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -1
- package/dist-server/server/modules/providers/services/provider-runtime.service.js +8 -0
- package/dist-server/server/modules/providers/services/provider-runtime.service.js.map +1 -1
- package/dist-server/server/modules/providers/services/provider-token-usage.service.js +139 -35
- package/dist-server/server/modules/providers/services/provider-token-usage.service.js.map +1 -1
- package/dist-server/server/modules/providers/services/session-conversations-search.service.js +2 -4
- package/dist-server/server/modules/providers/services/session-conversations-search.service.js.map +1 -1
- package/dist-server/server/modules/providers/services/session-history-cache.service.js +91 -0
- package/dist-server/server/modules/providers/services/session-history-cache.service.js.map +1 -0
- package/dist-server/server/modules/providers/services/session-synchronizer.service.js +105 -32
- package/dist-server/server/modules/providers/services/session-synchronizer.service.js.map +1 -1
- package/dist-server/server/modules/providers/services/sessions-watcher.service.js +3 -60
- package/dist-server/server/modules/providers/services/sessions-watcher.service.js.map +1 -1
- package/dist-server/server/modules/providers/services/sessions.service.js +252 -12
- package/dist-server/server/modules/providers/services/sessions.service.js.map +1 -1
- package/dist-server/server/modules/providers/tests/claude-auth.test.js +183 -0
- package/dist-server/server/modules/providers/tests/claude-auth.test.js.map +1 -1
- package/dist-server/server/modules/providers/tests/claude-background-work.test.js +172 -0
- package/dist-server/server/modules/providers/tests/claude-background-work.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/claude-compaction.test.js +84 -0
- package/dist-server/server/modules/providers/tests/claude-compaction.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/claude-message-model.test.js +123 -0
- package/dist-server/server/modules/providers/tests/claude-message-model.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/claude-models.test.js +39 -0
- package/dist-server/server/modules/providers/tests/claude-models.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/claude-runtime-hold.test.js +265 -0
- package/dist-server/server/modules/providers/tests/claude-runtime-hold.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/claude-sessions.test.js +1815 -33
- package/dist-server/server/modules/providers/tests/claude-sessions.test.js.map +1 -1
- package/dist-server/server/modules/providers/tests/claude-settings-permissions.test.js +228 -0
- package/dist-server/server/modules/providers/tests/claude-settings-permissions.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/claude-subagent-echo.test.js +16 -0
- package/dist-server/server/modules/providers/tests/claude-subagent-echo.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/claude-token-budget.test.js +85 -0
- package/dist-server/server/modules/providers/tests/claude-token-budget.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/codex-message-editing.test.js +338 -0
- package/dist-server/server/modules/providers/tests/codex-message-editing.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/codex-models.test.js +25 -0
- package/dist-server/server/modules/providers/tests/codex-models.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/codex-runtime.test.js +83 -0
- package/dist-server/server/modules/providers/tests/codex-runtime.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/codex-session-fork.test.js +54 -0
- package/dist-server/server/modules/providers/tests/codex-session-fork.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/codex-sessions.test.js +354 -15
- package/dist-server/server/modules/providers/tests/codex-sessions.test.js.map +1 -1
- package/dist-server/server/modules/providers/tests/cursor-auth.test.js +167 -0
- package/dist-server/server/modules/providers/tests/cursor-auth.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/opencode-models.test.js +196 -4
- package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -1
- package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +19 -1
- package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -1
- package/dist-server/server/modules/providers/tests/provider-capabilities.service.test.js +30 -0
- package/dist-server/server/modules/providers/tests/provider-capabilities.service.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/provider-models.service.test.js +79 -1
- package/dist-server/server/modules/providers/tests/provider-models.service.test.js.map +1 -1
- package/dist-server/server/modules/providers/tests/provider-token-usage.service.test.js +164 -1
- package/dist-server/server/modules/providers/tests/provider-token-usage.service.test.js.map +1 -1
- package/dist-server/server/modules/providers/tests/provider.routes.test.js +190 -1
- package/dist-server/server/modules/providers/tests/provider.routes.test.js.map +1 -1
- package/dist-server/server/modules/providers/tests/session-fork.test.js +113 -0
- package/dist-server/server/modules/providers/tests/session-fork.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/session-history-cache.service.test.js +139 -0
- package/dist-server/server/modules/providers/tests/session-history-cache.service.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/session-orphan-prune.test.js +90 -0
- package/dist-server/server/modules/providers/tests/session-orphan-prune.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/session-synchronizer-coalescing.test.js +122 -0
- package/dist-server/server/modules/providers/tests/session-synchronizer-coalescing.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/sessions.service.test.js +219 -1
- package/dist-server/server/modules/providers/tests/sessions.service.test.js.map +1 -1
- package/dist-server/server/modules/providers/tests/skills.test.js +15 -3
- package/dist-server/server/modules/providers/tests/skills.test.js.map +1 -1
- package/dist-server/server/modules/scheduled-messages/index.js +5 -0
- package/dist-server/server/modules/scheduled-messages/index.js.map +1 -0
- package/dist-server/server/modules/scheduled-messages/scheduled-messages.routes.js +47 -0
- package/dist-server/server/modules/scheduled-messages/scheduled-messages.routes.js.map +1 -0
- package/dist-server/server/modules/scheduled-messages/services/scheduled-message-dispatcher.service.js +175 -0
- package/dist-server/server/modules/scheduled-messages/services/scheduled-message-dispatcher.service.js.map +1 -0
- package/dist-server/server/modules/scheduled-messages/services/scheduled-messages.service.js +95 -0
- package/dist-server/server/modules/scheduled-messages/services/scheduled-messages.service.js.map +1 -0
- package/dist-server/server/modules/scheduled-messages/tests/scheduled-messages.test.js +290 -0
- package/dist-server/server/modules/scheduled-messages/tests/scheduled-messages.test.js.map +1 -0
- package/dist-server/server/modules/taskmaster/taskmaster.routes.js +23 -23
- package/dist-server/server/modules/taskmaster/taskmaster.routes.js.map +1 -1
- package/dist-server/server/modules/taskmaster/tests/taskmaster.routes.test.js +48 -0
- package/dist-server/server/modules/taskmaster/tests/taskmaster.routes.test.js.map +1 -1
- package/dist-server/server/modules/user/tests/user.service.test.js +70 -0
- package/dist-server/server/modules/user/tests/user.service.test.js.map +1 -1
- package/dist-server/server/modules/user/user.module.js +10 -1
- package/dist-server/server/modules/user/user.module.js.map +1 -1
- package/dist-server/server/modules/user/user.routes.js +44 -0
- package/dist-server/server/modules/user/user.routes.js.map +1 -1
- package/dist-server/server/modules/user/user.service.js +75 -0
- package/dist-server/server/modules/user/user.service.js.map +1 -1
- package/dist-server/server/modules/voice/tests/voice.service.test.js +3 -2
- package/dist-server/server/modules/voice/tests/voice.service.test.js.map +1 -1
- package/dist-server/server/modules/websocket/index.js +7 -0
- package/dist-server/server/modules/websocket/index.js.map +1 -1
- package/dist-server/server/modules/websocket/services/chat-run-registry.service.js +37 -49
- package/dist-server/server/modules/websocket/services/chat-run-registry.service.js.map +1 -1
- package/dist-server/server/modules/websocket/services/chat-session-writer.service.js +35 -5
- package/dist-server/server/modules/websocket/services/chat-session-writer.service.js.map +1 -1
- package/dist-server/server/modules/websocket/services/chat-websocket.service.js +204 -10
- package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
- package/dist-server/server/modules/websocket/services/session-upsert-broadcast.service.js +94 -0
- package/dist-server/server/modules/websocket/services/session-upsert-broadcast.service.js.map +1 -0
- package/dist-server/server/modules/websocket/services/shell-websocket.service.js +236 -20
- package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
- package/dist-server/server/modules/websocket/tests/chat-background-subscribe.test.js +137 -0
- package/dist-server/server/modules/websocket/tests/chat-background-subscribe.test.js.map +1 -0
- package/dist-server/server/modules/websocket/tests/chat-edit-send.test.js +263 -0
- package/dist-server/server/modules/websocket/tests/chat-edit-send.test.js.map +1 -0
- package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js +29 -3
- package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js.map +1 -1
- package/dist-server/server/modules/websocket/tests/chat-stop-task.test.js +96 -0
- package/dist-server/server/modules/websocket/tests/chat-stop-task.test.js.map +1 -0
- package/dist-server/server/modules/websocket/tests/session-upsert-broadcast.test.js +106 -0
- package/dist-server/server/modules/websocket/tests/session-upsert-broadcast.test.js.map +1 -0
- package/dist-server/server/modules/websocket/tests/shell-websocket.service.test.js +500 -0
- package/dist-server/server/modules/websocket/tests/shell-websocket.service.test.js.map +1 -1
- package/dist-server/server/shared/claude-cli-path.js +22 -5
- package/dist-server/server/shared/claude-cli-path.js.map +1 -1
- package/dist-server/server/shared/index.js +3 -0
- package/dist-server/server/shared/index.js.map +1 -0
- package/dist-server/server/shared/message-unification.js +376 -0
- package/dist-server/server/shared/message-unification.js.map +1 -0
- package/dist-server/server/shared/tests/claude-cli-path.test.js +45 -1
- package/dist-server/server/shared/tests/claude-cli-path.test.js.map +1 -1
- package/dist-server/server/shared/tests/message-unification.test.js +174 -0
- package/dist-server/server/shared/tests/message-unification.test.js.map +1 -0
- package/dist-server/server/shared/tests/read-only-roots.test.js +124 -0
- package/dist-server/server/shared/tests/read-only-roots.test.js.map +1 -0
- package/dist-server/server/shared/tests/strip-ansi-sequences.test.js +31 -0
- package/dist-server/server/shared/tests/strip-ansi-sequences.test.js.map +1 -0
- package/dist-server/server/shared/utils.js +160 -0
- package/dist-server/server/shared/utils.js.map +1 -1
- package/electron/desktopWindow.js +6 -0
- package/electron/viewHost.js +25 -2
- package/package.json +20 -17
- package/server/index.ts +16 -5
- package/server/modules/agent/agent.module.ts +11 -1
- package/server/modules/agent/agent.routes.ts +200 -142
- package/server/modules/agent/tests/agent-run-registration.test.ts +348 -0
- package/server/modules/agent/tests/agent.routes.test.ts +51 -2
- package/server/modules/browser-use/browser-use.routes.ts +96 -33
- package/server/modules/database/connection.ts +2 -1
- package/server/modules/database/index.ts +10 -0
- package/server/modules/database/migrations.ts +51 -0
- package/server/modules/database/repositories/provider-models.ts +79 -6
- package/server/modules/database/repositories/scheduled-messages.db.ts +127 -0
- package/server/modules/database/repositories/session-drafts.db.ts +171 -0
- package/server/modules/database/repositories/sessions.db.ts +204 -3
- package/server/modules/database/repositories/user-preferences.db.ts +80 -0
- package/server/modules/database/schema.ts +103 -0
- package/server/modules/database/tests/provider-models.db.integration.test.ts +113 -0
- package/server/modules/database/tests/sessions.db.integration.test.ts +52 -0
- package/server/modules/database/tests/user-preferences.db.integration.test.ts +135 -0
- package/server/modules/file-tree/file-tree.module.ts +2 -1
- package/server/modules/file-tree/file-tree.service.ts +49 -7
- package/server/modules/file-tree/tests/file-tree.service.test.ts +163 -1
- package/server/modules/git/git-branch-diff.service.ts +204 -0
- package/server/modules/git/git-branch.service.ts +2 -12
- package/server/modules/git/git.routes.ts +100 -6
- package/server/modules/git/tests/git-branch-diff.service.test.ts +210 -0
- package/server/modules/projects/project-clone.routes.ts +142 -0
- package/server/modules/projects/projects.routes.ts +16 -81
- package/server/modules/projects/services/project-clone-request.service.ts +56 -0
- package/server/modules/projects/services/project-clone.service.ts +102 -34
- package/server/modules/projects/services/project-delete.service.ts +13 -1
- package/server/modules/projects/services/project-star.service.ts +2 -2
- package/server/modules/projects/tests/project-clone.routes.test.ts +246 -0
- package/server/modules/projects/tests/project-clone.service.test.ts +376 -0
- package/server/modules/providers/index.ts +4 -0
- package/server/modules/providers/list/claude/claude-auth.provider.ts +81 -11
- package/server/modules/providers/list/claude/claude-fork.provider.ts +57 -0
- package/server/modules/providers/list/claude/claude-models.provider.ts +37 -17
- package/server/modules/providers/list/claude/claude-runtime.provider.js +510 -54
- package/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts +40 -14
- package/server/modules/providers/list/claude/claude-sessions.provider.ts +1195 -63
- package/server/modules/providers/list/claude/claude-skills.provider.ts +4 -1
- package/server/modules/providers/list/claude/claude.provider.ts +3 -0
- package/server/modules/providers/list/codex/codex-app-server.client.ts +243 -0
- package/server/modules/providers/list/codex/codex-fork.provider.ts +40 -0
- package/server/modules/providers/list/codex/codex-models.provider.ts +47 -0
- package/server/modules/providers/list/codex/{codex-runtime.provider.js → codex-runtime.provider.ts} +105 -70
- package/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +9 -0
- package/server/modules/providers/list/codex/codex-sessions.provider.ts +1847 -455
- package/server/modules/providers/list/codex/codex.provider.ts +3 -0
- package/server/modules/providers/list/cursor/cursor-auth.provider.ts +10 -2
- package/server/modules/providers/list/cursor/cursor-runtime.provider.js +17 -8
- package/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts +0 -1
- package/server/modules/providers/list/opencode/opencode-models.provider.ts +297 -9
- package/server/modules/providers/list/opencode/opencode-runtime.provider.js +5 -2
- package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +30 -2
- package/server/modules/providers/provider.routes.ts +162 -2
- package/server/modules/providers/services/claude-cli-liveness.service.ts +121 -0
- package/server/modules/providers/services/claude-settings-permissions.service.ts +127 -0
- package/server/modules/providers/services/provider-capabilities.service.ts +40 -1
- package/server/modules/providers/services/provider-models.service.ts +71 -4
- package/server/modules/providers/services/provider-runtime.service.ts +11 -0
- package/server/modules/providers/services/provider-token-usage.service.ts +166 -39
- package/server/modules/providers/services/session-conversations-search.service.ts +2 -5
- package/server/modules/providers/services/session-history-cache.service.ts +150 -0
- package/server/modules/providers/services/session-synchronizer.service.ts +125 -38
- package/server/modules/providers/services/sessions-watcher.service.ts +3 -64
- package/server/modules/providers/services/sessions.service.ts +309 -17
- package/server/modules/providers/tests/claude-auth.test.ts +208 -0
- package/server/modules/providers/tests/claude-background-work.test.ts +213 -0
- package/server/modules/providers/tests/claude-compaction.test.ts +101 -0
- package/server/modules/providers/tests/claude-message-model.test.ts +160 -0
- package/server/modules/providers/tests/claude-models.test.ts +87 -0
- package/server/modules/providers/tests/claude-runtime-hold.test.ts +309 -0
- package/server/modules/providers/tests/claude-sessions.test.ts +2167 -37
- package/server/modules/providers/tests/claude-settings-permissions.test.ts +271 -0
- package/server/modules/providers/tests/claude-subagent-echo.test.ts +25 -0
- package/server/modules/providers/tests/claude-token-budget.test.ts +104 -0
- package/server/modules/providers/tests/codex-message-editing.test.ts +419 -0
- package/server/modules/providers/tests/codex-models.test.ts +37 -0
- package/server/modules/providers/tests/codex-runtime.test.ts +94 -0
- package/server/modules/providers/tests/codex-session-fork.test.ts +59 -0
- package/server/modules/providers/tests/codex-sessions.test.ts +396 -15
- package/server/modules/providers/tests/cursor-auth.test.ts +187 -0
- package/server/modules/providers/tests/opencode-models.test.ts +266 -8
- package/server/modules/providers/tests/opencode-sessions.test.ts +40 -1
- package/server/modules/providers/tests/provider-capabilities.service.test.ts +37 -0
- package/server/modules/providers/tests/provider-models.service.test.ts +99 -1
- package/server/modules/providers/tests/provider-token-usage.service.test.ts +188 -1
- package/server/modules/providers/tests/provider.routes.test.ts +230 -1
- package/server/modules/providers/tests/session-fork.test.ts +173 -0
- package/server/modules/providers/tests/session-history-cache.service.test.ts +165 -0
- package/server/modules/providers/tests/session-orphan-prune.test.ts +107 -0
- package/server/modules/providers/tests/session-synchronizer-coalescing.test.ts +136 -0
- package/server/modules/providers/tests/sessions.service.test.ts +303 -1
- package/server/modules/providers/tests/skills.test.ts +21 -5
- package/server/modules/scheduled-messages/index.ts +8 -0
- package/server/modules/scheduled-messages/scheduled-messages.routes.ts +68 -0
- package/server/modules/scheduled-messages/services/scheduled-message-dispatcher.service.ts +216 -0
- package/server/modules/scheduled-messages/services/scheduled-messages.service.ts +121 -0
- package/server/modules/scheduled-messages/tests/scheduled-messages.test.ts +354 -0
- package/server/modules/taskmaster/taskmaster.routes.ts +23 -33
- package/server/modules/taskmaster/tests/taskmaster.routes.test.ts +57 -0
- package/server/modules/user/tests/user.service.test.ts +90 -0
- package/server/modules/user/user.module.ts +10 -1
- package/server/modules/user/user.routes.ts +44 -0
- package/server/modules/user/user.service.ts +105 -0
- package/server/modules/voice/tests/voice.service.test.ts +3 -2
- package/server/modules/websocket/README.md +4 -2
- package/server/modules/websocket/index.ts +8 -0
- package/server/modules/websocket/services/chat-run-registry.service.ts +45 -54
- package/server/modules/websocket/services/chat-session-writer.service.ts +36 -6
- package/server/modules/websocket/services/chat-websocket.service.ts +302 -16
- package/server/modules/websocket/services/session-upsert-broadcast.service.ts +108 -0
- package/server/modules/websocket/services/shell-websocket.service.ts +276 -22
- package/server/modules/websocket/tests/chat-background-subscribe.test.ts +167 -0
- package/server/modules/websocket/tests/chat-edit-send.test.ts +309 -0
- package/server/modules/websocket/tests/chat-run-registry.test.ts +34 -3
- package/server/modules/websocket/tests/chat-stop-task.test.ts +127 -0
- package/server/modules/websocket/tests/session-upsert-broadcast.test.ts +138 -0
- package/server/modules/websocket/tests/shell-websocket.service.test.ts +629 -0
- package/server/shared/claude-cli-path.ts +24 -6
- package/server/shared/index.ts +3 -0
- package/server/shared/interfaces.ts +91 -0
- package/server/shared/message-unification.ts +449 -0
- package/server/shared/tests/claude-cli-path.test.ts +58 -1
- package/server/shared/tests/message-unification.test.ts +218 -0
- package/server/shared/tests/read-only-roots.test.ts +139 -0
- package/server/shared/tests/strip-ansi-sequences.test.ts +42 -0
- package/server/shared/types.ts +382 -7
- package/server/shared/utils.ts +176 -0
- package/dist/assets/channel-_falYbU1.js +0 -1
- package/dist/assets/classDiagram-JCYQIIEL-Dn-3CfpR.js +0 -1
- package/dist/assets/classDiagram-v2-OCEON4UE-Dn-3CfpR.js +0 -1
- package/dist/assets/index-CzPLBvXT.css +0 -32
- package/dist/assets/index-DFkBBuSA.js +0 -1549
- package/dist/assets/stateDiagram-v2-UEYNNEHI-BvBxUUrZ.js +0 -1
- package/dist/assets/swimlanesDiagram-ULZ7WXOC-CsE-x4YK.js +0 -8
|
@@ -1,44 +1,1826 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
2
5
|
import test from 'node:test';
|
|
6
|
+
import { closeConnection, initializeDatabase, sessionsDb } from '../../../modules/database/index.js';
|
|
3
7
|
import { ClaudeSessionsProvider } from '../../../modules/providers/list/claude/claude-sessions.provider.js';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
8
|
+
import { ClaudeSessionSynchronizer } from '../../../modules/providers/list/claude/claude-session-synchronizer.provider.js';
|
|
9
|
+
import { buildLookupMap } from '../../../shared/utils.js';
|
|
10
|
+
async function withIsolatedDatabase(runTest) {
|
|
11
|
+
const previousDatabasePath = process.env.DATABASE_PATH;
|
|
12
|
+
const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'claude-provider-db-'));
|
|
13
|
+
closeConnection();
|
|
14
|
+
process.env.DATABASE_PATH = path.join(tempDirectory, 'auth.db');
|
|
15
|
+
await initializeDatabase();
|
|
16
|
+
try {
|
|
17
|
+
await runTest();
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
closeConnection();
|
|
21
|
+
if (previousDatabasePath === undefined) {
|
|
22
|
+
delete process.env.DATABASE_PATH;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
process.env.DATABASE_PATH = previousDatabasePath;
|
|
26
|
+
}
|
|
27
|
+
await rm(tempDirectory, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const patchHomeDir = (nextHomeDir) => {
|
|
31
|
+
const original = os.homedir;
|
|
32
|
+
os.homedir = () => nextHomeDir;
|
|
33
|
+
return () => {
|
|
34
|
+
os.homedir = original;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Writes a minimal valid Claude JSONL session file with enough fields for
|
|
39
|
+
* `extractFirstValidJsonlData` to parse `sessionId` and `cwd`.
|
|
40
|
+
*/
|
|
41
|
+
async function writeSessionJsonl(dirPath, fileName, lines) {
|
|
42
|
+
const filePath = path.join(dirPath, fileName);
|
|
43
|
+
const head = [
|
|
44
|
+
JSON.stringify({ type: 'mode', mode: 'normal', sessionId: 'test-session-1' }),
|
|
45
|
+
JSON.stringify({ type: 'permission-mode', permissionMode: 'default', sessionId: 'test-session-1' }),
|
|
46
|
+
JSON.stringify({
|
|
47
|
+
parentUuid: null,
|
|
48
|
+
isSidechain: false,
|
|
49
|
+
type: 'user',
|
|
50
|
+
message: { role: 'user', content: 'first prompt' },
|
|
51
|
+
uuid: 'msg-1',
|
|
52
|
+
timestamp: '2026-07-10T00:00:00.000Z',
|
|
53
|
+
cwd: '/workspace/demo',
|
|
54
|
+
sessionId: 'test-session-1',
|
|
55
|
+
}),
|
|
56
|
+
];
|
|
57
|
+
const content = [...head, ...lines, ''].join('\n');
|
|
58
|
+
await writeFile(filePath, content, 'utf8');
|
|
59
|
+
return filePath;
|
|
60
|
+
}
|
|
61
|
+
const SESSION_ID = 'claude-session-1';
|
|
62
|
+
const AGENT_ID = 'a1b2c3d4e5f60718';
|
|
63
|
+
const AGENT_TOOL_USE_ID = 'toolu_agent_1';
|
|
64
|
+
/**
|
|
65
|
+
* Writes the transcript pair current Claude versions produce for one async
|
|
66
|
+
* subagent: the parent session, and the agent's own transcript plus sidecar
|
|
67
|
+
* metadata under `<session>/subagents/`.
|
|
68
|
+
*/
|
|
69
|
+
async function writeClaudeSubagentSession(projectDirectory) {
|
|
70
|
+
const parentPath = path.join(projectDirectory, `${SESSION_ID}.jsonl`);
|
|
71
|
+
const subagentDirectory = path.join(projectDirectory, SESSION_ID, 'subagents');
|
|
72
|
+
await mkdir(subagentDirectory, { recursive: true });
|
|
73
|
+
const parentLines = [
|
|
74
|
+
{
|
|
75
|
+
type: 'assistant',
|
|
76
|
+
uuid: 'assistant-1',
|
|
77
|
+
sessionId: SESSION_ID,
|
|
78
|
+
timestamp: '2026-08-21T10:00:00.000Z',
|
|
79
|
+
message: {
|
|
80
|
+
role: 'assistant',
|
|
81
|
+
content: [{
|
|
82
|
+
type: 'tool_use',
|
|
83
|
+
id: AGENT_TOOL_USE_ID,
|
|
84
|
+
name: 'Agent',
|
|
85
|
+
input: { subagent_type: 'Explore', description: 'Survey the repo', prompt: 'Look around' },
|
|
86
|
+
}],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
type: 'user',
|
|
91
|
+
uuid: 'launch-ack-1',
|
|
92
|
+
sessionId: SESSION_ID,
|
|
93
|
+
timestamp: '2026-08-21T10:00:01.000Z',
|
|
94
|
+
message: {
|
|
95
|
+
role: 'user',
|
|
96
|
+
content: [{
|
|
97
|
+
type: 'tool_result',
|
|
98
|
+
tool_use_id: AGENT_TOOL_USE_ID,
|
|
99
|
+
content: 'Async agent launched successfully. agentId: internal bookkeeping',
|
|
100
|
+
}],
|
|
101
|
+
},
|
|
102
|
+
toolUseResult: {
|
|
103
|
+
isAsync: true,
|
|
104
|
+
status: 'async_launched',
|
|
105
|
+
agentId: AGENT_ID,
|
|
106
|
+
description: 'Survey the repo',
|
|
107
|
+
resolvedModel: 'claude-opus-5',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
type: 'user',
|
|
112
|
+
uuid: 'notification-1',
|
|
113
|
+
sessionId: SESSION_ID,
|
|
114
|
+
timestamp: '2026-08-21T10:05:00.000Z',
|
|
115
|
+
message: {
|
|
116
|
+
role: 'user',
|
|
117
|
+
content: [{
|
|
118
|
+
type: 'text',
|
|
119
|
+
text: [
|
|
120
|
+
'<task-notification>',
|
|
121
|
+
`<task-id>${AGENT_ID}</task-id>`,
|
|
122
|
+
`<tool-use-id>${AGENT_TOOL_USE_ID}</tool-use-id>`,
|
|
123
|
+
'<status>completed</status>',
|
|
124
|
+
'<summary>Agent "Survey the repo" finished</summary>',
|
|
125
|
+
'<result>The repo has two packages.</result>',
|
|
126
|
+
'</task-notification>',
|
|
127
|
+
].join('\n'),
|
|
128
|
+
}],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
await writeFile(parentPath, `${parentLines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf8');
|
|
133
|
+
const agentLines = [
|
|
134
|
+
{
|
|
135
|
+
type: 'assistant',
|
|
136
|
+
isSidechain: true,
|
|
137
|
+
agentId: AGENT_ID,
|
|
138
|
+
timestamp: '2026-08-21T10:00:30.000Z',
|
|
139
|
+
message: {
|
|
140
|
+
role: 'assistant',
|
|
141
|
+
model: 'claude-opus-5',
|
|
142
|
+
content: [
|
|
143
|
+
{ type: 'text', text: 'Starting the survey.' },
|
|
144
|
+
{ type: 'tool_use', id: 'toolu_child_1', name: 'Read', input: { file_path: '/repo/package.json' } },
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
type: 'user',
|
|
150
|
+
isSidechain: true,
|
|
151
|
+
agentId: AGENT_ID,
|
|
152
|
+
timestamp: '2026-08-21T10:00:31.000Z',
|
|
153
|
+
message: {
|
|
154
|
+
role: 'user',
|
|
155
|
+
content: [{ type: 'tool_result', tool_use_id: 'toolu_child_1', content: '{"name":"repo"}' }],
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
await writeFile(path.join(subagentDirectory, `agent-${AGENT_ID}.jsonl`), `${agentLines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf8');
|
|
160
|
+
await writeFile(path.join(subagentDirectory, `agent-${AGENT_ID}.meta.json`), JSON.stringify({ agentType: 'Explore', description: 'Survey the repo', toolUseId: AGENT_TOOL_USE_ID, spawnDepth: 1 }), 'utf8');
|
|
161
|
+
return parentPath;
|
|
162
|
+
}
|
|
163
|
+
test('Claude history attaches a subagent transcript stored under the session directory', { concurrency: false }, async () => {
|
|
164
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-subagent-'));
|
|
165
|
+
try {
|
|
166
|
+
const parentPath = await writeClaudeSubagentSession(tempRoot);
|
|
167
|
+
await withIsolatedDatabase(async () => {
|
|
168
|
+
const now = new Date().toISOString();
|
|
169
|
+
sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
|
|
170
|
+
const history = await new ClaudeSessionsProvider().fetchHistory(SESSION_ID, {
|
|
171
|
+
providerSessionId: SESSION_ID,
|
|
172
|
+
});
|
|
173
|
+
const agentRow = history.messages.find((message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID);
|
|
174
|
+
assert.ok(agentRow, 'the Agent call must be in the transcript');
|
|
175
|
+
assert.equal(agentRow.subagent?.id, AGENT_ID);
|
|
176
|
+
assert.equal(agentRow.subagent?.type, 'Explore');
|
|
177
|
+
assert.equal(agentRow.subagent?.description, 'Survey the repo');
|
|
178
|
+
assert.equal(agentRow.subagent?.status, 'completed');
|
|
179
|
+
// The agent's own work — prose and tool calls — comes from its separate
|
|
180
|
+
// transcript, which is the file the previous lookup never found.
|
|
181
|
+
assert.equal(agentRow.subagentTools?.length, 2);
|
|
182
|
+
assert.equal(agentRow.subagentTools?.[0].kind, 'text');
|
|
183
|
+
assert.equal(agentRow.subagentTools?.[1].toolName, 'Read');
|
|
184
|
+
assert.equal(agentRow.subagentTools?.[1].toolResult?.content, '{"name":"repo"}');
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
test('Claude history folds an agent task notification into the call that spawned it', { concurrency: false }, async () => {
|
|
192
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-notification-'));
|
|
193
|
+
try {
|
|
194
|
+
const parentPath = await writeClaudeSubagentSession(tempRoot);
|
|
195
|
+
await withIsolatedDatabase(async () => {
|
|
196
|
+
const now = new Date().toISOString();
|
|
197
|
+
sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
|
|
198
|
+
const history = await new ClaudeSessionsProvider().fetchHistory(SESSION_ID, {
|
|
199
|
+
providerSessionId: SESSION_ID,
|
|
200
|
+
});
|
|
201
|
+
const agentRow = history.messages.find((message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID);
|
|
202
|
+
// The launch acknowledgement is internal bookkeeping; the agent's answer
|
|
203
|
+
// is what belongs on its card.
|
|
204
|
+
assert.equal(agentRow?.toolResult?.content, 'The repo has two packages.');
|
|
205
|
+
const strayNotification = history.messages.find((message) => typeof message.content === 'string' && message.content.includes('<task-notification>'));
|
|
206
|
+
assert.equal(strayNotification, undefined, 'the folded notification must not also render on its own');
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
/**
|
|
214
|
+
* Rewrites the notification turn into the shape the harness writes when it
|
|
215
|
+
* consumes the report inline, while the parent turn is still running: a
|
|
216
|
+
* top-level `queue-operation` record with the payload in `content`, no
|
|
217
|
+
* `message`, no `uuid`, and no user-role turn anywhere in the file.
|
|
218
|
+
*/
|
|
219
|
+
async function useQueueOperationNotification(parentPath, operation) {
|
|
220
|
+
const lines = (await readFile(parentPath, 'utf8')).split('\n').filter(Boolean);
|
|
221
|
+
const rewritten = lines.map((line) => {
|
|
222
|
+
const row = JSON.parse(line);
|
|
223
|
+
if (!line.includes('task-notification')) {
|
|
224
|
+
return line;
|
|
225
|
+
}
|
|
226
|
+
return JSON.stringify({
|
|
227
|
+
type: 'queue-operation',
|
|
228
|
+
operation,
|
|
229
|
+
timestamp: row.timestamp,
|
|
230
|
+
sessionId: row.sessionId,
|
|
231
|
+
content: row.message.content[0].text,
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
await writeFile(parentPath, `${rewritten.join('\n')}\n`, 'utf8');
|
|
235
|
+
}
|
|
236
|
+
for (const operation of ['remove', 'enqueue']) {
|
|
237
|
+
test(`Claude history folds a ${operation} queue-operation notification onto the agent card`, { concurrency: false }, async () => {
|
|
238
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-queued-notification-'));
|
|
239
|
+
try {
|
|
240
|
+
const parentPath = await writeClaudeSubagentSession(tempRoot);
|
|
241
|
+
await useQueueOperationNotification(parentPath, operation);
|
|
242
|
+
await withIsolatedDatabase(async () => {
|
|
243
|
+
const now = new Date().toISOString();
|
|
244
|
+
sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
|
|
245
|
+
const history = await new ClaudeSessionsProvider().fetchHistory(SESSION_ID, {
|
|
246
|
+
providerSessionId: SESSION_ID,
|
|
247
|
+
});
|
|
248
|
+
const agentRow = history.messages.find((message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID);
|
|
249
|
+
// The report is in the file, just in the shape the collector used to
|
|
250
|
+
// skip — so the card has to read exactly as it does for a user-role
|
|
251
|
+
// notification: the agent's answer on the card, and a settled status.
|
|
252
|
+
assert.equal(agentRow?.toolResult?.content, 'The repo has two packages.');
|
|
253
|
+
assert.equal(agentRow?.subagent?.status, 'completed');
|
|
254
|
+
const strayNotification = history.messages.find((message) => typeof message.content === 'string' && message.content.includes('<task-notification>'));
|
|
255
|
+
assert.equal(strayNotification, undefined, 'a folded notification must never also render on its own');
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
test('Claude history keeps rows that have no uuid when a queue-operation notification is folded', { concurrency: false }, async () => {
|
|
264
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-uuidless-row-'));
|
|
265
|
+
try {
|
|
266
|
+
const parentPath = await writeClaudeSubagentSession(tempRoot);
|
|
267
|
+
await useQueueOperationNotification(parentPath, 'remove');
|
|
268
|
+
// A queue-operation record carries no `uuid`, so folding it must not put an
|
|
269
|
+
// empty string in the drop set — that would match every other uuid-less row
|
|
270
|
+
// and delete it from the transcript.
|
|
271
|
+
await writeFile(parentPath, `${(await readFile(parentPath, 'utf8')).trim()}\n${JSON.stringify({
|
|
272
|
+
type: 'assistant',
|
|
273
|
+
sessionId: SESSION_ID,
|
|
274
|
+
timestamp: '2026-08-21T10:06:00.000Z',
|
|
275
|
+
message: { role: 'assistant', content: [{ type: 'text', text: 'A row with no uuid.' }] },
|
|
276
|
+
})}\n`, 'utf8');
|
|
277
|
+
await withIsolatedDatabase(async () => {
|
|
278
|
+
const now = new Date().toISOString();
|
|
279
|
+
sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
|
|
280
|
+
const history = await new ClaudeSessionsProvider().fetchHistory(SESSION_ID, {
|
|
281
|
+
providerSessionId: SESSION_ID,
|
|
282
|
+
});
|
|
283
|
+
assert.ok(history.messages.some((message) => message.content === 'A row with no uuid.'), 'a row without a uuid must survive the fold');
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
test('Claude history drops every notification row of a task that reported more than once', { concurrency: false }, async () => {
|
|
291
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-renotified-agent-'));
|
|
292
|
+
try {
|
|
293
|
+
const parentPath = await writeClaudeSubagentSession(tempRoot);
|
|
294
|
+
// A resumed session first reports the task as stopped ("no completion
|
|
295
|
+
// record was found"), then the task finishes and reports again. The last
|
|
296
|
+
// word is what the card shows; the earlier row is superseded bookkeeping
|
|
297
|
+
// and must not linger as a bubble of its own.
|
|
298
|
+
const lines = (await readFile(parentPath, 'utf8')).split('\n').filter(Boolean);
|
|
299
|
+
const [notificationLine] = lines.filter((line) => line.includes('task-notification'));
|
|
300
|
+
const completed = JSON.parse(notificationLine);
|
|
301
|
+
const stopped = {
|
|
302
|
+
...completed,
|
|
303
|
+
uuid: 'notification-0',
|
|
304
|
+
timestamp: '2026-08-21T10:04:00.000Z',
|
|
305
|
+
message: {
|
|
306
|
+
role: 'user',
|
|
307
|
+
content: [{
|
|
308
|
+
type: 'text',
|
|
309
|
+
text: completed.message.content[0].text
|
|
310
|
+
.replace('<status>completed</status>', '<status>stopped</status>')
|
|
311
|
+
.replace(/<result>[\s\S]*<\/result>/, '')
|
|
312
|
+
.replace(/<summary>[^<]*<\/summary>/, '<summary>No completion record was found for this agent</summary>'),
|
|
313
|
+
}],
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
const rewritten = lines.flatMap((line) => (line === notificationLine ? [JSON.stringify(stopped), line] : [line]));
|
|
317
|
+
await writeFile(parentPath, `${rewritten.join('\n')}\n`, 'utf8');
|
|
318
|
+
await withIsolatedDatabase(async () => {
|
|
319
|
+
const now = new Date().toISOString();
|
|
320
|
+
sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
|
|
321
|
+
const history = await new ClaudeSessionsProvider().fetchHistory(SESSION_ID, {
|
|
322
|
+
providerSessionId: SESSION_ID,
|
|
323
|
+
});
|
|
324
|
+
const agentRow = history.messages.find((message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID);
|
|
325
|
+
assert.equal(agentRow?.subagent?.status, 'completed');
|
|
326
|
+
assert.equal(agentRow?.toolResult?.content, 'The repo has two packages.');
|
|
327
|
+
assert.ok(!history.messages.some((message) => (message.content ?? '').includes('<task-notification>')), 'no notification row may render on its own once the task is folded');
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
/** Strips the `<task-notification>` turn so the agent has no reported outcome. */
|
|
335
|
+
async function dropTaskNotification(parentPath) {
|
|
336
|
+
const raw = await readFile(parentPath, 'utf8');
|
|
337
|
+
await writeFile(parentPath, `${raw.split('\n').filter((line) => line && !line.includes('task-notification')).join('\n')}\n`, 'utf8');
|
|
338
|
+
}
|
|
339
|
+
test('Claude history keeps a background agent running until its outcome is reported', { concurrency: false }, async () => {
|
|
340
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-live-agent-'));
|
|
341
|
+
try {
|
|
342
|
+
const parentPath = await writeClaudeSubagentSession(tempRoot);
|
|
343
|
+
await dropTaskNotification(parentPath);
|
|
344
|
+
await withIsolatedDatabase(async () => {
|
|
345
|
+
const now = new Date().toISOString();
|
|
346
|
+
sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
|
|
347
|
+
// The run that launched the agent is still up, so it can still report.
|
|
348
|
+
const liveRunStartedAt = Date.parse('2026-08-21T09:59:00.000Z');
|
|
349
|
+
const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => liveRunStartedAt }).fetchHistory(SESSION_ID, {
|
|
350
|
+
providerSessionId: SESSION_ID,
|
|
351
|
+
});
|
|
352
|
+
const agentRow = history.messages.find((message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID);
|
|
353
|
+
// This agent's transcript ends on a resolved tool call, which used to be
|
|
354
|
+
// read as proof it had finished. A background agent routinely stops there
|
|
355
|
+
// while its work is outstanding — it says it is waiting on something and
|
|
356
|
+
// ends its turn — so the transcript proves nothing and only the
|
|
357
|
+
// notification does. Reporting `completed` here dropped the spinner and
|
|
358
|
+
// collapsed the card while the agent was still working.
|
|
359
|
+
assert.equal(agentRow?.subagent?.status, 'running');
|
|
360
|
+
assert.equal(agentRow?.toolResult?.content, '', 'the launch acknowledgement must never show as a result');
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
test('Claude history reports a background agent stopped once its session process is gone', { concurrency: false }, async () => {
|
|
368
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-stopped-agent-'));
|
|
369
|
+
try {
|
|
370
|
+
const parentPath = await writeClaudeSubagentSession(tempRoot);
|
|
371
|
+
await dropTaskNotification(parentPath);
|
|
372
|
+
await withIsolatedDatabase(async () => {
|
|
373
|
+
const now = new Date().toISOString();
|
|
374
|
+
sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
|
|
375
|
+
// A background agent runs inside the session's CLI process. With that
|
|
376
|
+
// process gone — the run was stopped, or crashed — an agent that never
|
|
377
|
+
// reported never will, and "still running" would pin a spinner on the
|
|
378
|
+
// card forever.
|
|
379
|
+
const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(SESSION_ID, {
|
|
380
|
+
providerSessionId: SESSION_ID,
|
|
381
|
+
});
|
|
382
|
+
const agentRow = history.messages.find((message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID);
|
|
383
|
+
assert.equal(agentRow?.subagent?.status, 'stopped');
|
|
384
|
+
assert.equal(agentRow?.toolResult?.content, '', 'the launch acknowledgement must never show as a result');
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
test('Claude history reports a background agent stopped when a later run of the session is live', { concurrency: false }, async () => {
|
|
392
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-orphaned-agent-'));
|
|
393
|
+
try {
|
|
394
|
+
const parentPath = await writeClaudeSubagentSession(tempRoot);
|
|
395
|
+
await dropTaskNotification(parentPath);
|
|
396
|
+
await withIsolatedDatabase(async () => {
|
|
397
|
+
const now = new Date().toISOString();
|
|
398
|
+
sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
|
|
399
|
+
// Each turn of a session is its own CLI process. The agent was launched
|
|
400
|
+
// at 10:00:01 by a process that has since exited; the run up now started
|
|
401
|
+
// an hour later and cannot deliver that agent's report. Reading it as
|
|
402
|
+
// `running` made an orphaned card flicker back to a spinner on every
|
|
403
|
+
// later turn.
|
|
404
|
+
const laterRunStartedAt = Date.parse('2026-08-21T11:00:00.000Z');
|
|
405
|
+
const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => laterRunStartedAt }).fetchHistory(SESSION_ID, {
|
|
406
|
+
providerSessionId: SESSION_ID,
|
|
407
|
+
});
|
|
408
|
+
const agentRow = history.messages.find((message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID);
|
|
409
|
+
assert.equal(agentRow?.subagent?.status, 'stopped');
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
test('Claude history asks after the session process by both the app and the provider session id', { concurrency: false }, async () => {
|
|
417
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-liveness-keys-'));
|
|
418
|
+
const appSessionId = 'app-session-1';
|
|
419
|
+
try {
|
|
420
|
+
const parentPath = await writeClaudeSubagentSession(tempRoot);
|
|
421
|
+
await dropTaskNotification(parentPath);
|
|
422
|
+
await withIsolatedDatabase(async () => {
|
|
423
|
+
const now = new Date().toISOString();
|
|
424
|
+
// The DB row carries the app-facing id; the transcript rows carry the
|
|
425
|
+
// provider-native one. The runtime keys its process map by whichever
|
|
426
|
+
// id started the run — the app id from the chat gateway, the provider
|
|
427
|
+
// id from the agent API — so a probe that knows only one of them must
|
|
428
|
+
// still find the run.
|
|
429
|
+
sessionsDb.createSession(appSessionId, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
|
|
430
|
+
for (const liveId of [appSessionId, SESSION_ID]) {
|
|
431
|
+
const history = await new ClaudeSessionsProvider({
|
|
432
|
+
getLiveRunStartTime: (sessionId) => (sessionId === liveId ? Date.parse('2026-08-21T09:59:00.000Z') : null),
|
|
433
|
+
}).fetchHistory(appSessionId, { providerSessionId: SESSION_ID });
|
|
434
|
+
const agentRow = history.messages.find((message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID);
|
|
435
|
+
assert.equal(agentRow?.subagent?.status, 'running', `a run keyed by ${liveId} must read as live`);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
finally {
|
|
440
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
/** Removes the agent's own transcript and sidecar, leaving only the parent's rows. */
|
|
444
|
+
async function dropSubagentTranscript(projectDirectory) {
|
|
445
|
+
await rm(path.join(projectDirectory, SESSION_ID, 'subagents'), { recursive: true, force: true });
|
|
446
|
+
}
|
|
447
|
+
for (const reported of ['completed', 'failed']) {
|
|
448
|
+
test(`Claude history settles a ${reported} background agent from its notification when its transcript is missing`, { concurrency: false }, async () => {
|
|
449
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-transcriptless-agent-'));
|
|
450
|
+
try {
|
|
451
|
+
const parentPath = await writeClaudeSubagentSession(tempRoot);
|
|
452
|
+
await useQueueOperationNotification(parentPath, 'remove');
|
|
453
|
+
await writeFile(parentPath, (await readFile(parentPath, 'utf8')).replace('<status>completed</status>', `<status>${reported}</status>`), 'utf8');
|
|
454
|
+
await dropSubagentTranscript(tempRoot);
|
|
455
|
+
await withIsolatedDatabase(async () => {
|
|
456
|
+
const now = new Date().toISOString();
|
|
457
|
+
sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
|
|
458
|
+
// The notification is the only record of the outcome, and it is
|
|
459
|
+
// enough: the agent's own transcript names its timeline, not whether
|
|
460
|
+
// it finished. Folding the answer onto the card while leaving the
|
|
461
|
+
// status unset read as `running` on a card whose result said "done".
|
|
462
|
+
const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(SESSION_ID, {
|
|
463
|
+
providerSessionId: SESSION_ID,
|
|
464
|
+
});
|
|
465
|
+
const agentRow = history.messages.find((message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID);
|
|
466
|
+
assert.equal(agentRow?.toolResult?.content, 'The repo has two packages.');
|
|
467
|
+
assert.equal(agentRow?.subagent?.status, reported);
|
|
468
|
+
assert.equal(agentRow?.subagent?.id, AGENT_ID);
|
|
469
|
+
assert.equal(agentRow?.subagent?.description, 'Survey the repo');
|
|
470
|
+
assert.equal(agentRow?.subagent?.model, 'claude-opus-5');
|
|
471
|
+
assert.equal(agentRow?.subagentTools, undefined, 'there is no timeline to show without a transcript');
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
finally {
|
|
475
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
test('Claude history reports a forked background agent stopped: no transcript, no notification, no process', { concurrency: false }, async () => {
|
|
480
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-forked-agent-'));
|
|
481
|
+
try {
|
|
482
|
+
// A fork copies the parent's `.jsonl` rows and nothing else — no
|
|
483
|
+
// `subagents/` directory and no queue-operation records — and the new
|
|
484
|
+
// session has no process of its own yet.
|
|
485
|
+
const parentPath = await writeClaudeSubagentSession(tempRoot);
|
|
486
|
+
await dropTaskNotification(parentPath);
|
|
487
|
+
await dropSubagentTranscript(tempRoot);
|
|
488
|
+
await withIsolatedDatabase(async () => {
|
|
489
|
+
const now = new Date().toISOString();
|
|
490
|
+
sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
|
|
491
|
+
const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(SESSION_ID, {
|
|
492
|
+
providerSessionId: SESSION_ID,
|
|
493
|
+
});
|
|
494
|
+
const agentRow = history.messages.find((message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID);
|
|
495
|
+
// Without `subagent` the card falls back to "async launch, therefore
|
|
496
|
+
// running", which is the spinner that never went away.
|
|
497
|
+
assert.equal(agentRow?.subagent?.status, 'stopped');
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
finally {
|
|
501
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
test('a live SDK tool result keeps its launch metadata under the stream key', () => {
|
|
505
|
+
// The transcript spells it `toolUseResult`; the SDK stream spells it
|
|
506
|
+
// `tool_use_result`. Reading only the former sent every live agent launch to
|
|
507
|
+
// the client without `isAsync`, so the card could not tell the launch
|
|
508
|
+
// acknowledgement apart from an answer and settled the agent at launch.
|
|
509
|
+
const [normalized] = new ClaudeSessionsProvider().normalizeMessage({
|
|
510
|
+
type: 'user',
|
|
511
|
+
session_id: SESSION_ID,
|
|
512
|
+
parent_tool_use_id: null,
|
|
513
|
+
uuid: 'launch-ack-live',
|
|
514
|
+
message: {
|
|
515
|
+
role: 'user',
|
|
516
|
+
content: [{
|
|
517
|
+
type: 'tool_result',
|
|
518
|
+
tool_use_id: AGENT_TOOL_USE_ID,
|
|
519
|
+
content: 'Async agent launched successfully. agentId: internal bookkeeping',
|
|
520
|
+
}],
|
|
521
|
+
},
|
|
522
|
+
tool_use_result: { isAsync: true, status: 'async_launched', agentId: AGENT_ID },
|
|
27
523
|
}, SESSION_ID);
|
|
28
|
-
assert.
|
|
524
|
+
assert.equal(normalized?.kind, 'tool_result');
|
|
525
|
+
assert.equal(normalized?.toolUseResult?.isAsync, true);
|
|
29
526
|
});
|
|
30
|
-
test('
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
527
|
+
test('a live SDK tool result caps the strings inside its structured output', () => {
|
|
528
|
+
// The stream's `tool_use_result` is the tool's whole structured output — an
|
|
529
|
+
// Edit's carries the entire file it edited — and it is sent to every client
|
|
530
|
+
// and kept in the run's replay buffer. The transcript path caps each nested
|
|
531
|
+
// string at the same limit before it leaves the server; the live path must
|
|
532
|
+
// not be the one place that forwards it whole.
|
|
533
|
+
const [normalized] = new ClaudeSessionsProvider().normalizeMessage({
|
|
534
|
+
type: 'user',
|
|
535
|
+
session_id: SESSION_ID,
|
|
536
|
+
parent_tool_use_id: null,
|
|
537
|
+
uuid: 'edit-result-live',
|
|
35
538
|
message: {
|
|
36
539
|
role: 'user',
|
|
37
|
-
content: [{ type: 'tool_result', tool_use_id: '
|
|
540
|
+
content: [{ type: 'tool_result', tool_use_id: 'toolu_edit', content: 'The file has been updated.' }],
|
|
38
541
|
},
|
|
542
|
+
tool_use_result: { filePath: '/repo/big.ts', originalFile: 'x'.repeat(50_000) },
|
|
39
543
|
}, SESSION_ID);
|
|
40
|
-
|
|
41
|
-
assert.equal(
|
|
42
|
-
assert.
|
|
544
|
+
const forwarded = normalized?.toolUseResult;
|
|
545
|
+
assert.equal(forwarded?.filePath, '/repo/big.ts');
|
|
546
|
+
assert.ok(forwarded?.originalFile && forwarded.originalFile.length < 50_000, 'the file body must be capped');
|
|
547
|
+
assert.match(forwarded?.originalFile ?? '', /… 10000 more characters$/);
|
|
548
|
+
});
|
|
549
|
+
const WORKFLOW_SESSION_ID = 'claude-workflow-session';
|
|
550
|
+
const WORKFLOW_TOOL_USE_ID = 'toolu_workflow_1';
|
|
551
|
+
const WORKFLOW_RUN_ID = 'wf_16fbf852-274';
|
|
552
|
+
const WORKFLOW_TASK_ID = 'wxkj4kcvd';
|
|
553
|
+
const WORKFLOW_SCRIPT = [
|
|
554
|
+
'export const meta = {',
|
|
555
|
+
" name: 'frontend-architecture-audit',",
|
|
556
|
+
" description: 'Evidence-based audit of the frontend',",
|
|
557
|
+
" phases: [{ title: 'Audit', detail: 'parallel deep-dives per module cluster' }],",
|
|
558
|
+
'}',
|
|
559
|
+
].join('\n');
|
|
560
|
+
/**
|
|
561
|
+
* The `<task-notification>` a finished workflow reports through, as the
|
|
562
|
+
* harness writes it — the real one in session 4820c6b8 carries a JSON
|
|
563
|
+
* `<result>` and names the tool call that launched the run.
|
|
564
|
+
*/
|
|
565
|
+
const workflowNotificationText = (status) => [
|
|
566
|
+
'<task-notification>',
|
|
567
|
+
`<task-id>${WORKFLOW_TASK_ID}</task-id>`,
|
|
568
|
+
`<tool-use-id>${WORKFLOW_TOOL_USE_ID}</tool-use-id>`,
|
|
569
|
+
`<output-file>/tmp/claude-1000/tasks/${WORKFLOW_TASK_ID}.output</output-file>`,
|
|
570
|
+
`<status>${status}</status>`,
|
|
571
|
+
'<summary>Dynamic workflow "Evidence-based audit of the frontend" completed</summary>',
|
|
572
|
+
'<result>{"audits":[{"area":"src/modules/chat","summary":"three large hooks"}]}</result>',
|
|
573
|
+
'</task-notification>',
|
|
574
|
+
].join('\n');
|
|
575
|
+
/**
|
|
576
|
+
* Writes the rows a `Workflow` launch leaves in a session transcript, copied
|
|
577
|
+
* from session 4820c6b8-b23b-468f-b462-74162ecf0f24 (lines 107, 108 and 294),
|
|
578
|
+
* plus the run's journal under `<session>/subagents/workflows/<runId>/`.
|
|
579
|
+
*
|
|
580
|
+
* The journal records three agents: one finished, one failed, one still
|
|
581
|
+
* going — the shape a run has partway through its second phase.
|
|
582
|
+
*/
|
|
583
|
+
async function writeClaudeWorkflowSession(projectDirectory, { notification, journal }) {
|
|
584
|
+
const parentPath = path.join(projectDirectory, `${WORKFLOW_SESSION_ID}.jsonl`);
|
|
585
|
+
const transcriptDir = path.join(projectDirectory, WORKFLOW_SESSION_ID, 'subagents', 'workflows', WORKFLOW_RUN_ID);
|
|
586
|
+
const scriptPath = path.join(projectDirectory, WORKFLOW_SESSION_ID, 'workflows', 'scripts', `frontend-architecture-audit-${WORKFLOW_RUN_ID}.js`);
|
|
587
|
+
const parentLines = [
|
|
588
|
+
{
|
|
589
|
+
type: 'assistant',
|
|
590
|
+
uuid: 'assistant-wf-1',
|
|
591
|
+
sessionId: WORKFLOW_SESSION_ID,
|
|
592
|
+
timestamp: '2026-08-21T10:32:10.000Z',
|
|
593
|
+
message: {
|
|
594
|
+
role: 'assistant',
|
|
595
|
+
content: [{
|
|
596
|
+
type: 'tool_use',
|
|
597
|
+
id: WORKFLOW_TOOL_USE_ID,
|
|
598
|
+
name: 'Workflow',
|
|
599
|
+
input: { script: WORKFLOW_SCRIPT, description: 'Parallel frontend architecture audit' },
|
|
600
|
+
}],
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
type: 'user',
|
|
605
|
+
uuid: 'workflow-ack-1',
|
|
606
|
+
sessionId: WORKFLOW_SESSION_ID,
|
|
607
|
+
timestamp: '2026-08-21T10:32:15.657Z',
|
|
608
|
+
message: {
|
|
609
|
+
role: 'user',
|
|
610
|
+
content: [{
|
|
611
|
+
type: 'tool_result',
|
|
612
|
+
tool_use_id: WORKFLOW_TOOL_USE_ID,
|
|
613
|
+
content: `Workflow launched in background. Task ID: ${WORKFLOW_TASK_ID}\nSummary: Evidence-based audit of the frontend\nTranscript dir: ${transcriptDir}\nScript file: ${scriptPath}`,
|
|
614
|
+
}],
|
|
615
|
+
},
|
|
616
|
+
toolUseResult: {
|
|
617
|
+
status: 'async_launched',
|
|
618
|
+
taskId: WORKFLOW_TASK_ID,
|
|
619
|
+
taskType: 'local_workflow',
|
|
620
|
+
workflowName: 'frontend-architecture-audit',
|
|
621
|
+
runId: WORKFLOW_RUN_ID,
|
|
622
|
+
summary: 'Evidence-based audit of the frontend',
|
|
623
|
+
transcriptDir,
|
|
624
|
+
scriptPath,
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
];
|
|
628
|
+
if (notification === 'user') {
|
|
629
|
+
parentLines.push({
|
|
630
|
+
type: 'user',
|
|
631
|
+
uuid: 'workflow-notification-1',
|
|
632
|
+
sessionId: WORKFLOW_SESSION_ID,
|
|
633
|
+
timestamp: '2026-08-21T11:27:00.000Z',
|
|
634
|
+
message: { role: 'user', content: [{ type: 'text', text: workflowNotificationText('completed') }] },
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
else if (notification !== 'none') {
|
|
638
|
+
parentLines.push({
|
|
639
|
+
type: 'queue-operation',
|
|
640
|
+
operation: notification,
|
|
641
|
+
timestamp: '2026-08-21T11:27:00.000Z',
|
|
642
|
+
sessionId: WORKFLOW_SESSION_ID,
|
|
643
|
+
content: workflowNotificationText('completed'),
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
await writeFile(parentPath, `${parentLines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf8');
|
|
647
|
+
if (journal) {
|
|
648
|
+
await mkdir(transcriptDir, { recursive: true });
|
|
649
|
+
const journalLines = [
|
|
650
|
+
{ type: 'launched' },
|
|
651
|
+
{ type: 'started', key: 'v2:one', agentId: 'aa1e064cf8bd159d6', label: 'audit:chat', phase: 'Audit' },
|
|
652
|
+
{ type: 'started', key: 'v2:two', agentId: 'a9cfe29aa8f2afcbf', label: 'audit:sidebar', phase: 'Audit' },
|
|
653
|
+
{ type: 'result', key: 'v2:one', agentId: 'aa1e064cf8bd159d6', result: { area: 'chat' } },
|
|
654
|
+
{ type: 'failed', key: 'v2:two', agentId: 'a9cfe29aa8f2afcbf' },
|
|
655
|
+
{ type: 'started', key: 'v2:three', agentId: 'ab89f2cde612a51b1', label: 'synthesize', phase: 'Synthesize' },
|
|
656
|
+
];
|
|
657
|
+
await writeFile(path.join(transcriptDir, 'journal.jsonl'), `${journalLines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf8');
|
|
658
|
+
}
|
|
659
|
+
return parentPath;
|
|
660
|
+
}
|
|
661
|
+
for (const notification of ['user', 'remove', 'enqueue']) {
|
|
662
|
+
test(`Claude history folds a workflow's ${notification} notification onto the call that launched it`, { concurrency: false }, async () => {
|
|
663
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-workflow-notification-'));
|
|
664
|
+
try {
|
|
665
|
+
const parentPath = await writeClaudeWorkflowSession(tempRoot, { notification, journal: true });
|
|
666
|
+
await withIsolatedDatabase(async () => {
|
|
667
|
+
const now = new Date().toISOString();
|
|
668
|
+
sessionsDb.createSession(WORKFLOW_SESSION_ID, 'claude', tempRoot, 'Workflow session', now, now, parentPath);
|
|
669
|
+
// The run is over, so no process is up — and that must not matter:
|
|
670
|
+
// the notification is the outcome.
|
|
671
|
+
const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(WORKFLOW_SESSION_ID, {
|
|
672
|
+
providerSessionId: WORKFLOW_SESSION_ID,
|
|
673
|
+
});
|
|
674
|
+
const workflowRow = history.messages.find((message) => message.kind === 'tool_use' && message.toolId === WORKFLOW_TOOL_USE_ID);
|
|
675
|
+
// A Workflow launch has no `agentId`, so the fold used to skip it: the
|
|
676
|
+
// card kept "Workflow launched in background. Task ID: …" as its result
|
|
677
|
+
// for good while the report rendered as a raw user bubble — or, for a
|
|
678
|
+
// queue-operation record, nowhere.
|
|
679
|
+
assert.equal(workflowRow?.toolResult?.content, '{"audits":[{"area":"src/modules/chat","summary":"three large hooks"}]}');
|
|
680
|
+
assert.equal(workflowRow?.workflow?.status, 'completed');
|
|
681
|
+
assert.equal(workflowRow?.workflow?.name, 'frontend-architecture-audit');
|
|
682
|
+
assert.equal(workflowRow?.workflow?.runId, WORKFLOW_RUN_ID);
|
|
683
|
+
assert.equal(workflowRow?.workflow?.description, 'Evidence-based audit of the frontend');
|
|
684
|
+
assert.match(workflowRow?.workflow?.scriptPath ?? '', /frontend-architecture-audit-wf_16fbf852-274\.js$/);
|
|
685
|
+
const strayNotification = history.messages.find((message) => typeof message.content === 'string' && message.content.includes('<task-notification>'));
|
|
686
|
+
assert.equal(strayNotification, undefined, 'the folded notification must not also render on its own');
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
finally {
|
|
690
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
test('Claude history reads a workflow\'s agents and their progress from its journal', { concurrency: false }, async () => {
|
|
695
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-workflow-journal-'));
|
|
696
|
+
try {
|
|
697
|
+
const parentPath = await writeClaudeWorkflowSession(tempRoot, { notification: 'none', journal: true });
|
|
698
|
+
await withIsolatedDatabase(async () => {
|
|
699
|
+
const now = new Date().toISOString();
|
|
700
|
+
sessionsDb.createSession(WORKFLOW_SESSION_ID, 'claude', tempRoot, 'Workflow session', now, now, parentPath);
|
|
701
|
+
// The run that launched the workflow is still up, so it is still going.
|
|
702
|
+
const liveRunStartedAt = Date.parse('2026-08-21T10:30:00.000Z');
|
|
703
|
+
const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => liveRunStartedAt }).fetchHistory(WORKFLOW_SESSION_ID, {
|
|
704
|
+
providerSessionId: WORKFLOW_SESSION_ID,
|
|
705
|
+
});
|
|
706
|
+
const workflowRow = history.messages.find((message) => message.kind === 'tool_use' && message.toolId === WORKFLOW_TOOL_USE_ID);
|
|
707
|
+
assert.equal(workflowRow?.workflow?.status, 'running');
|
|
708
|
+
assert.equal(workflowRow?.toolResult?.content, '', 'the launch acknowledgement must never show as a result');
|
|
709
|
+
// An agent with a `started` record and no `result` or `failed` is the
|
|
710
|
+
// one still working; the journal is the only place that says so.
|
|
711
|
+
assert.deepEqual(workflowRow?.workflow?.agents, [
|
|
712
|
+
{ id: 'aa1e064cf8bd159d6', label: 'audit:chat', phase: 'Audit', status: 'completed' },
|
|
713
|
+
{ id: 'a9cfe29aa8f2afcbf', label: 'audit:sidebar', phase: 'Audit', status: 'failed' },
|
|
714
|
+
{ id: 'ab89f2cde612a51b1', label: 'synthesize', phase: 'Synthesize', status: 'running' },
|
|
715
|
+
]);
|
|
716
|
+
assert.deepEqual(workflowRow?.workflow?.agentCounts, { total: 3, completed: 1, failed: 1, running: 1, stopped: 0 });
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
finally {
|
|
720
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
test('Claude history does not keep a journal agent running once the workflow itself has settled', { concurrency: false }, async () => {
|
|
724
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-workflow-orphan-agent-'));
|
|
725
|
+
try {
|
|
726
|
+
// The journal has a `started` with no `result` or `failed` for the
|
|
727
|
+
// synthesize step, but the run has reported completed — a resume re-ran
|
|
728
|
+
// that step under a new id, or the run was stopped under it. Either way
|
|
729
|
+
// nothing is still going, and a pulsing dot on a finished card said
|
|
730
|
+
// otherwise.
|
|
731
|
+
const parentPath = await writeClaudeWorkflowSession(tempRoot, { notification: 'user', journal: true });
|
|
732
|
+
await withIsolatedDatabase(async () => {
|
|
733
|
+
const now = new Date().toISOString();
|
|
734
|
+
sessionsDb.createSession(WORKFLOW_SESSION_ID, 'claude', tempRoot, 'Workflow session', now, now, parentPath);
|
|
735
|
+
const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(WORKFLOW_SESSION_ID, {
|
|
736
|
+
providerSessionId: WORKFLOW_SESSION_ID,
|
|
737
|
+
});
|
|
738
|
+
const workflowRow = history.messages.find((message) => message.kind === 'tool_use' && message.toolId === WORKFLOW_TOOL_USE_ID);
|
|
739
|
+
assert.equal(workflowRow?.workflow?.status, 'completed');
|
|
740
|
+
assert.deepEqual(workflowRow?.workflow?.agents.map((agent) => agent.status), ['completed', 'failed', 'stopped']);
|
|
741
|
+
assert.deepEqual(workflowRow?.workflow?.agentCounts, { total: 3, completed: 1, failed: 1, running: 0, stopped: 1 });
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
finally {
|
|
745
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
test('Claude history reports a workflow stopped, with no agents, when its run and journal are both gone', { concurrency: false }, async () => {
|
|
749
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-workflow-forked-'));
|
|
750
|
+
try {
|
|
751
|
+
// A fork copies the parent's `.jsonl` rows and nothing else: no
|
|
752
|
+
// `subagents/workflows/` directory, no queue-operation records, and no
|
|
753
|
+
// process of its own.
|
|
754
|
+
const parentPath = await writeClaudeWorkflowSession(tempRoot, { notification: 'none', journal: false });
|
|
755
|
+
await withIsolatedDatabase(async () => {
|
|
756
|
+
const now = new Date().toISOString();
|
|
757
|
+
sessionsDb.createSession(WORKFLOW_SESSION_ID, 'claude', tempRoot, 'Workflow session', now, now, parentPath);
|
|
758
|
+
const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(WORKFLOW_SESSION_ID, {
|
|
759
|
+
providerSessionId: WORKFLOW_SESSION_ID,
|
|
760
|
+
});
|
|
761
|
+
const workflowRow = history.messages.find((message) => message.kind === 'tool_use' && message.toolId === WORKFLOW_TOOL_USE_ID);
|
|
762
|
+
assert.equal(workflowRow?.workflow?.status, 'stopped');
|
|
763
|
+
assert.deepEqual(workflowRow?.workflow?.agents, []);
|
|
764
|
+
assert.deepEqual(workflowRow?.workflow?.agentCounts, { total: 0, completed: 0, failed: 0, running: 0, stopped: 0 });
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
finally {
|
|
768
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
test('Claude history folds a backgrounded Bash command\'s notification onto its call', { concurrency: false }, async () => {
|
|
772
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-background-bash-'));
|
|
773
|
+
const sessionId = 'claude-bash-session';
|
|
774
|
+
const toolUseId = 'toolu_bash_bg_1';
|
|
775
|
+
try {
|
|
776
|
+
// Copied from session 30ee5a44 (lines 167 and 234): the shell's own
|
|
777
|
+
// acknowledgement, and the report the harness later queues for it. A
|
|
778
|
+
// shell report carries no `<result>`, only its summary.
|
|
779
|
+
const rows = [
|
|
780
|
+
{
|
|
781
|
+
type: 'assistant',
|
|
782
|
+
uuid: 'assistant-bash-1',
|
|
783
|
+
sessionId,
|
|
784
|
+
timestamp: '2026-08-21T10:00:00.000Z',
|
|
785
|
+
message: {
|
|
786
|
+
role: 'assistant',
|
|
787
|
+
content: [{ type: 'tool_use', id: toolUseId, name: 'Bash', input: { command: 'npm run dev', run_in_background: true } }],
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
{
|
|
791
|
+
type: 'user',
|
|
792
|
+
uuid: 'bash-ack-1',
|
|
793
|
+
sessionId,
|
|
794
|
+
timestamp: '2026-08-21T10:00:01.000Z',
|
|
795
|
+
message: {
|
|
796
|
+
role: 'user',
|
|
797
|
+
content: [{
|
|
798
|
+
type: 'tool_result',
|
|
799
|
+
tool_use_id: toolUseId,
|
|
800
|
+
content: 'Command running in background with ID: b5xsbzu5k. Output is being written to: /tmp/tasks/b5xsbzu5k.output. You will be notified when it completes.',
|
|
801
|
+
}],
|
|
802
|
+
},
|
|
803
|
+
toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false, noOutputExpected: false, backgroundTaskId: 'b5xsbzu5k' },
|
|
804
|
+
},
|
|
805
|
+
{
|
|
806
|
+
type: 'queue-operation',
|
|
807
|
+
operation: 'enqueue',
|
|
808
|
+
timestamp: '2026-08-21T10:05:00.000Z',
|
|
809
|
+
sessionId,
|
|
810
|
+
content: [
|
|
811
|
+
'<task-notification>',
|
|
812
|
+
'<task-id>b5xsbzu5k</task-id>',
|
|
813
|
+
`<tool-use-id>${toolUseId}</tool-use-id>`,
|
|
814
|
+
'<output-file>/tmp/tasks/b5xsbzu5k.output</output-file>',
|
|
815
|
+
'<status>completed</status>',
|
|
816
|
+
'<summary>Background command "Start the dev server" completed (exit code 0)</summary>',
|
|
817
|
+
'</task-notification>',
|
|
818
|
+
].join('\n'),
|
|
819
|
+
},
|
|
820
|
+
];
|
|
821
|
+
const transcriptPath = path.join(tempRoot, `${sessionId}.jsonl`);
|
|
822
|
+
await writeFile(transcriptPath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
|
|
823
|
+
await withIsolatedDatabase(async () => {
|
|
824
|
+
const now = new Date().toISOString();
|
|
825
|
+
sessionsDb.createSession(sessionId, 'claude', tempRoot, 'Bash session', now, now, transcriptPath);
|
|
826
|
+
const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(sessionId, {
|
|
827
|
+
providerSessionId: sessionId,
|
|
828
|
+
});
|
|
829
|
+
const bashRow = history.messages.find((message) => message.kind === 'tool_use' && message.toolId === toolUseId);
|
|
830
|
+
assert.equal(bashRow?.toolResult?.content, 'Background command "Start the dev server" completed (exit code 0)');
|
|
831
|
+
// Without the report, the shell's acknowledgement stays: unlike an
|
|
832
|
+
// agent's or a workflow's, it names the output file, which is the only
|
|
833
|
+
// handle on the command's output until it reports.
|
|
834
|
+
await writeFile(transcriptPath, `${rows.slice(0, 2).map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
|
|
835
|
+
const unreported = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(sessionId, {
|
|
836
|
+
providerSessionId: sessionId,
|
|
837
|
+
});
|
|
838
|
+
const unreportedRow = unreported.messages.find((message) => message.kind === 'tool_use' && message.toolId === toolUseId);
|
|
839
|
+
assert.match(unreportedRow?.toolResult?.content ?? '', /Output is being written to: \/tmp\/tasks\/b5xsbzu5k\.output/);
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
finally {
|
|
843
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
test('a long workflow agent timeline keeps its newest steps within the transport cap', { concurrency: false }, async () => {
|
|
847
|
+
// The timeline is polled while the agent runs for what it is doing now; a
|
|
848
|
+
// cap that kept the first 200 steps would freeze it there, and the card's
|
|
849
|
+
// "N earlier steps are not included" would be the wrong way round.
|
|
850
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-workflow-agent-long-'));
|
|
851
|
+
try {
|
|
852
|
+
const parentPath = await writeClaudeWorkflowSession(tempRoot, { notification: 'none', journal: true });
|
|
853
|
+
const runDir = path.join(tempRoot, WORKFLOW_SESSION_ID, 'subagents', 'workflows', WORKFLOW_RUN_ID);
|
|
854
|
+
const agentId = 'ab89f2cde612a51b1';
|
|
855
|
+
const rows = [
|
|
856
|
+
{ isSidechain: true, agentId, type: 'user', uuid: 'wa-u1', timestamp: '2026-08-21T10:40:00.000Z', message: { role: 'user', content: 'Synthesize the audits.' } },
|
|
857
|
+
...Array.from({ length: 205 }, (_, index) => ({
|
|
858
|
+
isSidechain: true,
|
|
859
|
+
agentId,
|
|
860
|
+
type: 'assistant',
|
|
861
|
+
uuid: `wa-a${index + 1}`,
|
|
862
|
+
timestamp: new Date(Date.parse('2026-08-21T10:40:05.000Z') + index * 1_000).toISOString(),
|
|
863
|
+
message: { role: 'assistant', model: 'claude-opus-4-1', content: [{ type: 'thinking', thinking: `step ${index + 1}` }] },
|
|
864
|
+
})),
|
|
865
|
+
];
|
|
866
|
+
await writeFile(path.join(runDir, `agent-${agentId}.jsonl`), `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
|
|
867
|
+
await withIsolatedDatabase(async () => {
|
|
868
|
+
const now = new Date().toISOString();
|
|
869
|
+
sessionsDb.createSession(WORKFLOW_SESSION_ID, 'claude', tempRoot, 'Workflow session', now, now, parentPath);
|
|
870
|
+
const live = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => Date.parse('2026-08-21T10:30:00.000Z') })
|
|
871
|
+
.readWorkflowAgentActivity(WORKFLOW_SESSION_ID, WORKFLOW_RUN_ID, agentId);
|
|
872
|
+
assert.equal(live?.activityCount, 205);
|
|
873
|
+
assert.equal(live?.activity.length, 200);
|
|
874
|
+
assert.equal(live?.activity[0]?.content, 'step 6');
|
|
875
|
+
assert.equal(live?.activity.at(-1)?.content, 'step 205');
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
finally {
|
|
879
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
test('a workflow agent the journal has not settled is running only while the run that spawned it is', { concurrency: false }, async () => {
|
|
883
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-workflow-agent-activity-'));
|
|
884
|
+
try {
|
|
885
|
+
const parentPath = await writeClaudeWorkflowSession(tempRoot, { notification: 'none', journal: true });
|
|
886
|
+
// The synthesize step's transcript: the journal says `started` and nothing
|
|
887
|
+
// more, so only the process tells whether it is still working.
|
|
888
|
+
const runDir = path.join(tempRoot, WORKFLOW_SESSION_ID, 'subagents', 'workflows', WORKFLOW_RUN_ID);
|
|
889
|
+
const agentId = 'ab89f2cde612a51b1';
|
|
890
|
+
const rows = [
|
|
891
|
+
{ isSidechain: true, agentId, type: 'user', uuid: 'wa-u1', timestamp: '2026-08-21T10:40:00.000Z', message: { role: 'user', content: 'Synthesize the audits.' } },
|
|
892
|
+
{ isSidechain: true, agentId, type: 'assistant', uuid: 'wa-a1', timestamp: '2026-08-21T10:40:05.000Z', message: { role: 'assistant', model: 'claude-opus-4-1', content: [{ type: 'thinking', thinking: 'Two audits to merge.' }, { type: 'tool_use', id: 'toolu_wa_1', name: 'Read', input: { file_path: '/repo/audits.json' } }] } },
|
|
893
|
+
];
|
|
894
|
+
await writeFile(path.join(runDir, `agent-${agentId}.jsonl`), `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
|
|
895
|
+
await withIsolatedDatabase(async () => {
|
|
896
|
+
const now = new Date().toISOString();
|
|
897
|
+
sessionsDb.createSession(WORKFLOW_SESSION_ID, 'claude', tempRoot, 'Workflow session', now, now, parentPath);
|
|
898
|
+
// The run that spawned the agent is still up: it started before the
|
|
899
|
+
// agent's first row was written.
|
|
900
|
+
const live = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => Date.parse('2026-08-21T10:30:00.000Z') })
|
|
901
|
+
.readWorkflowAgentActivity(WORKFLOW_SESSION_ID, WORKFLOW_RUN_ID, agentId);
|
|
902
|
+
assert.deepEqual(live?.agent, { id: agentId, label: 'synthesize', model: 'claude-opus-4-1', status: 'running' });
|
|
903
|
+
assert.equal(live?.activityCount, 2);
|
|
904
|
+
assert.deepEqual(live?.activity.map((entry) => entry.kind), ['thinking', 'tool']);
|
|
905
|
+
assert.equal(live?.activity[0]?.content, 'Two audits to merge.');
|
|
906
|
+
assert.equal(live?.activity[1]?.toolName, 'Read');
|
|
907
|
+
// A later run of the session is a new process; the agent's own is gone.
|
|
908
|
+
const orphaned = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => Date.parse('2026-08-21T11:00:00.000Z') })
|
|
909
|
+
.readWorkflowAgentActivity(WORKFLOW_SESSION_ID, WORKFLOW_RUN_ID, agentId);
|
|
910
|
+
assert.equal(orphaned?.agent.status, 'stopped');
|
|
911
|
+
// No transcript for this agent: nothing to show, which the route turns
|
|
912
|
+
// into a 404 rather than an empty timeline.
|
|
913
|
+
const missing = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null })
|
|
914
|
+
.readWorkflowAgentActivity(WORKFLOW_SESSION_ID, WORKFLOW_RUN_ID, 'aa1e064cf8bd159d6');
|
|
915
|
+
assert.equal(missing, null);
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
finally {
|
|
919
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
test('the live stream\'s background-task events normalize to task_status', () => {
|
|
923
|
+
// Verified against a real query: the SDK reports on a launched workflow,
|
|
924
|
+
// agent or shell command through four `system` subtypes the transcript
|
|
925
|
+
// never records. They used to fall through every branch of the normalizer
|
|
926
|
+
// and vanish, so a running workflow's card could not move off its launch
|
|
927
|
+
// acknowledgement until the whole run had finished and history reloaded.
|
|
928
|
+
const provider = new ClaudeSessionsProvider();
|
|
929
|
+
const normalize = (raw) => provider.normalizeMessage({ type: 'system', session_id: WORKFLOW_SESSION_ID, uuid: `evt-${String(raw.subtype)}`, ...raw }, WORKFLOW_SESSION_ID);
|
|
930
|
+
const [started] = normalize({
|
|
931
|
+
subtype: 'task_started',
|
|
932
|
+
task_id: WORKFLOW_TASK_ID,
|
|
933
|
+
tool_use_id: WORKFLOW_TOOL_USE_ID,
|
|
934
|
+
description: 'Parallel frontend architecture audit',
|
|
935
|
+
task_type: 'local_workflow',
|
|
936
|
+
workflow_name: 'frontend-architecture-audit',
|
|
937
|
+
});
|
|
938
|
+
assert.equal(started?.kind, 'task_status');
|
|
939
|
+
assert.equal(started?.event, 'started');
|
|
940
|
+
assert.equal(started?.taskId, WORKFLOW_TASK_ID);
|
|
941
|
+
assert.equal(started?.toolUseId, WORKFLOW_TOOL_USE_ID);
|
|
942
|
+
assert.equal(started?.taskType, 'local_workflow');
|
|
943
|
+
assert.equal(started?.workflowName, 'frontend-architecture-audit');
|
|
944
|
+
assert.equal(started?.description, 'Parallel frontend architecture audit');
|
|
945
|
+
const [progress] = normalize({
|
|
946
|
+
subtype: 'task_progress',
|
|
947
|
+
task_id: WORKFLOW_TASK_ID,
|
|
948
|
+
tool_use_id: WORKFLOW_TOOL_USE_ID,
|
|
949
|
+
description: 'Parallel frontend architecture audit',
|
|
950
|
+
usage: { total_tokens: 12_345, tool_uses: 12, duration_ms: 65_000 },
|
|
951
|
+
summary: 'Verify 3/6',
|
|
952
|
+
});
|
|
953
|
+
assert.equal(progress?.kind, 'task_status');
|
|
954
|
+
assert.equal(progress?.event, 'progress');
|
|
955
|
+
assert.deepEqual(progress?.usage, { totalTokens: 12_345, toolUses: 12, durationMs: 65_000 });
|
|
956
|
+
assert.equal(progress?.summary, 'Verify 3/6');
|
|
957
|
+
// `task_updated` names no tool call — the client has to remember the task
|
|
958
|
+
// id from the start event — and spells a stop `killed`.
|
|
959
|
+
const [updated] = normalize({
|
|
960
|
+
subtype: 'task_updated',
|
|
961
|
+
task_id: WORKFLOW_TASK_ID,
|
|
962
|
+
patch: { status: 'killed', end_time: 1_700_000_000_000 },
|
|
963
|
+
});
|
|
964
|
+
assert.equal(updated?.kind, 'task_status');
|
|
965
|
+
assert.equal(updated?.event, 'updated');
|
|
966
|
+
assert.equal(updated?.taskId, WORKFLOW_TASK_ID);
|
|
967
|
+
assert.equal(updated?.toolUseId, undefined);
|
|
968
|
+
assert.equal(updated?.status, 'stopped');
|
|
969
|
+
const [notification] = normalize({
|
|
970
|
+
subtype: 'task_notification',
|
|
971
|
+
task_id: WORKFLOW_TASK_ID,
|
|
972
|
+
tool_use_id: WORKFLOW_TOOL_USE_ID,
|
|
973
|
+
status: 'completed',
|
|
974
|
+
summary: 'Dynamic workflow "Evidence-based audit of the frontend" completed',
|
|
975
|
+
output_file: `/tmp/claude-1000/tasks/${WORKFLOW_TASK_ID}.output`,
|
|
976
|
+
});
|
|
977
|
+
assert.equal(notification?.kind, 'task_status');
|
|
978
|
+
assert.equal(notification?.event, 'notification');
|
|
979
|
+
assert.equal(notification?.status, 'completed');
|
|
980
|
+
assert.equal(notification?.summary, 'Dynamic workflow "Evidence-based audit of the frontend" completed');
|
|
981
|
+
assert.equal(notification?.outputFile, `/tmp/claude-1000/tasks/${WORKFLOW_TASK_ID}.output`);
|
|
982
|
+
// Every other `system` subtype still normalizes to nothing.
|
|
983
|
+
assert.deepEqual(normalize({ subtype: 'hook_started', task_id: 'irrelevant' }), []);
|
|
984
|
+
assert.deepEqual(normalize({ subtype: 'init' }), []);
|
|
985
|
+
});
|
|
986
|
+
test('a workflow\'s progress event reports where each of its agents stands', () => {
|
|
987
|
+
// Verified against a real query (SDK 0.3.165 / CLI 2.1.274): a workflow's
|
|
988
|
+
// `task_progress` carries an undocumented `workflow_progress` with one entry
|
|
989
|
+
// per agent the script spawned, and its task-level `last_tool_name` is the
|
|
990
|
+
// current agent's label, not a tool — on every batch, with or without the
|
|
991
|
+
// agent list. Read as a tool, the card's usage line said
|
|
992
|
+
// "32 tool uses · 3m 30s · You are the analysis step of…".
|
|
993
|
+
const provider = new ClaudeSessionsProvider();
|
|
994
|
+
const [progress] = provider.normalizeMessage({
|
|
995
|
+
type: 'system',
|
|
996
|
+
subtype: 'task_progress',
|
|
997
|
+
session_id: WORKFLOW_SESSION_ID,
|
|
998
|
+
uuid: 'evt-workflow-progress',
|
|
999
|
+
task_id: WORKFLOW_TASK_ID,
|
|
1000
|
+
tool_use_id: WORKFLOW_TOOL_USE_ID,
|
|
1001
|
+
description: 'audit:sidebar',
|
|
1002
|
+
summary: 'Evidence-based audit of the frontend',
|
|
1003
|
+
usage: { total_tokens: 12_345, tool_uses: 32, duration_ms: 210_000 },
|
|
1004
|
+
last_tool_name: 'audit:sidebar',
|
|
1005
|
+
workflow_progress: [
|
|
1006
|
+
{
|
|
1007
|
+
type: 'workflow_agent',
|
|
1008
|
+
index: 0,
|
|
1009
|
+
label: 'audit:chat',
|
|
1010
|
+
phaseIndex: 0,
|
|
1011
|
+
phaseTitle: 'Audit',
|
|
1012
|
+
agentId: 'aa1e064cf8bd159d6',
|
|
1013
|
+
model: 'claude-opus-4-1',
|
|
1014
|
+
state: 'done',
|
|
1015
|
+
startedAt: 1_700_000_000_000,
|
|
1016
|
+
tokens: 8_000,
|
|
1017
|
+
toolCalls: 20,
|
|
1018
|
+
durationMs: 120_000,
|
|
1019
|
+
resultPreview: 'Three large hooks carry most of the module.',
|
|
1020
|
+
},
|
|
1021
|
+
{
|
|
1022
|
+
type: 'workflow_agent',
|
|
1023
|
+
index: 1,
|
|
1024
|
+
label: 'audit:sidebar',
|
|
1025
|
+
phaseIndex: 0,
|
|
1026
|
+
phaseTitle: 'Audit',
|
|
1027
|
+
agentId: 'a9cfe29aa8f2afcbf',
|
|
1028
|
+
state: 'progress',
|
|
1029
|
+
startedAt: 1_700_000_120_000,
|
|
1030
|
+
lastToolName: 'Grep',
|
|
1031
|
+
lastToolSummary: 'useSidebar',
|
|
1032
|
+
promptPreview: 'You are the analysis step of a code-quality audit',
|
|
1033
|
+
lastProgressAt: 1_700_000_200_000,
|
|
1034
|
+
tokens: 4_000,
|
|
1035
|
+
toolCalls: 12,
|
|
1036
|
+
},
|
|
1037
|
+
// Started but not yet spawned: no transcript, so no id.
|
|
1038
|
+
{ type: 'workflow_agent', index: 2, label: 'synthesize', state: 'start', queuedAt: 1_700_000_200_000 },
|
|
1039
|
+
{ type: 'workflow_agent', index: 3, label: 'audit:files', agentId: 'ab89f2cde612a51b1', state: 'start', startedAt: 1_700_000_201_000 },
|
|
1040
|
+
{ type: 'workflow_agent', index: 4, label: 'audit:terminal', agentId: 'ac89f2cde612a51b2', state: 'error', attempt: 2 },
|
|
1041
|
+
],
|
|
1042
|
+
}, WORKFLOW_SESSION_ID);
|
|
1043
|
+
assert.equal(progress?.kind, 'task_status');
|
|
1044
|
+
assert.equal(progress?.event, 'progress');
|
|
1045
|
+
assert.deepEqual(progress?.usage, { totalTokens: 12_345, toolUses: 32, durationMs: 210_000 });
|
|
1046
|
+
assert.equal('lastToolName' in (progress ?? {}), false, 'the current agent\'s label must not pass as a tool name');
|
|
1047
|
+
assert.deepEqual(progress?.agents, [
|
|
1048
|
+
{
|
|
1049
|
+
index: 0,
|
|
1050
|
+
label: 'audit:chat',
|
|
1051
|
+
phase: 'Audit',
|
|
1052
|
+
agentId: 'aa1e064cf8bd159d6',
|
|
1053
|
+
model: 'claude-opus-4-1',
|
|
1054
|
+
state: 'done',
|
|
1055
|
+
startedAt: 1_700_000_000_000,
|
|
1056
|
+
lastToolName: undefined,
|
|
1057
|
+
lastToolSummary: undefined,
|
|
1058
|
+
promptPreview: undefined,
|
|
1059
|
+
tokens: 8_000,
|
|
1060
|
+
toolCalls: 20,
|
|
1061
|
+
durationMs: 120_000,
|
|
1062
|
+
resultPreview: 'Three large hooks carry most of the module.',
|
|
1063
|
+
},
|
|
1064
|
+
{
|
|
1065
|
+
index: 1,
|
|
1066
|
+
label: 'audit:sidebar',
|
|
1067
|
+
phase: 'Audit',
|
|
1068
|
+
agentId: 'a9cfe29aa8f2afcbf',
|
|
1069
|
+
model: undefined,
|
|
1070
|
+
state: 'running',
|
|
1071
|
+
startedAt: 1_700_000_120_000,
|
|
1072
|
+
lastToolName: 'Grep',
|
|
1073
|
+
lastToolSummary: 'useSidebar',
|
|
1074
|
+
promptPreview: 'You are the analysis step of a code-quality audit',
|
|
1075
|
+
tokens: 4_000,
|
|
1076
|
+
toolCalls: 12,
|
|
1077
|
+
durationMs: undefined,
|
|
1078
|
+
resultPreview: undefined,
|
|
1079
|
+
},
|
|
1080
|
+
{
|
|
1081
|
+
index: 2,
|
|
1082
|
+
label: 'synthesize',
|
|
1083
|
+
phase: undefined,
|
|
1084
|
+
agentId: undefined,
|
|
1085
|
+
model: undefined,
|
|
1086
|
+
state: 'queued',
|
|
1087
|
+
startedAt: undefined,
|
|
1088
|
+
lastToolName: undefined,
|
|
1089
|
+
lastToolSummary: undefined,
|
|
1090
|
+
promptPreview: undefined,
|
|
1091
|
+
tokens: undefined,
|
|
1092
|
+
toolCalls: undefined,
|
|
1093
|
+
durationMs: undefined,
|
|
1094
|
+
resultPreview: undefined,
|
|
1095
|
+
},
|
|
1096
|
+
{
|
|
1097
|
+
index: 3,
|
|
1098
|
+
label: 'audit:files',
|
|
1099
|
+
phase: undefined,
|
|
1100
|
+
agentId: 'ab89f2cde612a51b1',
|
|
1101
|
+
model: undefined,
|
|
1102
|
+
state: 'running',
|
|
1103
|
+
startedAt: 1_700_000_201_000,
|
|
1104
|
+
lastToolName: undefined,
|
|
1105
|
+
lastToolSummary: undefined,
|
|
1106
|
+
promptPreview: undefined,
|
|
1107
|
+
tokens: undefined,
|
|
1108
|
+
toolCalls: undefined,
|
|
1109
|
+
durationMs: undefined,
|
|
1110
|
+
resultPreview: undefined,
|
|
1111
|
+
},
|
|
1112
|
+
{
|
|
1113
|
+
index: 4,
|
|
1114
|
+
label: 'audit:terminal',
|
|
1115
|
+
phase: undefined,
|
|
1116
|
+
agentId: 'ac89f2cde612a51b2',
|
|
1117
|
+
model: undefined,
|
|
1118
|
+
state: 'failed',
|
|
1119
|
+
startedAt: undefined,
|
|
1120
|
+
lastToolName: undefined,
|
|
1121
|
+
lastToolSummary: undefined,
|
|
1122
|
+
promptPreview: undefined,
|
|
1123
|
+
tokens: undefined,
|
|
1124
|
+
toolCalls: undefined,
|
|
1125
|
+
durationMs: undefined,
|
|
1126
|
+
resultPreview: undefined,
|
|
1127
|
+
},
|
|
1128
|
+
]);
|
|
1129
|
+
// A throttled batch — agents mid-step only — comes without the agent list
|
|
1130
|
+
// but still names the current agent as the tool.
|
|
1131
|
+
const [throttled] = provider.normalizeMessage({
|
|
1132
|
+
type: 'system',
|
|
1133
|
+
subtype: 'task_progress',
|
|
1134
|
+
session_id: WORKFLOW_SESSION_ID,
|
|
1135
|
+
uuid: 'evt-workflow-progress-throttled',
|
|
1136
|
+
task_id: WORKFLOW_TASK_ID,
|
|
1137
|
+
tool_use_id: WORKFLOW_TOOL_USE_ID,
|
|
1138
|
+
description: 'Audit: audit:sidebar',
|
|
1139
|
+
usage: { total_tokens: 12_400, tool_uses: 33, duration_ms: 212_000 },
|
|
1140
|
+
last_tool_name: 'audit:sidebar',
|
|
1141
|
+
}, WORKFLOW_SESSION_ID);
|
|
1142
|
+
assert.equal('lastToolName' in (throttled ?? {}), false);
|
|
1143
|
+
assert.equal(throttled?.agents, undefined);
|
|
1144
|
+
// An agent's own progress carries no agent list.
|
|
1145
|
+
const [agentProgress] = provider.normalizeMessage({
|
|
1146
|
+
type: 'system',
|
|
1147
|
+
subtype: 'task_progress',
|
|
1148
|
+
session_id: WORKFLOW_SESSION_ID,
|
|
1149
|
+
uuid: 'evt-agent-progress',
|
|
1150
|
+
task_id: 'agent-task-1',
|
|
1151
|
+
tool_use_id: 'toolu_agent_1',
|
|
1152
|
+
description: 'Survey the repo',
|
|
1153
|
+
usage: { total_tokens: 1, tool_uses: 3, duration_ms: 1 },
|
|
1154
|
+
last_tool_name: 'Read',
|
|
1155
|
+
}, WORKFLOW_SESSION_ID);
|
|
1156
|
+
assert.equal(agentProgress?.agents, undefined);
|
|
1157
|
+
});
|
|
1158
|
+
test('Claude history still settles a synchronous agent that stops mid tool call', { concurrency: false }, async () => {
|
|
1159
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-agent-'));
|
|
1160
|
+
try {
|
|
1161
|
+
const parentPath = await writeClaudeSubagentSession(tempRoot);
|
|
1162
|
+
await dropTaskNotification(parentPath);
|
|
1163
|
+
// A synchronous agent hands its answer back on its own tool result and
|
|
1164
|
+
// never sends a notification, so "no notification" must not read as
|
|
1165
|
+
// "still running" for one — not even with a dangling tool call, the
|
|
1166
|
+
// strongest possible hint that it stopped mid-flight.
|
|
1167
|
+
const parentRaw = await readFile(parentPath, 'utf8');
|
|
1168
|
+
await writeFile(parentPath, parentRaw.replace('"isAsync":true', '"isAsync":false'), 'utf8');
|
|
1169
|
+
const agentPath = path.join(tempRoot, SESSION_ID, 'subagents', `agent-${AGENT_ID}.jsonl`);
|
|
1170
|
+
const agentRaw = await readFile(agentPath, 'utf8');
|
|
1171
|
+
await writeFile(agentPath, `${agentRaw.split('\n').filter((line) => line && !line.includes('tool_result')).join('\n')}\n`, 'utf8');
|
|
1172
|
+
await withIsolatedDatabase(async () => {
|
|
1173
|
+
const now = new Date().toISOString();
|
|
1174
|
+
sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
|
|
1175
|
+
const history = await new ClaudeSessionsProvider().fetchHistory(SESSION_ID, {
|
|
1176
|
+
providerSessionId: SESSION_ID,
|
|
1177
|
+
});
|
|
1178
|
+
const agentRow = history.messages.find((message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID);
|
|
1179
|
+
assert.equal(agentRow?.subagent?.status, 'completed');
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
finally {
|
|
1183
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
test('Claude history trims a subagent timeline down to a preview', { concurrency: false }, async () => {
|
|
1187
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-big-agent-'));
|
|
1188
|
+
try {
|
|
1189
|
+
const parentPath = await writeClaudeSubagentSession(tempRoot);
|
|
1190
|
+
// One child command with a very large output, which is what makes an
|
|
1191
|
+
// agent-heavy session's history payload balloon.
|
|
1192
|
+
const hugeOutput = 'x'.repeat(50_000);
|
|
1193
|
+
const agentPath = path.join(tempRoot, SESSION_ID, 'subagents', `agent-${AGENT_ID}.jsonl`);
|
|
1194
|
+
const agentRaw = await readFile(agentPath, 'utf8');
|
|
1195
|
+
const enlarged = agentRaw
|
|
1196
|
+
.split('\n')
|
|
1197
|
+
.filter(Boolean)
|
|
1198
|
+
.map((line) => {
|
|
1199
|
+
const entry = JSON.parse(line);
|
|
1200
|
+
for (const part of entry.message?.content ?? []) {
|
|
1201
|
+
if (part.type === 'tool_result') {
|
|
1202
|
+
part.content = hugeOutput;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return JSON.stringify(entry);
|
|
1206
|
+
})
|
|
1207
|
+
.join('\n');
|
|
1208
|
+
await writeFile(agentPath, `${enlarged}\n`, 'utf8');
|
|
1209
|
+
await withIsolatedDatabase(async () => {
|
|
1210
|
+
const now = new Date().toISOString();
|
|
1211
|
+
sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
|
|
1212
|
+
const history = await new ClaudeSessionsProvider().fetchHistory(SESSION_ID, {
|
|
1213
|
+
providerSessionId: SESSION_ID,
|
|
1214
|
+
});
|
|
1215
|
+
const agentRow = history.messages.find((message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID);
|
|
1216
|
+
const childResult = String(agentRow?.subagentTools?.[1].toolResult?.content ?? '');
|
|
1217
|
+
assert.ok(childResult.length < 6000, `nested output must be trimmed, got ${childResult.length}`);
|
|
1218
|
+
assert.match(childResult, /more characters$/, 'the trim must say how much was omitted');
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
finally {
|
|
1222
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
const EDIT_SESSION_ID = 'claude-edit-session';
|
|
1226
|
+
/**
|
|
1227
|
+
* Writes a transcript where one prompt was edited: the replacement shares a
|
|
1228
|
+
* parent with the original, which is the shape Claude's resume-partway leaves
|
|
1229
|
+
* behind. Nothing is deleted from the file.
|
|
1230
|
+
*/
|
|
1231
|
+
async function writeEditedTranscript(projectDirectory) {
|
|
1232
|
+
const transcriptPath = path.join(projectDirectory, `${EDIT_SESSION_ID}.jsonl`);
|
|
1233
|
+
const rows = [
|
|
1234
|
+
{
|
|
1235
|
+
type: 'user', uuid: 'u1', parentUuid: null, sessionId: EDIT_SESSION_ID,
|
|
1236
|
+
timestamp: '2026-08-23T10:00:00.000Z',
|
|
1237
|
+
message: { role: 'user', content: [{ type: 'text', text: 'first prompt' }] },
|
|
1238
|
+
},
|
|
1239
|
+
{
|
|
1240
|
+
type: 'assistant', uuid: 'a1', parentUuid: 'u1', sessionId: EDIT_SESSION_ID,
|
|
1241
|
+
timestamp: '2026-08-23T10:00:01.000Z',
|
|
1242
|
+
message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'first answer' }] },
|
|
1243
|
+
},
|
|
1244
|
+
{
|
|
1245
|
+
type: 'user', uuid: 'u2', parentUuid: 'a1', sessionId: EDIT_SESSION_ID,
|
|
1246
|
+
timestamp: '2026-08-23T10:00:02.000Z',
|
|
1247
|
+
message: { role: 'user', content: [{ type: 'text', text: 'original second prompt' }] },
|
|
1248
|
+
},
|
|
1249
|
+
{
|
|
1250
|
+
type: 'assistant', uuid: 'a2', parentUuid: 'u2', sessionId: EDIT_SESSION_ID,
|
|
1251
|
+
timestamp: '2026-08-23T10:00:03.000Z',
|
|
1252
|
+
message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'answer to be replaced' }] },
|
|
1253
|
+
},
|
|
1254
|
+
// The edit: same parent as u2, written later.
|
|
1255
|
+
{
|
|
1256
|
+
type: 'user', uuid: 'u2b', parentUuid: 'a1', sessionId: EDIT_SESSION_ID,
|
|
1257
|
+
timestamp: '2026-08-23T10:00:04.000Z',
|
|
1258
|
+
message: { role: 'user', content: [{ type: 'text', text: 'edited second prompt' }] },
|
|
1259
|
+
},
|
|
1260
|
+
{
|
|
1261
|
+
type: 'assistant', uuid: 'a2b', parentUuid: 'u2b', sessionId: EDIT_SESSION_ID,
|
|
1262
|
+
timestamp: '2026-08-23T10:00:05.000Z',
|
|
1263
|
+
message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'answer to the edit' }] },
|
|
1264
|
+
},
|
|
1265
|
+
];
|
|
1266
|
+
await writeFile(transcriptPath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
|
|
1267
|
+
return transcriptPath;
|
|
1268
|
+
}
|
|
1269
|
+
test('an edited prompt replaces the one it superseded instead of stacking on it', { concurrency: false }, async () => {
|
|
1270
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-edit-history-'));
|
|
1271
|
+
try {
|
|
1272
|
+
const transcriptPath = await writeEditedTranscript(tempRoot);
|
|
1273
|
+
await withIsolatedDatabase(async () => {
|
|
1274
|
+
const now = new Date().toISOString();
|
|
1275
|
+
sessionsDb.createSession(EDIT_SESSION_ID, 'claude', tempRoot, 'Edited session', now, now, transcriptPath);
|
|
1276
|
+
const history = await new ClaudeSessionsProvider().fetchHistory(EDIT_SESSION_ID, {
|
|
1277
|
+
providerSessionId: EDIT_SESSION_ID,
|
|
1278
|
+
});
|
|
1279
|
+
const texts = history.messages.map((message) => message.content);
|
|
1280
|
+
assert.deepEqual(texts, [
|
|
1281
|
+
'first prompt',
|
|
1282
|
+
'first answer',
|
|
1283
|
+
'edited second prompt',
|
|
1284
|
+
'answer to the edit',
|
|
1285
|
+
]);
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
finally {
|
|
1289
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
1290
|
+
}
|
|
1291
|
+
});
|
|
1292
|
+
const CONCURRENT_SESSION_ID = 'claude-concurrent-session';
|
|
1293
|
+
/**
|
|
1294
|
+
* Rows two live writers leave behind: Chat and the native `claude` CLI both
|
|
1295
|
+
* branch off the assistant row each of them saw last. `last-prompt` is the row
|
|
1296
|
+
* every CLI process appends when it exits, naming the leaf it was on.
|
|
1297
|
+
*/
|
|
1298
|
+
const concurrentRow = {
|
|
1299
|
+
sharedPrompt: {
|
|
1300
|
+
type: 'user', uuid: 'u1', parentUuid: null, sessionId: CONCURRENT_SESSION_ID,
|
|
1301
|
+
timestamp: '2026-08-23T10:00:00.000Z',
|
|
1302
|
+
message: { role: 'user', content: [{ type: 'text', text: 'shared prompt' }] },
|
|
1303
|
+
},
|
|
1304
|
+
sharedAnswer: {
|
|
1305
|
+
type: 'assistant', uuid: 'a1', parentUuid: 'u1', sessionId: CONCURRENT_SESSION_ID,
|
|
1306
|
+
timestamp: '2026-08-23T10:00:01.000Z',
|
|
1307
|
+
message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'shared answer' }] },
|
|
1308
|
+
},
|
|
1309
|
+
chatPrompt: {
|
|
1310
|
+
type: 'user', uuid: 'u2', parentUuid: 'a1', sessionId: CONCURRENT_SESSION_ID,
|
|
1311
|
+
timestamp: '2026-08-23T10:00:02.000Z',
|
|
1312
|
+
message: { role: 'user', content: [{ type: 'text', text: 'chat prompt' }] },
|
|
1313
|
+
},
|
|
1314
|
+
chatAnswer: {
|
|
1315
|
+
type: 'assistant', uuid: 'a2', parentUuid: 'u2', sessionId: CONCURRENT_SESSION_ID,
|
|
1316
|
+
timestamp: '2026-08-23T10:00:03.000Z',
|
|
1317
|
+
message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'chat answer' }] },
|
|
1318
|
+
},
|
|
1319
|
+
// The native CLI had loaded the session before `u2` existed, so its prompt
|
|
1320
|
+
// parents onto `a1` too.
|
|
1321
|
+
terminalPrompt: {
|
|
1322
|
+
type: 'user', uuid: 't1', parentUuid: 'a1', sessionId: CONCURRENT_SESSION_ID,
|
|
1323
|
+
timestamp: '2026-08-23T10:00:04.000Z',
|
|
1324
|
+
message: { role: 'user', content: [{ type: 'text', text: 'terminal prompt' }] },
|
|
1325
|
+
},
|
|
1326
|
+
terminalAnswer: {
|
|
1327
|
+
type: 'assistant', uuid: 't2', parentUuid: 't1', sessionId: CONCURRENT_SESSION_ID,
|
|
1328
|
+
timestamp: '2026-08-23T10:00:05.000Z',
|
|
1329
|
+
message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'terminal answer' }] },
|
|
1330
|
+
},
|
|
1331
|
+
chatFollowUp: {
|
|
1332
|
+
type: 'user', uuid: 'u3', parentUuid: 'a2', sessionId: CONCURRENT_SESSION_ID,
|
|
1333
|
+
timestamp: '2026-08-23T10:00:06.000Z',
|
|
1334
|
+
message: { role: 'user', content: [{ type: 'text', text: 'chat follow-up' }] },
|
|
1335
|
+
},
|
|
1336
|
+
chatFollowUpAnswer: {
|
|
1337
|
+
type: 'assistant', uuid: 'a3', parentUuid: 'u3', sessionId: CONCURRENT_SESSION_ID,
|
|
1338
|
+
timestamp: '2026-08-23T10:00:07.000Z',
|
|
1339
|
+
message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'chat follow-up answer' }] },
|
|
1340
|
+
},
|
|
1341
|
+
};
|
|
1342
|
+
const exitStamp = (leafUuid) => ({
|
|
1343
|
+
type: 'last-prompt', lastPrompt: 'redacted', leafUuid, sessionId: CONCURRENT_SESSION_ID,
|
|
1344
|
+
});
|
|
1345
|
+
async function readConcurrentlyForkedHistory(rows) {
|
|
1346
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-concurrent-history-'));
|
|
1347
|
+
try {
|
|
1348
|
+
const transcriptPath = path.join(tempRoot, `${CONCURRENT_SESSION_ID}.jsonl`);
|
|
1349
|
+
await writeFile(transcriptPath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
|
|
1350
|
+
let texts = [];
|
|
1351
|
+
await withIsolatedDatabase(async () => {
|
|
1352
|
+
const now = new Date().toISOString();
|
|
1353
|
+
sessionsDb.createSession(CONCURRENT_SESSION_ID, 'claude', tempRoot, 'Concurrently forked session', now, now, transcriptPath);
|
|
1354
|
+
const history = await new ClaudeSessionsProvider().fetchHistory(CONCURRENT_SESSION_ID, {
|
|
1355
|
+
providerSessionId: CONCURRENT_SESSION_ID,
|
|
1356
|
+
});
|
|
1357
|
+
texts = history.messages.map((message) => message.content);
|
|
1358
|
+
});
|
|
1359
|
+
return texts;
|
|
1360
|
+
}
|
|
1361
|
+
finally {
|
|
1362
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
const SHARED_TURN = ['shared prompt', 'shared answer'];
|
|
1366
|
+
const TERMINAL_TURN = ['terminal prompt', 'terminal answer'];
|
|
1367
|
+
test('a fork with no exit stamp shows the branch ending at the newest row', { concurrency: false }, async () => {
|
|
1368
|
+
const r = concurrentRow;
|
|
1369
|
+
// With no `last-prompt` in the file, `--resume` falls back to the newest row:
|
|
1370
|
+
// Chat forked first but kept the session going.
|
|
1371
|
+
const texts = await readConcurrentlyForkedHistory([
|
|
1372
|
+
r.sharedPrompt, r.sharedAnswer, r.chatPrompt, r.chatAnswer,
|
|
1373
|
+
r.terminalPrompt, r.terminalAnswer, r.chatFollowUp, r.chatFollowUpAnswer,
|
|
1374
|
+
]);
|
|
1375
|
+
assert.deepEqual(texts, [...SHARED_TURN, 'chat prompt', 'chat answer', 'chat follow-up', 'chat follow-up answer']);
|
|
1376
|
+
});
|
|
1377
|
+
test('a fork shows the branch Chat stamped when Chat exited last', { concurrency: false }, async () => {
|
|
1378
|
+
const r = concurrentRow;
|
|
1379
|
+
const texts = await readConcurrentlyForkedHistory([
|
|
1380
|
+
r.sharedPrompt, r.sharedAnswer, r.chatPrompt, r.chatAnswer, exitStamp('a2'),
|
|
1381
|
+
r.terminalPrompt, r.terminalAnswer, r.chatFollowUp, r.chatFollowUpAnswer, exitStamp('a3'),
|
|
1382
|
+
]);
|
|
1383
|
+
assert.deepEqual(texts, [...SHARED_TURN, 'chat prompt', 'chat answer', 'chat follow-up', 'chat follow-up answer']);
|
|
1384
|
+
});
|
|
1385
|
+
test('a fork shows the terminal branch once the terminal exits after Chat', { concurrency: false }, async () => {
|
|
1386
|
+
const r = concurrentRow;
|
|
1387
|
+
// Chat wrote the newest rows, but closing the terminal stamps its own leaf
|
|
1388
|
+
// last, and that stamp is what the next resume continues from.
|
|
1389
|
+
const texts = await readConcurrentlyForkedHistory([
|
|
1390
|
+
r.sharedPrompt, r.sharedAnswer, r.chatPrompt, r.chatAnswer, exitStamp('a2'),
|
|
1391
|
+
r.terminalPrompt, r.terminalAnswer, r.chatFollowUp, r.chatFollowUpAnswer, exitStamp('a3'),
|
|
1392
|
+
exitStamp('t2'),
|
|
1393
|
+
]);
|
|
1394
|
+
assert.deepEqual(texts, [...SHARED_TURN, ...TERMINAL_TURN]);
|
|
1395
|
+
});
|
|
1396
|
+
test('a fork keeps the stamped branch while the other writer is still open', { concurrency: false }, async () => {
|
|
1397
|
+
const r = concurrentRow;
|
|
1398
|
+
// Chat exited after its turn; the terminal then typed a prompt and is still
|
|
1399
|
+
// open. Its rows are the newest, but they do not descend from Chat's stamp.
|
|
1400
|
+
const texts = await readConcurrentlyForkedHistory([
|
|
1401
|
+
r.sharedPrompt, r.sharedAnswer, r.chatPrompt, r.chatAnswer, exitStamp('a2'),
|
|
1402
|
+
r.terminalPrompt, r.terminalAnswer,
|
|
1403
|
+
]);
|
|
1404
|
+
assert.deepEqual(texts, [...SHARED_TURN, 'chat prompt', 'chat answer']);
|
|
1405
|
+
});
|
|
1406
|
+
test('parallel tool calls are not mistaken for an edit', { concurrency: false }, async () => {
|
|
1407
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-parallel-tools-'));
|
|
1408
|
+
const sessionId = 'claude-parallel-session';
|
|
1409
|
+
try {
|
|
1410
|
+
const transcriptPath = path.join(tempRoot, `${sessionId}.jsonl`);
|
|
1411
|
+
// One assistant turn issuing two tools: each tool_result parents onto the
|
|
1412
|
+
// same row, so this row has two children — a branch point that must not be
|
|
1413
|
+
// pruned, or tool output disappears from every transcript in the app.
|
|
1414
|
+
const rows = [
|
|
1415
|
+
{
|
|
1416
|
+
type: 'user', uuid: 'p1', parentUuid: null, sessionId,
|
|
1417
|
+
timestamp: '2026-08-23T10:00:00.000Z',
|
|
1418
|
+
message: { role: 'user', content: [{ type: 'text', text: 'do two things' }] },
|
|
1419
|
+
},
|
|
1420
|
+
{
|
|
1421
|
+
type: 'assistant', uuid: 'pa1', parentUuid: 'p1', sessionId,
|
|
1422
|
+
timestamp: '2026-08-23T10:00:01.000Z',
|
|
1423
|
+
message: {
|
|
1424
|
+
role: 'assistant', model: 'claude-opus-5',
|
|
1425
|
+
content: [{ type: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: '/a' } }],
|
|
1426
|
+
},
|
|
1427
|
+
},
|
|
1428
|
+
{
|
|
1429
|
+
type: 'user', uuid: 'pr1', parentUuid: 'pa1', sessionId,
|
|
1430
|
+
timestamp: '2026-08-23T10:00:02.000Z',
|
|
1431
|
+
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'contents of a' }] },
|
|
1432
|
+
},
|
|
1433
|
+
{
|
|
1434
|
+
type: 'user', uuid: 'pr2', parentUuid: 'pa1', sessionId,
|
|
1435
|
+
timestamp: '2026-08-23T10:00:03.000Z',
|
|
1436
|
+
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tool-2', content: 'contents of b' }] },
|
|
1437
|
+
},
|
|
1438
|
+
];
|
|
1439
|
+
await writeFile(transcriptPath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
|
|
1440
|
+
await withIsolatedDatabase(async () => {
|
|
1441
|
+
const now = new Date().toISOString();
|
|
1442
|
+
sessionsDb.createSession(sessionId, 'claude', tempRoot, 'Parallel tools', now, now, transcriptPath);
|
|
1443
|
+
const history = await new ClaudeSessionsProvider().fetchHistory(sessionId, {
|
|
1444
|
+
providerSessionId: sessionId,
|
|
1445
|
+
});
|
|
1446
|
+
assert.equal(history.messages.some((message) => message.content === 'do two things'), true);
|
|
1447
|
+
const toolRow = history.messages.find((message) => message.kind === 'tool_use');
|
|
1448
|
+
assert.ok(toolRow, 'the tool call survives');
|
|
1449
|
+
assert.equal(toolRow?.toolResult?.content, 'contents of a');
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
finally {
|
|
1453
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
1454
|
+
}
|
|
1455
|
+
});
|
|
1456
|
+
test('resolving an edit anchor returns the assistant turn before it', { concurrency: false }, async () => {
|
|
1457
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-edit-anchor-'));
|
|
1458
|
+
try {
|
|
1459
|
+
const transcriptPath = await writeEditedTranscript(tempRoot);
|
|
1460
|
+
await withIsolatedDatabase(async () => {
|
|
1461
|
+
const now = new Date().toISOString();
|
|
1462
|
+
sessionsDb.createSession(EDIT_SESSION_ID, 'claude', tempRoot, 'Edited session', now, now, transcriptPath);
|
|
1463
|
+
const provider = new ClaudeSessionsProvider();
|
|
1464
|
+
// Resuming is inclusive of the row it names, so replacing `u2b` must
|
|
1465
|
+
// resume through `a1` — naming `u2b` itself would leave the prompt being
|
|
1466
|
+
// replaced in context.
|
|
1467
|
+
assert.deepEqual(await provider.resolveEditAnchor(EDIT_SESSION_ID, 'u2b'), { found: true, resumeThroughId: 'a1' });
|
|
1468
|
+
// Nothing precedes the first prompt, so the conversation starts over.
|
|
1469
|
+
assert.deepEqual(await provider.resolveEditAnchor(EDIT_SESSION_ID, 'u1'), { found: true, resumeThroughId: null });
|
|
1470
|
+
assert.deepEqual(await provider.resolveEditAnchor(EDIT_SESSION_ID, 'not-in-transcript'), { found: false, resumeThroughId: null });
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
finally {
|
|
1474
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
1475
|
+
}
|
|
1476
|
+
});
|
|
1477
|
+
test('user turns carry the transcript uuid so they can be edited', { concurrency: false }, async () => {
|
|
1478
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-anchor-ids-'));
|
|
1479
|
+
try {
|
|
1480
|
+
const transcriptPath = await writeEditedTranscript(tempRoot);
|
|
1481
|
+
await withIsolatedDatabase(async () => {
|
|
1482
|
+
const now = new Date().toISOString();
|
|
1483
|
+
sessionsDb.createSession(EDIT_SESSION_ID, 'claude', tempRoot, 'Edited session', now, now, transcriptPath);
|
|
1484
|
+
const history = await new ClaudeSessionsProvider().fetchHistory(EDIT_SESSION_ID, {
|
|
1485
|
+
providerSessionId: EDIT_SESSION_ID,
|
|
1486
|
+
});
|
|
1487
|
+
const userRows = history.messages.filter((message) => message.role === 'user');
|
|
1488
|
+
assert.deepEqual(userRows.map((message) => message.transcriptAnchorId), ['u1', 'u2b']);
|
|
1489
|
+
// Assistant rows are never an anchor: the UI only offers editing on a
|
|
1490
|
+
// turn the user typed.
|
|
1491
|
+
assert.equal(history.messages.some((message) => message.role !== 'user' && message.transcriptAnchorId), false);
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
finally {
|
|
1495
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
1496
|
+
}
|
|
1497
|
+
});
|
|
1498
|
+
test('resolving an edit anchor skips rows that are not conversation turns', { concurrency: false }, async () => {
|
|
1499
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-anchor-skip-'));
|
|
1500
|
+
const sessionId = 'claude-anchor-skip-session';
|
|
1501
|
+
try {
|
|
1502
|
+
const transcriptPath = path.join(tempRoot, `${sessionId}.jsonl`);
|
|
1503
|
+
// An attachment row sits between the assistant turn and the next prompt.
|
|
1504
|
+
// Resuming names an assistant message, so the walk has to pass over it —
|
|
1505
|
+
// naming the attachment would resume at something the SDK cannot address.
|
|
1506
|
+
const rows = [
|
|
1507
|
+
{
|
|
1508
|
+
type: 'user', uuid: 'su1', parentUuid: null, sessionId,
|
|
1509
|
+
timestamp: '2026-08-23T10:00:00.000Z',
|
|
1510
|
+
message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
|
|
1511
|
+
},
|
|
1512
|
+
{
|
|
1513
|
+
type: 'assistant', uuid: 'sa1', parentUuid: 'su1', sessionId,
|
|
1514
|
+
timestamp: '2026-08-23T10:00:01.000Z',
|
|
1515
|
+
message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'hi' }] },
|
|
1516
|
+
},
|
|
1517
|
+
{
|
|
1518
|
+
type: 'attachment', uuid: 'sat1', parentUuid: 'sa1', sessionId,
|
|
1519
|
+
timestamp: '2026-08-23T10:00:02.000Z',
|
|
1520
|
+
},
|
|
1521
|
+
{
|
|
1522
|
+
type: 'user', uuid: 'su2', parentUuid: 'sat1', sessionId,
|
|
1523
|
+
timestamp: '2026-08-23T10:00:03.000Z',
|
|
1524
|
+
message: { role: 'user', content: [{ type: 'text', text: 'second prompt' }] },
|
|
1525
|
+
},
|
|
1526
|
+
];
|
|
1527
|
+
await writeFile(transcriptPath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
|
|
1528
|
+
await withIsolatedDatabase(async () => {
|
|
1529
|
+
const now = new Date().toISOString();
|
|
1530
|
+
sessionsDb.createSession(sessionId, 'claude', tempRoot, 'Anchor skip', now, now, transcriptPath);
|
|
1531
|
+
assert.deepEqual(await new ClaudeSessionsProvider().resolveEditAnchor(sessionId, 'su2'), { found: true, resumeThroughId: 'sa1' });
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
finally {
|
|
1535
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
1536
|
+
}
|
|
1537
|
+
});
|
|
1538
|
+
// ---------------------------------------------------------------------------
|
|
1539
|
+
// buildLookupMap
|
|
1540
|
+
// ---------------------------------------------------------------------------
|
|
1541
|
+
test('buildLookupMap returns first-seen value when key appears multiple times', async () => {
|
|
1542
|
+
const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-lookup-'));
|
|
1543
|
+
const filePath = path.join(tmp, 'history.jsonl');
|
|
1544
|
+
try {
|
|
1545
|
+
await writeFile(filePath, [
|
|
1546
|
+
JSON.stringify({ sessionId: 's1', display: 'first-message' }),
|
|
1547
|
+
JSON.stringify({ sessionId: 's1', display: 'second-message' }),
|
|
1548
|
+
JSON.stringify({ sessionId: 's2', display: 'only-message' }),
|
|
1549
|
+
].join('\n'), 'utf8');
|
|
1550
|
+
const map = await buildLookupMap(filePath, 'sessionId', 'display');
|
|
1551
|
+
assert.equal(map.size, 2);
|
|
1552
|
+
assert.equal(map.get('s1'), 'first-message');
|
|
1553
|
+
assert.equal(map.get('s2'), 'only-message');
|
|
1554
|
+
}
|
|
1555
|
+
finally {
|
|
1556
|
+
await rm(tmp, { recursive: true, force: true });
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
1559
|
+
test('buildLookupMap returns empty map for missing file', async () => {
|
|
1560
|
+
const map = await buildLookupMap(path.join(os.tmpdir(), 'does-not-exist.jsonl'), 'k', 'v');
|
|
1561
|
+
assert.equal(map.size, 0);
|
|
1562
|
+
});
|
|
1563
|
+
test('buildLookupMap returns empty map for empty file', async () => {
|
|
1564
|
+
const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-lookup-'));
|
|
1565
|
+
const filePath = path.join(tmp, 'empty.jsonl');
|
|
1566
|
+
try {
|
|
1567
|
+
await writeFile(filePath, '', 'utf8');
|
|
1568
|
+
const map = await buildLookupMap(filePath, 'k', 'v');
|
|
1569
|
+
assert.equal(map.size, 0);
|
|
1570
|
+
}
|
|
1571
|
+
finally {
|
|
1572
|
+
await rm(tmp, { recursive: true, force: true });
|
|
1573
|
+
}
|
|
1574
|
+
});
|
|
1575
|
+
test('buildLookupMap skips rows with non-string key or value', async () => {
|
|
1576
|
+
const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-lookup-'));
|
|
1577
|
+
const filePath = path.join(tmp, 'history.jsonl');
|
|
1578
|
+
try {
|
|
1579
|
+
await writeFile(filePath, [
|
|
1580
|
+
JSON.stringify({ sessionId: 123, display: 'not-a-string-key' }),
|
|
1581
|
+
JSON.stringify({ sessionId: 's1', display: 456 }),
|
|
1582
|
+
JSON.stringify({ sessionId: 's1', display: 'valid-entry' }),
|
|
1583
|
+
].join('\n'), 'utf8');
|
|
1584
|
+
const map = await buildLookupMap(filePath, 'sessionId', 'display');
|
|
1585
|
+
assert.equal(map.size, 1);
|
|
1586
|
+
assert.equal(map.get('s1'), 'valid-entry');
|
|
1587
|
+
}
|
|
1588
|
+
finally {
|
|
1589
|
+
await rm(tmp, { recursive: true, force: true });
|
|
1590
|
+
}
|
|
1591
|
+
});
|
|
1592
|
+
// ---------------------------------------------------------------------------
|
|
1593
|
+
// extractSessionTitle — tested via synchronizeFile
|
|
1594
|
+
// ---------------------------------------------------------------------------
|
|
1595
|
+
test('synchronizeFile uses ai-title from JSONL when no DB custom_name exists', { concurrency: false }, async () => {
|
|
1596
|
+
const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-aititle-'));
|
|
1597
|
+
const workspacePath = path.join(tmp, 'workspace');
|
|
1598
|
+
await mkdir(workspacePath, { recursive: true });
|
|
1599
|
+
const restoreHomeDir = patchHomeDir(tmp);
|
|
1600
|
+
try {
|
|
1601
|
+
// Create ~/.claude/history.jsonl with a competing display name.
|
|
1602
|
+
const claudeHome = path.join(tmp, '.claude');
|
|
1603
|
+
await mkdir(claudeHome, { recursive: true });
|
|
1604
|
+
await writeFile(path.join(claudeHome, 'history.jsonl'), JSON.stringify({ sessionId: 'test-session-1', display: 'user-first-prompt-from-history' }) + '\n', 'utf8');
|
|
1605
|
+
// Write session JSONL with ai-title before last-prompt.
|
|
1606
|
+
await writeSessionJsonl(workspacePath, 'test-session-1.jsonl', [
|
|
1607
|
+
JSON.stringify({ type: 'ai-title', aiTitle: 'AI generated title', sessionId: 'test-session-1' }),
|
|
1608
|
+
JSON.stringify({
|
|
1609
|
+
parentUuid: 'msg-1',
|
|
1610
|
+
isSidechain: false,
|
|
1611
|
+
message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }] },
|
|
1612
|
+
type: 'assistant',
|
|
1613
|
+
uuid: 'msg-2',
|
|
1614
|
+
}),
|
|
1615
|
+
JSON.stringify({ type: 'last-prompt', lastPrompt: 'first prompt', sessionId: 'test-session-1' }),
|
|
1616
|
+
]);
|
|
1617
|
+
await withIsolatedDatabase(async () => {
|
|
1618
|
+
const synchronizer = new ClaudeSessionSynchronizer();
|
|
1619
|
+
const result = await synchronizer.synchronizeFile(path.join(workspacePath, 'test-session-1.jsonl'));
|
|
1620
|
+
assert.ok(result, 'synchronizeFile should return a session id');
|
|
1621
|
+
const session = sessionsDb.getSessionById(result);
|
|
1622
|
+
assert.equal(session?.custom_name, 'AI generated title');
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
finally {
|
|
1626
|
+
restoreHomeDir();
|
|
1627
|
+
await rm(tmp, { recursive: true, force: true });
|
|
1628
|
+
}
|
|
1629
|
+
});
|
|
1630
|
+
test('synchronizeFile uses custom-title from JSONL when no DB custom_name and no ai-title', { concurrency: false }, async () => {
|
|
1631
|
+
const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-customtitle-'));
|
|
1632
|
+
const workspacePath = path.join(tmp, 'workspace');
|
|
1633
|
+
await mkdir(workspacePath, { recursive: true });
|
|
1634
|
+
const restoreHomeDir = patchHomeDir(tmp);
|
|
1635
|
+
try {
|
|
1636
|
+
const claudeHome = path.join(tmp, '.claude');
|
|
1637
|
+
await mkdir(claudeHome, { recursive: true });
|
|
1638
|
+
await writeFile(path.join(claudeHome, 'history.jsonl'), '', 'utf8');
|
|
1639
|
+
await writeSessionJsonl(workspacePath, 'test-session-1.jsonl', [
|
|
1640
|
+
JSON.stringify({
|
|
1641
|
+
parentUuid: 'msg-1',
|
|
1642
|
+
isSidechain: false,
|
|
1643
|
+
message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] },
|
|
1644
|
+
type: 'assistant',
|
|
1645
|
+
uuid: 'msg-2',
|
|
1646
|
+
}),
|
|
1647
|
+
JSON.stringify({ type: 'custom-title', customTitle: 'Renamed via cli', sessionId: 'test-session-1' }),
|
|
1648
|
+
JSON.stringify({ type: 'last-prompt', lastPrompt: 'first prompt', sessionId: 'test-session-1' }),
|
|
1649
|
+
]);
|
|
1650
|
+
await withIsolatedDatabase(async () => {
|
|
1651
|
+
const synchronizer = new ClaudeSessionSynchronizer();
|
|
1652
|
+
const result = await synchronizer.synchronizeFile(path.join(workspacePath, 'test-session-1.jsonl'));
|
|
1653
|
+
assert.ok(result);
|
|
1654
|
+
const session = sessionsDb.getSessionById(result);
|
|
1655
|
+
assert.equal(session?.custom_name, 'Renamed via cli');
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
finally {
|
|
1659
|
+
restoreHomeDir();
|
|
1660
|
+
await rm(tmp, { recursive: true, force: true });
|
|
1661
|
+
}
|
|
1662
|
+
});
|
|
1663
|
+
test('synchronizeFile falls back to history.jsonl display when JSONL has no title events', { concurrency: false }, async () => {
|
|
1664
|
+
const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-fallback-'));
|
|
1665
|
+
const workspacePath = path.join(tmp, 'workspace');
|
|
1666
|
+
await mkdir(workspacePath, { recursive: true });
|
|
1667
|
+
const restoreHomeDir = patchHomeDir(tmp);
|
|
1668
|
+
try {
|
|
1669
|
+
const claudeHome = path.join(tmp, '.claude');
|
|
1670
|
+
await mkdir(claudeHome, { recursive: true });
|
|
1671
|
+
await writeFile(path.join(claudeHome, 'history.jsonl'), JSON.stringify({ sessionId: 'test-session-1', display: 'fallback display name' }) + '\n', 'utf8');
|
|
1672
|
+
// Session JSONL with NO ai-title, custom-title, or last-prompt.
|
|
1673
|
+
await writeSessionJsonl(workspacePath, 'test-session-1.jsonl', [
|
|
1674
|
+
JSON.stringify({
|
|
1675
|
+
parentUuid: 'msg-1',
|
|
1676
|
+
isSidechain: false,
|
|
1677
|
+
message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] },
|
|
1678
|
+
type: 'assistant',
|
|
1679
|
+
uuid: 'msg-2',
|
|
1680
|
+
}),
|
|
1681
|
+
]);
|
|
1682
|
+
await withIsolatedDatabase(async () => {
|
|
1683
|
+
const synchronizer = new ClaudeSessionSynchronizer();
|
|
1684
|
+
const result = await synchronizer.synchronizeFile(path.join(workspacePath, 'test-session-1.jsonl'));
|
|
1685
|
+
assert.ok(result);
|
|
1686
|
+
const session = sessionsDb.getSessionById(result);
|
|
1687
|
+
assert.equal(session?.custom_name, 'fallback display name');
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
finally {
|
|
1691
|
+
restoreHomeDir();
|
|
1692
|
+
await rm(tmp, { recursive: true, force: true });
|
|
1693
|
+
}
|
|
1694
|
+
});
|
|
1695
|
+
test('synchronizeFile falls back to Untitled Claude Session when all sources are empty', { concurrency: false }, async () => {
|
|
1696
|
+
const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-untitled-'));
|
|
1697
|
+
const workspacePath = path.join(tmp, 'workspace');
|
|
1698
|
+
await mkdir(workspacePath, { recursive: true });
|
|
1699
|
+
const restoreHomeDir = patchHomeDir(tmp);
|
|
1700
|
+
try {
|
|
1701
|
+
const claudeHome = path.join(tmp, '.claude');
|
|
1702
|
+
await mkdir(claudeHome, { recursive: true });
|
|
1703
|
+
await writeFile(path.join(claudeHome, 'history.jsonl'), '', 'utf8');
|
|
1704
|
+
// Session JSONL with NO title events at all.
|
|
1705
|
+
await writeSessionJsonl(workspacePath, 'test-session-1.jsonl', [
|
|
1706
|
+
JSON.stringify({
|
|
1707
|
+
parentUuid: 'msg-1',
|
|
1708
|
+
isSidechain: false,
|
|
1709
|
+
message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] },
|
|
1710
|
+
type: 'assistant',
|
|
1711
|
+
uuid: 'msg-2',
|
|
1712
|
+
}),
|
|
1713
|
+
]);
|
|
1714
|
+
await withIsolatedDatabase(async () => {
|
|
1715
|
+
const synchronizer = new ClaudeSessionSynchronizer();
|
|
1716
|
+
const result = await synchronizer.synchronizeFile(path.join(workspacePath, 'test-session-1.jsonl'));
|
|
1717
|
+
assert.ok(result);
|
|
1718
|
+
const session = sessionsDb.getSessionById(result);
|
|
1719
|
+
assert.equal(session?.custom_name, 'Untitled Claude Session');
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
finally {
|
|
1723
|
+
restoreHomeDir();
|
|
1724
|
+
await rm(tmp, { recursive: true, force: true });
|
|
1725
|
+
}
|
|
1726
|
+
});
|
|
1727
|
+
// ---------------------------------------------------------------------------
|
|
1728
|
+
// Priority: DB custom_name > JSONL title > history.jsonl
|
|
1729
|
+
// ---------------------------------------------------------------------------
|
|
1730
|
+
test('synchronizeFile preserves existing DB custom_name regardless of JSONL and history.jsonl', { concurrency: false }, async () => {
|
|
1731
|
+
const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-dbwins-'));
|
|
1732
|
+
const workspacePath = path.join(tmp, 'workspace');
|
|
1733
|
+
await mkdir(workspacePath, { recursive: true });
|
|
1734
|
+
const restoreHomeDir = patchHomeDir(tmp);
|
|
1735
|
+
try {
|
|
1736
|
+
const claudeHome = path.join(tmp, '.claude');
|
|
1737
|
+
await mkdir(claudeHome, { recursive: true });
|
|
1738
|
+
await writeFile(path.join(claudeHome, 'history.jsonl'), JSON.stringify({ sessionId: 'test-session-1', display: 'history-display-name' }) + '\n', 'utf8');
|
|
1739
|
+
// Write session JSONL with competing ai-title.
|
|
1740
|
+
await writeSessionJsonl(workspacePath, 'test-session-1.jsonl', [
|
|
1741
|
+
JSON.stringify({ type: 'ai-title', aiTitle: 'JSONL ai title', sessionId: 'test-session-1' }),
|
|
1742
|
+
JSON.stringify({ type: 'last-prompt', lastPrompt: 'first prompt', sessionId: 'test-session-1' }),
|
|
1743
|
+
]);
|
|
1744
|
+
await withIsolatedDatabase(async () => {
|
|
1745
|
+
// Pre-seed the DB with a custom_name set via CloudCLI sidebar rename.
|
|
1746
|
+
sessionsDb.createSession('test-session-1', 'claude', workspacePath, 'Sidebar custom name');
|
|
1747
|
+
const synchronizer = new ClaudeSessionSynchronizer();
|
|
1748
|
+
const result = await synchronizer.synchronizeFile(path.join(workspacePath, 'test-session-1.jsonl'));
|
|
1749
|
+
assert.ok(result);
|
|
1750
|
+
const session = sessionsDb.getSessionById(result);
|
|
1751
|
+
// DB custom_name must win over JSONL ai-title AND history.jsonl display.
|
|
1752
|
+
assert.equal(session?.custom_name, 'Sidebar custom name');
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
finally {
|
|
1756
|
+
restoreHomeDir();
|
|
1757
|
+
await rm(tmp, { recursive: true, force: true });
|
|
1758
|
+
}
|
|
1759
|
+
});
|
|
1760
|
+
test('synchronizeFile does NOT treat "Untitled Claude Session" in DB as a real custom_name', { concurrency: false }, async () => {
|
|
1761
|
+
const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-untitled-db-'));
|
|
1762
|
+
const workspacePath = path.join(tmp, 'workspace');
|
|
1763
|
+
await mkdir(workspacePath, { recursive: true });
|
|
1764
|
+
const restoreHomeDir = patchHomeDir(tmp);
|
|
1765
|
+
try {
|
|
1766
|
+
const claudeHome = path.join(tmp, '.claude');
|
|
1767
|
+
await mkdir(claudeHome, { recursive: true });
|
|
1768
|
+
await writeFile(path.join(claudeHome, 'history.jsonl'), '', 'utf8');
|
|
1769
|
+
// Session JSONL with an ai-title that should win over the DB default.
|
|
1770
|
+
await writeSessionJsonl(workspacePath, 'test-session-1.jsonl', [
|
|
1771
|
+
JSON.stringify({ type: 'ai-title', aiTitle: 'Real AI title from JSONL', sessionId: 'test-session-1' }),
|
|
1772
|
+
JSON.stringify({ type: 'last-prompt', lastPrompt: 'first prompt', sessionId: 'test-session-1' }),
|
|
1773
|
+
]);
|
|
1774
|
+
await withIsolatedDatabase(async () => {
|
|
1775
|
+
// Seed with the default fallback name — should be ignored.
|
|
1776
|
+
sessionsDb.createSession('test-session-1', 'claude', workspacePath, 'Untitled Claude Session');
|
|
1777
|
+
const synchronizer = new ClaudeSessionSynchronizer();
|
|
1778
|
+
const result = await synchronizer.synchronizeFile(path.join(workspacePath, 'test-session-1.jsonl'));
|
|
1779
|
+
assert.ok(result);
|
|
1780
|
+
const session = sessionsDb.getSessionById(result);
|
|
1781
|
+
assert.equal(session?.custom_name, 'Real AI title from JSONL');
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
finally {
|
|
1785
|
+
restoreHomeDir();
|
|
1786
|
+
await rm(tmp, { recursive: true, force: true });
|
|
1787
|
+
}
|
|
1788
|
+
});
|
|
1789
|
+
// ---------------------------------------------------------------------------
|
|
1790
|
+
// Edge cases
|
|
1791
|
+
// ---------------------------------------------------------------------------
|
|
1792
|
+
test('synchronizeFile skips subagent transcripts', { concurrency: false }, async () => {
|
|
1793
|
+
const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-subagent-'));
|
|
1794
|
+
const workspacePath = path.join(tmp, 'workspace');
|
|
1795
|
+
await mkdir(workspacePath, { recursive: true });
|
|
1796
|
+
const restoreHomeDir = patchHomeDir(tmp);
|
|
1797
|
+
try {
|
|
1798
|
+
const claudeHome = path.join(tmp, '.claude');
|
|
1799
|
+
await mkdir(claudeHome, { recursive: true });
|
|
1800
|
+
await writeFile(path.join(claudeHome, 'history.jsonl'), '', 'utf8');
|
|
1801
|
+
// Create a file whose path contains "subagents".
|
|
1802
|
+
const subagentsDir = path.join(workspacePath, 'test-session-1', 'subagents');
|
|
1803
|
+
await mkdir(subagentsDir, { recursive: true });
|
|
1804
|
+
await writeSessionJsonl(subagentsDir, 'agent-1.jsonl', [
|
|
1805
|
+
JSON.stringify({ type: 'ai-title', aiTitle: 'Subagent title', sessionId: 'test-session-1' }),
|
|
1806
|
+
]);
|
|
1807
|
+
await withIsolatedDatabase(async () => {
|
|
1808
|
+
const synchronizer = new ClaudeSessionSynchronizer();
|
|
1809
|
+
const result = await synchronizer.synchronizeFile(path.join(subagentsDir, 'agent-1.jsonl'));
|
|
1810
|
+
// Subagent transcripts should be silently skipped (return null).
|
|
1811
|
+
assert.equal(result, null);
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
finally {
|
|
1815
|
+
restoreHomeDir();
|
|
1816
|
+
await rm(tmp, { recursive: true, force: true });
|
|
1817
|
+
}
|
|
1818
|
+
});
|
|
1819
|
+
test('synchronizeFile skips non-jsonl files', { concurrency: false }, async () => {
|
|
1820
|
+
await withIsolatedDatabase(async () => {
|
|
1821
|
+
const synchronizer = new ClaudeSessionSynchronizer();
|
|
1822
|
+
const result = await synchronizer.synchronizeFile('/tmp/not-a-jsonl.txt');
|
|
1823
|
+
assert.equal(result, null);
|
|
1824
|
+
});
|
|
43
1825
|
});
|
|
44
1826
|
//# sourceMappingURL=claude-sessions.test.js.map
|