From 5c0ec2938ac3a01624b6503e5e5df80c9b08f46a Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 30 Jul 2026 20:01:57 +0800 Subject: [PATCH 01/20] fix(web): upgrade markstream-vue to 1.0.9-beta.1 and enable Monaco code highlighting (#2415) * fix(web): upgrade markstream-vue to 1.0.9-beta.1 and enable Monaco code highlighting * fix(nix): update pnpm deps hash for markstream-vue 1.0.9-beta.1 --- .changeset/kimi-web-markstream-monaco.md | 5 + apps/kimi-web/package.json | 5 +- .../kimi-web/src/components/chat/Markdown.vue | 10 +- flake.nix | 2 +- pnpm-lock.yaml | 240 +++++++++++++++++- 5 files changed, 250 insertions(+), 12 deletions(-) create mode 100644 .changeset/kimi-web-markstream-monaco.md diff --git a/.changeset/kimi-web-markstream-monaco.md b/.changeset/kimi-web-markstream-monaco.md new file mode 100644 index 0000000000..833432d493 --- /dev/null +++ b/.changeset/kimi-web-markstream-monaco.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Enable Monaco-based highlighting for code blocks, and fix line numbers overlapping or drifting out of alignment in fallback-rendered code blocks. diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json index 4245ec8728..87d0a2041c 100644 --- a/apps/kimi-web/package.json +++ b/apps/kimi-web/package.json @@ -18,10 +18,13 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "katex": "^0.17.0", - "markstream-vue": "^1.0.7", + "markstream-vue": "^1.0.9-beta.1", "mermaid": "^11.15.0", + "monaco-editor": "^0.55.1", "shiki": "^4.3.0", + "stream-diffs": "^0.0.2", "stream-markdown": "^0.0.16", + "stream-monaco": "^0.0.49", "vue": "^3.5.35", "vue-i18n": "^11.4.5" }, diff --git a/apps/kimi-web/src/components/chat/Markdown.vue b/apps/kimi-web/src/components/chat/Markdown.vue index 42535be66b..25a9936c05 100644 --- a/apps/kimi-web/src/components/chat/Markdown.vue +++ b/apps/kimi-web/src/components/chat/Markdown.vue @@ -327,7 +327,14 @@ const CODE_DARK_THEME = 'github-dark'; // blank placeholders. Pinning `loading` to false drops the skeleton entirely: // the block renders its plain-text fallback immediately and shiki upgrades it to // the highlighted version when the highlighter is ready. Streaming blocks are -// unaffected (their `stream` is true, so the skeleton gate was already false). +// unaffected (their `stream` is true, so the skeleton gate was already +// false). +// Chat code blocks show no gutter: line numbers eat 3+ characters of reading +// width and every chat block starts at line 1 anyway. stream-diffs derives its +// gutter from `lineNumbers === false` (boolean, not monaco's 'off' string). +// This rides inside codeBlockProps because markstream 1.0.7 only forwards the +// top-level codeBlockMonacoOptions to the 'monaco' renderer kind — the 'shiki' +// kind's props object omits it, while codeBlockProps reach the same component. const codeBlockProps = { showHeader: true, showCopyButton: true, @@ -336,6 +343,7 @@ const codeBlockProps = { showCollapseButton: false, showFontSizeButtons: false, loading: false, + monacoOptions: { lineNumbers: false }, }; // Root cause for the "large session turns into code skeletons" failure: diff --git a/flake.nix b/flake.nix index 81f0aa0fe8..0f3c134493 100644 --- a/flake.nix +++ b/flake.nix @@ -162,7 +162,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-bL1AaInlb8dE+ua7a6llvQWkibEwEzfI3oQW5IOpX6I="; + hash = "sha256-FNxf3Zpr+s6ubtLIqXCkVgecBGejGM4hovUNVRnNoX4="; }; nativeBuildInputs = [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6276af9425..980988e3bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,17 +218,26 @@ importers: specifier: ^0.17.0 version: 0.17.0 markstream-vue: - specifier: ^1.0.7 - version: 1.0.7(katex@0.17.0)(mermaid@11.15.0)(stream-markdown@0.0.16(react@19.2.5)(shiki@4.3.0)(vue@3.5.35(typescript@6.0.2)))(vue-i18n@11.4.5(vue@3.5.35(typescript@6.0.2)))(vue@3.5.35(typescript@6.0.2)) + specifier: ^1.0.9-beta.1 + version: 1.0.9-beta.1(katex@0.17.0)(mermaid@11.15.0)(stream-diffs@0.0.2(@shikijs/themes@4.3.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vue@3.5.35(typescript@6.0.2)))(stream-markdown@0.0.16(react@19.2.5)(shiki@4.3.0)(vue@3.5.35(typescript@6.0.2)))(stream-monaco@0.0.49(monaco-editor@0.55.1))(vue-i18n@11.4.5(vue@3.5.35(typescript@6.0.2)))(vue@3.5.35(typescript@6.0.2)) mermaid: specifier: ^11.15.0 version: 11.15.0 + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 shiki: specifier: ^4.3.0 version: 4.3.0 + stream-diffs: + specifier: ^0.0.2 + version: 0.0.2(@shikijs/themes@4.3.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vue@3.5.35(typescript@6.0.2)) stream-markdown: specifier: ^0.0.16 version: 0.0.16(react@19.2.5)(shiki@4.3.0)(vue@3.5.35(typescript@6.0.2)) + stream-monaco: + specifier: ^0.0.49 + version: 0.0.49(monaco-editor@0.55.1) vue: specifier: ^3.5.35 version: 3.5.35(typescript@6.0.2) @@ -2804,6 +2813,36 @@ packages: cpu: [x64] os: [win32] + '@pierre/diffs@1.2.12': + resolution: {integrity: sha512-pY/gmgWL03WnagqCyCnBi3QtRXUv4hCIY6FYqd5b1ZGaoI6a4Bsji8j+yRl2RfzPh/8Hf19rCl1GE80G6a1cLQ==} + peerDependencies: + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + + '@pierre/theme@1.1.0': + resolution: {integrity: sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ==} + engines: {vscode: ^1.0.0} + + '@pierre/theming@0.0.2': + resolution: {integrity: sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw==} + peerDependencies: + '@pierre/theme': ^1.1.0 + '@shikijs/themes': ^3.0.0 || ^4.0.0 + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + shiki: ^3.0.0 || ^4.0.0 + peerDependenciesMeta: + '@pierre/theme': + optional: true + '@shikijs/themes': + optional: true + react: + optional: true + react-dom: + optional: true + shiki: + optional: true + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -4061,9 +4100,16 @@ packages: resolution: {integrity: sha512-EooU3i9F6IAE8kEu+AnGf9DFZWkQBZ+hJn3tLVbsH+61mtQiva5biai66fAA6nvFPXkLgvrh7BrR7YcJU83xQQ==} engines: {node: '>=20'} + '@shikijs/core@4.3.1': + resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==} + engines: {node: '>=20'} + '@shikijs/engine-javascript@2.5.0': resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + '@shikijs/engine-javascript@3.23.0': + resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} + '@shikijs/engine-javascript@4.3.0': resolution: {integrity: sha512-hTv/KiFf2tpiqlACPiztGGurEARWIutB8YUhcrA1pUC7VzzwKO+g5crUocrLztrZ5ro5Z4hbXg7bYclETn3gSQ==} engines: {node: '>=20'} @@ -4071,6 +4117,9 @@ packages: '@shikijs/engine-oniguruma@2.5.0': resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + '@shikijs/engine-oniguruma@4.3.0': resolution: {integrity: sha512-1vMdN3gHfnKfLYwecUI2ITJI4RhHt96xEaJumVn7Heb0IlJ8WQMIH0Voak+2j22BpSNKdnOfB/pCTPnPm2gq7A==} engines: {node: '>=20'} @@ -4078,17 +4127,30 @@ packages: '@shikijs/langs@2.5.0': resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + '@shikijs/langs@4.3.0': resolution: {integrity: sha512-rnlqFbBRSys9bT4gl/5rw9RnS0W/I84ZldXPkO7cvlEMoV85TyF/aU01N7/NbSR776RNLjrJKjfFUXJR6wN1Cg==} engines: {node: '>=20'} + '@shikijs/monaco@3.23.0': + resolution: {integrity: sha512-OCApTdAGTHMFUXSYwGztW6EnlxXsWNrpnGf+uO+AznE+khC6V1/8QjuJESIcvZUIq9iAp4ZCNYosZKSVj1Hctg==} + '@shikijs/primitive@4.3.0': resolution: {integrity: sha512-CPkz64PTa5diRW1ggzMZH9VM/du4RNChYgVtgqrFcgruvIybmCvySv8GkiHSczUHXYuuR8TdKEwFx+UnZMpgdg==} engines: {node: '>=20'} + '@shikijs/primitive@4.3.1': + resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==} + engines: {node: '>=20'} + '@shikijs/themes@2.5.0': resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + '@shikijs/themes@4.3.0': resolution: {integrity: sha512-Avgt05YiT+Y3prjIc9lmQxhJzHBcCfR6cjiFW4OyaMBbt2A6trX5rfjUzx+Vj/mE9qpArYjatnqo9XPjQNW/AQ==} engines: {node: '>=20'} @@ -4096,6 +4158,10 @@ packages: '@shikijs/transformers@2.5.0': resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + '@shikijs/transformers@4.3.1': + resolution: {integrity: sha512-z6ir0bGDgWcF2FduktEfPgIsdOtIlDiLAjFBgBzE42Q9xHbkkIXZtORHzlLVB71iZP9elEcqKg6keajvOUwE2A==} + engines: {node: '>=20'} + '@shikijs/types@2.5.0': resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} @@ -4106,6 +4172,10 @@ packages: resolution: {integrity: sha512-oc8b9U2SYvofKZk8e/737nIX0qwf6eV2vHFATeObAu7r+mUVpLs8Re0BmVkIjAWAYgkmG/CzLNo7rzuBzRu/wQ==} engines: {node: '>=20'} + '@shikijs/types@4.3.1': + resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==} + engines: {node: '>=20'} + '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -4958,6 +5028,9 @@ packages: resolution: {integrity: sha512-fHA8+kXTbjagw3jkLiaS7KKrH8qe2DyOsiUhGlN4cdT77PEsfqXZl7ewDk1hsg+pJnPlnE50XtLxjR91iJOpmg==} engines: {node: '>= 14.0.0'} + alien-signals@2.0.8: + resolution: {integrity: sha512-844G1VLkk0Pe2SJjY0J8vp8ADI73IM4KliNu2OGlYzWpO28NexEUvjHTcFjFX3VXoiUtwTbHxLNI9ImkcoBqzA==} + alien-signals@3.2.1: resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} @@ -5806,6 +5879,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + dompurify@3.4.7: resolution: {integrity: sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==} @@ -7256,6 +7332,9 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lru_map@0.4.1: + resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -7311,6 +7390,11 @@ packages: peerDependencies: marked: '>=1 <16' + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + marked@16.4.2: resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} @@ -7329,8 +7413,8 @@ packages: markstream-core@1.0.3: resolution: {integrity: sha512-QXn+yERo1q+RD6YlGDW61zc/af65uhkQEH3K8YvEKkprHbgRJ/JIeBeEQjELCTyBnPoxqtKQ7I565rylY+PePg==} - markstream-vue@1.0.7: - resolution: {integrity: sha512-88UVoAH34jh/BXGUVYtK8t+Tw9JETbHhRVF2DwBAIE8r8W6MO27xRADotHpz1Qn34Mas6XRW6NmqvzwwjtvciQ==} + markstream-vue@1.0.9-beta.1: + resolution: {integrity: sha512-JMv5XyX/Grpw45AuclbKRT42/dc0qYppLBLoZaJHCT/DVMjyVB7Vw5Z0i0qRKkd+LaUmELkoVNt7uMqtdS092A==} peerDependencies: '@antv/infographic': ^0.2.3 '@terrastruct/d2': '>=0.1.33' @@ -7621,6 +7705,9 @@ packages: engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -8728,6 +8815,9 @@ packages: shiki@2.5.0: resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + shiki@3.23.0: + resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} + shiki@4.3.0: resolution: {integrity: sha512-NKKjWzR6LIGL3sXBrWDw9sDS9cxx42/DkysaNqJEeOWE8Kix5gpak0bc00OfDVEO4oyXSyz8+aRaqKoBD1yo7A==} engines: {node: '>=20'} @@ -8896,14 +8986,28 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - stream-markdown-parser@1.1.4: - resolution: {integrity: sha512-fjkHUh7uIOH5Yp/kt26l2td+q/4sRusMFO4iB0d+GcWj40ZZuH+HxxiLxGeSD1oo8YdyapokzHOfchDIYFnNgg==} + stream-diffs@0.0.2: + resolution: {integrity: sha512-mYH+kBCmrN2ebVPNkvQIFUSPemFxUPPPk+NT4H/wEzZ9NCt5Eaa8OE05f/1MA5HofZ64LYbTOliQTU6VghHeVA==} + peerDependencies: + vue: ^3.3.0 + peerDependenciesMeta: + vue: + optional: true + + stream-markdown-parser@1.1.7: + resolution: {integrity: sha512-+0TE9Xcg6lh8xghVHlZiqU6vF5rK1Lqjb+IwgCsN+/gE4HjEQLGyhWJfz+gngUh6B7O4uaCSRvu8+ZYNOBmakw==} stream-markdown@0.0.16: resolution: {integrity: sha512-2WoOxlpc3N5RLc3zGuW+g/w76z6ketWBY0N1YzDYbXds1qw7zrUBv5PTQ3DOtpqgCjLuF3P2iCfjJTEqWGv0NQ==} peerDependencies: shiki: '>=3.23.0' + stream-monaco@0.0.49: + resolution: {integrity: sha512-ksZYJXw45NralnpgWqcptqrgDdbGZaTyYmvATvhlK8eY0tyeTxs0iuQzMTF4cpGf5OlIgkNJOfbFh4M97OozSw==} + hasBin: true + peerDependencies: + monaco-editor: '>=0.52.2 <0.56.0' + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -11898,6 +12002,30 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.59.0': optional: true + '@pierre/diffs@1.2.12(@shikijs/themes@4.3.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@pierre/theme': 1.1.0 + '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(shiki@4.3.0) + '@shikijs/transformers': 4.3.1 + diff: 9.0.0 + hast-util-to-html: 9.0.5 + lru_map: 0.4.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + shiki: 4.3.0 + transitivePeerDependencies: + - '@shikijs/themes' + + '@pierre/theme@1.1.0': {} + + '@pierre/theming@0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(shiki@4.3.0)': + optionalDependencies: + '@pierre/theme': 1.1.0 + '@shikijs/themes': 4.3.0 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + shiki: 4.3.0 + '@pinojs/redact@0.4.0': {} '@pkgjs/parseargs@0.11.0': @@ -13049,12 +13177,26 @@ snapshots: '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 + '@shikijs/core@4.3.1': + dependencies: + '@shikijs/primitive': 4.3.1 + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + '@shikijs/engine-javascript@2.5.0': dependencies: '@shikijs/types': 2.5.0 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 3.1.1 + '@shikijs/engine-javascript@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + '@shikijs/engine-javascript@4.3.0': dependencies: '@shikijs/types': 4.3.0 @@ -13066,6 +13208,11 @@ snapshots: '@shikijs/types': 2.5.0 '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/engine-oniguruma@4.3.0': dependencies: '@shikijs/types': 4.3.0 @@ -13075,20 +13222,40 @@ snapshots: dependencies: '@shikijs/types': 2.5.0 + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/langs@4.3.0': dependencies: '@shikijs/types': 4.3.0 + '@shikijs/monaco@3.23.0': + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/primitive@4.3.0': dependencies: '@shikijs/types': 4.3.0 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + '@shikijs/primitive@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/themes@2.5.0': dependencies: '@shikijs/types': 2.5.0 + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/themes@4.3.0': dependencies: '@shikijs/types': 4.3.0 @@ -13098,6 +13265,11 @@ snapshots: '@shikijs/core': 2.5.0 '@shikijs/types': 2.5.0 + '@shikijs/transformers@4.3.1': + dependencies: + '@shikijs/core': 4.3.1 + '@shikijs/types': 4.3.1 + '@shikijs/types@2.5.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 @@ -13113,6 +13285,11 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + '@shikijs/types@4.3.1': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/vscode-textmate@10.0.2': {} '@sindresorhus/is@4.6.0': {} @@ -14060,6 +14237,8 @@ snapshots: '@algolia/requester-fetch': 5.52.1 '@algolia/requester-node-http': 5.52.1 + alien-signals@2.0.8: {} + alien-signals@3.2.1: {} ansi-colors@4.1.3: {} @@ -14910,6 +15089,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dompurify@3.4.7: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -16554,6 +16737,8 @@ snapshots: dependencies: yallist: 4.0.0 + lru_map@0.4.1: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -16618,6 +16803,8 @@ snapshots: node-emoji: 2.2.0 supports-hyperlinks: 3.2.0 + marked@14.0.0: {} + marked@16.4.2: {} marked@18.0.5: {} @@ -16626,17 +16813,19 @@ snapshots: markstream-core@1.0.3: {} - markstream-vue@1.0.7(katex@0.17.0)(mermaid@11.15.0)(stream-markdown@0.0.16(react@19.2.5)(shiki@4.3.0)(vue@3.5.35(typescript@6.0.2)))(vue-i18n@11.4.5(vue@3.5.35(typescript@6.0.2)))(vue@3.5.35(typescript@6.0.2)): + markstream-vue@1.0.9-beta.1(katex@0.17.0)(mermaid@11.15.0)(stream-diffs@0.0.2(@shikijs/themes@4.3.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vue@3.5.35(typescript@6.0.2)))(stream-markdown@0.0.16(react@19.2.5)(shiki@4.3.0)(vue@3.5.35(typescript@6.0.2)))(stream-monaco@0.0.49(monaco-editor@0.55.1))(vue-i18n@11.4.5(vue@3.5.35(typescript@6.0.2)))(vue@3.5.35(typescript@6.0.2)): dependencies: '@chenglou/pretext': 0.0.8 '@floating-ui/dom': 1.8.0 markstream-core: 1.0.3 - stream-markdown-parser: 1.1.4 + stream-markdown-parser: 1.1.7 vue: 3.5.35(typescript@6.0.2) optionalDependencies: katex: 0.17.0 mermaid: 11.15.0 + stream-diffs: 0.0.2(@shikijs/themes@4.3.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vue@3.5.35(typescript@6.0.2)) stream-markdown: 0.0.16(react@19.2.5)(shiki@4.3.0)(vue@3.5.35(typescript@6.0.2)) + stream-monaco: 0.0.49(monaco-editor@0.55.1) vue-i18n: 11.4.5(vue@3.5.35(typescript@6.0.2)) math-intrinsics@1.1.0: {} @@ -17157,6 +17346,11 @@ snapshots: yargs-parser: 21.1.1 yargs-unparser: 2.0.0 + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + mri@1.2.0: {} ms@2.1.3: {} @@ -18573,6 +18767,17 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + shiki@3.23.0: + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/engine-javascript': 3.23.0 + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + shiki@4.3.0: dependencies: '@shikijs/core': 4.3.0 @@ -18743,7 +18948,17 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - stream-markdown-parser@1.1.4: + stream-diffs@0.0.2(@shikijs/themes@4.3.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vue@3.5.35(typescript@6.0.2)): + dependencies: + '@pierre/diffs': 1.2.12(@shikijs/themes@4.3.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + optionalDependencies: + vue: 3.5.35(typescript@6.0.2) + transitivePeerDependencies: + - '@shikijs/themes' + - react + - react-dom + + stream-markdown-parser@1.1.7: dependencies: markdown-it-container: 4.0.0 markdown-it-footnote: 4.0.0 @@ -18763,6 +18978,13 @@ snapshots: - solid-js - vue + stream-monaco@0.0.49(monaco-editor@0.55.1): + dependencies: + '@shikijs/monaco': 3.23.0 + alien-signals: 2.0.8 + monaco-editor: 0.55.1 + shiki: 3.23.0 + strict-event-emitter@0.5.1: {} string-argv@0.3.2: {} From 17dfd49768f753a4f0fe97d8e7d3317dab560575 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Fri, 31 Jul 2026 00:38:11 +0800 Subject: [PATCH 02/20] feat(agent-core-v2): introduce the Workspace domain and the agent-profile registry extension point (#2366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent-core-v2): insert Workspace lifecycle scope and remove mutable cwd paths - Insert LifecycleScope.Workspace between App and Session - Delete session/workspaceCommand domain (addAdditionalDir) and node-sdk RPC - Remove profile cwd mutation; cwd is fixed at creation - Make ISessionWorkspaceContext read-only; seed additionalDirs at creation - Remove TUI and vscode /add-dir commands (to return workspace-scoped) * feat(agent-core-v2): add Workspace scope with handler-owned session lifecycle - Add IWorkspaceLifecycleService (App scope): handler registry, create-or-get handlerFor with inflight join - Add workspace/workspaceContext seed and workspace/workspaceHandler (session create/resume/fork as handler child scopes) - Delete App-level ISessionLifecycleService; callers compose index -> handlerFor -> handler via sessionLookup helpers - Slim IBootstrapService; persistence addressing via handler chain (disk layout byte-identical) - kap-server routes rewire internally; /api/v1 wire unchanged, debug surface gains workspace addressing - Pin red line in domain lint: session/agent must not import workspace domains * feat(agent-core-v2): collect workspace resources into the handler scope - Add Workspace-scope catalogs for skills and agent profiles, instructions service, and a shared MCP connection manager (built at materialization, refreshed by watch/plugin events) - Session catalogs keep their APIs but read seeded snapshots and refresh via change events; ISessionMcpService removed - Session create options carry no mcpServers; MCP sources are config file (wins on name conflicts) and plugins only - Agent profile/mcp consume the seeded providers * feat(agent-core-v2): restore add-dir as a workspace-level capability - Add workspace/workspaceDirs: shared additional-dir set with addDir({path, persist}); persist=true writes .kimi-code/local.toml, local.toml watch drives cross-process refresh - ISessionWorkspaceContext becomes a live read view fed by the ISessionWorkspaceInfo seed contract and change events - Restore Session.addAdditionalDir in kimi-code-sdk 1:1, mapping to the workspace service; restore TUI/vscode /add-dir verbatim * feat(agent-core-v2): collect os-level services into the workspace scope - Move fs service, fs watch (shared subscription fan-out), process runner, and a git facade to Workspace scope; sessionFs domain removed - Add IWorkspaceToolPolicy with workspace veto wired through tool activation, execution guard, composed evaluation, and profile prompt projection; injected via ISessionToolPolicyGate seed - kap-server fs routes and fs.watch bridge remap to the workspace services; wire unchanged * refactor(agent-core-v2): clean up workspace-domain leftovers and docs - Drop dead code: v2 mergeCallerMcpServers, the transitional ISessionContext.additionalDirs field, an unreachable guard - Fix stale domain references in comments; correct test names - Give the fs-watch refresh test a realistic wait budget under load - Document the four-scope model and workspace domain in AGENTS.md, agent-core-v2 docs, and the agent-core-dev skill * test(node-sdk): wait for the initial MCP connect to settle in the parity list test v1 connects in the background after create resolves while v2 awaits it inside create, so an immediate list can catch either side still pending under CI load * refactor(agent-core-v2): extract git work-tree discovery into the git domain - add the pure findGitWorkTree probe in app/git/workTree and expose it as IGitService.findWorkTree - switch the git permission policies off the local findLocalGitWorkTreeMarker helper to the DI service - reuse findGitWorkTree for AGENTS.md project-root discovery in agent/profile/context.ts - add findWorkTree coverage to gitService.test.ts * feat(kimi-inspect): add Workspace Services view - add WorkspaceServicesView rail view with a workspace picker on top; proxies resolve workspace-scope Services on the /workspace/:id route - extend ChannelScope, ServiceTarget, and ServicePanelDef scope with 'workspace', routed via client.workspace(id).service - wire the new view into NavRail and App * refactor(agent-core-v2): extract mcpCore and workspaceMcpConfig domains - move the scope-agnostic MCP connection layer (stdio/http/sse clients, connection manager, oauth, config schema, tool naming) from agent/mcp to the new mcpCore domain - move the [mcp] config section to app/mcpConfig and OAuth credential persistence to app/mcpConfig/oauthStore - introduce the workspace/workspaceMcpConfig domain owning the effective MCP server set (mcp.json files + plugin contributions, refreshed by fs watch); workspaceMcp keeps pure connection orchestration - update the plugin domain, session MCP handle, klient/node-sdk contracts, and tests accordingly * refactor(agent-core-v2): remove the fault-injection experimental feature - delete the faultInjection domain (flag definition, IFaultInjectionService contract, FaultInjectionService implementation) - drop the requester-side take() injection point and the constructor dependency from llmRequester - remove the flag-gated test cases and the IFlagService stub they needed - regenerate the state manifest without the faultInjection state keys * feat(agent-core-v2): gate project-level MCP config behind workspace trust Add the Workspace-scope IWorkspaceTrust service: an explicit, per-workspace trust marker persisted under the home (IAtomicDocumentStore, keyed by encodeWorkDirKey(root)) so a checked-out tree cannot pre-trust itself. While a workspace is untrusted, workspaceMcpConfig skips the project-level .mcp.json and .kimi-code/mcp.json files (user-level config and plugin contributions still load); a trust flip reuses the reload path, so project servers connect on trust and disconnect on untrust. Expose the state over kap-server REST: GET /workspaces/{id}/trust, POST /workspaces/{id}/trust, POST /workspaces/{id}/untrust. * feat(kimi-inspect): replace the workspace picker with a directory browser The Workspace Services view now keeps a server-side directory browser in a left sidebar (over IHostFolderBrowser) instead of a of registered + * workspaces. Directories are listed through the App-scope + * `IHostFolderBrowser` (home + recent roots + one-level directory listing); + * entries that are registered workspaces carry a `workspace` badge plus their + * trust state (`trusted` / `untrusted`, read through the Workspace-scope + * `IWorkspaceTrust`; servers predating that service simply show no trust + * badge). "Select" registers the current folder on demand — + * `IWorkspaceService.createOrTouch` is idempotent on root — before handing + * the workspace to the parent. + */ + +import { + IHostFolderBrowser, + type FsBrowseEntry, +} from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; +import { + IWorkspaceService, + type Workspace, +} from '@moonshot-ai/agent-core-v2/app/workspace/workspace'; +import { IWorkspaceTrust } from '@moonshot-ai/agent-core-v2/workspace/workspaceTrust/workspaceTrust'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; + +import type { InspectClient } from '../channel'; +import { ErrorLine } from '../ui'; + +function normalizePath(path: string): string { + return path.replaceAll('\\', '/').replace(/\/+$/, ''); +} + +function baseName(path: string): string { + const normalized = normalizePath(path); + return normalized.split('/').pop() ?? normalized; +} + +export function WorkspaceDirBrowser(props: { + klient: InspectClient; + workspaces: readonly Workspace[] | undefined; + onSelect: (workspace: Workspace) => void; +}) { + const { klient, workspaces, onSelect } = props; + const queryClient = useQueryClient(); + /** Current browsed directory; null = the server default ($HOME). */ + const [path, setPath] = useState(null); + + const home = useQuery({ + queryKey: ['fs-home', klient.baseUrl], + queryFn: () => klient.core(IHostFolderBrowser).home(), + }); + const browse = useQuery({ + queryKey: ['fs-browse', klient.baseUrl, path], + queryFn: () => + path === null + ? klient.core(IHostFolderBrowser).browse() + : klient.core(IHostFolderBrowser).browse(path), + }); + + const select = useMutation({ + mutationFn: (root: string) => klient.core(IWorkspaceService).createOrTouch(root), + onSuccess: async (workspace) => { + await queryClient.invalidateQueries({ queryKey: ['workspaces', klient.baseUrl] }); + onSelect(workspace); + }, + }); + + const workspaceList = workspaces ?? []; + /** normalized root → trusted; undefined when the server predates `workspaceTrust`. */ + const trustByRoot = useQuery({ + queryKey: ['workspace-trust', klient.baseUrl, workspaceList.map((ws) => ws.id).join(',')], + enabled: workspaceList.length > 0, + retry: false, + queryFn: async () => { + const entries = await Promise.all( + workspaceList.map(async (ws) => { + try { + const trusted = await klient.workspace(ws.id).service(IWorkspaceTrust).get(); + return [normalizePath(ws.root), trusted] as const; + } catch { + return [normalizePath(ws.root), undefined] as const; + } + }), + ); + return new Map(entries); + }, + }); + + const workspaceRoots = new Set(workspaceList.map((ws) => normalizePath(ws.root))); + const current = browse.data ?? null; + const recents = home.data?.recent_roots ?? []; + const isCurrentWorkspace = current !== null && workspaceRoots.has(normalizePath(current.path)); + + const renderEntry = (entry: FsBrowseEntry) => { + const isWorkspace = workspaceRoots.has(normalizePath(entry.path)); + const trusted = trustByRoot.data?.get(normalizePath(entry.path)); + return ( + + ); + }; + + return ( +
+
+
+ + + +
+ + {current?.path ?? '…'} + +
+ {recents.length > 0 ? ( +
+ + recent + + {recents.map((root) => ( + + ))} +
+ ) : null} +
+ {browse.isError ? ( +
+ +
+ ) : current === null ? ( +
loading…
+ ) : current.entries.length === 0 ? ( +
no subdirectories
+ ) : ( + current.entries.map(renderEntry) + )} +
+ {select.isError ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/apps/kimi-inspect/src/components/WorkspaceServicesView.tsx b/apps/kimi-inspect/src/components/WorkspaceServicesView.tsx new file mode 100644 index 0000000000..4b094c9245 --- /dev/null +++ b/apps/kimi-inspect/src/components/WorkspaceServicesView.tsx @@ -0,0 +1,93 @@ +/** + * Workspace Services view — the workspace-scope Service reflection as a + * standalone rail view. Same Postman-style three-pane layout + * (`ScopePanelsScrollspy`) as App Services, plus a left sidebar holding the + * workspace picker: a server-side directory browser (`WorkspaceDirBrowser`, + * over the App-scope `IHostFolderBrowser`) that marks already-registered + * workspaces and registers a picked folder on demand. The proxies resolve on + * the `/workspace/:id` route, so a workspace must be selected before any + * Service is callable. Picking one materializes its handler on demand + * server-side (`IWorkspaceLifecycleService.handlerFor` is create-or-get), no + * manual join needed. + */ + +import { IWorkspaceService } from '@moonshot-ai/agent-core-v2/app/workspace/workspace'; +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useEffect, useState } from 'react'; + +import { serviceByName } from '../channel'; +import { useConnection } from '../connection'; +import type { AnyService } from '../panels'; +import { ErrorLine } from '../ui'; +import { ScopePanelsScrollspy } from './ServicePanels'; +import { WorkspaceDirBrowser } from './WorkspaceDirBrowser'; + +export function WorkspaceServicesView() { + const { klient, baseUrl } = useConnection(); + const [workspaceId, setWorkspaceId] = useState(null); + + const workspaces = useQuery({ + queryKey: ['workspaces', klient.baseUrl], + queryFn: () => klient.core(IWorkspaceService).list(), + }); + + // Switching servers invalidates the selection: workspaces belong to the + // server they were listed from. + useEffect(() => { + setWorkspaceId(null); + }, [baseUrl]); + + const selected = (workspaces.data ?? []).find((ws) => ws.id === workspaceId); + + const proxyFor = useCallback( + (name: string): AnyService | null => + workspaceId === null + ? null + : (serviceByName(klient, name, { scope: 'workspace', workspaceId }) ?? null), + [klient, workspaceId], + ); + + return ( +
+ +
+ {workspaceId === null ? ( +
+ select a workspace to inspect its Services +
+ ) : ( + + )} +
+
+ ); +} diff --git a/apps/kimi-inspect/src/panels.ts b/apps/kimi-inspect/src/panels.ts index 92abed8a4f..109590517d 100644 --- a/apps/kimi-inspect/src/panels.ts +++ b/apps/kimi-inspect/src/panels.ts @@ -65,7 +65,7 @@ export interface ServicePanelDef { readonly id: string; readonly label: string; /** Wire scope the Service is called on (`app` maps to the `core` route). */ - readonly scope: 'app' | 'session' | 'agent'; + readonly scope: 'app' | 'workspace' | 'session' | 'agent'; readonly fetch?: (svc: AnyService) => Promise; readonly actions?: readonly PanelAction[]; } diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index a9ef407bee..6707fd4cae 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -356,6 +356,11 @@ export class AcpServer implements Agent { // record verbatim. The `@ts-expect-error` documents this contract; // if the SDK ever switches from spread-passthrough to explicit field // copy, this line breaks and we revisit the boundary. + // NOTE (workspace-domain consolidation): the passthrough reaches the + // v1 kernel only. The v2 engine has NO caller `mcpServers` channel on + // session create/resume (its MCP manager is per-workspace-handler, fed + // by config files and plugins only) — how ACP-supplied servers should + // reach the v2 engine is left to a future ACP-specific design. const mcpServers = acpMcpServersToConfigs(params.mcpServers); if (!this.conn) { // Defensive: every code path that constructs `AcpServer` (the diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index 8646b066a2..f8c82bbb7a 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -2,6 +2,10 @@ > New agent engine built on the DI Scope architecture — work-in-progress port of `packages/agent-core`. Design: `plan/PLAN.md`. Porting status: `GAP_ANALYSIS.md`. +## Scopes + +Four `LifecycleScope` tiers — `App` (0) / `Workspace` (1) / `Session` (2) / `Agent` (3) (`src/_base/di/scope.ts`). The `workspace/` domain owns the Workspace tier: the App-scope `workspaceLifecycle` holds the live handler registry (one handler per workspaceId, create-or-get + join, never closed), and each handler's `workspaceHandler` owns the session lifecycle (create/resume/fork/close) as its child scopes. Workspace-scope services (`workspaceSkillCatalog` / `workspaceAgentProfileLoader` / `workspaceInstructions` / `workspaceMcp` / `workspaceDirs` / `workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit` / `workspaceToolPolicy` / `workspaceTrust`) hold the handler-shared resources — loaded once at handler materialization, then refreshed by fs watch — and sessions consume them through session-domain seed contracts with change events. Agent profiles follow the Contribution / Registry / Catalog extension point instead of a workspace catalog: the `workspaceAgentProfileLoader` domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit runtime files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) register `AgentProfileContribution`s into the App-scope `IAgentProfileRegistry`, tagged with the handler's `workspaceId` (the registry dedups per source id; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles), and each Session-scope `sessionAgentProfileCatalog` projects the registry into the merged, name-deduped read view directly — its seed carries only the workspace key. `workspaceTrust` records the per-workspace trust marker (persisted under the home, keyed by `encodeWorkDirKey(root)`); while untrusted, `workspaceMcpConfig` skips the project-level MCP config files (`.mcp.json`, `.kimi-code/mcp.json`). Dependency red line: **Session/Agent never import the Workspace domain**; the App-level `ISessionLifecycleService` / `ISessionMcpService` / `ISessionFsService` are gone — compose `sessionIndex` → `workspaceLifecycle.handlerFor` → the handler instead. + ## Examples > The runnable examples have moved to the standalone `kimi-code-mini-bench` package at `../kimi-code-mini-bench`. They are wired to `agent-core-v2` through a pnpm `link:` dependency and run as a separate Vitest project. diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index d89be92abe..8092e735f1 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -14,12 +14,12 @@ # defaultPermissionMode src/agent/permissionMode/configSection.ts # defaultPlanMode src/agent/plan/configSection.ts # experimental src/app/flag/flag.ts -# extraAgentDirs src/app/agentFileCatalog/configSection.ts +# extraAgentDirs src/workspace/workspaceAgentProfileLoader/configSection.ts # extraSkillDirs src/app/skillCatalog/configSection.ts # hooks src/agent/externalHooks/configSection.ts # image src/agent/media/configSection.ts # loopControl src/agent/loop/configSection.ts -# mcp src/agent/mcp/configSection.ts +# mcp src/app/mcpConfig/configSection.ts # mergeAllAvailableSkills src/app/skillCatalog/configSection.ts # modelCatalog src/app/kosongConfig/configSection.ts # models src/app/kosongConfig/configSection.ts @@ -106,7 +106,7 @@ default_plan_mode = false # ########################################################################## # extraAgentDirs (config.toml: extra_agent_dirs) -# owner: src/app/agentFileCatalog/configSection.ts +# owner: src/workspace/workspaceAgentProfileLoader/configSection.ts # scope: core # ########################################################################## @@ -167,7 +167,7 @@ extra_skill_dirs = [] # ########################################################################## # mcp -# owner: src/agent/mcp/configSection.ts +# owner: src/app/mcpConfig/configSection.ts # scope: core # hooks: stripEnv # env: diff --git a/packages/agent-core-v2/docs/di.md b/packages/agent-core-v2/docs/di.md index e892be8966..15be4b8474 100644 --- a/packages/agent-core-v2/docs/di.md +++ b/packages/agent-core-v2/docs/di.md @@ -139,15 +139,16 @@ const meta = accessor.get(ISessionMetadata); // 类型是 ISessionMetadata > 你要做的:每个会话一份、或每个 agent 一份。参考 [`sessionMetadata`](../src/session/sessionMetadata/sessionMetadata.ts)、[`turn`](../src/turn/turn.ts)。 -这一步引入:**`LifecycleScope` 三层生命周期** 与 **父子 scope 的可见性**。 +这一步引入:**`LifecycleScope` 四层生命周期** 与 **父子 scope 的可见性**。 -### 3.1 三层,按寿命从长到短 +### 3.1 四层,按寿命从长到短 ```ts export enum LifecycleScope { - App = 0, // 进程级,全局一份 - Session = 1, // 一次会话 - Agent = 2, // 一个 agent + App = 0, // 进程级,全局一份 + Workspace = 1, // 一个工作区 handler(与 Session 一对多) + Session = 2, // 一次会话 + Agent = 3, // 一个 agent } ``` @@ -171,15 +172,16 @@ Scope 是一棵树,`kind` 必须沿父子方向**严格递增**: ``` App (0) - └── Session (1) - └── Agent (2) + └── Workspace (1) + └── Session (2) + └── Agent (3) ``` 解析服务时,容器先看自己这一层,没有就**递归问父 scope**。所以一条铁律: > **短寿命的服务可以注入长寿命的服务,反过来不行。** -- ✅ Agent 服务注入 Session / App 服务(往上找,找得到)。 +- ✅ Agent 服务注入 Session / Workspace / App 服务(往上找,找得到)。 - ❌ App 服务注入 Session 服务(App 创建时 Session 还不存在,且父不会往下找)。 这条规则由树的结构强制保证,不靠纪律维持。 @@ -350,7 +352,7 @@ A 创建中要 B,B 创建中又要 A——容器会抛 `CyclicDependencyError` ### 9.2 为什么不允许 -- scope 分层让正常依赖天然是 DAG(Agent → Session → App 向上找),一个环几乎总是设计味道。 +- scope 分层让正常依赖天然是 DAG(Agent → Session → Workspace → App 向上找),一个环几乎总是设计味道。 - 靠「让环刚好能跑」会把构造顺序变成隐式约定,难调试、难排错。 所以 v2 的立场是:**依赖图必须是无环的。** diff --git a/packages/agent-core-v2/docs/rw-model-design.md b/packages/agent-core-v2/docs/rw-model-design.md index ccb2a71eac..766946ee75 100644 --- a/packages/agent-core-v2/docs/rw-model-design.md +++ b/packages/agent-core-v2/docs/rw-model-design.md @@ -65,7 +65,7 @@ - W3 Session 域借 main agent 的 wire 写(todo/cron),main 缺失时**静默丢写** (`sessionTodoService.ts:99-100`),且要 `as never` 绕过类型。 - W4 fork 直接在 appendLogStore 层改写 wire log,绕过全部写模型 - (`sessionLifecycleService.ts:303-337`)。 + (`workspaceHandlerService.ts` 的 `fork` / `copyAgentWire`)。 - W5 restore 期 append 在 wireRecord 层被静默吞掉(`wireRecordService.ts:81`), 但 recordService 仍然 foldViews、仍然跑 facet——"进内存不进磁盘"完全隐式。 @@ -98,7 +98,7 @@ onChange 处理器若 append 会无检测地重入。 - L3 restore 正确性依赖三重隐式契约:DI 构造顺序 + hook 注册顺序 + "resumer 先于 hooks";`doResume` 需手动预热 contextMemory - (`sessionLifecycleService.ts:158-162`)。 + (现 `workspaceHandlerService.ts` 的 `doResume` / `materializeSession`)。 - L4 相位规则(restoring / postRestoring / live)在 append/signal/push/hook 四条通道上各不相同,没有一处集中定义。 @@ -189,7 +189,7 @@ 逻辑 seq 顺序,因此边缘 journal 的 seq 与核心逻辑 seq 单调一致。 - fork 保持现实现(复制 main 的 wire log);接口上表达为 `stream.forkInto(target)`,实现仍走 appendLogStore(W4 的接口层收口: - 唯一入口,不再散落在 sessionLifecycle 里手写)。 + 唯一入口,不再散落在 workspaceHandler 里手写)。 - App scope 一条逻辑流(config/model catalog/session 生命周期),取代 `IEventService`(V4)——App 流本就无持久化,纯接口替换。 - **Topic = 流上的类型化过滤视角**,不是独立机制。订阅方用 diff --git a/packages/agent-core-v2/docs/service-design.md b/packages/agent-core-v2/docs/service-design.md index e78aa848eb..54bd301179 100644 --- a/packages/agent-core-v2/docs/service-design.md +++ b/packages/agent-core-v2/docs/service-design.md @@ -37,11 +37,12 @@ Every principle below derives from two root questions: **First principle: Scope = the identity + lifetime of the owned state.** -`App` / `Session` / `Agent` are three tiers of identity + lifetime: +`App` / `Workspace` / `Session` / `Agent` are four tiers of identity + lifetime: | Scope | State identity (keyed by) | Lifetime | |---|---|---| | `App` | none (single global instance) | the process | +| `Workspace` | `workspaceId` | one workspace handler (materialized once per workspace, never closed — dies with the process) | | `Session` | `sessionId` | one session | | `Agent` | `agentId` | one agent | @@ -55,6 +56,7 @@ Every principle below derives from two root questions: **Q2. What is the identity of that state?** - one global instance → **`App`** +- one per workspace (shared by every session of that workspace) → **`Workspace`** - one per session → **`Session`** - one per agent → **`Agent`** - a mix (a global registry *and* per-instance state) → **do not put it in one Service; @@ -108,7 +110,7 @@ job well. | Tier | Role | Naming tends to | |---|---|---| | `App` | **global registry / catalog / factory** — knows "all of them" and how to create one | `XxxStore` / `XxxRegistry` / `XxxCatalog` | -| `Session` / `Agent` | **one instance** — only the state of "this one" | `XxxService` / `ISessionXxx` / `IAgentXxx` | +| `Workspace` / `Session` / `Agent` | **one instance** — only the state of "this one" | `XxxService` / `IWorkspaceXxx` / `ISessionXxx` / `IAgentXxx` | This pattern recurs throughout the codebase and confirms the rule: diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index f1027e72b0..dfe4bd4f11 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -15,44 +15,32 @@ // expand structurally; classes render as their public instance shape. The // defining source file heads each group. // -// External ambient types referenced but not expanded (from node_modules): Readable, Writable -// // snapshot() returns JSON-safe deep copies of these values: Maps become plain // objects (or [key, value] entry arrays when a key is not string/number), Sets // become arrays, bigints become strings, functions are dropped, circular // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (Session: 28 keys · Agent: 67 keys) +// Index (Session: 18 keys · Agent: 67 keys) // Session -// cron.inFlight src/session/cron/sessionCronServiceImpl.ts -// cron.lastSeenAt src/session/cron/sessionCronServiceImpl.ts -// cron.parsedCache src/session/cron/sessionCronServiceImpl.ts -// cron.seededFromStore src/session/cron/sessionCronServiceImpl.ts -// cron.started src/session/cron/sessionCronServiceImpl.ts -// cron.tasks src/session/cron/sessionCronServiceImpl.ts -// interaction.nextId src/session/interaction/interactionService.ts -// interaction.pending src/session/interaction/interactionService.ts -// interaction.recentlyResolved src/session/interaction/interactionService.ts -// sessionActivity.current src/session/sessionActivity/sessionActivityService.ts -// sessionActivity.folds src/session/sessionActivity/sessionActivityService.ts -// sessionAgentProfileCatalog.contributions src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts -// sessionAgentProfileCatalog.merged src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts -// sessionFs.realRootsCache src/session/sessionFs/fsService.ts -// sessionFs.rgResolution src/session/sessionFs/fsService.ts -// sessionFsWatch.gitignoreLoaded src/session/sessionFs/fsWatchService.ts -// sessionFsWatch.pending src/session/sessionFs/fsWatchService.ts -// sessionFsWatch.rawCount src/session/sessionFs/fsWatchService.ts -// sessionFsWatch.truncated src/session/sessionFs/fsWatchService.ts -// sessionFsWatch.watched src/session/sessionFs/fsWatchService.ts -// sessionLog.rootLevel src/session/sessionLog/sessionLogService.ts -// sessionMetadata.data src/session/sessionMetadata/sessionMetadataService.ts -// sessionSkillCatalog.contributions src/session/sessionSkillCatalog/skillCatalogService.ts -// sessionSkillCatalog.merged src/session/sessionSkillCatalog/skillCatalogService.ts -// sessionToolPolicy.state src/session/sessionToolPolicy/sessionToolPolicyService.ts -// workspaceCommand.pendingMainInjections src/session/workspaceCommand/workspaceCommandService.ts -// workspaceContext.additionalDirs src/session/workspaceContext/workspaceContextService.ts -// workspaceContext.workDir src/session/workspaceContext/workspaceContextService.ts +// cron.inFlight src/session/cron/sessionCronServiceImpl.ts +// cron.lastSeenAt src/session/cron/sessionCronServiceImpl.ts +// cron.parsedCache src/session/cron/sessionCronServiceImpl.ts +// cron.seededFromStore src/session/cron/sessionCronServiceImpl.ts +// cron.started src/session/cron/sessionCronServiceImpl.ts +// cron.tasks src/session/cron/sessionCronServiceImpl.ts +// interaction.nextId src/session/interaction/interactionService.ts +// interaction.pending src/session/interaction/interactionService.ts +// interaction.recentlyResolved src/session/interaction/interactionService.ts +// sessionActivity.current src/session/sessionActivity/sessionActivityService.ts +// sessionActivity.folds src/session/sessionActivity/sessionActivityService.ts +// sessionLog.rootLevel src/session/sessionLog/sessionLogService.ts +// sessionMetadata.data src/session/sessionMetadata/sessionMetadataService.ts +// sessionSkillCatalog.contributions src/session/sessionSkillCatalog/skillCatalogService.ts +// sessionSkillCatalog.merged src/session/sessionSkillCatalog/skillCatalogService.ts +// sessionToolPolicy.state src/session/sessionToolPolicy/sessionToolPolicyService.ts +// workspaceContext.additionalDirs src/session/workspaceContext/workspaceContextService.ts +// workspaceContext.workDir src/session/workspaceContext/workspaceContextService.ts // Agent // activityView.background src/agent/activityView/activityViewService.ts // activityView.current src/agent/activityView/activityViewService.ts @@ -176,156 +164,6 @@ export interface SessionStateSnapshot { background: number; lastTurnReason?: 'completed' | 'cancelled' | 'failed'; }>; - // src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts - 'sessionAgentProfileCatalog.contributions': Map string; - readonly promptPrefix?: (ctx: /* AgentProfilePromptPrefixContext — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ { - readonly cwd: string; - readonly runner: /* ISessionProcessRunner — packages/agent-core-v2/src/session/process/processRunner.ts */ { - readonly _serviceBrand: undefined; - exec: (args: readonly string[], options?: /* ProcessExecOptions — packages/agent-core-v2/src/session/process/processRunner.ts */ { - readonly cwd?: string; - readonly env?: Record; - }) => Promise Promise; - kill: (signal?: 'SIGABRT' | 'SIGALRM' | 'SIGBUS' | 'SIGCHLD' | 'SIGCONT' | 'SIGFPE' | 'SIGHUP' | 'SIGILL' | 'SIGINT' | 'SIGIO' | 'SIGIOT' | 'SIGKILL' | 'SIGPIPE' | 'SIGPOLL' | 'SIGPROF' | 'SIGPWR' | 'SIGQUIT' | 'SIGSEGV' | 'SIGSTKFLT' | 'SIGSTOP' | 'SIGSYS' | 'SIGTERM' | 'SIGTRAP' | 'SIGTSTP' | 'SIGTTIN' | 'SIGTTOU' | 'SIGUNUSED' | 'SIGURG' | 'SIGUSR1' | 'SIGUSR2' | 'SIGVTALRM' | 'SIGWINCH' | 'SIGXCPU' | 'SIGXFSZ' | 'SIGBREAK' | 'SIGLOST' | 'SIGINFO') => Promise; - dispose: () => void | Promise; - }>; - }; - readonly log?: /* ILogger — packages/agent-core-v2/src/_base/log/log.ts */ { - error: (message: string, payload?: unknown) => void; - warn: (message: string, payload?: unknown) => void; - info: (message: string, payload?: unknown) => void; - debug: (message: string, payload?: unknown) => void; - child: (ctx: /* LogContext — packages/agent-core-v2/src/_base/log/log.ts */ { - [key: string]: unknown; - }) => /* ILogger — recursive (packages/agent-core-v2/src/_base/log/log.ts) */ unknown; - }; - }) => Promise; - readonly summaryPolicy?: /* AgentProfileSummaryPolicy — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ { - readonly minChars: number; - readonly continuationPrompt: string; - readonly retries: number; - }; - }[]; - readonly skipped?: readonly /* SkippedAgentFile — packages/agent-core-v2/src/app/agentFileCatalog/types.ts */ { - readonly path: string; - readonly reason: string; - }[]; - readonly scannedRoots?: readonly string[]; - }; - readonly priority: number; - }>; - 'sessionAgentProfileCatalog.merged': Map string; - readonly promptPrefix?: (ctx: /* AgentProfilePromptPrefixContext — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ { - readonly cwd: string; - readonly runner: /* ISessionProcessRunner — packages/agent-core-v2/src/session/process/processRunner.ts */ { - readonly _serviceBrand: undefined; - exec: (args: readonly string[], options?: /* ProcessExecOptions — packages/agent-core-v2/src/session/process/processRunner.ts */ { - readonly cwd?: string; - readonly env?: Record; - }) => Promise Promise; - kill: (signal?: 'SIGABRT' | 'SIGALRM' | 'SIGBUS' | 'SIGCHLD' | 'SIGCONT' | 'SIGFPE' | 'SIGHUP' | 'SIGILL' | 'SIGINT' | 'SIGIO' | 'SIGIOT' | 'SIGKILL' | 'SIGPIPE' | 'SIGPOLL' | 'SIGPROF' | 'SIGPWR' | 'SIGQUIT' | 'SIGSEGV' | 'SIGSTKFLT' | 'SIGSTOP' | 'SIGSYS' | 'SIGTERM' | 'SIGTRAP' | 'SIGTSTP' | 'SIGTTIN' | 'SIGTTOU' | 'SIGUNUSED' | 'SIGURG' | 'SIGUSR1' | 'SIGUSR2' | 'SIGVTALRM' | 'SIGWINCH' | 'SIGXCPU' | 'SIGXFSZ' | 'SIGBREAK' | 'SIGLOST' | 'SIGINFO') => Promise; - dispose: () => void | Promise; - }>; - }; - readonly log?: /* ILogger — packages/agent-core-v2/src/_base/log/log.ts */ { - error: (message: string, payload?: unknown) => void; - warn: (message: string, payload?: unknown) => void; - info: (message: string, payload?: unknown) => void; - debug: (message: string, payload?: unknown) => void; - child: (ctx: /* LogContext — packages/agent-core-v2/src/_base/log/log.ts */ { - [key: string]: unknown; - }) => /* ILogger — recursive (packages/agent-core-v2/src/_base/log/log.ts) */ unknown; - }; - }) => Promise; - readonly summaryPolicy?: /* AgentProfileSummaryPolicy — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ { - readonly minChars: number; - readonly continuationPrompt: string; - readonly retries: number; - }; - }>; - // src/session/sessionFs/fsService.ts - 'sessionFs.realRootsCache': { - readonly key: string; - readonly roots: readonly string[]; - } | undefined; - 'sessionFs.rgResolution': /* RgResolution — packages/agent-core-v2/src/session/sessionFs/rgLocator.ts */ { - readonly path: string; - readonly source: /* RgResolutionSource — packages/agent-core-v2/src/session/sessionFs/rgLocator.ts */ 'system-path' | 'share-bin-cached'; - } | null | undefined; - // src/session/sessionFs/fsWatchService.ts - 'sessionFsWatch.gitignoreLoaded': boolean; - 'sessionFsWatch.pending': /* FsChangeEntry — packages/agent-core-v2/src/session/sessionFs/fsWatch.ts */ { - path: string; - change: /* FsChangeAction — packages/agent-core-v2/src/session/sessionFs/fsWatch.ts */ 'created' | 'modified' | 'deleted'; - kind: /* FsChangeKind — packages/agent-core-v2/src/session/sessionFs/fsWatch.ts */ 'file' | 'directory' | 'symlink'; - size_delta?: number; - etag?: string; - }[]; - 'sessionFsWatch.rawCount': number; - 'sessionFsWatch.truncated': boolean; - 'sessionFsWatch.watched': Set; // src/session/sessionLog/sessionLogService.ts 'sessionLog.rootLevel': /* LogLevelState — packages/agent-core-v2/src/_base/log/logService.ts */ { level: /* LogLevel — packages/agent-core-v2/src/_base/log/log.ts */ 'info' | 'off' | 'error' | 'warn' | 'debug'; @@ -588,112 +426,6 @@ export interface SessionStateSnapshot { 'sessionToolPolicy.state': /* SessionToolPolicyState — packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts */ { readonly disabledTools: readonly string[]; }; - // src/session/workspaceCommand/workspaceCommandService.ts - 'workspaceCommand.pendingMainInjections': (/* ContextMessage — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* Message — packages/agent-core-v2/src/kosong/contract/message.ts */ { - readonly role: /* Role — packages/agent-core-v2/src/kosong/contract/message.ts */ 'user' | 'system' | 'assistant' | 'tool'; - readonly name?: string; - readonly content: (/* ContentPart — packages/agent-core-v2/src/kosong/contract/message.ts */ /* TextPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { - type: 'text'; - text: string; - } | /* ThinkPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { - type: 'think'; - think: string; - encrypted?: string; - } | /* ImageURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { - type: 'image_url'; - imageUrl: { - url: string; - id?: string; - }; - } | /* AudioURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { - type: 'audio_url'; - audioUrl: { - url: string; - id?: string; - }; - } | /* VideoURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { - type: 'video_url'; - videoUrl: { - url: string; - id?: string; - }; - })[]; - readonly toolCalls: /* ToolCall — packages/agent-core-v2/src/kosong/contract/message.ts */ { - type: 'function'; - id: string; - name: string; - arguments: string | null; - extras?: Record; - _streamIndex?: string | number; - }[]; - readonly toolCallId?: string; - readonly partial?: boolean; - readonly tools?: readonly /* Tool — packages/agent-core-v2/src/kosong/contract/tool.ts */ { - name: string; - description: string; - parameters: Record; - deferred?: true; - }[]; - } & { - readonly id?: string; - readonly providerMessageId?: string; - readonly origin?: /* UserPromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'user'; - } | /* SkillActivationOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'skill_activation'; - readonly activationId: string; - readonly skillName: string; - readonly skillArgs?: string; - readonly trigger: 'user-slash' | 'model-tool' | 'nested-skill'; - readonly skillType?: string; - readonly skillPath?: string; - readonly skillSource?: 'project' | 'user' | 'extra' | 'builtin'; - } | /* PluginCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'plugin_command'; - readonly activationId: string; - readonly pluginId: string; - readonly commandName: string; - readonly commandArgs?: string; - readonly trigger: 'user-slash'; - } | /* InjectionOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'injection'; - readonly variant: string; - readonly ownerPromptId?: string; - } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'shell_command'; - readonly phase: 'input' | 'output'; - readonly isError?: boolean; - } | /* CompactionSummaryOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'compaction_summary'; - } | /* SystemTriggerOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'system_trigger'; - readonly name: string; - } | /* TaskOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'task'; - readonly taskId: string; - readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost'; - readonly notificationId: string; - } | /* CronJobOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'cron_job'; - readonly jobId: string; - readonly cron: string; - readonly recurring: boolean; - readonly coalescedCount: number; - readonly stale: boolean; - } | /* CronMissedOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'cron_missed'; - readonly count: number; - } | /* HookResultOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'hook_result'; - readonly event: string; - readonly blocked?: boolean; - } | /* RetryOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'retry'; - readonly trigger?: string; - }; - readonly isError?: boolean; - readonly note?: string; - })[]; // src/session/workspaceContext/workspaceContextService.ts 'workspaceContext.additionalDirs': string[]; 'workspaceContext.workDir': string; @@ -768,7 +500,7 @@ export interface AgentStateSnapshot { readonly trigger?: string; }; readonly phase: /* TurnPhase — packages/agent-core-v2/src/agent/activityView/activityView.ts */ 'running' | 'streaming' | 'tool_call' | 'retrying'; - readonly stream?: 'assistant' | 'tool_call' | 'thinking'; + readonly stream?: 'tool_call' | 'assistant' | 'thinking'; readonly step: number; readonly ending: boolean; readonly endingReason?: 'error' | 'aborted' | 'max_steps'; @@ -813,7 +545,7 @@ export interface AgentStateSnapshot { 'activityView.lifecycle': /* ActivityViewLifecycle — packages/agent-core-v2/src/agent/activityView/activityView.ts */ 'ready' | 'disposed'; 'activityView.turn': /* MutableTurn — packages/agent-core-v2/src/agent/activityView/activityViewService.ts */ { phase: /* TurnPhase — packages/agent-core-v2/src/agent/activityView/activityView.ts */ 'running' | 'streaming' | 'tool_call' | 'retrying'; - stream: 'assistant' | 'tool_call' | 'thinking' | undefined; + stream: 'tool_call' | 'assistant' | 'thinking' | undefined; step: number; ending: boolean; endingReason: 'error' | 'aborted' | 'max_steps' | undefined; @@ -948,7 +680,7 @@ export interface AgentStateSnapshot { readonly trigger?: string; }; readonly phase: /* TurnPhase — packages/agent-core-v2/src/agent/activityView/activityView.ts */ 'running' | 'streaming' | 'tool_call' | 'retrying'; - readonly stream?: 'assistant' | 'tool_call' | 'thinking'; + readonly stream?: 'tool_call' | 'assistant' | 'thinking'; readonly step: number; readonly ending: boolean; readonly endingReason?: 'error' | 'aborted' | 'max_steps'; @@ -1008,7 +740,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': MapagentTask', 'llmRequester>session', - 'loop>mcp', // `registerMediaTools` (media, L4) imports the `ReadMediaFileTool` // implementation from the `tools` domain (L7) to register it for media // capability agents. @@ -505,7 +628,6 @@ const ALLOWED_EXCEPTIONS = new Set([ // projection side references the context message type by structure only. 'record>contextMemory', 'plugin>externalHooks', - 'plugin>mcp', 'profile>session', 'replayBuilder>agentTask', 'replayBuilder>rpc', @@ -520,7 +642,6 @@ const ALLOWED_EXCEPTIONS = new Set([ 'filestore>persistence/backends', 'process>os/backends', 'terminal>os/backends', - 'sessionFs>os/backends', 'blobStore>persistence/backends', // `sessionIndex` (L2) reads the `persistence_minidb_readmodel` experimental // flag (L3) to switch session listings between the legacy N+1 disk read and @@ -639,6 +760,22 @@ export function checkSource(source, absFile) { continue; } + // Rule 2b: scope direction — Session/Agent tiers never import the + // Workspace tier (`src/workspace/**` or `src/app/workspaceLifecycle/**`). + const sourceTier = scopeTierOf(absFile); + if (SESSION_AGENT_TIERS.has(sourceTier)) { + const targetTier = scopeTierOf(targetAbs); + const targetRel = relative(SRC_ROOT, targetAbs).split(/[\\/]/).join('/'); + if (targetTier === 'workspace' || targetRel.startsWith(WORKSPACE_LIFECYCLE_PREFIX)) { + violations.push({ + file: absFile, + line, + message: `scope violation: '${sourceTier}' tier must not import Workspace-tier code ('${targetRel}' via '${specifier}') — workspace capabilities reach sessions only through session-domain contracts + scope seeds`, + }); + continue; + } + } + // Rule 3b: kosong-internal layering. Runs even for same-domain imports // because the provider/bases sub-boundary also bans same-domain targets // (registries and contrib modules live beside the bases). diff --git a/packages/agent-core-v2/src/_base/contribution/registry.ts b/packages/agent-core-v2/src/_base/contribution/registry.ts new file mode 100644 index 0000000000..03238616da --- /dev/null +++ b/packages/agent-core-v2/src/_base/contribution/registry.ts @@ -0,0 +1,71 @@ +/** + * `_base/contribution` domain (L1) — generic source-keyed contribution + * registry. + * + * The storage half of the Contribution / Registry / Catalog extension-point + * pattern: a *contribution* is a plain data structure offered by an outer + * contributor (a loader, a plugin, a code module); the *registry* stores at + * most one contribution per `sourceId` — re-registering the same `sourceId` + * replaces the previous entry, which is the only dedup this layer performs. + * Content-level dedup (e.g. by item name), ordering, and merge rules are the + * Catalog's projection job, never the registry's. `register` returns a handle + * whose `dispose` unregisters — but only the entry it registered, so a stale + * handle can never evict a newer re-registration. Every mutation fires + * `onDidChange` with the affected `sourceId` so catalogs can re-project. + */ + +import { Disposable, type IDisposable } from '../di/lifecycle'; +import { Emitter, type Event } from '../event'; + +export interface ContributionRegistration { + readonly sourceId: string; + readonly priority: number; + readonly contribution: T; +} + +export interface RegisterContributionOptions { + readonly priority?: number; +} + +export class ContributionRegistry extends Disposable { + private readonly registrations = new Map>(); + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + + register( + sourceId: string, + contribution: T, + options?: RegisterContributionOptions, + ): IDisposable { + const registration: ContributionRegistration = { + sourceId, + priority: options?.priority ?? 0, + contribution, + }; + this.registrations.set(sourceId, registration); + this.onDidChangeEmitter.fire(sourceId); + let active = true; + return { + dispose: () => { + if (!active) return; + active = false; + if (this.registrations.get(sourceId) !== registration) return; + this.registrations.delete(sourceId); + this.onDidChangeEmitter.fire(sourceId); + }, + }; + } + + unregister(sourceId: string): void { + if (!this.registrations.delete(sourceId)) return; + this.onDidChangeEmitter.fire(sourceId); + } + + entries(): readonly ContributionRegistration[] { + return [...this.registrations.values()]; + } + + get(sourceId: string): ContributionRegistration | undefined { + return this.registrations.get(sourceId); + } +} diff --git a/packages/agent-core-v2/src/_base/di/scope.ts b/packages/agent-core-v2/src/_base/di/scope.ts index 2aac50e691..dc720c9ba1 100644 --- a/packages/agent-core-v2/src/_base/di/scope.ts +++ b/packages/agent-core-v2/src/_base/di/scope.ts @@ -13,8 +13,9 @@ import { ServiceCollection } from './serviceCollection'; export enum LifecycleScope { App = 0, - Session = 1, - Agent = 2, + Workspace = 1, + Session = 2, + Agent = 3, } export enum ScopeActivation { @@ -76,6 +77,7 @@ export interface IScopeHandle { } export type IAppScopeHandle = IScopeHandle; +export type IWorkspaceScopeHandle = IScopeHandle; export type ISessionScopeHandle = IScopeHandle; export type IAgentScopeHandle = IScopeHandle; diff --git a/packages/agent-core-v2/src/_base/text/frontmatter.ts b/packages/agent-core-v2/src/_base/text/frontmatter.ts new file mode 100644 index 0000000000..a28d6a4230 --- /dev/null +++ b/packages/agent-core-v2/src/_base/text/frontmatter.ts @@ -0,0 +1,54 @@ +/** + * `_base` text helpers — Markdown frontmatter parsing. + * + * Splits a Markdown document into its YAML frontmatter block and body. Pure + * text processing with no IO and no domain knowledge: skill definitions, + * agent-definition files, and plugin command files all share this one parser + * instead of depending on each other's domains for it. A document without a + * leading `---` fence parses as all body with `data: null`; an unterminated + * fence is a `FrontmatterError`. + */ + +import { load as loadYaml } from 'js-yaml'; + +export class FrontmatterError extends Error { + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'FrontmatterError'; + if (cause !== undefined) { + Object.defineProperty(this, 'cause', { value: cause, configurable: true }); + } + } +} + +export interface ParsedFrontmatter { + readonly data: unknown; + readonly body: string; +} + +const FENCE = '---'; + +export function parseFrontmatter(text: string): ParsedFrontmatter { + const lines = text.split(/\r?\n/); + if (lines[0]?.trim() !== FENCE) { + return { data: null, body: text }; + } + + const close = lines.findIndex((line, index) => index > 0 && line.trim() === FENCE); + if (close === -1) { + throw new FrontmatterError('Missing closing frontmatter fence'); + } + + const yamlText = lines.slice(1, close).join('\n').trim(); + const body = lines.slice(close + 1).join('\n'); + if (yamlText === '') { + return { data: {}, body }; + } + + try { + return { data: loadYaml(yamlText) ?? {}, body }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new FrontmatterError(message, error); + } +} diff --git a/packages/agent-core-v2/src/_base/utils/fileMeta.ts b/packages/agent-core-v2/src/_base/utils/fileMeta.ts index f3419c5b35..19757d895e 100644 --- a/packages/agent-core-v2/src/_base/utils/fileMeta.ts +++ b/packages/agent-core-v2/src/_base/utils/fileMeta.ts @@ -2,7 +2,7 @@ * File content metadata helpers — binary detection, line counting, etag, and * extension-based mime / language guessing. * - * Shared by the fs edge domains (`sessionFs`) and the kap-server fs routes so + * Shared by the fs edge domains (`workspaceFs`) and the kap-server fs routes so * every read-style surface classifies and labels file content the same way. * Pure functions over bytes, text, and stat-like shapes; no io happens here. * Binary detection samples the leading `FS_BINARY_SAMPLE_BYTES` of a file and diff --git a/packages/agent-core-v2/src/_base/utils/isoDateTime.ts b/packages/agent-core-v2/src/_base/utils/isoDateTime.ts index f5346c4d2b..6b1d75a6a9 100644 --- a/packages/agent-core-v2/src/_base/utils/isoDateTime.ts +++ b/packages/agent-core-v2/src/_base/utils/isoDateTime.ts @@ -6,7 +6,7 @@ const ISO_8601_REGEX = /** * Wire-schema primitive for ISO 8601 datetime strings: validates the shape and * normalizes to `Date#toISOString()` output. Shared by the edge DTO schemas - * (`sessionFs`, `file`, `terminal`, `auth`, …) that expose timestamps. + * (`workspaceFs`, `file`, `terminal`, `auth`, …) that expose timestamps. */ export const isoDateTimeSchema = z .string() diff --git a/packages/agent-core-v2/src/_base/utils/paths.ts b/packages/agent-core-v2/src/_base/utils/paths.ts new file mode 100644 index 0000000000..0bb09858e5 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/paths.ts @@ -0,0 +1,34 @@ +/** + * Path-filter helpers — pure string predicates, no IO. + */ + +function normalizeSlashes(p: string): string { + return p.replaceAll('\\', '/'); +} + +/** + * A chokidar `ignored` predicate that prunes a recursive watch on `root` + * down to the given candidate subtrees (files or directories beneath it). + * The root itself, every ancestor of a candidate (so traversal can reach + * it), and every path inside a candidate pass the filter; everything else + * is ignored, so large sibling trees (dependency folders, VCS metadata) + * are never walked. This is the reliable way to watch candidates that may + * not exist yet: watching a missing path directly silently never fires + * when its parent directory is missing too. + */ +export function subtreeWatchFilter( + root: string, + candidates: readonly string[], +): (path: string) => boolean { + const normRoot = normalizeSlashes(root); + const normCandidates = candidates.map(normalizeSlashes); + return (p: string): boolean => { + const norm = normalizeSlashes(p); + if (norm === normRoot) return false; + for (const candidate of normCandidates) { + if (norm === candidate || norm.startsWith(`${candidate}/`)) return false; + if (candidate.startsWith(`${norm}/`)) return false; + } + return true; + }; +} diff --git a/packages/agent-core-v2/src/_base/utils/timer.ts b/packages/agent-core-v2/src/_base/utils/timer.ts index 2d6f00d551..38094f6831 100644 --- a/packages/agent-core-v2/src/_base/utils/timer.ts +++ b/packages/agent-core-v2/src/_base/utils/timer.ts @@ -49,3 +49,38 @@ export class IntervalTimer implements IDisposable { this.cancel(); } } + +/** + * One-shot debounce primitive — a disposable `setTimeout` wrapper. + * + * `TimeoutTimer` owns a single `setTimeout` handle: every `cancelAndSet` + * cancels the pending run and re-arms, so a burst of triggers collapses into + * one deferred run (watch-event debounce). `dispose` guarantees the handle is + * cleared. Mirrors VS Code's `TimeoutTimer`. + */ +export class TimeoutTimer implements IDisposable { + private handle: ReturnType | undefined; + + cancel(): void { + if (this.handle !== undefined) { + clearTimeout(this.handle); + this.handle = undefined; + } + } + + cancelAndSet(runner: () => void, timeoutMs: number): void { + this.cancel(); + const handle = setTimeout(() => { + this.handle = undefined; + runner(); + }, timeoutMs); + if (typeof handle === 'object' && handle !== null && 'unref' in handle) { + (handle as { unref: () => void }).unref(); + } + this.handle = handle; + } + + dispose(): void { + this.cancel(); + } +} diff --git a/packages/agent-core-v2/src/agent/mcp/mcp.ts b/packages/agent-core-v2/src/agent/mcp/mcp.ts index c7a6792d59..0e47b09725 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcp.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcp.ts @@ -2,9 +2,9 @@ import type { Tool as KosongTool } from '#/kosong/contract/tool'; import { createDecorator } from "#/_base/di/instantiation"; import { type IDisposable } from "#/_base/di/lifecycle"; -import type { McpServerEntry } from './connection-manager'; -import type { McpOAuthService } from '#/agent/mcp/oauth/service'; -import type { MCPClient, MCPToolDefinition } from './types'; +import type { McpServerEntry } from '#/mcpCore/connection-manager'; +import type { McpOAuthService } from '#/mcpCore/oauth/service'; +import type { MCPClient, MCPToolDefinition } from '#/mcpCore/types'; export interface McpResolvedServer { readonly client: MCPClient; diff --git a/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts b/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts index 4ea63a17c0..226e2aa3bb 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts @@ -8,7 +8,7 @@ import { z } from 'zod'; import { defineModel } from '#/wire/model'; -import type { MCPToolDefinition } from './types'; +import type { MCPToolDefinition } from '#/mcpCore/types'; export interface McpToolCollision { readonly qualified: string; diff --git a/packages/agent-core-v2/src/agent/mcp/mcpService.ts b/packages/agent-core-v2/src/agent/mcp/mcpService.ts index 5323674386..d7f933d81d 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcpService.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcpService.ts @@ -1,8 +1,10 @@ /** * `mcp` domain (L5) — `IAgentMcpService` implementation. * - * Mirrors the session-level MCP connection manager's server set into the - * agent's tool registry: registers qualified tools for connected servers, + * Mirrors the workspace-level shared MCP connection manager's server set + * into the agent's tool registry (the manager arrives through the seeded + * `ISessionMcpHandle` — one manager per workspace handler, shared by every + * session and agent): registers qualified tools for connected servers, * keeps them registered across reconnects, swaps in the OAuth tool for * `needs-auth` servers, journals tool discoveries on the wire (queued until * restore finishes), and publishes `mcp.server.status` / `tool.list.updated` @@ -32,11 +34,11 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { createMcpAuthTool } from '#/agent/mcp/tools/auth'; import { createMcpTool } from '#/agent/mcp/tools/mcp'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { ISessionMcpService } from '#/session/mcp/sessionMcp'; -import type { McpServerEntry } from './connection-manager'; +import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import type { McpServerEntry } from '#/mcpCore/connection-manager'; import { IAgentMcpService } from './mcp'; -import { qualifyMcpToolName } from './tool-naming'; -import type { MCPClient, MCPToolDefinition } from './types'; +import { qualifyMcpToolName } from '#/mcpCore/tool-naming'; +import type { MCPClient, MCPToolDefinition } from '#/mcpCore/types'; import { IWireService } from '#/wire/wire'; import { McpDiscoveryModel, @@ -97,7 +99,7 @@ export class AgentMcpService extends Disposable implements IAgentMcpService { private readonly pendingDiscoveries: Array<() => void> = []; constructor( - @ISessionMcpService private readonly sessionMcp: ISessionMcpService, + @ISessionMcpHandle private readonly mcpHandle: ISessionMcpHandle, @ISessionContext private readonly sessionContext: ISessionContext, @IAgentToolRegistryService private readonly registry: IAgentToolRegistryService, @IEventBus private readonly eventBus: IEventBus, @@ -136,32 +138,32 @@ export class AgentMcpService extends Disposable implements IAgentMcpService { } get oauthService() { - return this.sessionMcp.connectionManager().oauthService; + return this.mcpHandle.connectionManager.oauthService; } waitForInitialLoad(signal?: AbortSignal): Promise { - return this.sessionMcp.connectionManager().waitForInitialLoad(signal); + return this.mcpHandle.connectionManager.waitForInitialLoad(signal); } initialLoadDurationMs(): number { - return this.sessionMcp.connectionManager().initialLoadDurationMs(); + return this.mcpHandle.connectionManager.initialLoadDurationMs(); } list() { - return this.sessionMcp.connectionManager().list(); + return this.mcpHandle.connectionManager.list(); } resolved(name: string) { - return this.sessionMcp.connectionManager().resolved(name); + return this.mcpHandle.connectionManager.resolved(name); } getRemoteServerUrl(name: string) { - return this.sessionMcp.connectionManager().getRemoteServerUrl(name); + return this.mcpHandle.connectionManager.getRemoteServerUrl(name); } async reconnect(name: string, signal?: AbortSignal): Promise { signal?.throwIfAborted(); - await this.sessionMcp.connectionManager().reconnect(name); + await this.mcpHandle.connectionManager.reconnect(name); signal?.throwIfAborted(); } @@ -180,13 +182,13 @@ export class AgentMcpService extends Disposable implements IAgentMcpService { ): Promise { const healed = this.resolved(serverName)?.client; if (healed !== undefined && healed !== staleClient) return healed; - await this.sessionMcp.connectionManager().reconnectAndJoin(serverName); + await this.mcpHandle.connectionManager.reconnectAndJoin(serverName); const current = this.resolved(serverName)?.client; return current !== undefined && current !== staleClient ? current : undefined; } onStatusChange(listener: Parameters[0]) { - const unsubscribe = this.sessionMcp.connectionManager().onStatusChange(listener); + const unsubscribe = this.mcpHandle.connectionManager.onStatusChange(listener); return { dispose: unsubscribe, }; diff --git a/packages/agent-core-v2/src/agent/mcp/oauth/store.ts b/packages/agent-core-v2/src/agent/mcp/oauth/store.ts deleted file mode 100644 index 98af7f2cd9..0000000000 --- a/packages/agent-core-v2/src/agent/mcp/oauth/store.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * `mcp` domain (L5) — MCP OAuth credential store. - * - * Persists OAuth tokens, registered DCR client info, and discovery state for - * MCP HTTP servers through the `storage` access-pattern store - * (`IAtomicDocumentStore`) under the `credentials/mcp` scope - * (`/credentials/mcp/-*.json`). One logical record per - * `(serverName, serverUrl)` identity, addressed by {@link mcpOAuthStoreKey}. - * - * Read semantics: missing or corrupt JSON resolves to `undefined` (never - * throws). The provider treats `undefined` as "not stored". - */ - -import { createHash } from 'node:crypto'; - -import { basename } from 'pathe'; - -import type { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; - -const CREDENTIALS_SCOPE = 'credentials/mcp'; - -export function sanitizeStoreKey(name: string): string { - const safe = basename(name).replaceAll(/[^a-zA-Z0-9_-]/g, '_').replaceAll(/_+/g, '_'); - if (safe.length === 0 || safe.startsWith('.')) { - throw new Error(`Invalid MCP OAuth store key: "${name}"`); - } - return safe; -} - -export function canonicalMcpOAuthResource(serverUrl: string | URL): string { - const url = new URL(serverUrl); - url.hash = ''; - return url.toString(); -} - -export function mcpOAuthStoreKey(serverName: string, serverUrl: string | URL): string { - const safeName = sanitizeStoreKey(serverName); - const resource = canonicalMcpOAuthResource(serverUrl); - const digest = createHash('sha256') - .update(serverName) - .update('\0') - .update(resource) - .digest('hex') - .slice(0, 24); - return `${safeName}-${digest}`; -} - -export interface McpOAuthStore { - read(key: string): Promise; - write(key: string, data: unknown): Promise; - remove(key: string): Promise; -} - -export function createMcpOAuthStore(docs: IAtomicDocumentStore): McpOAuthStore { - return { - async read(key: string): Promise { - try { - return await docs.get(CREDENTIALS_SCOPE, key); - } catch { - return undefined; - } - }, - write(key, data) { - return docs.set(CREDENTIALS_SCOPE, key, data); - }, - remove(key) { - return docs.delete(CREDENTIALS_SCOPE, key); - }, - }; -} diff --git a/packages/agent-core-v2/src/agent/mcp/output.ts b/packages/agent-core-v2/src/agent/mcp/output.ts index 137930e540..79cd3a3df5 100644 --- a/packages/agent-core-v2/src/agent/mcp/output.ts +++ b/packages/agent-core-v2/src/agent/mcp/output.ts @@ -36,7 +36,7 @@ import { isModelAcceptedImageMime, } from '#/agent/media/image-format-policy'; import { persistOriginalImage } from '#/agent/media/image-originals'; -import type { MCPContentBlock, MCPToolResult } from './types'; +import type { MCPContentBlock, MCPToolResult } from '#/mcpCore/types'; export interface McpOutputOptions { readonly originalsDir?: string; diff --git a/packages/agent-core-v2/src/agent/mcp/session-config.ts b/packages/agent-core-v2/src/agent/mcp/session-config.ts deleted file mode 100644 index b8a7b1b21c..0000000000 --- a/packages/agent-core-v2/src/agent/mcp/session-config.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { McpServerConfig } from './config-schema'; - -import { loadMcpServers } from './config-loader'; - -export interface SessionMcpConfig { - readonly servers: Record; -} - -export interface ResolveSessionMcpConfigInput { - readonly cwd: string; - readonly homeDir?: string; -} - -export async function resolveSessionMcpConfig( - input: ResolveSessionMcpConfigInput, -): Promise { - const servers = await loadMcpServers({ - cwd: input.cwd, - homeDir: input.homeDir, - }); - if (Object.keys(servers).length === 0) return undefined; - return { servers }; -} - -export function mergeCallerMcpServers( - base: SessionMcpConfig | undefined, - callerServers: Readonly> | undefined, -): SessionMcpConfig | undefined { - if (callerServers === undefined || Object.keys(callerServers).length === 0) { - return base; - } - return { - servers: { - ...base?.servers, - ...callerServers, - }, - }; -} diff --git a/packages/agent-core-v2/src/agent/mcp/tools/auth.ts b/packages/agent-core-v2/src/agent/mcp/tools/auth.ts index 69df9c4e28..8e2aef9ed5 100644 --- a/packages/agent-core-v2/src/agent/mcp/tools/auth.ts +++ b/packages/agent-core-v2/src/agent/mcp/tools/auth.ts @@ -32,8 +32,8 @@ import { type ExecutableToolResult, } from '#/tool/toolContract'; import { toInputJsonSchema } from '#/tool/input-schema'; -import { AlreadyAuthorizedError, type McpOAuthService } from '#/agent/mcp/oauth/service'; -import { qualifyMcpToolName } from '#/agent/mcp/tool-naming'; +import { AlreadyAuthorizedError, type McpOAuthService } from '#/mcpCore/oauth/service'; +import { qualifyMcpToolName } from '#/mcpCore/tool-naming'; /** * `ToolUpdate.customKind` emitted by the MCP auth tool when the OAuth diff --git a/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts b/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts index 7b066a7b7c..8632cb30cf 100644 --- a/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts +++ b/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts @@ -30,13 +30,13 @@ import { isAbortError } from '#/_base/utils/abort'; import type { ExecutableTool, ExecutableToolContext, ExecutableToolResult } from '#/tool/toolContract'; import { mcpResultToExecutableOutput } from '#/agent/mcp/output'; -import type { MCPClient, MCPToolResult } from '#/agent/mcp/types'; +import type { MCPClient, MCPToolResult } from '#/mcpCore/types'; import { isMcpConnectionClosedError, isMcpMalformedResultError, isMcpTransportFailure, probeMcpLiveness, -} from '#/agent/mcp/client-shared'; +} from '#/mcpCore/client-shared'; interface McpToolOptions { readonly originalsDir?: string; diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts index a2c58567c2..a2d8f132ef 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts @@ -1,4 +1,6 @@ import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { IGitService } from '#/app/git/git'; +import type { IGitService as GitService } from '#/app/git/git'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import type { IHostEnvironment as HostEnvironment } from '#/os/interface/hostEnvironment'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; @@ -9,7 +11,6 @@ import type { } from '#/agent/permissionPolicy/types'; import { fileAccesses, - findLocalGitWorkTreeMarker, hasGitPathComponent, isGitControlPath, } from './path-utils'; @@ -20,6 +21,7 @@ export class GitControlPathAccessAskPermissionPolicyService implements Permissio constructor( @IHostEnvironment private readonly env: HostEnvironment, @ISessionWorkspaceContext private readonly workspace: WorkspaceContext, + @IGitService private readonly git: GitService, ) {} async evaluate( @@ -36,7 +38,7 @@ export class GitControlPathAccessAskPermissionPolicyService implements Permissio ); if (directGitAccess !== undefined) return { kind: 'ask' }; - const marker = await findLocalGitWorkTreeMarker(cwd); + const marker = await this.git.findWorkTree(cwd); if (marker === null) return undefined; const access = accesses.find((fileAccess) => isGitControlPath(fileAccess.path, marker, pathClass), diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts index 11ec34a084..5b55977fbf 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts @@ -1,5 +1,7 @@ import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; import { isWithinWorkspace } from '#/tool/path-access'; +import { IGitService } from '#/app/git/git'; +import type { IGitService as GitService } from '#/app/git/git'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import type { IHostEnvironment as HostEnvironment } from '#/os/interface/hostEnvironment'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; @@ -8,10 +10,7 @@ import type { PermissionPolicy, PermissionPolicyResult, } from '#/agent/permissionPolicy/types'; -import { - findLocalGitWorkTreeMarker, - writeFileAccesses, -} from './path-utils'; +import { writeFileAccesses } from './path-utils'; export class GitCwdWriteApprovePermissionPolicyService implements PermissionPolicy { readonly name = 'git-cwd-write-approve'; @@ -19,6 +18,7 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli constructor( @IHostEnvironment private readonly env: HostEnvironment, @ISessionWorkspaceContext private readonly workspace: WorkspaceContext, + @IGitService private readonly git: GitService, ) {} async evaluate( @@ -45,7 +45,7 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli return undefined; } - return (await findLocalGitWorkTreeMarker(cwd)) === null + return (await this.git.findWorkTree(cwd)) === null ? undefined : { kind: 'approve' }; } diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/path-utils.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/path-utils.ts index 529fbdf616..b64439e546 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/path-utils.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/path-utils.ts @@ -1,8 +1,7 @@ -import { readFile, stat } from 'node:fs/promises'; -import * as nodePath from 'node:path'; import * as posixPath from 'node:path/posix'; import * as win32Path from 'node:path/win32'; +import type { GitWorkTree } from '#/app/git/workTree'; import type { ToolFileAccess } from '#/tool/toolContract'; import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; import { @@ -10,11 +9,6 @@ import { type PathClass, } from '#/tool/path-access'; -export interface PermissionGitWorkTreeMarker { - readonly dotGitPath: string; - readonly controlDirPath: string; -} - export function fileAccesses(context: ResolvedToolExecutionHookContext): ToolFileAccess[] { return ( context.execution.accesses?.filter((access): access is ToolFileAccess => access.kind === 'file') ?? @@ -40,7 +34,7 @@ export function hasGitPathComponent( export function isGitControlPath( targetPath: string, - marker: PermissionGitWorkTreeMarker, + marker: GitWorkTree, pathClass: PathClass, ): boolean { return ( @@ -53,24 +47,6 @@ export function defaultPathClass(): PathClass { return process.platform === 'win32' ? 'win32' : 'posix'; } -export async function findLocalGitWorkTreeMarker( - cwd: string, -): Promise { - if (cwd.length === 0 || !nodePath.isAbsolute(cwd)) return null; - - let current = nodePath.normalize(cwd); - for (let depth = 0; depth < 256; depth += 1) { - const dotGitPath = nodePath.join(current, '.git'); - const marker = await probeLocalGitMarker(dotGitPath, current); - if (marker !== null) return marker; - - const parent = nodePath.dirname(current); - if (parent === current) return null; - current = parent; - } - return null; -} - function relativePathParts(targetPath: string, cwd: string, pathClass: PathClass): string[] { return pathMod(pathClass) .relative(cwd, targetPath) @@ -81,32 +57,3 @@ function relativePathParts(targetPath: string, cwd: string, pathClass: PathClass function pathMod(pathClass: PathClass): typeof posixPath { return pathClass === 'win32' ? win32Path : posixPath; } - -async function probeLocalGitMarker( - dotGitPath: string, - markerParent: string, -): Promise { - try { - const markerStat = await stat(dotGitPath); - if (markerStat.isDirectory()) return { dotGitPath, controlDirPath: dotGitPath }; - if (!markerStat.isFile()) return null; - - const content = await readFile(dotGitPath, 'utf8'); - const controlDirPath = parseLocalGitDir(content, markerParent); - return controlDirPath === undefined ? null : { dotGitPath, controlDirPath }; - } catch { - return null; - } -} - -function parseLocalGitDir(content: string, markerParent: string): string | undefined { - const stripped = content.codePointAt(0) === 0xfeff ? content.slice(1) : content; - const line = stripped.trimStart().split(/\r?\n/, 1)[0]?.trim(); - if (line === undefined || !line.startsWith('gitdir:')) return undefined; - - const rawPath = line.slice('gitdir:'.length).trim(); - if (rawPath.length === 0) return undefined; - return nodePath.normalize( - nodePath.isAbsolute(rawPath) ? rawPath : nodePath.join(markerParent, rawPath), - ); -} diff --git a/packages/agent-core-v2/src/agent/plan/configSection.ts b/packages/agent-core-v2/src/agent/plan/configSection.ts index 04b1cfce0a..18c1844553 100644 --- a/packages/agent-core-v2/src/agent/plan/configSection.ts +++ b/packages/agent-core-v2/src/agent/plan/configSection.ts @@ -4,7 +4,7 @@ * Top-level boolean preference (`default_plan_mode` on disk, v1-compatible): * when `true`, every freshly created session starts in plan mode. Resumed / * forked sessions restore plan state from wire records and ignore this. Read by - * `sessionLifecycle` at session creation; runtime plan state lives on the wire + * `workspaceHandler` at session creation; runtime plan state lives on the wire * `PlanModel`, not here. */ diff --git a/packages/agent-core-v2/src/agent/plan/planOps.ts b/packages/agent-core-v2/src/agent/plan/planOps.ts index 1781af0184..31087362f6 100644 --- a/packages/agent-core-v2/src/agent/plan/planOps.ts +++ b/packages/agent-core-v2/src/agent/plan/planOps.ts @@ -26,7 +26,7 @@ * `agent.status.updated` planMode slice — are NOT part of `apply`: they run * after `wire.dispatch` on the live path, and `wire.replay` rebuilds the * Model silently from the persisted `plan_mode.*` / `plan.revision` records - * (seeded by `sessionLifecycle`). The legacy `toReplay: plan_updated` + * (seeded by `workspaceHandler`). The legacy `toReplay: plan_updated` * projection is dropped (inert — nothing reads it). `plan.revision` carries * a `toEvent` so the live transcript projector can map it onto a marker plus * the plan badge; replay never emits it. Consumed by the Agent-scope diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index 3fc8198b61..0b2d361d69 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -20,10 +20,10 @@ import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IPluginService } from '#/app/plugin/plugin'; import type { EnabledPluginSessionStart } from '#/app/plugin/types'; +import { PLUGIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource'; import type { SkillCatalog, SkillDefinition } from '#/app/skillCatalog/types'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; -import { PLUGIN_SKILL_SOURCE_ID } from '#/session/sessionSkillCatalog/pluginSkillSource'; import { IAgentPluginService } from './agentPlugin'; diff --git a/packages/agent-core-v2/src/agent/profile/context.ts b/packages/agent-core-v2/src/agent/profile/context.ts index ebd5619acf..260c30fef8 100644 --- a/packages/agent-core-v2/src/agent/profile/context.ts +++ b/packages/agent-core-v2/src/agent/profile/context.ts @@ -2,8 +2,15 @@ * `profile` domain (L4) — system-prompt context assembly. * * Loads the AGENTS.md instruction hierarchy (user-level brand + generic files, - * then project-level files from the project root down to the cwd) and assembles + * then project-level files from the project root down to the cwd — the root + * discovered through the `git` domain's work-tree probe) and assembles * the {@link SystemPromptContext} bag consumed by `IAgentProfileService.useProfile`. + * `agentsMdWatchRoots` exposes the watch plan for the probed file set so the + * Workspace-scope `workspaceInstructions` service can watch exactly what the + * loader reads, and `prepareSystemPromptContext` accepts a + * `preloadedAgentsMd` snapshot so the + * agent-side profile service can inject that workspace snapshot instead of + * re-reading the files. * * Runs on top of the os `IHostFileSystem` (for `readText` / `stat` / `readdir`) * plus the host's `homeDir` — supplied together as a small `ProfileContextDeps` @@ -18,6 +25,7 @@ import { dirname, join, normalize } from 'pathe'; +import { findGitWorkTree } from '#/app/git/workTree'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; import type { SystemPromptContext } from './profile'; @@ -32,6 +40,8 @@ interface ProfileContextDeps { readonly homeDir: string; } +export type { ProfileContextDeps }; + export interface PreparedSystemPromptContext extends SystemPromptContext { readonly cwdListing?: string; readonly agentsMd?: string; @@ -41,6 +51,11 @@ export interface PreparedSystemPromptContext extends SystemPromptContext { export interface PrepareSystemPromptContextOptions { readonly additionalDirs?: readonly string[]; + /** + * A pre-loaded AGENTS.md result (e.g. the workspace instructions snapshot). + * When provided, the loader is not re-run — the caller owns freshness. + */ + readonly preloadedAgentsMd?: LoadedAgentsMd; } export async function prepareSystemPromptContext( @@ -52,7 +67,9 @@ export async function prepareSystemPromptContext( const additionalDirs = dedupeDirs(options?.additionalDirs ?? []); const [cwdListing, agentsMdResult, additionalDirsInfo] = await Promise.all([ listDirectory(deps, workDir, { collapseHiddenDirs: true }), - loadAgentsMdForRoots(deps, brandHome, [workDir]), + options?.preloadedAgentsMd !== undefined + ? Promise.resolve(options.preloadedAgentsMd) + : loadAgentsMdForRoots(deps, brandHome, [workDir]), loadAdditionalDirsInfo(deps, additionalDirs), ]); return { @@ -77,7 +94,9 @@ interface LoadedAgentsMd { readonly warning: string | undefined; } -async function loadAgentsMdForRoots( +export type { LoadedAgentsMd }; + +export async function loadAgentsMdForRoots( deps: ProfileContextDeps, brandHome: string | undefined, workDirs: readonly string[], @@ -113,7 +132,7 @@ async function loadAgentsMdForRoots( for (const workDir of workDirs) { const rootWorkDir = normalize(workDir); - const projectRoot = await findProjectRoot(deps, rootWorkDir); + const projectRoot = (await findGitWorkTree(deps.fs, rootWorkDir))?.root ?? rootWorkDir; const dirs = dirsRootToLeaf(rootWorkDir, projectRoot); for (const dir of dirs) { @@ -137,6 +156,50 @@ async function loadAgentsMdForRoots( return { content, warning }; } +/** + * The watch plan for {@link loadAgentsMdForRoots}: one entry per existing + * watch root with the candidate instruction files beneath it (brand dir, + * real home for the `.agents` generic files, and the project root for the + * root→leaf `.kimi-code/AGENTS.md` / `AGENTS.md` / `agents.md` chain) — + * regardless of existence or the first-existing-wins precedence the loader + * applies, since a higher-precedence file appearing later must still + * invalidate the snapshot. Consumers watch each root recursively and filter + * events to the candidates (watching a missing file directly silently never + * fires when its parent directory is missing too). + */ +export interface AgentsMdWatchRoot { + readonly root: string; + readonly candidates: readonly string[]; +} + +export async function agentsMdWatchRoots( + deps: ProfileContextDeps, + workDir: string, + brandHome?: string, +): Promise { + const realHome = deps.homeDir; + const brandDir = brandHome ?? join(realHome, '.kimi-code'); + const plan: AgentsMdWatchRoot[] = [ + { root: brandDir, candidates: [join(brandDir, 'AGENTS.md')] }, + { + root: realHome, + candidates: [join(realHome, '.agents', 'AGENTS.md'), join(realHome, '.agents', 'agents.md')], + }, + ]; + const rootWorkDir = normalize(workDir); + const projectRoot = (await findGitWorkTree(deps.fs, rootWorkDir))?.root ?? rootWorkDir; + const projectCandidates: string[] = []; + for (const dir of dirsRootToLeaf(rootWorkDir, projectRoot)) { + projectCandidates.push( + join(dir, '.kimi-code', 'AGENTS.md'), + join(dir, 'AGENTS.md'), + join(dir, 'agents.md'), + ); + } + plan.push({ root: projectRoot, candidates: projectCandidates }); + return plan; +} + async function loadAdditionalDirsInfo( deps: ProfileContextDeps, additionalDirs: readonly string[], @@ -150,18 +213,6 @@ async function loadAdditionalDirsInfo( return sections.join('\n\n'); } -async function findProjectRoot(deps: ProfileContextDeps, workDir: string): Promise { - const initial = normalize(workDir); - let current = initial; - - while (true) { - if (await pathExists(deps, join(current, '.git'))) return current; - const parent = dirname(current); - if (parent === current) return initial; - current = parent; - } -} - function dirsRootToLeaf(workDir: string, projectRoot: string): string[] { const dirs: string[] = []; let current = normalize(workDir); diff --git a/packages/agent-core-v2/src/agent/profile/profile.ts b/packages/agent-core-v2/src/agent/profile/profile.ts index 84f39fe8c0..0751d0f337 100644 --- a/packages/agent-core-v2/src/agent/profile/profile.ts +++ b/packages/agent-core-v2/src/agent/profile/profile.ts @@ -38,7 +38,6 @@ export class ProfileError extends Error2 { } export interface AgentConfigData { - cwd: string; modelAlias?: string; modelCapabilities: ModelCapability; profileName?: string; @@ -47,7 +46,6 @@ export interface AgentConfigData { } export type AgentConfigUpdateData = Partial<{ - cwd: string; modelAlias: string; profileName: string; thinkingLevel: string; @@ -67,7 +65,6 @@ export interface ProfileData extends AgentConfigData { } export type ProfileUpdateData = Partial<{ - cwd: string; modelAlias: string; profileName: string; thinkingLevel: string; @@ -77,7 +74,6 @@ export type ProfileUpdateData = Partial<{ }>; export interface ProfileBindingSnapshot { - readonly cwd: string; readonly modelAlias?: string; readonly profileName?: string; readonly thinkingLevel: string; @@ -88,8 +84,6 @@ export interface ProfileBindingSnapshot { } export interface ProfileServiceOptions { - readonly cwd?: string | (() => string | undefined); - readonly chdir?: (cwd: string) => void | Promise; readonly emitStatusUpdated?: () => void; } @@ -117,7 +111,6 @@ export interface BindAgentInput { readonly model?: string; readonly thinking?: string; readonly strictThinking?: boolean; - readonly cwd?: string; } export interface IAgentProfileService { diff --git a/packages/agent-core-v2/src/agent/profile/profileOps.ts b/packages/agent-core-v2/src/agent/profile/profileOps.ts index 65a707b1b9..3db302d594 100644 --- a/packages/agent-core-v2/src/agent/profile/profileOps.ts +++ b/packages/agent-core-v2/src/agent/profile/profileOps.ts @@ -2,7 +2,7 @@ * `profile` domain (L3) — wire Model (`ProfileModel`) and the `config.update` * Op (`configUpdate`) for the agent's persistent configuration slice. * - * Declares the persistent profile config — `cwd`, `modelAlias`, `profileName`, + * Declares the persistent profile config — `modelAlias`, `profileName`, * the resolved base thinking effort, `systemPrompt`, and the profile * `disallowedTools` denylist and `subagents` delegation allowlist — as a wire * Model (initial `defaultProfileModel()`), plus the single Op whose `apply` is @@ -18,10 +18,13 @@ * `modelCapabilities` is intentionally NOT in the Model — it is * derived live from `IModelCatalog` so resume never pins stale capabilities. * Each `apply` returns the same reference when nothing changes so the wire's - * reference-equality gate stays quiet. The `chdir` side effect and the - * `agent.status.updated` emission are NOT part of `apply`: they run after + * reference-equality gate stays quiet. The `agent.status.updated` emission is + * NOT part of `apply`: it runs after * `wire.dispatch` on the live path only, so `wire.replay` rebuilds the Model - * silently. + * silently. The agent's working directory is deliberately NOT part of the + * binding: it is always the session's frozen cwd, read from `sessionContext` + * at render time rather than persisted here. Legacy `profile.bind` records + * that still carry a `cwd` field replay fine — the schema strips it. * * Also declares `ActiveToolsModel` (`readonly string[] | undefined`, initial * `undefined` = every tool active), the `tools.set_active_tools` whole-set @@ -42,7 +45,6 @@ import type { PayloadOf } from '#/wire/types'; import { ProfileError, ProfileErrors } from './profile'; export interface ProfileModelState { - readonly cwd?: string; readonly modelAlias?: string; readonly profileName?: string; readonly thinkingLevel: string; @@ -58,7 +60,6 @@ export const ProfileModel = defineModel('profile', () => ({ export const profileBind = ProfileModel.defineOp('profile.bind', { schema: z.object({ - cwd: z.string().optional(), modelAlias: z.string().optional(), profileName: z.string().optional(), thinkingEffort: z.custom(), @@ -68,7 +69,6 @@ export const profileBind = ProfileModel.defineOp('profile.bind', { subagents: z.array(z.string()).readonly().optional(), }), apply: (s, p) => ({ - cwd: p.cwd ?? s.cwd, modelAlias: p.modelAlias ?? s.modelAlias, profileName: p.profileName ?? s.profileName, thinkingLevel: p.thinkingEffort, @@ -80,7 +80,6 @@ export const profileBind = ProfileModel.defineOp('profile.bind', { export const configUpdate = ProfileModel.defineOp('config.update', { schema: z.object({ - cwd: z.string().optional(), modelAlias: z.string().optional(), profileName: z.string().optional(), thinkingEffort: z.custom().optional(), @@ -90,9 +89,6 @@ export const configUpdate = ProfileModel.defineOp('config.update', { }), apply: (s, p) => { let next: ProfileModelState | undefined; - if (p.cwd !== undefined && p.cwd !== s.cwd) { - next = { ...(next ?? s), cwd: p.cwd }; - } if (p.modelAlias !== undefined && p.modelAlias !== s.modelAlias) { next = { ...(next ?? s), modelAlias: p.modelAlias }; } diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 5e8c92855b..efd2d6aefd 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -21,9 +21,9 @@ * so no restore-ordering coupling with `userTool` arises. Profile and client * policy are persisted independently. The `agent.status.updated` * / `warning` events now ride `IEventBus` (`agent.status.updated` canonical in - * `usageOps`). `chdir` and - * `emitStatusUpdated` run live-only after the dispatch, so `wire.replay` - * rebuilds the Models silently; the same live-only path mirrors the resolved + * `usageOps`). `emitStatusUpdated` runs live-only after the dispatch, so + * `wire.replay` rebuilds the Models silently; the same live-only path mirrors + * the resolved * model protocol into the ambient telemetry context (`provider_type` / * `protocol`) whenever the model alias changes. * `bind()` is first-bind only — a profile is the session's identity: the @@ -31,11 +31,16 @@ * in the synchronous segment before the first dispatch, so concurrent binds * cannot both pass (an edge-level guard always leaves an interleaving * window); a same-name rebind keeps the persisted thinking effort unless the - * caller explicitly overrides it. Prompt builds inject the enabled plugins' + * caller explicitly overrides it. The AGENTS.md portion of the system-prompt + * context comes from the seeded `ISessionInstructionsProvider` (the + * workspace handler's shared, watch-refreshed snapshot — the working + * directory is always the session's frozen cwd, so the snapshot always + * applies), and the provider's change event drives a `refreshSystemPrompt`. Prompt builds inject the enabled plugins' * system-prompt sections (budget-capped, see `PLUGIN_SECTIONS_MAX_BYTES`); - * plugin changes reach the prompt when the session skill catalog re-pulls - * its plugin source on explicit plugin reload — the same point where plugin - * skills take effect. `refreshSystemPrompt` never rejects: a + * plugin changes reach the prompt when the skill catalog re-pulls its plugin + * source on explicit plugin reload (the Workspace-scope catalog forwards the + * plugin source's change through the session seed) — the same point where + * plugin skills take effect. `refreshSystemPrompt` never rejects: a * failed context build keeps the current prompt and surfaces a warning, * because the `[tools]` config watcher fires it voided (an unhandled * rejection would crash kap-server) and the Session tool-policy fan-out @@ -50,7 +55,7 @@ * The mutable plain-data state (`activeToolNamesOverlay` / `agentsMdWarning` * / the three emitted-warning dedupe sets) is registered into `agentState` * (`IAgentStateService`) and read/written through it; `optionsValue` (holds - * the `cwd` / `chdir` / `emitStatusUpdated` callbacks) and `activeProfile` + * the `cwd` / `emitStatusUpdated` callbacks) and `activeProfile` * (a `ResolvedAgentProfile` carrying the `systemPrompt` function) stay plain * fields because the container only holds pure data structures. Bound at * Agent scope. @@ -76,7 +81,8 @@ import { type ThinkingConfig, } from '#/kosong/model/thinking'; import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; -import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; import { ErrorCodes, Error2 } from "#/errors"; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; @@ -86,10 +92,12 @@ import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import type { ToolSource } from '#/tool/toolContract'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; -import { PLUGIN_SKILL_SOURCE_ID } from '#/session/sessionSkillCatalog/pluginSkillSource'; +import { PLUGIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { IPluginService } from '#/app/plugin/plugin'; import type { ResolvedAgentProfile, SystemPromptContext } from '#/agent/profile/profile'; import { IAgentStateService } from '#/agent/state/agentState'; @@ -100,7 +108,7 @@ import { IWireService } from '#/wire/wire'; import type { PayloadOf } from '#/wire/types'; import { IEventBus } from '#/app/event/eventBus'; import { IHostIdentity } from '#/app/hostIdentity/hostIdentity'; -import { prepareSystemPromptContext } from './context'; +import { prepareSystemPromptContext, type LoadedAgentsMd } from './context'; import type { ApplyProfileOptions, BindAgentInput, @@ -206,9 +214,11 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, + @ISessionInstructionsProvider private readonly instructions: ISessionInstructionsProvider, @ISessionToolPolicy private readonly sessionToolPolicy: ISessionToolPolicy, + @ISessionToolPolicyGate private readonly toolPolicyGate: ISessionToolPolicyGate, @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, - @IAgentProfileCatalogService private readonly builtinProfiles: IAgentProfileCatalogService, + @IBuiltinAgentProfileLoader private readonly builtinProfiles: IBuiltinAgentProfileLoader, @IAgentStateService private readonly states: IAgentStateService, @IHostIdentity private readonly hostIdentity: IHostIdentity, @IPluginService private readonly plugins: IPluginService, @@ -225,6 +235,13 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ event.waitUntil(this.refreshSystemPrompt()); }), ); + this._register( + // The workspace AGENTS.md snapshot changed (fs watch on the handler + // side): rebuild the system prompt off the fresh snapshot. + this.instructions.onDidChange(() => { + void this.refreshSystemPrompt(); + }), + ); this._register( this.config.onDidSectionChange(({ domain }) => { if (domain === TOOLS_SECTION) { @@ -272,8 +289,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ configure(options: ProfileServiceOptions): void { this.optionsValue = { - cwd: options.cwd ?? this.optionsValue.cwd, - chdir: options.chdir ?? this.optionsValue.chdir, emitStatusUpdated: options.emitStatusUpdated ?? this.optionsValue.emitStatusUpdated, }; } @@ -300,7 +315,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ this.activeToolNamesOverlay = undefined; this.wire.dispatch( profileBind({ - cwd: snapshot.cwd, modelAlias: snapshot.modelAlias, profileName: snapshot.profileName, thinkingEffort: snapshot.thinkingLevel, @@ -311,7 +325,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ }), ); this.afterConfigDispatch({ - cwd: snapshot.cwd, modelAlias: snapshot.modelAlias, profileName: snapshot.profileName, thinkingLevel: snapshot.thinkingLevel, @@ -349,7 +362,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } await this.sessionToolPolicy.ready; - const context = await this.buildSystemPromptContext(profile, input.cwd); + const context = await this.buildSystemPromptContext(profile); this.assertBindable(profile.name); const currentProfileName = this.profileName; const systemPrompt = profile.systemPrompt(context); @@ -363,7 +376,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ this.activeToolNamesOverlay = undefined; this.wire.dispatch(profileBind({ - cwd: input.cwd, modelAlias: alias, profileName: profile.name, thinkingEffort: thinkingLevel, @@ -373,7 +385,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ subagents: profile.subagents, })); this.afterConfigDispatch({ - cwd: input.cwd, modelAlias: alias, profileName: profile.name, thinkingLevel, @@ -445,7 +456,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } async applyProfile(profile: ResolvedAgentProfile, options?: ApplyProfileOptions): Promise { - const context = await this.buildSystemPromptContext(profile, undefined, options); + const context = await this.buildSystemPromptContext(profile, options); this.useProfile(profile, context); this.cacheAgentsMdWarning(context); this.publishAgentsMdWarning(); @@ -458,7 +469,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ let context: SystemPromptContext; try { - context = await this.buildSystemPromptContext(profile, this.cwd); + context = await this.buildSystemPromptContext(profile); } catch (error) { this.eventBus.publish({ type: 'warning', @@ -483,7 +494,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ data(): ProfileData { const model = this.tryResolveRawModel(); return { - cwd: this.cwd, modelAlias: this.modelAlias, modelCapabilities: model?.capabilities ?? UNKNOWN_CAPABILITY, profileName: this.profileName, @@ -583,7 +593,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ const payload: { -readonly [K in keyof PayloadOf]: PayloadOf[K]; } = {}; - if (changed.cwd !== undefined) payload.cwd = changed.cwd; if (changed.modelAlias !== undefined) payload.modelAlias = changed.modelAlias; if (changed.profileName !== undefined) payload.profileName = changed.profileName; if (changed.thinkingLevel !== undefined || changed.modelAlias !== undefined) { @@ -600,9 +609,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } private afterConfigDispatch(changed: Omit): void { - if (changed.cwd !== undefined) { - void this.optionsValue.chdir?.(changed.cwd); - } if (changed.modelAlias !== undefined) { const model = this.tryResolveRawModel(); this.telemetryContext.set({ @@ -678,10 +684,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ return this.wire.getModel(ProfileModel); } - private get cwd(): string { - return this.profileState.cwd ?? this.readConfiguredCwd() ?? ''; - } - private get model(): string { const modelAlias = this.modelAlias; if (modelAlias === undefined) { @@ -859,21 +861,26 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ private async buildSystemPromptContext( profile: ResolvedAgentProfile, - cwd?: string, options?: ApplyProfileOptions, ): Promise { - const effectiveCwd = cwd ?? this.sessionContext.cwd; + // The working directory is always the session's frozen cwd, so the + // workspace instructions snapshot (which covers the handler root) always + // applies. + const preloadedAgentsMd = await this.workspaceInstructionsSnapshot(); const base = await prepareSystemPromptContext( { fs: this.fs, homeDir: this.env.homeDir }, - effectiveCwd, + this.sessionContext.cwd, this.bootstrap.homeDir, - { additionalDirs: options?.additionalDirs ?? this.workspace.additionalDirs }, + { + additionalDirs: options?.additionalDirs ?? this.workspace.additionalDirs, + preloadedAgentsMd, + }, ); const skills = await this.resolveSkillListing(); const pluginSections = await this.resolvePluginSections(); return { ...base, - cwd: effectiveCwd, + cwd: this.sessionContext.cwd, osKind: this.env.osKind, shellName: this.env.shellName, shellPath: this.env.shellPath, @@ -886,6 +893,14 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ }; } + private async workspaceInstructionsSnapshot(): Promise { + await this.instructions.ready; + return { + content: this.instructions.agentsMd ?? '', + warning: this.instructions.agentsMdWarning, + }; + } + private isToolActiveForProfile( profile: ResolvedAgentProfile, name: string, @@ -893,6 +908,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ ): boolean { return isToolActiveComposed( { + workspaceDisabledTools: this.toolPolicyGate.disabledTools, profile, global: this.config.get(TOOLS_SECTION), sessionDisabledTools: this.sessionToolPolicy.disabledTools(), @@ -941,11 +957,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } return parts.join('\n\n'); } - - private readConfiguredCwd(): string | undefined { - const cwd = this.optionsValue.cwd; - return typeof cwd === 'function' ? cwd() : cwd; - } } registerScopedService( diff --git a/packages/agent-core-v2/src/agent/rpc/core-api.ts b/packages/agent-core-v2/src/agent/rpc/core-api.ts index 0b6668471e..d00bb433c5 100644 --- a/packages/agent-core-v2/src/agent/rpc/core-api.ts +++ b/packages/agent-core-v2/src/agent/rpc/core-api.ts @@ -24,7 +24,6 @@ import type { PermissionMode } from '#/agent/permissionPolicy/types'; import type { SwarmModeTrigger } from '#/agent/swarm/swarm'; import type { ToolDisclosure, ToolInfo } from '#/tool/toolContract'; import type { ResolvedConfig } from '#/app/config/config'; -import type { McpServerConfig } from '#/agent/mcp/config-schema'; import type { ExperimentalFeatureState } from '#/app/flag/flag'; import type { ResumeSessionResult } from '#/agent/replayBuilder/types'; import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; @@ -65,7 +64,6 @@ export interface CreateSessionPayload { readonly thinking?: string | undefined; readonly permission?: PermissionMode | undefined; readonly metadata?: JsonObject | undefined; - readonly mcpServers?: Readonly>; readonly additionalDirs?: readonly string[]; readonly client?: ClientTelemetryInfo | undefined; } @@ -80,7 +78,6 @@ export interface ArchiveSessionPayload { export interface ResumeSessionPayload { readonly sessionId: string; - readonly mcpServers?: Readonly>; readonly additionalDirs?: readonly string[]; } @@ -257,18 +254,6 @@ export interface GetPluginInfoPayload { export type ReloadPluginsResult = ReloadSummary; export type { PluginSummary, PluginInfo }; -export interface AddAdditionalDirPayload { - readonly path: string; - readonly persist: boolean; -} - -export interface AddAdditionalDirResult { - readonly additionalDirs: readonly string[]; - readonly projectRoot: string; - readonly configPath: string; - readonly persisted: boolean; -} - export interface RenameSessionPayload { readonly title: string; } @@ -336,7 +321,6 @@ export interface SessionAPI extends AgentAPIWithId { reconnectMcpServer: (payload: ReconnectMcpServerPayload) => void; generateAgentsMd: (payload: EmptyPayload) => void; getSessionWarnings: (payload: EmptyPayload) => readonly SessionWarning[]; - addAdditionalDir: (payload: AddAdditionalDirPayload) => AddAdditionalDirResult; } type SessionAPIWithId = WithSessionId; diff --git a/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts b/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts index 1ee66e1fcd..751553641c 100644 --- a/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts +++ b/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts @@ -3,7 +3,8 @@ * * Owns the activation pass that turns the module-level `registerAgentToolService` * contributions (`toolRegistry`, L3) into entries of the per-agent runtime - * registry: a contribution activates only when its `when` predicate holds + * registry: a contribution activates only when its `when` predicate holds, + * the workspace os-level veto (`sessionToolPolicyGate`) does not disable it, * and its declared `name` is allowed by the bound Profile's tool policy * (`profile`, L4). `AgentLifecycleService.create` awaits one activation pass * after restore and profile binding, so an Agent's tools reflect the Profile diff --git a/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts b/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts index 840e5ef7ed..643789448f 100644 --- a/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts +++ b/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts @@ -2,7 +2,8 @@ * `toolActivation` domain (L4) — `IAgentToolActivationService` implementation. * * Iterates the `toolRegistry` contribution table and, for each entry allowed - * by the bound Profile's tool policy (`profile`), resolves the Agent-scope + * by the workspace os-level veto (the seeded `sessionToolPolicyGate`) AND + * the bound Profile's tool policy (`profile`), resolves the Agent-scope * service through the container — nothing constructs the tool before this * `accessor.get` — and registers the real instance into the runtime * registry. @@ -30,6 +31,7 @@ import { IAgentProfileService } from '#/agent/profile/profile'; import { isToolActive } from '#/agent/toolPolicy/evaluate'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { getAgentToolContributions } from '#/agent/toolRegistry/toolContribution'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { IAgentToolActivationService } from './toolActivation'; @@ -40,6 +42,7 @@ export class AgentToolActivationService extends Disposable implements IAgentTool @IInstantiationService private readonly instantiationService: IInstantiationService, @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, @IAgentProfileService private readonly profile: IAgentProfileService, + @ISessionToolPolicyGate private readonly toolPolicyGate: ISessionToolPolicyGate, @IEventBus eventBus: IEventBus, ) { super(); @@ -53,10 +56,15 @@ export class AgentToolActivationService extends Disposable implements IAgentTool activate(): Promise { const data = this.profile.data(); const policy = { tools: data.activeToolNames, disallowedTools: data.disallowedTools }; + const workspaceVeto = { disallowedTools: this.toolPolicyGate.disabledTools }; this.instantiationService.invokeFunction((accessor) => { for (const { id, options } of getAgentToolContributions()) { const source = options.source ?? 'builtin'; if (this.toolRegistry.resolve(options.name) !== undefined) continue; + // The workspace (os-level) veto outranks the profile: a disabled tool + // never activates, so it never reaches the registry (and therefore + // the schema) at all. + if (!isToolActive(workspaceVeto, options.name, source)) continue; if (!isToolActive(policy, options.name, source)) continue; if (options.when !== undefined && !options.when(accessor)) continue; const tool = accessor.get(id); diff --git a/packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts b/packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts index e119dc6a26..0c6958ffcd 100644 --- a/packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts +++ b/packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts @@ -3,8 +3,9 @@ * * Applies allowlists and denylists with builtin/MCP matching semantics shared * by Agent authorization, profile prompt construction, and child-agent setup. - * `isToolActiveComposed` intersects the three policy layers (profile, global - * `[tools]` config, Session denylist) so every consumer evaluates the same + * `isToolActiveComposed` intersects the policy layers (workspace os-level + * veto, profile, global `[tools]` config, Session denylist — the workspace + * veto first, outranking the rest) so every consumer evaluates the same * combination instead of re-implementing it. An empty/absent global `enabled` * list means unconstrained — an explicit empty list must never disable * everything. @@ -58,6 +59,12 @@ export interface GlobalToolsPolicy { } export interface ToolPolicyLayers { + /** + * The workspace (os-level) veto: tools the runtime / workspace disables. + * Evaluated FIRST — it outranks every other layer, so a workspace-disabled + * tool is inactive no matter what profile, config, or session layers say. + */ + readonly workspaceDisabledTools?: readonly string[]; readonly profile: ToolActivationPolicy; readonly global?: GlobalToolsPolicy; readonly sessionDisabledTools?: readonly string[]; @@ -69,6 +76,7 @@ export function isToolActiveComposed( source: ToolSource = 'builtin', ): boolean { return ( + isToolActive({ disallowedTools: layers.workspaceDisabledTools }, name, source) && isToolActive(layers.profile, name, source) && isToolActive( { diff --git a/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts b/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts index 994933ffae..7e89510bdf 100644 --- a/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts +++ b/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts @@ -1,12 +1,14 @@ /** * `toolPolicy` domain (L4) — Agent-scope tool authorization service. * - * Intersects the bound profile policy, global `[tools]` configuration, and - * Session denylist (composed by `isToolActiveComposed` in `./evaluate`), and - * installs the resulting authorization check into the L3 executor preflight so - * direct tool calls cannot bypass schema filtering. Disclosure entries retain - * their implicit availability when a profile allowlist omits them, while - * explicit deny layers still apply. + * Intersects the workspace os-level veto (the seeded `sessionToolPolicyGate`, + * which outranks everything below it), the bound profile policy, global + * `[tools]` configuration, and Session denylist (composed by + * `isToolActiveComposed` in `./evaluate`), and installs the resulting + * authorization check into the L3 executor preflight so direct tool calls + * cannot bypass schema filtering. Disclosure entries retain their implicit + * availability when a profile allowlist omits them, while explicit deny + * layers still apply. */ import { Disposable } from '#/_base/di/lifecycle'; @@ -16,6 +18,7 @@ import { TOOLS_SECTION, type ToolsConfig } from './configSection'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IConfigService } from '#/app/config/config'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { SELECT_TOOLS_TOOL_NAME } from '#/agent/toolSelect/toolSelect'; import type { ToolSource } from '#/tool/toolContract'; @@ -29,6 +32,7 @@ export class AgentToolPolicyService extends Disposable implements IAgentToolPoli @IAgentProfileService private readonly profile: IAgentProfileService, @IConfigService private readonly config: IConfigService, @ISessionToolPolicy private readonly sessionToolPolicy: ISessionToolPolicy, + @ISessionToolPolicyGate private readonly toolPolicyGate: ISessionToolPolicyGate, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, ) { super(); @@ -61,6 +65,7 @@ export class AgentToolPolicyService extends Disposable implements IAgentToolPoli const profile = this.profile.data(); return isToolActiveComposed( { + workspaceDisabledTools: this.toolPolicyGate.disabledTools, profile: { disallowedTools: profile.disallowedTools }, global: this.config.get(TOOLS_SECTION), sessionDisabledTools: this.sessionToolPolicy.disabledTools(), @@ -77,6 +82,7 @@ export class AgentToolPolicyService extends Disposable implements IAgentToolPoli ): boolean { return isToolActiveComposed( { + workspaceDisabledTools: this.toolPolicyGate.disabledTools, profile, global: this.config.get(TOOLS_SECTION), sessionDisabledTools: this.sessionToolPolicy.disabledTools(), diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index 6d4d62b5ea..ff249b2f59 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -276,7 +276,6 @@ export class SubagentTool implements ISubagentTool { profile: profile.name, model: binding.model, thinking: binding.thinking, - cwd: own.cwd, }, labels: subagentLabels(this.callerAgentId), }); diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileSource.ts b/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileSource.ts deleted file mode 100644 index 3fa3661e98..0000000000 --- a/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileSource.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * `agentFileCatalog` domain (L3) — agent-profile source contract. - * - * `IAgentProfileSource` is the producer half of the agent-file subsystem: each - * source loads an `AgentProfileContribution` and advertises a `priority` so the - * Session catalog can ordered-merge contributions (higher priority wins name - * collisions). Mirrors `skillCatalog/skillSource`, with one deliberate - * deviation: `explicit` outranks every other source (in the skill system it - * aliases `user`) because `--agent-file` is a one-shot command-line intent that - * must always win. Concrete sources (user at App scope; plugin / project / - * extra / explicit at Session scope) each bind their own DI token extending - * this contract. - * - * A source may mark `load()` failures as `fatal`: the Session catalog lets - * them propagate into `ready` so awaiters see the error (`explicit` does — - * `--agent-file` is an explicit user intent that must not be silently - * dropped); without it a failure degrades to a warning and keeps any - * previously loaded contribution, because directory sources must never poison - * a session over a transient fs error. `profilesFromDiscovery` binds each - * profile's `${base_prompt}` placeholder lazily at render time, so it always - * reflects the effective default profile (builtin, or the `SYSTEM.md` - * override) rather than any file-based definition. - */ - -import type { Event } from '#/_base/event'; -import type { - AgentProfile, - AgentProfileContext, -} from '#/app/agentProfileCatalog/agentProfileCatalog'; - -import { agentProfileFromFile } from './agentProfileFromFile'; -import type { AgentFileDiscoveryResult, SkippedAgentFile } from './types'; - -export interface AgentProfileContribution { - readonly profiles: readonly AgentProfile[]; - readonly skipped?: readonly SkippedAgentFile[]; - readonly scannedRoots?: readonly string[]; -} - -export const AGENT_PROFILE_SOURCE_PRIORITY = { - plugin: 5, - user: 10, - extra: 20, - project: 30, - explicit: 40, -} as const; - -export interface IAgentProfileSource { - readonly _serviceBrand: undefined; - readonly id: string; - readonly priority: number; - readonly onDidChange?: Event; - readonly fatal?: boolean; - load(): Promise; -} - -export function profilesFromDiscovery( - result: AgentFileDiscoveryResult, - basePrompt: (context: AgentProfileContext) => string, -): AgentProfileContribution { - return { - profiles: result.agents.map((definition) => agentProfileFromFile(definition, basePrompt)), - skipped: result.skipped, - scannedRoots: result.scannedRoots, - }; -} diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/userFileAgentSource.ts b/packages/agent-core-v2/src/app/agentFileCatalog/userFileAgentSource.ts deleted file mode 100644 index cbd8840ccd..0000000000 --- a/packages/agent-core-v2/src/app/agentFileCatalog/userFileAgentSource.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * `agentFileCatalog` domain (L3) — user `IAgentProfileSource` producer. - * - * Discovers user agent profiles through `bootstrap` home paths and `hostFs`, - * reports skipped files through `log`, and appends the `/SYSTEM.md` - * prompt-override profile (synthesized against the builtin default from the - * App profile catalog) after the scanned profiles so it wins same-name - * collisions within this contribution. Also exposes the effective default - * profile — the `SYSTEM.md` override when present, else the builtin default, - * refreshed on each `load()` pass — so every agent-file source can back - * `${base_prompt}` with it. Bound at App scope. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { ILogService } from '#/_base/log/log'; -import { - IAgentProfileCatalogService, - type AgentProfile, -} from '#/app/agentProfileCatalog/agentProfileCatalog'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IHostFileSystem } from '#/os/interface/hostFileSystem'; - -import { discoverAgentFiles } from './agentFileDiscovery'; -import { - AGENT_PROFILE_SOURCE_PRIORITY, - profilesFromDiscovery, - type AgentProfileContribution, - type IAgentProfileSource, -} from './agentProfileSource'; -import { userAgentRoots } from './agentRoots'; -import { loadSystemMdProfile } from './systemFile'; - -export interface IUserFileAgentSource extends IAgentProfileSource { - readonly _serviceBrand: undefined; - getDefaultProfile(): AgentProfile; -} - -export const IUserFileAgentSource: ServiceIdentifier = - createDecorator('userFileAgentSource'); - -export class UserFileAgentSource implements IUserFileAgentSource { - declare readonly _serviceBrand: undefined; - - readonly id = 'user'; - readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.user; - - private defaultProfile: AgentProfile; - - constructor( - @IBootstrapService private readonly bootstrap: IBootstrapService, - @IHostFileSystem private readonly fs: IHostFileSystem, - @ILogService private readonly log: ILogService, - @IAgentProfileCatalogService private readonly builtin: IAgentProfileCatalogService, - ) { - this.defaultProfile = builtin.getDefault(); - } - - getDefaultProfile(): AgentProfile { - return this.defaultProfile; - } - - async load(): Promise { - const roots = await userAgentRoots( - this.fs, - this.bootstrap.homeDir, - this.bootstrap.osHomeDir, - (message, error) => { - this.log.warn(message, error); - }, - ); - const systemMd = await loadSystemMdProfile( - this.fs, - this.bootstrap.homeDir, - this.builtin.getDefault(), - (message) => this.log.warn(message), - ); - this.defaultProfile = systemMd ?? this.builtin.getDefault(); - const contribution = profilesFromDiscovery( - await discoverAgentFiles(this.fs, roots, (message) => this.log.warn(message)), - (context) => this.defaultProfile.systemPrompt(context), - ); - if (systemMd === undefined) return contribution; - return { ...contribution, profiles: [...contribution.profiles, systemMd] }; - } -} - -registerScopedService( - LifecycleScope.App, - IUserFileAgentSource, - UserFileAgentSource, - ScopeActivation.OnScopeCreated, - 'agentFileCatalog', -); diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts index 5b07df921b..c0735dcdf8 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts @@ -1,6 +1,6 @@ /** - * `agentProfileCatalog` domain (L3) — App-scope registry of named agent - * profiles. + * `agentProfileCatalog` domain (L3) — the agent-profile domain types and the + * App-scope extension point (`IAgentProfileRegistry`). * * A profile is "how an Agent runs": the full system prompt it renders for a * given context, the tool set it may use, plus optional per-invocation and @@ -22,17 +22,17 @@ * an allowlist of subagent profile names the agent may delegate to * (`undefined` = any type). * - * Profiles are contributed at module load via `registerAgentProfile(...)`, the - * same "import = register" pattern used by `registerAgentToolService` and - * `registerConfigSection`. `AgentProfileCatalogService` consumes the accumulated - * contributions on construction and exposes `get(name)` / `getDefault()` / - * `list()` to callers (the `Agent` tool, the swarm scheduler, and the per-agent - * profile binding). Contributions are keyed by `name`; a later-registered - * profile with the same name overrides an earlier one. + * Profiles reach agents through the Contribution / Registry / Catalog + * extension point: loaders (builtin code contributions via + * `registerAgentProfile(...)`, plugin / user file scans at App scope, + * workspace / extra / explicit file scans at Workspace scope) register + * `AgentProfileContribution`s into the App-scope `IAgentProfileRegistry`, + * keyed by source id; the Session-scope `ISessionAgentProfileCatalog` + * projects the registry into the merged, name-deduped read view that + * consumers (the `Agent` tool, the swarm scheduler, the per-agent profile + * binding) resolve profiles through. */ -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; - import type { ILogger } from '#/_base/log/log'; import type { ISessionProcessRunner } from '#/session/process/processRunner'; @@ -82,14 +82,3 @@ export interface AgentProfile { readonly promptPrefix?: (ctx: AgentProfilePromptPrefixContext) => Promise; readonly summaryPolicy?: AgentProfileSummaryPolicy; } - -export interface IAgentProfileCatalogService { - readonly _serviceBrand: undefined; - - get(name: string): AgentProfile | undefined; - getDefault(): AgentProfile; - list(): readonly AgentProfile[]; -} - -export const IAgentProfileCatalogService: ServiceIdentifier = - createDecorator('agentProfileCatalogService'); diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalogService.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalogService.ts deleted file mode 100644 index 5e7dac7674..0000000000 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalogService.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * `agentProfileCatalog` domain (L3) — `IAgentProfileCatalogService` impl. - * - * Snapshots the module-level contributions on construction. Register-after- - * construction is not supported: like `IAgentToolRegistryService`, the - * expectation is that contributions accumulate at import time before the - * container resolves the service. `getDefault()` throws a plain `Error` when - * the builtin default profile is missing — that is a programming-time - * invariant violation, not a request failure, so it does not warrant a wire - * error code. - */ - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; - -import type { AgentProfile } from './agentProfileCatalog'; -import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from './agentProfileCatalog'; -import { getAgentProfileContributions } from './contribution'; - -export class AgentProfileCatalogService implements IAgentProfileCatalogService { - declare readonly _serviceBrand: undefined; - - private readonly byName: Map; - private readonly ordered: readonly AgentProfile[]; - - constructor() { - const contributions = getAgentProfileContributions(); - this.ordered = [...contributions]; - this.byName = new Map(this.ordered.map((def) => [def.name, def])); - } - - get(name: string): AgentProfile | undefined { - return this.byName.get(name); - } - - getDefault(): AgentProfile { - const profile = this.byName.get(DEFAULT_AGENT_PROFILE_NAME); - if (profile === undefined) { - throw new Error( - `Default agent profile "${DEFAULT_AGENT_PROFILE_NAME}" is not registered`, - ); - } - return profile; - } - - list(): readonly AgentProfile[] { - return this.ordered; - } -} - -registerScopedService( - LifecycleScope.App, - IAgentProfileCatalogService, - AgentProfileCatalogService, - ScopeActivation.OnScopeCreated, - 'agentProfileCatalog', -); diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileContribution.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileContribution.ts new file mode 100644 index 0000000000..17533f5b19 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileContribution.ts @@ -0,0 +1,42 @@ +/** + * `agentProfileCatalog` domain (L3) — the agent-profile Contribution shape and + * source priorities. + * + * `AgentProfileContribution` is the Contribution of the agent-profile + * extension point: the plain data structure a loader registers into the + * App-scope `IAgentProfileRegistry` under its source id. It is pure payload — + * the source id and priority are registration metadata passed to `register`, + * never part of the contribution. Name-level dedup is NOT done here or in the + * registry; it is the Session catalog's projection job. The shape lives beside + * the registry (not beside any loader) because it is the registry's contract + * with every contributor, whatever scope the contributor runs in. + * + * `AGENT_PROFILE_SOURCE_PRIORITY` orders the sources for that projection + * (higher wins name collisions), with one deliberate deviation from the skill + * system: `explicit` outranks every other source (in the skill system it + * aliases `user`) because `--agent-file` is a one-shot command-line intent + * that must always win. + */ + +import type { AgentProfile } from './agentProfileCatalog'; + +/** A file a discovery pass could not parse, paired with the reason. */ +export interface SkippedAgentFile { + readonly path: string; + readonly reason: string; +} + +export interface AgentProfileContribution { + readonly profiles: readonly AgentProfile[]; + readonly skipped?: readonly SkippedAgentFile[]; + readonly scannedRoots?: readonly string[]; +} + +export const AGENT_PROFILE_SOURCE_PRIORITY = { + builtin: 0, + plugin: 5, + user: 10, + extra: 20, + workspace: 30, + explicit: 40, +} as const; diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistry.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistry.ts new file mode 100644 index 0000000000..ccf1c8105e --- /dev/null +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistry.ts @@ -0,0 +1,64 @@ +/** + * `agentProfileCatalog` domain (L3) — `IAgentProfileRegistry` contract. + * + * The Registry of the Contribution / Registry / Catalog extension-point + * pattern for agent profiles. A contribution is a plain data structure + * (`AgentProfileContribution`) offered by a loader; the registry stores at + * most one contribution per (`sourceId`, `workspaceKey`) pair — re-registering + * replaces the previous entry, which is the only dedup this layer performs. + * Name-level dedup, priority ordering, and the builtin-override rule are the + * Catalog's projection job (`ISessionAgentProfileCatalog`), never the + * registry's. + * + * Bound at App scope so contributors from ANY scope can register: App loaders + * (builtin / plugin / user) register global contributions (`workspaceKey` + * absent), while each Workspace-scope loader registers its workspace-local + * contribution tagged with the handler's `workspaceKey`, and multiple + * workspaces never collide. `register` returns a handle whose `dispose` + * unregisters — but only the entry it registered, so a stale handle can never + * evict a newer re-registration. Every mutation fires `onDidChange` with the + * affected (sourceId, workspaceKey) so session catalogs can re-project. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IDisposable } from '#/_base/di/lifecycle'; +import type { Event } from '#/_base/event'; +import type { AgentProfileContribution } from './agentProfileContribution'; + +export interface AgentProfileRegistration { + readonly sourceId: string; + readonly priority: number; + /** Absent = global contribution; present = specific to one workspace handler. */ + readonly workspaceKey?: string; + readonly contribution: AgentProfileContribution; +} + +export interface AgentProfileRegistryChange { + readonly sourceId: string; + readonly workspaceKey?: string; +} + +export interface RegisterAgentProfileOptions { + readonly priority?: number; + /** Tag a workspace-local contribution; global contributors omit it. */ + readonly workspaceKey?: string; +} + +export interface IAgentProfileRegistry { + readonly _serviceBrand: undefined; + + readonly onDidChange: Event; + + register( + sourceId: string, + contribution: AgentProfileContribution, + options?: RegisterAgentProfileOptions, + ): IDisposable; + + unregister(sourceId: string, workspaceKey?: string): void; + + entries(): readonly AgentProfileRegistration[]; +} + +export const IAgentProfileRegistry: ServiceIdentifier = + createDecorator('agentProfileRegistry'); diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistryService.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistryService.ts new file mode 100644 index 0000000000..d670e59d63 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileRegistryService.ts @@ -0,0 +1,81 @@ +/** + * `agentProfileCatalog` domain (L3) — `IAgentProfileRegistry` impl. + * + * App-scope singleton backed by the generic `ContributionRegistry`: storage + * keys encode the (sourceId, workspaceKey) pair so a workspace-local source id + * (`workspace`, `extra`, `explicit`) coexists across handlers, while global + * sources (`builtin`, `plugin`, `user`) register once. The registry is pure + * storage — merging, name dedup, and override rules live in the Session-scope + * catalog projection. + */ + +import { ContributionRegistry } from '#/_base/contribution/registry'; +import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; +import type { Event } from '#/_base/event'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import type { AgentProfileContribution } from './agentProfileContribution'; + +import type { + AgentProfileRegistration, + AgentProfileRegistryChange, + IAgentProfileRegistry, + RegisterAgentProfileOptions, +} from './agentProfileRegistry'; +import { IAgentProfileRegistry as IAgentProfileRegistryDecorator } from './agentProfileRegistry'; + +function encodeKey(sourceId: string, workspaceKey: string | undefined): string { + return JSON.stringify([sourceId, workspaceKey ?? null]); +} + +function decodeKey(key: string): AgentProfileRegistryChange { + const [sourceId, workspaceKey] = JSON.parse(key) as [string, string | null]; + return { sourceId, workspaceKey: workspaceKey ?? undefined }; +} + +export class AgentProfileRegistryService + extends Disposable + implements IAgentProfileRegistry +{ + declare readonly _serviceBrand: undefined; + + private readonly registry = this._register( + new ContributionRegistry(), + ); + + readonly onDidChange: Event = (listener, thisArg, disposables) => + this.registry.onDidChange( + (key) => listener.call(thisArg, decodeKey(key)), + undefined, + disposables, + ); + + register( + sourceId: string, + contribution: AgentProfileContribution, + options?: RegisterAgentProfileOptions, + ): IDisposable { + const registration: AgentProfileRegistration = { + sourceId, + priority: options?.priority ?? 0, + workspaceKey: options?.workspaceKey, + contribution, + }; + return this.registry.register(encodeKey(sourceId, options?.workspaceKey), registration); + } + + unregister(sourceId: string, workspaceKey?: string): void { + this.registry.unregister(encodeKey(sourceId, workspaceKey)); + } + + entries(): readonly AgentProfileRegistration[] { + return this.registry.entries().map((entry) => entry.contribution); + } +} + +registerScopedService( + LifecycleScope.App, + IAgentProfileRegistryDecorator, + AgentProfileRegistryService, + ScopeActivation.OnScopeCreated, + 'agentProfileCatalog', +); diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoader.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoader.ts new file mode 100644 index 0000000000..7601b84358 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoader.ts @@ -0,0 +1,28 @@ +/** + * `agentProfileCatalog` domain (L3) — `IBuiltinAgentProfileLoader` contract. + * + * The builtin loader of the agent-profile extension point: owns the global + * `builtin` contribution (priority 0) in the App-scope `IAgentProfileRegistry` + * — the code-defined profiles accumulated at module load via + * `registerAgentProfile(...)`. Also exposes the static `get` / `getDefault` / + * `list` read view for loader-time consumers that need the builtin default + * before any session catalog exists (`UserAgentProfileLoader`'s `SYSTEM.md` + * synthesis). App-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { AgentProfile } from './agentProfileCatalog'; + +export const BUILTIN_AGENT_PROFILE_SOURCE_ID = 'builtin'; + +export interface IBuiltinAgentProfileLoader { + readonly _serviceBrand: undefined; + + get(name: string): AgentProfile | undefined; + getDefault(): AgentProfile; + list(): readonly AgentProfile[]; +} + +export const IBuiltinAgentProfileLoader: ServiceIdentifier = + createDecorator('builtinAgentProfileLoader'); diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoaderService.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoaderService.ts new file mode 100644 index 0000000000..65844a5265 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoaderService.ts @@ -0,0 +1,75 @@ +/** + * `agentProfileCatalog` domain (L3) — `IBuiltinAgentProfileLoader` implementation. + * + * Snapshots the module-level contributions (`registerAgentProfile`, the + * "import = register" pattern) on construction and registers them into + * `IAgentProfileRegistry`. Register-after-construction is not supported: like + * `IAgentToolRegistryService`, contributions are expected to accumulate at + * import time before the container resolves the service. `getDefault()` + * throws a plain `Error` when the builtin default profile is missing — a + * programming-time invariant violation, not a request failure. Bound at App + * scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; + +import type { AgentProfile } from './agentProfileCatalog'; +import { DEFAULT_AGENT_PROFILE_NAME } from './agentProfileCatalog'; +import { AGENT_PROFILE_SOURCE_PRIORITY } from './agentProfileContribution'; +import { IAgentProfileRegistry } from './agentProfileRegistry'; +import { + BUILTIN_AGENT_PROFILE_SOURCE_ID, + IBuiltinAgentProfileLoader, +} from './builtinAgentProfileLoader'; +import { getAgentProfileContributions } from './contribution'; + +export class BuiltinAgentProfileLoaderService + extends Disposable + implements IBuiltinAgentProfileLoader +{ + declare readonly _serviceBrand: undefined; + + private readonly byName: Map; + private readonly ordered: readonly AgentProfile[]; + + constructor(@IAgentProfileRegistry registry: IAgentProfileRegistry) { + super(); + const contributions = getAgentProfileContributions(); + this.ordered = [...contributions]; + this.byName = new Map(this.ordered.map((def) => [def.name, def])); + this._register( + registry.register( + BUILTIN_AGENT_PROFILE_SOURCE_ID, + { profiles: this.ordered }, + { priority: AGENT_PROFILE_SOURCE_PRIORITY.builtin }, + ), + ); + } + + get(name: string): AgentProfile | undefined { + return this.byName.get(name); + } + + getDefault(): AgentProfile { + const profile = this.byName.get(DEFAULT_AGENT_PROFILE_NAME); + if (profile === undefined) { + throw new Error( + `Default agent profile "${DEFAULT_AGENT_PROFILE_NAME}" is not registered`, + ); + } + return profile; + } + + list(): readonly AgentProfile[] { + return this.ordered; + } +} + +registerScopedService( + LifecycleScope.App, + IBuiltinAgentProfileLoader, + BuiltinAgentProfileLoaderService, + ScopeActivation.OnScopeCreated, + 'agentProfileCatalog', +); diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts index 509b9566db..530ff9eea1 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -71,10 +71,6 @@ export interface IBootstrapService { readonly logsDir: string; getEnv(name: string): string | undefined; scope(name: PersistenceScopeName): string; - sessionScope(workspaceId: string, sessionId: string): string; - agentScope(workspaceId: string, sessionId: string, agentId: string): string; - sessionDir(workspaceId: string, sessionId: string): string; - agentHomedir(workspaceId: string, sessionId: string, agentId: string): string; readonly configKey: string; } diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts index afeb61eca5..13dd69b334 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts @@ -2,13 +2,11 @@ * `bootstrap` domain (L1) — `IBootstrapService` implementation. * * Holds the resolved startup snapshot from the seeded `IBootstrapOptions` and - * exposes the host facts, app path layout, and semantic scope mapping. All - * `scope*(...)` methods and `configKey` are computed once at construction so - * business code can read them synchronously. Path fields (`homeDir` / `*Dir` / - * `configPath`) are kept alongside for now to ease migration, but new business - * code should prefer `scope(name)` / `sessionScope(...)` / `agentScope(...)` — - * only the file-only accessors (`sessionDir` / `agentHomedir`) still hand out - * absolute paths, for the small number of legacy APIs that need them. + * exposes the host facts, app path layout, and top-level scope mapping. All + * `scope(name)` values and `configKey` are computed once at construction so + * business code can read them synchronously. Session/agent persistence + * addressing is NOT here — it derives from the workspace handler's + * persistence scope (`workspaceHandler` addressing). * * Bound at App scope. */ @@ -62,7 +60,7 @@ export class BootstrapService implements IBootstrapService { this.configKey = basename(options.configPath); this.scopes = { config: '', - sessions: relative(options.homeDir, this.sessionsDir), + sessions: relative(options.homeDir, join(options.homeDir, 'sessions')), blobs: relative(options.homeDir, this.blobsDir), store: relative(options.homeDir, this.storeDir), logs: relative(options.homeDir, this.logsDir), @@ -79,22 +77,6 @@ export class BootstrapService implements IBootstrapService { scope(name: PersistenceScopeName): string { return this.scopes[name]; } - - sessionScope(workspaceId: string, sessionId: string): string { - return join(this.scopes.sessions, workspaceId, sessionId); - } - - agentScope(workspaceId: string, sessionId: string, agentId: string): string { - return join(this.sessionScope(workspaceId, sessionId), 'agents', agentId); - } - - sessionDir(workspaceId: string, sessionId: string): string { - return join(this.homeDir, this.sessionScope(workspaceId, sessionId)); - } - - agentHomedir(workspaceId: string, sessionId: string, agentId: string): string { - return join(this.homeDir, this.agentScope(workspaceId, sessionId, agentId)); - } } registerScopedService(LifecycleScope.App, IBootstrapService, BootstrapService, ScopeActivation.OnScopeCreated, 'bootstrap'); diff --git a/packages/agent-core-v2/src/app/gateway/gateway.ts b/packages/agent-core-v2/src/app/gateway/gateway.ts index a4a9437fd5..80dab0fbe4 100644 --- a/packages/agent-core-v2/src/app/gateway/gateway.ts +++ b/packages/agent-core-v2/src/app/gateway/gateway.ts @@ -2,8 +2,9 @@ * `gateway` domain (L7) — REST/WS gateways. * * Defines the public contracts of the gateway layer: the `IRestGateway` / - * `IWSGateway` entry points. Session scope creation is owned by - * `sessionLifecycle`; the gateway resolves sessions through it. + * `IWSGateway` entry points. Session scope creation is owned by the workspace + * handler (`workspaceHandler`); the gateway resolves sessions through the live + * handler registry (`workspaceLifecycle`). * App-scoped — shared across the application. */ diff --git a/packages/agent-core-v2/src/app/gateway/gatewayService.ts b/packages/agent-core-v2/src/app/gateway/gatewayService.ts index 954aeb9046..8fec17b534 100644 --- a/packages/agent-core-v2/src/app/gateway/gatewayService.ts +++ b/packages/agent-core-v2/src/app/gateway/gatewayService.ts @@ -1,7 +1,8 @@ /** * `gateway` domain (L7) — `IRestGateway` / `IWSGateway` implementations. * - * Owns the REST/WS entry points; resolves sessions through `sessionLifecycle`, + * Owns the REST/WS entry points; resolves sessions through the live handler + * registry (`workspaceLifecycle` → the handler's `IWorkspaceHandlerService`), * agents through `agentLifecycle`, drives turns through `prompt` / `loop`, * and flushes logs through `log`. Bound at App scope. * @@ -18,7 +19,8 @@ import { } from '#/_base/di/scope'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ILogService } from '#/_base/log/log'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { IAgentLoopService } from '#/agent/loop/loop'; @@ -28,12 +30,12 @@ export class RestGateway implements IRestGateway { declare readonly _serviceBrand: undefined; constructor( - @ISessionLifecycleService private readonly sessions: ISessionLifecycleService, + @IWorkspaceLifecycleService private readonly workspaceLifecycle: IWorkspaceLifecycleService, @ILogService private readonly log: ILogService, ) { } private agent(sessionId: string, agentId: string): IAgentScopeHandle { - const session = this.sessions.get(sessionId); + const session = this.liveSession(sessionId); if (session === undefined) throw new Error(`unknown session '${sessionId}'`); const agents = session.accessor.get(IAgentLifecycleService); const agent = agents.get(agentId); @@ -41,6 +43,14 @@ export class RestGateway implements IRestGateway { return agent; } + private liveSession(sessionId: string) { + for (const handler of this.workspaceLifecycle.handlers.list()) { + const handle = handler.accessor.get(IWorkspaceHandlerService).get(sessionId); + if (handle !== undefined) return handle; + } + return undefined; + } + async prompt( sessionId: string, agentId: string, @@ -78,11 +88,11 @@ export class RestGateway implements IRestGateway { return Promise.resolve(); } getStatus(sessionId: string): Promise { - return Promise.resolve(this.sessions.get(sessionId) !== undefined); + return Promise.resolve(this.liveSession(sessionId) !== undefined); } async flushLogs(sessionId: string): Promise { - const session = this.sessions.get(sessionId); + const session = this.liveSession(sessionId); if (session === undefined) return; await session.accessor.get(ILogService).flush(); } @@ -96,10 +106,6 @@ export class WSGateway implements IWSGateway { declare readonly _serviceBrand: undefined; private readonly connections = new Set(); - constructor( - @ISessionLifecycleService _sessions: ISessionLifecycleService, - ) { } - connect(connectionId: string): void { this.connections.add(connectionId); } diff --git a/packages/agent-core-v2/src/app/git/git.ts b/packages/agent-core-v2/src/app/git/git.ts index 1063cef0f8..7bf0ef0395 100644 --- a/packages/agent-core-v2/src/app/git/git.ts +++ b/packages/agent-core-v2/src/app/git/git.ts @@ -2,17 +2,23 @@ * `git` domain (L1) — git integration for a repository on the local disk. * * Defines the `IGitService` that runs `git status` / `git diff` (plus `gh pr - * view`) against a repository identified by an absolute `cwd`. App-scoped; it - * spawns `git` / `gh` through the `os/interface` process service rather than a - * Session's execution environment, so it never depends on a Session. Path - * confinement is the caller's responsibility — the service receives - * already-resolved absolute `cwd` and repo-relative paths. + * view`) against a repository identified by an absolute `cwd`, and discovers + * the enclosing git work tree of a directory (`findWorkTree`, the DI entry + * point over the pure `workTree` probe). App-scoped; it spawns `git` / `gh` + * through the `os/interface` process service rather than a Session's + * execution environment, so it never depends on a Session. Path confinement + * is the caller's responsibility — the service receives already-resolved + * absolute `cwd` and repo-relative paths. */ import { z } from 'zod'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { GitWorkTree } from './workTree'; + +export type { GitWorkTree } from './workTree'; + export const fsGitStatusSchema = z.enum([ 'clean', 'modified', @@ -71,6 +77,7 @@ export interface IGitService { status(cwd: string, pathFilter?: ReadonlySet): Promise; diff(cwd: string, relPath: string, absPath: string): Promise; + findWorkTree(cwd: string): Promise; } export const IGitService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/app/git/gitParsers.ts b/packages/agent-core-v2/src/app/git/gitParsers.ts index 613ed7b8a6..f2c6c651d6 100644 --- a/packages/agent-core-v2/src/app/git/gitParsers.ts +++ b/packages/agent-core-v2/src/app/git/gitParsers.ts @@ -3,9 +3,8 @@ * * Parses `git status --porcelain=v1 --branch`, `git diff --numstat`, and * `gh pr view --json` output into the protocol `FsGitStatusResponse` shape. - * No IO, no DI — plain functions so they can be unit-tested directly. Moved - * from `session/sessionFs/fsGit.ts` (originally ported from v1 - * `services/fs/fsGit.ts`). + * No IO, no DI — plain functions so they can be unit-tested directly. + * Originally ported from v1 `services/fs/fsGit.ts`. */ import type { FsGitStatus, FsGitStatusResponse, FsPullRequest } from './git'; diff --git a/packages/agent-core-v2/src/app/git/gitService.ts b/packages/agent-core-v2/src/app/git/gitService.ts index 7f60172372..52938adb1d 100644 --- a/packages/agent-core-v2/src/app/git/gitService.ts +++ b/packages/agent-core-v2/src/app/git/gitService.ts @@ -2,7 +2,9 @@ * `git` domain (L1) — `IGitService` implementation. * * Runs `git status` / `git diff` (and `gh pr view`) against a repository on - * the local disk. Process spawning goes through the App-scope + * the local disk, and discovers the enclosing git work tree of a directory + * (`findWorkTree`, delegating to the pure `workTree` probe). Process spawning + * goes through the App-scope * `IHostProcessService` from `os/interface`, and the single path-existence * probe in `diff` goes through `IHostFileSystem`; no Node platform API is * imported directly. Bound at App scope — it owns no Session dependency, so @@ -19,6 +21,7 @@ import { IHostProcessService } from '#/os/interface/hostProcess'; import { IGitService } from './git'; import { parseNumstat, parsePorcelain, parsePullRequest } from './gitParsers'; +import { findGitWorkTree, type GitWorkTree } from './workTree'; const DIFF_MAX_BYTES = 1_048_576; @@ -123,6 +126,10 @@ export class GitService implements IGitService { }; } + findWorkTree(cwd: string): Promise { + return findGitWorkTree(this.fs, cwd); + } + private async readPullRequest(cwd: string): Promise { const cached = this.pullRequestCache.get(cwd); const now = Date.now(); diff --git a/packages/agent-core-v2/src/app/git/workTree.ts b/packages/agent-core-v2/src/app/git/workTree.ts new file mode 100644 index 0000000000..527ac7a5a1 --- /dev/null +++ b/packages/agent-core-v2/src/app/git/workTree.ts @@ -0,0 +1,75 @@ +/** + * `git` domain (L1) — git work-tree discovery. + * + * Walks up from a directory to find the enclosing git work tree: the nearest + * ancestor containing a `.git` entry, either a directory (plain repository) + * or a file holding a `gitdir:` pointer (linked worktree / submodule) whose + * target is resolved into `controlDirPath`. Entries that are neither — or + * files without a parseable pointer — do not count and the walk continues. + * All filesystem access goes through the os `IHostFileSystem` and paths are + * pathe-normalized (Windows-aware, forward slashes). Pure functions — the + * single work-tree probe behind `IGitService.findWorkTree` and the direct + * consumers in `profile` / `mcp`. + */ + +import { dirname, isAbsolute, join, normalize } from 'pathe'; + +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; + +export interface GitWorkTree { + /** Ancestor directory that contains the `.git` entry. */ + readonly root: string; + /** Path of the `.git` entry itself (`/.git`). */ + readonly dotGitPath: string; + /** + * The repository's real git control directory — `dotGitPath` for a plain + * repository, the resolved `gitdir:` target for a linked worktree / + * submodule. + */ + readonly controlDirPath: string; +} + +export async function findGitWorkTree( + fs: IHostFileSystem, + cwd: string, +): Promise { + if (cwd.length === 0 || !isAbsolute(cwd)) return null; + + let current = normalize(cwd); + while (true) { + const dotGitPath = join(current, '.git'); + const controlDirPath = await probeGitControlDir(fs, dotGitPath, current); + if (controlDirPath !== null) return { root: current, dotGitPath, controlDirPath }; + + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } +} + +async function probeGitControlDir( + fs: IHostFileSystem, + dotGitPath: string, + markerParent: string, +): Promise { + try { + const stat = await fs.stat(dotGitPath); + if (stat.isDirectory) return dotGitPath; + if (!stat.isFile) return null; + + const content = await fs.readText(dotGitPath); + return parseGitDirPointer(content, markerParent) ?? null; + } catch { + return null; + } +} + +function parseGitDirPointer(content: string, markerParent: string): string | undefined { + const stripped = content.codePointAt(0) === 0xfeff ? content.slice(1) : content; + const line = stripped.trimStart().split(/\r?\n/, 1)[0]?.trim(); + if (line === undefined || !line.startsWith('gitdir:')) return undefined; + + const rawPath = line.slice('gitdir:'.length).trim(); + if (rawPath.length === 0) return undefined; + return normalize(isAbsolute(rawPath) ? rawPath : join(markerParent, rawPath)); +} diff --git a/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowser.ts b/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowser.ts index 82bfd2263a..1ec2fececb 100644 --- a/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowser.ts +++ b/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowser.ts @@ -3,8 +3,8 @@ * * Defines the `IHostFolderBrowser` used by the program side (TUI / server) to * let the user browse the real local filesystem when choosing a workspace - * folder. Distinct from the Session-side `sessionFs`, which is sandboxed and may - * be remote. App-scoped. + * folder. Distinct from the Workspace-side `workspaceFs`, which is sandboxed and + * may be remote. App-scoped. * * The wire shapes (`FsBrowseResponse` / `FsHomeResponse`) are defined here as * zod schemas so the `/api/v1` and `/api/v2` transports share one contract. diff --git a/packages/agent-core-v2/src/agent/mcp/configSection.ts b/packages/agent-core-v2/src/app/mcpConfig/configSection.ts similarity index 89% rename from packages/agent-core-v2/src/agent/mcp/configSection.ts rename to packages/agent-core-v2/src/app/mcpConfig/configSection.ts index 82a624b7df..ee68ba630f 100644 --- a/packages/agent-core-v2/src/agent/mcp/configSection.ts +++ b/packages/agent-core-v2/src/app/mcpConfig/configSection.ts @@ -1,5 +1,5 @@ /** - * `mcp` domain (L5) — registers MCP timeout preferences into `config`. + * `mcpConfig` domain (L5) — registers MCP timeout preferences into `config`. * * Owns the global MCP startup and tool-call timeout preferences, including * their environment bindings and persistence guard. Registered into `config` @@ -10,7 +10,7 @@ import { z } from 'zod'; import { type EnvBindings, envBindings, stripEnvBoundFields } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; -import { MAX_MCP_TIMEOUT_MS, McpTimeoutMsSchema } from './config-schema'; +import { MAX_MCP_TIMEOUT_MS, McpTimeoutMsSchema } from '#/mcpCore/config-schema'; export const MCP_SECTION = 'mcp'; diff --git a/packages/agent-core-v2/src/app/mcpConfig/oauthStore.ts b/packages/agent-core-v2/src/app/mcpConfig/oauthStore.ts new file mode 100644 index 0000000000..f347e3928f --- /dev/null +++ b/packages/agent-core-v2/src/app/mcpConfig/oauthStore.ts @@ -0,0 +1,80 @@ +/** + * `mcpConfig` domain (L5) — `IMcpOAuthStore`, the App-scope persistence + * adapter for MCP OAuth credentials. + * + * Implements the `mcp` domain's `McpOAuthStore` port over the `persistence` + * access-pattern store (`IAtomicDocumentStore`) under the `credentials/mcp` + * scope (`/credentials/mcp/-*.json`). One App-scope instance is + * shared by every workspace handler's `McpOAuthService`, replacing the + * per-handler stores they used to build ad hoc; the on-disk layout is + * unchanged, so credentials stay shared with out-of-engine readers (the + * node-sdk global OAuth surface). The {@link createMcpOAuthStore} factory + * remains exported for those out-of-engine callers, which run an + * `McpOAuthService` outside the DI container. + * + * Read semantics: missing or corrupt JSON resolves to `undefined` (never + * throws). The provider treats `undefined` as "not stored". + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; + +import type { McpOAuthStore } from '#/mcpCore/oauth/store'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; + +export interface IMcpOAuthStore extends McpOAuthStore { + readonly _serviceBrand: undefined; +} + +export const IMcpOAuthStore: ServiceIdentifier = + createDecorator('mcpOAuthStore'); + +const CREDENTIALS_SCOPE = 'credentials/mcp'; + +export function createMcpOAuthStore(docs: IAtomicDocumentStore): McpOAuthStore { + return { + async read(key: string): Promise { + try { + return await docs.get(CREDENTIALS_SCOPE, key); + } catch { + return undefined; + } + }, + write(key, data) { + return docs.set(CREDENTIALS_SCOPE, key, data); + }, + remove(key) { + return docs.delete(CREDENTIALS_SCOPE, key); + }, + }; +} + +export class McpOAuthStoreAdapter implements IMcpOAuthStore { + declare readonly _serviceBrand: undefined; + + private readonly delegate: McpOAuthStore; + + constructor(@IAtomicDocumentStore docs: IAtomicDocumentStore) { + this.delegate = createMcpOAuthStore(docs); + } + + read(key: string): Promise { + return this.delegate.read(key); + } + + write(key: string, data: unknown): Promise { + return this.delegate.write(key, data); + } + + remove(key: string): Promise { + return this.delegate.remove(key); + } +} + +registerScopedService( + LifecycleScope.App, + IMcpOAuthStore, + McpOAuthStoreAdapter, + ScopeActivation.OnDemand, + 'mcpConfig', +); diff --git a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts index 3fb648cfc0..1ff08d60a3 100644 --- a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts +++ b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts @@ -41,7 +41,11 @@ import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IWireService } from '#/wire/wire'; import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; +import { resumeSessionById } from '#/app/workspaceLifecycle/sessionLookup'; +import { + IInstantiationService, + type ServicesAccessor, +} from '#/_base/di/instantiation'; import { ErrorCodes, Error2 } from '#/errors'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; @@ -53,11 +57,22 @@ const MAX_PAGE_SIZE = 100; export class MessageLegacyService implements IMessageLegacyService { declare readonly _serviceBrand: undefined; + /** + * Stable accessor over the App container (same wrapper `Scope.accessor` + * uses — one `invokeFunction` per resolution, never a stashed transient + * accessor), feeding the `sessionLookup` composition helpers. + */ + private readonly services: ServicesAccessor; + constructor( - @ISessionLifecycleService private readonly lifecycle: ISessionLifecycleService, + @IInstantiationService instantiation: IInstantiationService, @ISessionIndex private readonly index: ISessionIndex, @IAppendLogStore private readonly appendLog: IAppendLogStore, - ) {} + ) { + this.services = { + get: (id) => instantiation.invokeFunction((accessor) => accessor.get(id)), + }; + } async list(sessionId: string, query: MessageListQuery): Promise> { const all = await this.loadMessages(sessionId); @@ -107,7 +122,8 @@ export class MessageLegacyService implements IMessageLegacyService { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); } - const session = await this.lifecycle.resume(sessionId); + // The shared index → handler → session-lifecycle composition. + const session = await resumeSessionById(this.services, sessionId); if (session === undefined) return []; const agent = await ensureMainAgent(session); diff --git a/packages/agent-core-v2/src/app/plugin/commands.ts b/packages/agent-core-v2/src/app/plugin/commands.ts index 6983aadbdd..7dbe9c99fc 100644 --- a/packages/agent-core-v2/src/app/plugin/commands.ts +++ b/packages/agent-core-v2/src/app/plugin/commands.ts @@ -1,7 +1,7 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; -import { parseFrontmatter } from '#/app/skillCatalog/parser'; +import { parseFrontmatter } from '#/_base/text/frontmatter'; import type { PluginCommandDef } from './types'; diff --git a/packages/agent-core-v2/src/app/plugin/manager.ts b/packages/agent-core-v2/src/app/plugin/manager.ts index d5772737ad..38f1eacc2f 100644 --- a/packages/agent-core-v2/src/app/plugin/manager.ts +++ b/packages/agent-core-v2/src/app/plugin/manager.ts @@ -11,8 +11,8 @@ import path from 'node:path'; import { Error2, PluginErrors } from '#/errors'; import type { HookDef } from '#/agent/externalHooks/types'; -import type { McpServerConfig } from '#/agent/mcp/config-schema'; -import type { AgentFileRoot } from '#/app/agentFileCatalog/types'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; +import type { PluginAgentRoot } from './types'; import { discoverFileSkills } from '#/app/skillCatalog/fileSkillDiscovery'; import type { SkillDiscoveryResult } from '#/app/skillCatalog/skillDiscovery'; import type { SkillRoot } from '#/app/skillCatalog/types'; @@ -314,8 +314,8 @@ export class PluginManager { return roots; } - pluginAgentRoots(): readonly AgentFileRoot[] { - const roots: AgentFileRoot[] = []; + pluginAgentRoots(): readonly PluginAgentRoot[] { + const roots: PluginAgentRoot[] = []; for (const record of this.records.values()) { if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue; for (const dir of record.manifest.agents ?? []) { diff --git a/packages/agent-core-v2/src/app/plugin/manifest.ts b/packages/agent-core-v2/src/app/plugin/manifest.ts index ac3b7ddc0a..3a3a7bae0b 100644 --- a/packages/agent-core-v2/src/app/plugin/manifest.ts +++ b/packages/agent-core-v2/src/app/plugin/manifest.ts @@ -2,7 +2,7 @@ import { readdir, readFile, realpath, stat } from 'node:fs/promises'; import path from 'node:path'; import { HookDefSchema, type HookDefConfig } from '#/agent/externalHooks/configSection'; -import { McpServerConfigSchema, type McpServerConfig } from '#/agent/mcp/config-schema'; +import { McpServerConfigSchema, type McpServerConfig } from '#/mcpCore/config-schema'; import { PLUGIN_NAME_REGEX, diff --git a/packages/agent-core-v2/src/app/plugin/plugin.ts b/packages/agent-core-v2/src/app/plugin/plugin.ts index f9d1162bb5..7e300e06a2 100644 --- a/packages/agent-core-v2/src/app/plugin/plugin.ts +++ b/packages/agent-core-v2/src/app/plugin/plugin.ts @@ -10,13 +10,13 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { Event } from '#/_base/event'; import type { HookDef } from '#/agent/externalHooks/types'; -import type { McpServerConfig } from '#/agent/mcp/config-schema'; -import type { AgentFileRoot } from '#/app/agentFileCatalog/types'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; import type { SkillRoot } from '#/app/skillCatalog/types'; import type { EnabledPluginSessionStart, EnabledPluginSystemPrompt, + PluginAgentRoot, PluginCommandDef, PluginInfo, PluginSummary, @@ -60,7 +60,7 @@ export interface IPluginService { listPluginCommands(): Promise; checkUpdates(): Promise; pluginSkillRoots(): Promise; - pluginAgentRoots(): Promise; + pluginAgentRoots(): Promise; enabledSessionStarts(): Promise; enabledSystemPrompts(): Promise; enabledMcpServers(): Promise>; diff --git a/packages/agent-core-v2/src/app/plugin/pluginService.ts b/packages/agent-core-v2/src/app/plugin/pluginService.ts index 83fe5d1bc1..827282ab22 100644 --- a/packages/agent-core-v2/src/app/plugin/pluginService.ts +++ b/packages/agent-core-v2/src/app/plugin/pluginService.ts @@ -20,8 +20,8 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IProviderService } from '#/kosong/provider/provider'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; import type { HookDef } from '#/agent/externalHooks/types'; -import type { McpServerConfig } from '#/agent/mcp/config-schema'; -import type { AgentFileRoot } from '#/app/agentFileCatalog/types'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; +import type { PluginAgentRoot } from './types'; import type { SkillRoot } from '#/app/skillCatalog/types'; import { PluginManager } from './manager'; @@ -159,7 +159,7 @@ export class PluginService extends Disposable implements IPluginService { return this.runConsumptionRead([], async () => this.manager.pluginSkillRoots()); } - pluginAgentRoots(): Promise { + pluginAgentRoots(): Promise { return this.runConsumptionRead([], async () => this.manager.pluginAgentRoots()); } diff --git a/packages/agent-core-v2/src/app/plugin/types.ts b/packages/agent-core-v2/src/app/plugin/types.ts index 414f8d3a51..4c66f047c0 100644 --- a/packages/agent-core-v2/src/app/plugin/types.ts +++ b/packages/agent-core-v2/src/app/plugin/types.ts @@ -1,5 +1,5 @@ import type { HookDefConfig } from '#/agent/externalHooks/configSection'; -import type { McpServerConfig } from '#/agent/mcp/config-schema'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; export type PluginDiagnosticSeverity = 'error' | 'warn' | 'info'; @@ -17,6 +17,17 @@ export interface PluginSessionStart { readonly skill: string; } +/** + * A plugin-contributed agent-definition root directory (`manifest.agents` + * entries). Structurally compatible with the workspace agent-profile loader's + * `AgentFileRoot` (`source: 'plugin'`) so the loader can feed them straight + * into discovery without the plugin domain importing it. + */ +export interface PluginAgentRoot { + readonly path: string; + readonly source: 'plugin'; +} + export interface PluginInterface { readonly displayName?: string; readonly shortDescription?: string; diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts index 7f97631baa..7024d1ef11 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts @@ -1,21 +1,29 @@ /** * `sessionExport` domain (L6) — `ISessionExportService` implementation. * - * Coordinates live session flushing through `sessionLifecycle`, derives session - * paths from `bootstrap`, reads persisted summaries through `sessionIndex`, and - * packages diagnostic files through the local zip writer. Bound at App scope. + * Coordinates live session flushing through the live handler registry + * (`workspaceLifecycle` → the handler's `IWorkspaceHandlerService`), derives + * session paths from the handler-chain addressing, reads persisted summaries + * through `sessionIndex`, and packages diagnostic files through the local + * zip writer. Bound at App scope. */ import { join, resolve } from 'pathe'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import type { ISessionScopeHandle } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { resolveGlobalLogPath } from '#/_base/log/logConfig'; import { IWireService } from '#/wire/wire'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; import { IWorkspaceService } from '#/app/workspace/workspace'; +import { + sessionDirOf, + workspacePersistenceScope, +} from '#/workspace/workspaceHandler/addressing'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; import { ErrorCodes, Error2 } from '#/errors'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; @@ -47,7 +55,7 @@ export class SessionExportService implements ISessionExportService { constructor( @IBootstrapService private readonly bootstrap: IBootstrapService, @ISessionIndex private readonly index: ISessionIndex, - @ISessionLifecycleService private readonly lifecycle: ISessionLifecycleService, + @IWorkspaceLifecycleService private readonly workspaceLifecycle: IWorkspaceLifecycleService, @IWorkspaceService private readonly workspaces: IWorkspaceService, @ILogService private readonly log: ILogService, ) {} @@ -98,14 +106,18 @@ export class SessionExportService implements ISessionExportService { private async flushLiveSession(summary: SessionSummary): Promise { const workspace = await this.workspaces.get(summary.workspaceId); - const sessionDir = this.bootstrap.sessionDir(summary.workspaceId, summary.id); + const sessionDir = sessionDirOf( + this.bootstrap.homeDir, + workspacePersistenceScope(this.bootstrap.scope('sessions'), summary.workspaceId), + summary.id, + ); let exportSummary: ExportSessionDirectorySummary = { id: summary.id, title: summary.title, workspaceDir: workspace?.root, sessionDir, }; - const handle = this.lifecycle.get(summary.id); + const handle = this.liveSession(summary.id); if (handle === undefined) { return exportSummary; } @@ -137,6 +149,14 @@ export class SessionExportService implements ISessionExportService { return exportSummary; } + private liveSession(sessionId: string): ISessionScopeHandle | undefined { + for (const handler of this.workspaceLifecycle.handlers.list()) { + const handle = handler.accessor.get(IWorkspaceHandlerService).get(sessionId); + if (handle !== undefined) return handle; + } + return undefined; + } + private async warnIfFails( message: string, operation: () => Promise, diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts index 2c196b6b91..743e85717c 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts @@ -5,7 +5,7 @@ * query facade over the set of persisted sessions (open or closed). It * enumerates sessions and derives session identity (`workspaceId`), returning * data (`SessionSummary`) or counts — never filesystem paths or live handles. - * Writes (create / archive) live in `sessionLifecycle` / `session`; the index + * Writes (create / archive) live in `workspaceHandler` / `session`; the index * is a read model. Backends are deployment-specific (local filesystem today; * database / query store on a server). */ diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts index 3886beff1d..555969dede 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts @@ -5,12 +5,12 @@ * metadata merge, and the cross-domain `agent_config` patch), * `GET /sessions/{id}/status` (`status`), and `GET /sessions/{id}/goal` * (`goal`) on top of the native v2 services - * (`ISessionLifecycleService`, `IAgentProfileService`, …). + * (`IWorkspaceHandlerService`, `IAgentProfileService`, …). * * The thin pass-through actions (`fork` / `compact` / `abort` / `archive`), the * `:undo` action, and the `/sessions/{id}/children` endpoints are deliberately * NOT wrapped here: the edge route calls the native services directly — - * `ISessionLifecycleService.fork` / `archive` / `createChild`, + * `IWorkspaceHandlerService.fork` / `archive` / `createChild`, * `IAgentFullCompactionService.begin`, `IAgentRPCService.cancel`, * `IAgentPromptService.undo`, and `ISessionIndex.list({ childOf })` — because * none of them carries v1-only projection worth centralizing beyond what the diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index 312f866ca7..c49b754797 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -2,13 +2,15 @@ * `sessionLegacy` domain — `ISessionLegacyService` implementation. * * Stateless App-scope dispatcher: each method resolves the target session (and - * its main agent) per call, delegates to the native v2 services, and projects + * its main agent) per call through the shared `sessionLookup` composition + * (`sessionIndex` → `workspaceLifecycle.handlerFor` → the handler's + * `IWorkspaceHandlerService`), delegates to the native v2 services, and projects * the result into the v1 wire shape. Only `updateProfile` (the cross-domain * `agent_config` patch), `status` (the best-effort status rollup), and `goal` * (the current-goal read) live here; * the `:undo`, `fork`-as-child, and child-listing actions were pushed down into * the native services (`IAgentPromptService.undo`, - * `ISessionLifecycleService.createChild`, `ISessionIndex.list({ childOf })`) and + * `IWorkspaceHandlerService.createChild`, `ISessionIndex.list({ childOf })`) and * are called by the edge route directly. No business logic is duplicated here; * the real work stays in the native services. */ @@ -19,10 +21,15 @@ import type { SessionStatusResponse, UpdateSessionProfileRequest } from './sessi import { type IAgentScopeHandle, + type ISessionScopeHandle, LifecycleScope, ScopeActivation, registerScopedService, } from '#/_base/di/scope'; +import { + IInstantiationService, + type ServicesAccessor, +} from '#/_base/di/instantiation'; import { IAgentContextSizeService } from '#/agent/contextSize/contextSize'; import { IAgentGoalService } from '#/agent/goal/goal'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; @@ -31,8 +38,11 @@ import { IAgentPlanService } from '#/agent/plan/plan'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { IConfigService } from '#/app/config/config'; +import { + getLiveSessionById, + resumeSessionById, +} from '#/app/workspaceLifecycle/sessionLookup'; import { IModelCatalog } from '#/kosong/model/catalog'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; import { ErrorCodes, Error2 } from '#/errors'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; @@ -45,13 +55,29 @@ import { ISessionLegacyService, type SessionWireFields } from './sessionLegacy'; export class SessionLegacyService implements ISessionLegacyService { declare readonly _serviceBrand: undefined; - constructor(@ISessionLifecycleService private readonly lifecycle: ISessionLifecycleService) {} + /** + * Stable accessor over the App container (same wrapper `Scope.accessor` + * uses — one `invokeFunction` per resolution, never a stashed transient + * accessor), feeding the `sessionLookup` composition helpers. + */ + private readonly services: ServicesAccessor; + + constructor(@IInstantiationService instantiation: IInstantiationService) { + this.services = { + get: (id) => instantiation.invokeFunction((accessor) => accessor.get(id)), + }; + } + + /** The shared index → handler → session-lifecycle composition (no App facade). */ + private resume(sessionId: string): Promise { + return resumeSessionById(this.services, sessionId); + } async updateProfile( sessionId: string, body: UpdateSessionProfileRequest, ): Promise { - const session = await this.lifecycle.resume(sessionId); + const session = await this.resume(sessionId); if (session === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); } @@ -141,7 +167,7 @@ export class SessionLegacyService implements ISessionLegacyService { } private async resolveMainAgent(sessionId: string): Promise { - const session = await this.lifecycle.resume(sessionId); + const session = await this.resume(sessionId); if (session === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); } @@ -194,7 +220,7 @@ export class SessionLegacyService implements ISessionLegacyService { * level — a cold session is simply not busy. */ private readBusy(sessionId: string): boolean { - const handle = this.lifecycle.get(sessionId); + const handle = getLiveSessionById(this.services, sessionId); if (handle === undefined) return false; for (const agent of handle.accessor.get(IAgentLifecycleService).list()) { const state = agent.accessor.get(IAgentActivityView).state(); diff --git a/packages/agent-core-v2/src/app/skillCatalog/parser.ts b/packages/agent-core-v2/src/app/skillCatalog/parser.ts index be7924c782..af6870230c 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/parser.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/parser.ts @@ -4,26 +4,17 @@ * Parses a SKILL.md (frontmatter + body) into a `SkillDefinition` and extracts * flowchart blocks. Pure functions with no IO: callers (the catalog Store * backends) read bytes however they like and pass the decoded text in. Keeping - * parsing here lets the Store layer stay filesystem-agnostic. + * parsing here lets the Store layer stay filesystem-agnostic. The frontmatter + * split itself is shared text infrastructure (`_base/text/frontmatter`). */ import path from 'pathe'; -import { load as loadYaml } from 'js-yaml'; +import { FrontmatterError, parseFrontmatter } from '#/_base/text/frontmatter'; import type { SkillDefinition, SkillMetadata, SkillSource } from './types'; import { isSupportedSkillType } from './types'; -export class FrontmatterError extends Error { - constructor(message: string, cause?: unknown) { - super(message); - this.name = 'FrontmatterError'; - if (cause !== undefined) { - Object.defineProperty(this, 'cause', { value: cause, configurable: true }); - } - } -} - export class SkillParseError extends Error { readonly reason?: unknown; @@ -56,11 +47,6 @@ export interface ParseSkillTextOptions extends ParseSkillOptions { readonly text: string; } -export interface ParsedFrontmatter { - readonly data: unknown; - readonly body: string; -} - const FENCE = '---'; const METADATA_ALIASES: Readonly> = { 'when-to-use': 'whenToUse', @@ -69,31 +55,6 @@ const METADATA_ALIASES: Readonly> = { disable_model_invocation: 'disableModelInvocation', }; -export function parseFrontmatter(text: string): ParsedFrontmatter { - const lines = text.split(/\r?\n/); - if (lines[0]?.trim() !== FENCE) { - return { data: null, body: text }; - } - - const close = lines.findIndex((line, index) => index > 0 && line.trim() === FENCE); - if (close === -1) { - throw new FrontmatterError('Missing closing frontmatter fence'); - } - - const yamlText = lines.slice(1, close).join('\n').trim(); - const body = lines.slice(close + 1).join('\n'); - if (yamlText === '') { - return { data: {}, body }; - } - - try { - return { data: loadYaml(yamlText) ?? {}, body }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new FrontmatterError(message, error); - } -} - export function parseSkillText(options: ParseSkillTextOptions): SkillDefinition { const isDirectorySkill = path.basename(options.skillMdPath) === 'SKILL.md'; if (isDirectorySkill && options.text.split(/\r?\n/, 1)[0]?.trim() !== FENCE) { diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts index c0a1c76330..7d1022f983 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts @@ -48,6 +48,29 @@ export async function projectRoots( return roots; } +/** + * The project root and the raw project skill-root candidates + * (`.kimi-code/skills` and `.agents/skills` under it) regardless of + * existence — the watch plan for project-skill refreshes, since + * `projectRoots` filters to directories that already exist. + */ +export interface ProjectSkillRootCandidates { + readonly projectRoot: string; + readonly candidates: readonly string[]; +} + +export async function projectSkillRootCandidates( + workDir: string, +): Promise { + const projectRoot = await findProjectRoot(workDir); + return { + projectRoot, + candidates: [...PROJECT_BRAND_DIRS, ...PROJECT_GENERIC_DIRS].map((dir) => + path.join(projectRoot, dir), + ), + }; +} + export async function configuredRoots( dirs: readonly string[], workDir: string, diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts b/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts index 9db2f74d45..77c4cf7308 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts @@ -30,6 +30,13 @@ export const SKILL_SOURCE_PRIORITY = { workspace: 30, } as const; +/** + * The well-known `ISkillSource.id` of the plugin source, shared by every + * scope tier the source is bound at (today: Workspace scope) and by change + * listeners matching plugin-sourced refreshes. + */ +export const PLUGIN_SKILL_SOURCE_ID = 'plugin'; + export interface ISkillSource { readonly _serviceBrand: undefined; readonly id: string; diff --git a/packages/agent-core-v2/src/app/workspaceLifecycle/sessionLookup.ts b/packages/agent-core-v2/src/app/workspaceLifecycle/sessionLookup.ts new file mode 100644 index 0000000000..3249ecd2f3 --- /dev/null +++ b/packages/agent-core-v2/src/app/workspaceLifecycle/sessionLookup.ts @@ -0,0 +1,132 @@ +/** + * `workspaceLifecycle` domain (L6) — pure session-lookup helpers over the handler chain. + * + * The explicit `sessionIndex` → `IWorkspaceLifecycleService.handlerFor` → + * handler `IWorkspaceHandlerService` composition, shared by every caller + * that addresses a session by id from outside the Workspace scope (edge + * routes, in-process SDKs). These are plain functions over a STABLE + * accessor (a `Scope` / scope-handle `accessor`, never a transient + * `invokeFunction` one) — they are not an App-scope session lifecycle + * facade: the live registry and every lifecycle method stay on the + * handler's own service. Own no scoped state. + */ + +import type { ServicesAccessor } from '#/_base/di/instantiation'; +import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle'; +import type { ISessionScopeHandle, IWorkspaceScopeHandle } from '#/_base/di/scope'; +import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { ErrorCodes, isError2 } from '#/errors'; +import { + IWorkspaceHandlerService, + type ResumeSessionOptions, +} from '#/workspace/workspaceHandler/workspaceHandler'; + +import { IWorkspaceLifecycleService } from './workspaceLifecycle'; + +/** + * The handler of one session, resolved through the persisted index and + * materialized on demand. Returns `undefined` when the session is unknown + * (or its workspace cannot be resolved to a root — the old resume path's + * `undefined` outcome). + */ +export async function handlerForSession( + accessor: ServicesAccessor, + sessionId: string, +): Promise { + const summary = await accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) return undefined; + try { + return await accessor + .get(IWorkspaceLifecycleService) + .handlerFor({ workspaceId: summary.workspaceId, root: summary.cwd }); + } catch (error) { + if (isError2(error) && error.code === ErrorCodes.WORKSPACE_NOT_FOUND) return undefined; + throw error; + } +} + +/** + * Resume a session by id through its handler; `undefined` when unknown. + * Failures in the routing leg (index read, handler materialization) report + * the same `session_load_failed` telemetry the handler reports for its own + * resume failures — the old App-scope resume covered both from one call. + */ +export async function resumeSessionById( + accessor: ServicesAccessor, + sessionId: string, + opts?: ResumeSessionOptions, +): Promise { + let handler: IWorkspaceScopeHandle | undefined; + try { + handler = await handlerForSession(accessor, sessionId); + } catch (error) { + accessor + .get(ITelemetryService) + .withContext({ sessionId }) + .track2('session_load_failed', { + reason: isError2(error) ? error.code : error instanceof Error ? error.name : 'unknown', + }); + throw error; + } + if (handler === undefined) return undefined; + return handler.accessor.get(IWorkspaceHandlerService).resume(sessionId, opts); +} + +/** The live handler holding `sessionId`, without materializing anything. */ +export function liveHandlerForSession( + accessor: ServicesAccessor, + sessionId: string, +): IWorkspaceScopeHandle | undefined { + for (const handler of accessor.get(IWorkspaceLifecycleService).handlers.list()) { + if (handler.accessor.get(IWorkspaceHandlerService).get(sessionId) !== undefined) { + return handler; + } + } + return undefined; +} + +/** The live session handle for `sessionId`, without materializing anything. */ +export function getLiveSessionById( + accessor: ServicesAccessor, + sessionId: string, +): ISessionScopeHandle | undefined { + return liveHandlerForSession(accessor, sessionId)?.accessor + .get(IWorkspaceHandlerService) + .get(sessionId); +} + +/** Close a live session through its handler; a no-op when the session is not live. */ +export async function closeSessionById( + accessor: ServicesAccessor, + sessionId: string, +): Promise { + const handler = liveHandlerForSession(accessor, sessionId); + if (handler === undefined) return; + await handler.accessor.get(IWorkspaceHandlerService).close(sessionId); +} + +/** + * Subscribe `follow` to the `IWorkspaceHandlerService` of every handler — + * present and future (handlers are never closed, so subscriptions stay + * valid for the App lifetime). For App-scope observers of per-handler + * events (e.g. `onDidCloseSession`). + */ +export function followWorkspaceHandlers( + accessor: ServicesAccessor, + follow: (service: IWorkspaceHandlerService) => IDisposable, +): IDisposable { + const lifecycle = accessor.get(IWorkspaceLifecycleService); + const store = new DisposableStore(); + for (const handler of lifecycle.handlers.list()) { + store.add(follow(handler.accessor.get(IWorkspaceHandlerService))); + } + store.add( + lifecycle.onDidMaterializeHandler((handler) => { + if (!store.isDisposed) { + store.add(follow(handler.accessor.get(IWorkspaceHandlerService))); + } + }), + ); + return store; +} diff --git a/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycle.ts b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycle.ts new file mode 100644 index 0000000000..01ab161954 --- /dev/null +++ b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycle.ts @@ -0,0 +1,53 @@ +/** + * `workspaceLifecycle` domain (L6) — workspace handler lifecycle contract. + * + * Defines the `IWorkspaceLifecycleService`, the App-scope owner of the live + * workspace handler registry: one `IWorkspaceScopeHandle` per workspaceId, + * materialized on demand through `handlerFor` (create-or-get with an + * in-flight join, so concurrent sessions of one workspace never duplicate a + * handler) and never closed afterwards — handlers die with the App scope. + * A handler is addressed by `workspaceId` or by `root` (folded through the + * `workspace` catalog, which is also the local runtime's metadata source); + * the remote-runtime keying (`osBackendId` × `persistenceBackendId`) rides + * on the handler's `workspaceContext` seed as an internal abstraction only. + * Read side: `handlers.list()` and `sessions.list(workspaceId)`, plus + * `onDidMaterializeHandler` for App-scope observers that must follow every + * handler's per-handler services. There is deliberately NO App-scope + * session lifecycle entry point — session create/resume/fork lives on the + * handler's `IWorkspaceHandlerService`; callers compose `sessionIndex` → + * `handlerFor` → handler (see `sessionLookup`). + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IWorkspaceScopeHandle } from '#/_base/di/scope'; +import type { Event } from '#/_base/event'; + +/** + * Handler address: a `workspaceId` (optionally with a `root` hint used when + * the catalog record is missing and the handler must be materialized), or a + * bare `root` resolved through the `workspace` catalog. + */ +export type WorkspaceRef = + | { readonly workspaceId: string; readonly root?: string } + | { readonly root: string }; + +export interface WorkspaceHandlerRegistry { + list(): readonly IWorkspaceScopeHandle[]; +} + +export interface WorkspaceSessionRegistry { + /** Live session ids of one handler (empty when the handler is not materialized). */ + list(workspaceId: string): readonly string[]; +} + +export interface IWorkspaceLifecycleService { + readonly _serviceBrand: undefined; + + readonly onDidMaterializeHandler: Event; + handlerFor(ref: WorkspaceRef): Promise; + readonly handlers: WorkspaceHandlerRegistry; + readonly sessions: WorkspaceSessionRegistry; +} + +export const IWorkspaceLifecycleService: ServiceIdentifier = + createDecorator('workspaceLifecycleService'); diff --git a/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts new file mode 100644 index 0000000000..744fb61f9c --- /dev/null +++ b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts @@ -0,0 +1,161 @@ +/** + * `workspaceLifecycle` domain (L6) — `IWorkspaceLifecycleService` implementation. + * + * Holds the live handler registry (`Map`) + * and materializes handlers through the DI scope tree, seeding each + * Workspace scope with its `workspaceContext` (identity, catalog metadata, + * the `sessions/{wd_id}` persistence scope, and the local runtime keying + * pair). `handlerFor` is create-or-get with an in-flight join keyed by + * workspaceId, so concurrent materializations of one workspace — by id or + * by any alias spelling of its root — converge on a single handler; a + * failed materialization only drops its own in-flight entry and never + * disturbs live handlers. Materialization refreshes the catalog record + * through `workspace.createOrTouch` (the same write the old per-session + * materialization performed, now once per handler) — only local workspaces + * are ever written to `workspaces.json`. Handlers are never closed: they + * die with the App scope's disposal cascade. Bound at App scope. + */ + +import { IInstantiationService } from '#/_base/di/instantiation'; +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; +import { + createScopedChildHandle, + type IWorkspaceScopeHandle, + LifecycleScope, + ScopeActivation, + registerScopedService, +} from '#/_base/di/scope'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; +import { ErrorCodes, Error2 } from '#/errors'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { + LOCAL_OS_BACKEND_ID, + LOCAL_PERSISTENCE_BACKEND_ID, + workspaceContextSeed, + type IWorkspaceContext, +} from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { workspacePersistenceScope } from '#/workspace/workspaceHandler/addressing'; + +import { + IWorkspaceLifecycleService, + type WorkspaceHandlerRegistry, + type WorkspaceRef, + type WorkspaceSessionRegistry, +} from './workspaceLifecycle'; + +export class WorkspaceLifecycleService extends Disposable implements IWorkspaceLifecycleService { + declare readonly _serviceBrand: undefined; + private readonly live = new Map(); + /** In-flight materializations, keyed by workspaceId. Concurrent + * `handlerFor` calls for the same workspace join the in-flight one + * (never a duplicate handler). */ + private readonly materializing = new Map>(); + private readonly _onDidMaterializeHandler = this._register( + new Emitter(), + ); + readonly onDidMaterializeHandler: Event = + this._onDidMaterializeHandler.event; + + readonly handlers: WorkspaceHandlerRegistry = { + list: () => [...this.live.values()], + }; + + readonly sessions: WorkspaceSessionRegistry = { + list: (workspaceId: string) => { + const handler = this.live.get(workspaceId); + if (handler === undefined) return []; + return handler.accessor + .get(IWorkspaceHandlerService) + .list() + .map((session) => session.id); + }, + }; + + constructor( + @IInstantiationService private readonly instantiation: IInstantiationService, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IWorkspaceService private readonly workspaces: IWorkspaceService, + @IHostEnvironment private readonly hostEnv: IHostEnvironment, + ) { + super(); + } + + async handlerFor(ref: WorkspaceRef): Promise { + if ('workspaceId' in ref) { + const existing = this.live.get(ref.workspaceId); + if (existing !== undefined) return existing; + const root = ref.root ?? (await this.workspaces.get(ref.workspaceId))?.root; + if (root === undefined) { + throw new Error2( + ErrorCodes.WORKSPACE_NOT_FOUND, + `workspace ${ref.workspaceId} does not exist`, + ); + } + return this.joinMaterialization(ref.workspaceId, root); + } + // Resolve the id through the catalog first (registry folding may reuse an + // id minted for another spelling) so alias spellings join one + // materialization; the resolved record doubles as the seed metadata, so + // the catalog is written exactly once per handler. + const workspace = await this.workspaces.createOrTouch(ref.root); + const existing = this.live.get(workspace.id); + if (existing !== undefined) return existing; + return this.joinMaterialization(workspace.id, workspace.root, workspace); + } + + private joinMaterialization( + workspaceId: string, + root: string, + known?: Workspace, + ): Promise { + const inflight = this.materializing.get(workspaceId); + if (inflight !== undefined) return inflight; + const promise = this.doMaterialize(workspaceId, root, known).finally(() => + this.materializing.delete(workspaceId), + ); + this.materializing.set(workspaceId, promise); + return promise; + } + + private async doMaterialize( + workspaceId: string, + root: string, + known?: Workspace, + ): Promise { + // Refresh the catalog record (re-creating it when the caller addressed + // the handler by id + root hint and the record is gone) and seed the + // canonical root the registry resolved. + const workspace = known ?? (await this.workspaces.createOrTouch(root)); + const ctx: IWorkspaceContext = { + _serviceBrand: undefined, + workspaceId, + cwd: workspace.root, + source: 'local', + meta: workspace, + persistenceScope: workspacePersistenceScope(this.bootstrap.scope('sessions'), workspaceId), + osBackendId: LOCAL_OS_BACKEND_ID, + persistenceBackendId: LOCAL_PERSISTENCE_BACKEND_ID, + }; + await this.hostEnv.ready; + const handle = createScopedChildHandle( + this.instantiation, + LifecycleScope.Workspace, + workspaceId, + { extra: workspaceContextSeed(ctx) }, + ) as IWorkspaceScopeHandle; + this.live.set(workspaceId, handle); + this._onDidMaterializeHandler.fire(handle); + return handle; + } +} + +registerScopedService( + LifecycleScope.App, + IWorkspaceLifecycleService, + WorkspaceLifecycleService, + ScopeActivation.OnScopeCreated, + 'workspaceLifecycle', +); diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index a390735277..cdf271d7d2 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -14,11 +14,11 @@ import { TaskErrors } from '#/agent/task/errors'; import { ProtocolErrors } from '#/kosong/protocol/errors'; import { ConfigErrors } from '#/app/config/errors'; import { FileErrors } from '#/app/file/fileService'; -import { FsErrors } from '#/session/sessionFs/errors'; +import { FsErrors } from '#/workspace/workspaceFs/errors'; import { FullCompactionErrors } from '#/agent/fullCompaction/errors'; import { GoalErrors } from '#/agent/goal/errors'; import { LoopErrors } from '#/agent/loop/errors'; -import { McpErrors } from '#/agent/mcp/errors'; +import { McpErrors } from '#/mcpCore/errors'; import { MessageLegacyErrors } from '#/app/messageLegacy/errors'; import { ModelCatalogErrors } from '#/kosong/model/errors'; import { OsFsErrors } from '#/os/interface/hostFsErrors'; @@ -47,11 +47,11 @@ export { TaskErrors } from '#/agent/task/errors'; export { ProtocolErrors } from '#/kosong/protocol/errors'; export { ConfigErrors } from '#/app/config/errors'; export { FileErrors } from '#/app/file/fileService'; -export { FsErrors } from '#/session/sessionFs/errors'; +export { FsErrors } from '#/workspace/workspaceFs/errors'; export { FullCompactionErrors } from '#/agent/fullCompaction/errors'; export { GoalErrors } from '#/agent/goal/errors'; export { LoopErrors } from '#/agent/loop/errors'; -export { McpErrors } from '#/agent/mcp/errors'; +export { McpErrors } from '#/mcpCore/errors'; export { MessageLegacyErrors } from '#/app/messageLegacy/errors'; export { ModelCatalogErrors } from '#/kosong/model/errors'; export { OsFsErrors } from '#/os/interface/hostFsErrors'; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 517b460e51..88391356e9 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -65,6 +65,7 @@ import '#/app/event/eventService'; export { IEventBus, type DomainEvent } from '#/app/event/eventBus'; export { IEventService, type DomainEvent as GlobalEvent } from '#/app/event/event'; export * from '#/_base/state/stateRegistry'; +export * from '#/_base/contribution/registry'; export * from '#/app/state/state'; import '#/app/state/stateService'; export * from '#/session/state/sessionState'; @@ -156,7 +157,11 @@ import '#/kosong/provider/bases/openai/index'; import '#/kosong/provider/providers/kimi/kimi.contrib'; import '#/kosong/provider/providers/standard.contrib'; export * from '#/app/agentProfileCatalog/agentProfileCatalog'; -export * from '#/app/agentProfileCatalog/agentProfileCatalogService'; +export * from '#/app/agentProfileCatalog/agentProfileContribution'; +export * from '#/app/agentProfileCatalog/agentProfileRegistry'; +export * from '#/app/agentProfileCatalog/agentProfileRegistryService'; +export * from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; +export * from '#/app/agentProfileCatalog/builtinAgentProfileLoaderService'; export * from '#/app/agentProfileCatalog/profile-shared'; export * from '#/app/agentProfileCatalog/promptPrefix'; export { @@ -164,16 +169,14 @@ export { getAgentProfileContributions, _clearAgentProfileContributionsForTests, } from '#/app/agentProfileCatalog/contribution'; -export * from '#/app/agentFileCatalog/types'; -export * from '#/app/agentFileCatalog/paths'; -export * from '#/app/agentFileCatalog/agentRoots'; -export * from '#/app/agentFileCatalog/agentFile'; -export * from '#/app/agentFileCatalog/agentFileDiscovery'; -export * from '#/app/agentFileCatalog/agentProfileFromFile'; -export * from '#/app/agentFileCatalog/configSection'; -export * from '#/app/agentFileCatalog/agentProfileSource'; -export * from '#/app/agentFileCatalog/agentCatalogRuntimeOptions'; -export * from '#/app/agentFileCatalog/userFileAgentSource'; +export * from '#/workspace/workspaceAgentProfileLoader/configSection'; +// Public agent-file primitives consumed out-of-package (the v2 print CLI's +// `--agent-file` path); everything else under `internal/` stays domain-private. +export { parseAgentFileText } from '#/workspace/workspaceAgentProfileLoader/internal/agentFile'; +export { resolveAgentPath } from '#/workspace/workspaceAgentProfileLoader/internal/paths'; +export * from '#/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions'; +export * from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +export * from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService'; export * from '#/app/hostIdentity/hostIdentity'; export * from '#/app/plugin/types'; export * from '#/app/plugin/commands'; @@ -185,6 +188,8 @@ export * from '#/app/plugin/archive'; export * from '#/app/plugin/manager'; export * from '#/app/plugin/plugin'; export * from '#/app/plugin/pluginService'; +export * from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; +export * from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService'; export type { SkillSource } from '#/app/skillCatalog/types'; export * from '#/agent/tools/skill/skill'; @@ -205,17 +210,29 @@ export * from '#/app/skillCatalog/builtin/builtin'; export * from '#/app/skillCatalog/builtinSkillSource'; export * from '#/app/skillCatalog/userFileSkillSource'; export * from '#/session/sessionSkillCatalog/skillCatalog'; +export * from '#/session/sessionSkillCatalog/skillCatalogData'; export * from '#/session/sessionSkillCatalog/skillCatalogService'; -export * from '#/session/sessionSkillCatalog/extraFileSkillSource'; -export * from '#/session/sessionSkillCatalog/explicitFileSkillSource'; -export * from '#/session/sessionSkillCatalog/workspaceFileSkillSource'; -export * from '#/session/sessionSkillCatalog/pluginSkillSource'; export * from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; +export * from '#/session/sessionAgentProfileCatalog/agentProfileCatalogSeed'; export * from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService'; -export * from '#/session/sessionAgentProfileCatalog/pluginAgentProfileSource'; -export * from '#/session/sessionAgentProfileCatalog/projectFileAgentSource'; -export * from '#/session/sessionAgentProfileCatalog/extraFileAgentSource'; -export * from '#/session/sessionAgentProfileCatalog/explicitFileAgentSource'; +export * from '#/session/sessionInstructions/instructionsProvider'; +export * from '#/session/workspaceInfo/workspaceInfo'; +export * from '#/workspace/workspaceDirs/workspaceDirs'; +export * from '#/workspace/workspaceDirs/workspaceDirsService'; +export * from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; +export * from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalogService'; +export * from '#/workspace/workspaceSkillCatalog/extraFileSkillSource'; +export * from '#/workspace/workspaceSkillCatalog/explicitFileSkillSource'; +export * from '#/workspace/workspaceSkillCatalog/rootFileSkillSource'; +export * from '#/workspace/workspaceSkillCatalog/pluginSkillSource'; +export * from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; +export * from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService'; +export * from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader'; +export * from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService'; +export * from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader'; +export * from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService'; +export * from '#/workspace/workspaceInstructions/workspaceInstructions'; +export * from '#/workspace/workspaceInstructions/workspaceInstructionsService'; export * from '#/agent/permissionGate/permissionGate'; export * from '#/agent/permissionGate/permissionGateService'; export * from '#/agent/toolApproval/toolApproval'; @@ -314,8 +331,18 @@ import '#/session/agentLifecycle/profile/profiles'; export * from '#/session/agentLifecycle/agentLifecycle'; export * from '#/session/agentLifecycle/agentLifecycleService'; export * from '#/session/agentLifecycle/mainAgent'; -export * from '#/session/mcp/sessionMcp'; -export * from '#/session/mcp/sessionMcpService'; +export * from '#/session/mcp/sessionMcpHandle'; +import '#/app/mcpConfig/configSection'; +export { + MCP_SECTION, + McpSectionSchema, + type McpSection, +} from '#/app/mcpConfig/configSection'; +export * from '#/app/mcpConfig/oauthStore'; +export * from '#/workspace/workspaceMcpConfig/workspaceMcpConfig'; +export * from '#/workspace/workspaceMcpConfig/workspaceMcpConfigService'; +export * from '#/workspace/workspaceMcp/workspaceMcp'; +export * from '#/workspace/workspaceMcp/workspaceMcpService'; export * from '#/session/subagent/subagent'; export * from '#/session/subagent/subagentService'; import '#/session/subagent/flag'; @@ -327,8 +354,14 @@ export * from '#/session/subagent/mirrorAgentRun'; import '#/session/subagent/configSection'; export * from '#/agent/tools/agent/agent'; import '#/agent/tools/agent/agentTool'; -export * from '#/app/sessionLifecycle/sessionLifecycle'; -export * from '#/app/sessionLifecycle/sessionLifecycleService'; +export * from '#/app/workspaceLifecycle/workspaceLifecycle'; +export * from '#/app/workspaceLifecycle/workspaceLifecycleService'; +export * from '#/app/workspaceLifecycle/sessionLookup'; +export * from '#/workspace/workspaceContext/workspaceContext'; +export * from '#/workspace/workspaceHandler/workspaceHandler'; +export * from '#/workspace/workspaceHandler/workspaceHandlerService'; +export * from '#/workspace/workspaceHandler/addressing'; +export * from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; export * from '#/session/externalHooks/externalHooks'; export * from '#/session/externalHooks/externalHooksService'; import '#/app/sessionExport/errors'; @@ -356,8 +389,6 @@ export * from '#/app/gateway/gatewayService'; export * from '#/session/workspaceContext/workspaceContext'; export * from '#/session/workspaceContext/workspaceContextService'; -export * from '#/session/workspaceCommand/workspaceCommand'; -export * from '#/session/workspaceCommand/workspaceCommandService'; export * from '#/app/projectLocalConfig/projectLocalConfig'; export * from '#/app/workspace/workspace'; export * from '#/app/workspace/workspaceService'; @@ -373,14 +404,23 @@ export * from '#/app/bashParser/bashParser'; import '#/app/bashParser/bashParserService'; export * from '#/session/process/processRunner'; export * from '#/session/process/processRunnerService'; -export * from '#/session/sessionFs/errors'; -export * from '#/session/sessionFs/fs'; -export * from '#/session/sessionFs/fsService'; -export * from '#/session/sessionFs/fsWatch'; -export * from '#/session/sessionFs/fsWatchService'; -export * from '#/session/sessionFs/gitContext'; -export * from '#/session/sessionFs/rgLocator'; -export * from '#/session/sessionFs/runRg'; +export * from '#/workspace/workspaceProcess/workspaceProcessRunnerService'; +export * from '#/workspace/workspaceFs/errors'; +export * from '#/workspace/workspaceFs/fs'; +export * from '#/workspace/workspaceFs/fsService'; +export * from '#/workspace/workspaceFs/fsWatch'; +export * from '#/workspace/workspaceFs/fsWatchService'; +export * from '#/session/agentLifecycle/profile/gitContext'; +export * from '#/workspace/workspaceFs/rgLocator'; +export * from '#/workspace/workspaceFs/runRg'; +export * from '#/workspace/workspaceGit/workspaceGit'; +export * from '#/workspace/workspaceGit/workspaceGitService'; +export * from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; +export * from '#/session/sessionToolPolicyGate/sessionToolPolicyGateService'; +export * from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; +export * from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; +export * from '#/workspace/workspaceTrust/workspaceTrust'; +export * from '#/workspace/workspaceTrust/workspaceTrustService'; export * from '#/app/hostFolderBrowser/hostFolderBrowser'; export * from '#/app/hostFolderBrowser/hostFolderBrowserService'; export * from '#/persistence/interface/storage'; @@ -494,7 +534,7 @@ export * from '#/agent/loop/loopContinuationService'; export * from '#/agent/mcp/mcp'; export * from '#/agent/mcp/mcpService'; export * from '#/agent/mcp/mcpDiscoveryOps'; -export * from '#/agent/mcp/config-schema'; +export * from '#/mcpCore/config-schema'; export * from '#/agent/media/mediaTools'; export * from '#/agent/media/mediaToolsRegistrar'; export * from '#/agent/media/registerMediaTools'; diff --git a/packages/agent-core-v2/src/agent/mcp/client-http.ts b/packages/agent-core-v2/src/mcpCore/client-http.ts similarity index 98% rename from packages/agent-core-v2/src/agent/mcp/client-http.ts rename to packages/agent-core-v2/src/mcpCore/client-http.ts index bdfa0a40f7..342b40ad93 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-http.ts +++ b/packages/agent-core-v2/src/mcpCore/client-http.ts @@ -1,3 +1,7 @@ +/** + * `mcpCore` domain (L2) — Streamable HTTP transport MCP client. + */ + import type { McpServerHttpConfig } from './config-schema'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; diff --git a/packages/agent-core-v2/src/agent/mcp/client-remote.ts b/packages/agent-core-v2/src/mcpCore/client-remote.ts similarity index 91% rename from packages/agent-core-v2/src/agent/mcp/client-remote.ts rename to packages/agent-core-v2/src/mcpCore/client-remote.ts index 8ca292abd6..eef8f4e01a 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-remote.ts +++ b/packages/agent-core-v2/src/mcpCore/client-remote.ts @@ -1,3 +1,7 @@ +/** + * `mcpCore` domain (L2) — remote (HTTP/SSE) server config guards and request-header builders. + */ + import type { McpRemoteServerConfig, McpServerConfig } from './config-schema'; import { ErrorCodes, Error2 } from '#/errors'; diff --git a/packages/agent-core-v2/src/agent/mcp/client-shared.ts b/packages/agent-core-v2/src/mcpCore/client-shared.ts similarity index 97% rename from packages/agent-core-v2/src/agent/mcp/client-shared.ts rename to packages/agent-core-v2/src/mcpCore/client-shared.ts index ce3e1db662..c8a6687e52 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-shared.ts +++ b/packages/agent-core-v2/src/mcpCore/client-shared.ts @@ -1,3 +1,7 @@ +/** + * `mcpCore` domain (L2) — shared MCP client helpers — request options, liveness probes, result conversion. + */ + import { getCoreVersion } from '#/_base/version'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; diff --git a/packages/agent-core-v2/src/agent/mcp/client-sse.ts b/packages/agent-core-v2/src/mcpCore/client-sse.ts similarity index 98% rename from packages/agent-core-v2/src/agent/mcp/client-sse.ts rename to packages/agent-core-v2/src/mcpCore/client-sse.ts index 5da36c0a79..700d9c518a 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-sse.ts +++ b/packages/agent-core-v2/src/mcpCore/client-sse.ts @@ -1,3 +1,7 @@ +/** + * `mcpCore` domain (L2) — SSE transport MCP client. + */ + import type { McpServerSseConfig } from './config-schema'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; diff --git a/packages/agent-core-v2/src/agent/mcp/client-stdio.ts b/packages/agent-core-v2/src/mcpCore/client-stdio.ts similarity index 98% rename from packages/agent-core-v2/src/agent/mcp/client-stdio.ts rename to packages/agent-core-v2/src/mcpCore/client-stdio.ts index d5d5ebab49..c3e84229eb 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-stdio.ts +++ b/packages/agent-core-v2/src/mcpCore/client-stdio.ts @@ -1,3 +1,7 @@ +/** + * `mcpCore` domain (L2) — stdio transport MCP client. + */ + import { ErrorCodes, Error2 } from '#/errors'; import type { McpServerStdioConfig } from './config-schema'; import { proxyEnvForChild, reconcileChildNoProxy } from '#/_base/utils/proxy'; diff --git a/packages/agent-core-v2/src/agent/mcp/config-schema.ts b/packages/agent-core-v2/src/mcpCore/config-schema.ts similarity index 97% rename from packages/agent-core-v2/src/agent/mcp/config-schema.ts rename to packages/agent-core-v2/src/mcpCore/config-schema.ts index db31f929f2..e5026d509c 100644 --- a/packages/agent-core-v2/src/agent/mcp/config-schema.ts +++ b/packages/agent-core-v2/src/mcpCore/config-schema.ts @@ -1,5 +1,5 @@ /** - * `mcp` domain (L5) — MCP server configuration schemas. + * `mcpCore` domain (L2) — MCP server configuration schemas. * * Owns the `McpServerConfig` schema and its transport variants. These describe * the shape of MCP server entries as they appear in configuration (whether in diff --git a/packages/agent-core-v2/src/agent/mcp/connection-manager.ts b/packages/agent-core-v2/src/mcpCore/connection-manager.ts similarity index 96% rename from packages/agent-core-v2/src/agent/mcp/connection-manager.ts rename to packages/agent-core-v2/src/mcpCore/connection-manager.ts index 5aaa4ec89b..aa5dbc89cc 100644 --- a/packages/agent-core-v2/src/agent/mcp/connection-manager.ts +++ b/packages/agent-core-v2/src/mcpCore/connection-manager.ts @@ -1,13 +1,14 @@ /** - * `mcp` domain (L5) — `McpConnectionManager`, the per-session MCP server - * connection orchestrator. + * `mcpCore` domain (L2) — `McpConnectionManager`, the workspace-shared MCP + * server connection orchestrator. * * Owns the configured MCP servers and their runtime clients: connects * (stdio / SSE / HTTP), discovers and registers tools, attaches the OAuth - * provider through `mcp/oauth` when tokens are present, flips failing - * servers into `needs-auth` on 401, and reconnects after authentication. - * Applies per-server settings over session defaults and emits status changes - * to subscribers. Constructed by `SessionMcpService`. + * provider when tokens are present, flips failing servers into `needs-auth` + * on 401, and reconnects after authentication. Applies per-server settings + * over the configured defaults and emits status changes to subscribers. + * Constructed by `WorkspaceMcpService` and shared by every session of the + * workspace handler. */ import { ErrorCodes, Error2 } from '#/errors'; @@ -21,7 +22,7 @@ import { isRemoteMcpConfig } from './client-remote'; import { SseMcpClient } from './client-sse'; import type { UnexpectedCloseReason } from './client-shared'; import { StdioMcpClient } from './client-stdio'; -import type { McpOAuthService } from '#/agent/mcp/oauth/service'; +import type { McpOAuthService } from '#/mcpCore/oauth/service'; import { assertMcpInputSchema, type MCPClient, type MCPToolDefinition } from './types'; export type McpServerStatus = 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth'; @@ -121,6 +122,11 @@ export class McpConnectionManager { return entry !== undefined ? toPublicEntry(entry) : undefined; } + /** The config an entry was last connected with, for idempotence checks. */ + configOf(name: string): McpServerConfig | undefined { + return this.entries.get(name)?.config; + } + resolved( name: string, ): diff --git a/packages/agent-core-v2/src/agent/mcp/errors.ts b/packages/agent-core-v2/src/mcpCore/errors.ts similarity index 90% rename from packages/agent-core-v2/src/agent/mcp/errors.ts rename to packages/agent-core-v2/src/mcpCore/errors.ts index f568f57a3f..d5b20d1844 100644 --- a/packages/agent-core-v2/src/agent/mcp/errors.ts +++ b/packages/agent-core-v2/src/mcpCore/errors.ts @@ -1,5 +1,5 @@ /** - * `mcp` domain error codes. + * `mcpCore` domain (L2) — error codes. */ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; diff --git a/packages/agent-core-v2/src/agent/mcp/oauth/callback-server.ts b/packages/agent-core-v2/src/mcpCore/oauth/callback-server.ts similarity index 98% rename from packages/agent-core-v2/src/agent/mcp/oauth/callback-server.ts rename to packages/agent-core-v2/src/mcpCore/oauth/callback-server.ts index 070b1546e1..cde9faf008 100644 --- a/packages/agent-core-v2/src/agent/mcp/oauth/callback-server.ts +++ b/packages/agent-core-v2/src/mcpCore/oauth/callback-server.ts @@ -1,5 +1,5 @@ /** - * One-shot localhost OAuth callback listener. + * `mcpCore` domain (L2) — one-shot localhost OAuth callback listener. * * `startCallbackServer()` binds 127.0.0.1 on a random free port and returns a * handle exposing the resulting `redirect_uri` and an awaitable diff --git a/packages/agent-core-v2/src/agent/mcp/oauth/provider.ts b/packages/agent-core-v2/src/mcpCore/oauth/provider.ts similarity index 98% rename from packages/agent-core-v2/src/agent/mcp/oauth/provider.ts rename to packages/agent-core-v2/src/mcpCore/oauth/provider.ts index c92606280d..23c87e50f1 100644 --- a/packages/agent-core-v2/src/agent/mcp/oauth/provider.ts +++ b/packages/agent-core-v2/src/mcpCore/oauth/provider.ts @@ -1,5 +1,5 @@ /** - * `mcp` domain (L5) — `McpOAuthClientProvider`, the `OAuthClientProvider` + * `mcpCore` domain (L2) — `McpOAuthClientProvider`, the `OAuthClientProvider` * backed by the MCP OAuth credential store (`McpOAuthStore` over * `IAtomicDocumentStore`). * diff --git a/packages/agent-core-v2/src/agent/mcp/oauth/service.ts b/packages/agent-core-v2/src/mcpCore/oauth/service.ts similarity index 98% rename from packages/agent-core-v2/src/agent/mcp/oauth/service.ts rename to packages/agent-core-v2/src/mcpCore/oauth/service.ts index 1ee5e3c4d3..39b11ee60e 100644 --- a/packages/agent-core-v2/src/agent/mcp/oauth/service.ts +++ b/packages/agent-core-v2/src/mcpCore/oauth/service.ts @@ -1,5 +1,5 @@ /** - * `mcp` domain (L5) — `McpOAuthService`, the per-process OAuth orchestrator + * `mcpCore` domain (L2) — `McpOAuthService`, the per-process OAuth orchestrator * for MCP HTTP servers. * * Owns one {@link McpOAuthClientProvider} per server/resource and mediates the diff --git a/packages/agent-core-v2/src/mcpCore/oauth/store.ts b/packages/agent-core-v2/src/mcpCore/oauth/store.ts new file mode 100644 index 0000000000..d2c9b56e42 --- /dev/null +++ b/packages/agent-core-v2/src/mcpCore/oauth/store.ts @@ -0,0 +1,47 @@ +/** + * `mcpCore` domain (L2) — MCP OAuth credential store port and key addressing. + * + * Defines the {@link McpOAuthStore} port the OAuth provider/service read and + * write credentials through, plus the store-key scheme: one logical record + * per `(serverName, serverUrl)` identity, addressed by {@link mcpOAuthStoreKey} + * (sanitized name prefix + a digest of name and canonicalized URL). The + * persistence implementation lives in the `mcpConfig` wrapper domain + * (`IMcpOAuthStore`, backed by `IAtomicDocumentStore` under the + * `credentials/mcp` scope); this file holds no IO. + */ + +import { createHash } from 'node:crypto'; + +import { basename } from 'pathe'; + +export function sanitizeStoreKey(name: string): string { + const safe = basename(name).replaceAll(/[^a-zA-Z0-9_-]/g, '_').replaceAll(/_+/g, '_'); + if (safe.length === 0 || safe.startsWith('.')) { + throw new Error(`Invalid MCP OAuth store key: "${name}"`); + } + return safe; +} + +export function canonicalMcpOAuthResource(serverUrl: string | URL): string { + const url = new URL(serverUrl); + url.hash = ''; + return url.toString(); +} + +export function mcpOAuthStoreKey(serverName: string, serverUrl: string | URL): string { + const safeName = sanitizeStoreKey(serverName); + const resource = canonicalMcpOAuthResource(serverUrl); + const digest = createHash('sha256') + .update(serverName) + .update('\0') + .update(resource) + .digest('hex') + .slice(0, 24); + return `${safeName}-${digest}`; +} + +export interface McpOAuthStore { + read(key: string): Promise; + write(key: string, data: unknown): Promise; + remove(key: string): Promise; +} diff --git a/packages/agent-core-v2/src/agent/mcp/tool-naming.ts b/packages/agent-core-v2/src/mcpCore/tool-naming.ts similarity index 90% rename from packages/agent-core-v2/src/agent/mcp/tool-naming.ts rename to packages/agent-core-v2/src/mcpCore/tool-naming.ts index 111fa9cbdb..c4053febd4 100644 --- a/packages/agent-core-v2/src/agent/mcp/tool-naming.ts +++ b/packages/agent-core-v2/src/mcpCore/tool-naming.ts @@ -1,7 +1,10 @@ +/** + * `mcpCore` domain (L2) — qualified `mcp__server__tool` name sanitizing and hashing. + */ + const MCP_NAME_PREFIX = 'mcp__'; const MCP_NAME_SEPARATOR = '__'; -export { isMcpToolName } from '#/tool/toolContract'; const MAX_QUALIFIED_LENGTH = 64; export function sanitizeMcpNamePart(part: string): string { diff --git a/packages/agent-core-v2/src/agent/mcp/types.ts b/packages/agent-core-v2/src/mcpCore/types.ts similarity index 94% rename from packages/agent-core-v2/src/agent/mcp/types.ts rename to packages/agent-core-v2/src/mcpCore/types.ts index ff783c6b6a..2527963d45 100644 --- a/packages/agent-core-v2/src/agent/mcp/types.ts +++ b/packages/agent-core-v2/src/mcpCore/types.ts @@ -1,5 +1,6 @@ /** - * MCP protocol types and the minimal client contract `ToolManager` consumes. + * `mcpCore` domain (L2) — MCP protocol types and the minimal client contract + * `ToolManager` consumes. * * Lives in its own file (rather than `toolset.ts`) because the agent-side * tool-runtime layer is `ExecutableTool`, not the legacy `Toolset` interface. diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts index 5969455396..2a8127376f 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts @@ -7,7 +7,7 @@ * predicate. Mode-specific argument building and output parsing stay in the * tools themselves. * - * Ported from `session/sessionFs/runRg` onto the os tools: the subprocess now + * Ported from `workspace/workspaceFs/runRg` onto the os tools: the subprocess now * goes through `IHostProcessService.spawn` instead of the session * `ISessionProcessRunner.exec`. */ diff --git a/packages/agent-core-v2/src/os/interface/hostEnvironment.ts b/packages/agent-core-v2/src/os/interface/hostEnvironment.ts index c9856592b5..ec024f33b3 100644 --- a/packages/agent-core-v2/src/os/interface/hostEnvironment.ts +++ b/packages/agent-core-v2/src/os/interface/hostEnvironment.ts @@ -11,7 +11,8 @@ * * Async initialization: probing (`ready`) discovers the shell path — on * Windows this may run `git.exe --exec-path`. The composition root - * (`sessionLifecycle`) `await`s `ready` before creating any Session scope, so + * (`workspaceLifecycle` / `workspaceHandler`) `await`s `ready` before creating + * any Session scope, so * every Session/Agent-scope consumer reads the sync fields safely. * * App-scoped — one shared instance for the whole process. diff --git a/packages/agent-core-v2/src/os/interface/hostFsWatch.ts b/packages/agent-core-v2/src/os/interface/hostFsWatch.ts index 890ce98c61..02830826e6 100644 --- a/packages/agent-core-v2/src/os/interface/hostFsWatch.ts +++ b/packages/agent-core-v2/src/os/interface/hostFsWatch.ts @@ -4,7 +4,7 @@ * Defines the `IHostFsWatchService`, a thin primitive over the host OS file * watcher. It reports raw create/modify/delete events under an absolute path * and knows nothing about sessions, connections, workspaces or wire frames. - * App-scoped — one shared instance. Higher layers (e.g. `sessionFsWatch`) + * App-scoped — one shared instance. Higher layers (e.g. `workspaceFsWatch`) * subscribe, confine events to a workspace, debounce/coalesce and re-expose * them as domain events. */ diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts index 571f6ba295..45556addaa 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts @@ -5,9 +5,10 @@ * registry (`get` / `list` / `remove`), and the lifecycle events — plus the * session-wide fan-outs only the live registry can reach * (`broadcastPermissionMode`). Driving turns on an agent — and the hook/event - * surface those runs announce — lives in the `subagent` domain; session-level - * MCP lives in the `sessionMcp` domain. Session-scoped — one instance per - * session. + * surface those runs announce — lives in the `subagent` domain; the shared MCP + * connection manager lives in the Workspace-scope `workspaceMcp` domain and + * reaches agents through the seeded `ISessionMcpHandle`. Session-scoped — one + * instance per session. * * Invariants: * - The registry is flat: agents have no nesting. There is no parent/child or diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index df5e66a165..5d904289b7 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -16,14 +16,15 @@ * with the conventional `MAIN_AGENT_ID`, and `fork` requires its source to * exist. Caller-facing orchestration (record mirroring, hooks, telemetry, * prompt prefixes) lives with the callers — driving turns on an agent is the - * `subagent` domain (`ISessionSubagentService`); the session's shared MCP - * subsystem is the `sessionMcp` domain (`ISessionMcpService`), which this - * service awaits during creation. + * `subagent` domain (`ISessionSubagentService`); the workspace's shared MCP + * manager arrives through the seeded `ISessionMcpHandle`, whose initial + * connect this service awaits during creation. */ import { IInstantiationService } from '#/_base/di/instantiation'; import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; import { Emitter } from '#/_base/event'; +import { join } from 'pathe'; import { createScopedChildHandle, type IAgentScopeHandle, @@ -40,7 +41,7 @@ import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { IAgentTaskService } from '#/agent/task/task'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; -import { ISessionMcpService } from '#/session/mcp/sessionMcp'; +import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentProfileService } from '#/agent/profile/profile'; @@ -85,7 +86,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle @ISessionMetadata private readonly sessionMetadata: ISessionMetadata, @IBootstrapService private readonly bootstrap: IBootstrapService, @IConfigService private readonly config: IConfigService, - @ISessionMcpService private readonly sessionMcp: ISessionMcpService, + @ISessionMcpHandle private readonly mcpHandle: ISessionMcpHandle, @ISessionInteractionService private readonly interaction: ISessionInteractionService, @ITelemetryService private readonly telemetry: ITelemetryService, ) { @@ -151,25 +152,20 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle } private async doCreate(agentId: string, opts: CreateAgentOptions): Promise { - const mcpReady = this.sessionMcp.ensureMcpReady(); - const agentHomedir = this.bootstrap.agentHomedir( - this.ctx.workspaceId, - this.ctx.sessionId, - agentId, - ); - const agentScope = this.bootstrap.agentScope( - this.ctx.workspaceId, - this.ctx.sessionId, - agentId, - ); + const mcpReady = this.mcpHandle.ready; + // Agent persistence addressing derives from the session's scope string + // (itself handler-bound): `{sessionScope}/agents/{agentId}` — identical + // to the layout the pre-Workspace engine wrote. + const agentScope = this.ctx.scope(`agents/${agentId}`); + const agentHomedir = join(this.bootstrap.homeDir, agentScope); const handle = createScopedChildHandle( this.instantiation, LifecycleScope.Agent, agentId, // Seed identity facts and the telemetry view. Every other agent-scope // service either derives its configuration from `IAgentScopeContext` - // (wire, blob) or resolves it through the scope tree (the - // session's shared MCP manager via `ISessionMcpService`). + // (wire, blob) or resolves it through the scope tree (the workspace's + // shared MCP manager via the seeded `ISessionMcpHandle`). { extra: [ [IAgentScopeContext, makeAgentScopeContext({ agentId, agentScope })], @@ -244,13 +240,11 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle profile: override.profile, model: override.model ?? sourceData.modelAlias, thinking: override?.thinking ?? sourceData.thinkingLevel, - cwd: override?.cwd ?? sourceData.cwd, }); } else { childProfile.applyBindingSnapshot(sourceData); if (override?.model !== undefined) await childProfile.setModel(override.model); if (override?.thinking !== undefined) childProfile.setThinking(override.thinking); - if (override?.cwd !== undefined) childProfile.update({ cwd: override.cwd }); } const sourceMessages = source.accessor.get(IAgentContextMemoryService)?.get(); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts b/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts index 4d81f6f56e..17e4c55672 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts @@ -12,7 +12,7 @@ * and returns an already-created main agent as-is — so concurrent * bootstrappers always receive the same, fully-bootstrapped handle (activity * lane `idle`). Session services activated when their scope is created (cron, - * external hooks) are materialized by `sessionLifecycle.materializeSession`; + * external hooks) are materialized by `workspaceHandler.materializeSession`; * the default permission posture is * applied in `bindBootstrap`. * diff --git a/packages/agent-core-v2/src/session/sessionFs/gitContext.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/gitContext.ts similarity index 100% rename from packages/agent-core-v2/src/session/sessionFs/gitContext.ts rename to packages/agent-core-v2/src/session/agentLifecycle/profile/gitContext.ts diff --git a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts index 43d5befca0..c2287149bf 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts @@ -12,7 +12,7 @@ * list before `AgentProfileCatalogService` constructs. */ -import { collectGitContext } from '#/session/sessionFs/gitContext'; +import { collectGitContext } from './gitContext'; import { registerAgentProfile } from '#/app/agentProfileCatalog/contribution'; import { renderSystemPrompt, diff --git a/packages/agent-core-v2/src/session/errors.ts b/packages/agent-core-v2/src/session/errors.ts index 5413a89c54..d39a72fc1d 100644 --- a/packages/agent-core-v2/src/session/errors.ts +++ b/packages/agent-core-v2/src/session/errors.ts @@ -1,6 +1,6 @@ /** * `session` domain error codes — shared across the session layer - * (`sessionLifecycle` / `sessionLegacy` / `messageLegacy`). + * (`workspaceHandler` / `sessionLegacy` / `messageLegacy`). */ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; diff --git a/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts index 4acbc33b33..f31af7adc0 100644 --- a/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts +++ b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts @@ -2,8 +2,10 @@ * `externalHooks` domain (L6) — Session-scope adapter for external hook * commands. * - * Registers with `sessionLifecycle` hook slots to run `SessionStart` and - * `SessionEnd` external commands for the current `sessionContext`, and + * Registers with the per-session `sessionLifecycleHooks` slots (seeded by + * the Workspace-scope `workspaceHandler`, which runs them around + * create/close) to run `SessionStart` and `SessionEnd` external commands + * for the current `sessionContext`, and * observes the requester-side agent-run hook slot (`onWillStartAgentTask`) and * stop event (`onDidStopAgentTask`) hosted on the `subagent` domain's * `ISessionSubagentService` to translate them into the `SubagentStart` / @@ -19,12 +21,14 @@ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IExternalHooksRunnerService } from '#/app/externalHooksRunner/externalHooksRunner'; +import type { Hooks } from '#/hooks'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { - ISessionLifecycleService, + ISessionLifecycleHooks, type SessionCloseReason, type SessionCreateSource, -} from '#/app/sessionLifecycle/sessionLifecycle'; -import { ISessionContext } from '#/session/sessionContext/sessionContext'; + type SessionLifecycleHookSlots, +} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; import { type AgentTaskStartHookContext, type AgentTaskStopHookContext, @@ -43,24 +47,22 @@ export class SessionExternalHooksService constructor( @ISessionContext private readonly context: ISessionContext, - @ISessionLifecycleService lifecycle: ISessionLifecycleService, + @ISessionLifecycleHooks lifecycleHooks: Hooks, @ISessionSubagentService subagents: ISessionSubagentService, @IExternalHooksRunnerService private readonly runner: IExternalHooksRunnerService, ) { super(); this._register( - lifecycle.hooks.onDidCreateSession.register('externalHooks', async (event, next) => { - if (event.sessionId === this.context.sessionId && event.source !== 'fork') { + lifecycleHooks.onDidCreateSession.register('externalHooks', async (event, next) => { + if (event.source !== 'fork') { await this.triggerSessionStart(event.source); } await next(); }), ); this._register( - lifecycle.hooks.onWillCloseSession.register('externalHooks', async (event, next) => { - if (event.sessionId === this.context.sessionId) { - await this.triggerSessionEnd(event.reason); - } + lifecycleHooks.onWillCloseSession.register('externalHooks', async (event, next) => { + await this.triggerSessionEnd(event.reason); await next(); }), ); diff --git a/packages/agent-core-v2/src/session/mcp/sessionMcp.ts b/packages/agent-core-v2/src/session/mcp/sessionMcp.ts deleted file mode 100644 index b0ec61cbf4..0000000000 --- a/packages/agent-core-v2/src/session/mcp/sessionMcp.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * `mcp` domain (L5) — session-scoped MCP subsystem contract. - * - * Defines `ISessionMcpService` for connecting the session's servers and - * exposing their shared connection manager. Bound at Session scope. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { McpConnectionManager } from '#/agent/mcp/connection-manager'; -import type { McpServerConfig } from '#/agent/mcp/config-schema'; - -export interface ISessionMcpService { - readonly _serviceBrand: undefined; - - /** - * Resolve the session/plugin MCP config and wait for the initial connection - * attempt to finish. The initial connect waits for `config.ready` so global - * timeout preferences apply deterministically. Per-server failures are - * reflected in MCP status entries rather than rejecting this promise; an - * outright failure is logged. `callerServers` (caller-supplied servers from - * session create) merge into the initial connect between file config and - * plugin servers; the first call wins — the initial load is cached and - * later calls ignore the arg. - */ - ensureMcpReady(callerServers?: Readonly>): Promise; - - /** - * The session's shared connection manager. Built lazily on first call and - * always available, independent of the initial connect's progress; global - * timeout defaults are read from `config` at each (re)connect. - */ - connectionManager(): McpConnectionManager; -} - -export const ISessionMcpService: ServiceIdentifier = - createDecorator('sessionMcpService'); diff --git a/packages/agent-core-v2/src/session/mcp/sessionMcpHandle.ts b/packages/agent-core-v2/src/session/mcp/sessionMcpHandle.ts new file mode 100644 index 0000000000..1f40ac1a34 --- /dev/null +++ b/packages/agent-core-v2/src/session/mcp/sessionMcpHandle.ts @@ -0,0 +1,31 @@ +/** + * `mcp` domain (L5) — seeded MCP shared-handle contract. + * + * Defines `ISessionMcpHandle`, the pure-data injection contract the + * Workspace-scope `workspaceMcp` service hands to every Session scope it + * creates: the handler's one shared `McpConnectionManager` (all sessions of + * the workspace connect through the same manager — no per-session + * connections exist) plus the initial-connect readiness promise. The contract + * carries no IO of its own — connecting, reloading and watching MCP config + * files live on the workspace side. Seeded into the Session scope by + * `workspaceHandler` when the session is materialized; the Agent-scope `mcp` + * mirror resolves it upward through the scope tree. Session-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import type { McpConnectionManager } from '#/mcpCore/connection-manager'; + +export interface ISessionMcpHandle { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly connectionManager: McpConnectionManager; +} + +export const ISessionMcpHandle: ServiceIdentifier = + createDecorator('sessionMcpHandle'); + +export function sessionMcpHandleSeed(handle: ISessionMcpHandle): ScopeSeed { + return [[ISessionMcpHandle as ServiceIdentifier, handle]]; +} diff --git a/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts b/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts deleted file mode 100644 index 09bb137e59..0000000000 --- a/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * `mcp` domain (L5) — `ISessionMcpService` implementation. - * - * Owns the session-wide `McpConnectionManager` (built lazily, shared by every - * agent), resolves the session + caller-supplied + plugin MCP config, drives - * the initial connect (`ensureMcpReady`, cached so session creation and first - * agent creation can both await it), and reports connection telemetry. The - * initial connect waits for `config.ready` so global timeout preferences are - * deterministic; the manager reads them again at each (re)connect. An - * outright initial-load failure is logged (per-server failures are status - * entries). Bound at Session scope. - */ - -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { McpConnectionManager } from '#/agent/mcp/connection-manager'; -import type { McpServerConfig } from '#/agent/mcp/config-schema'; -import { MCP_SECTION, type McpSection } from '#/agent/mcp/configSection'; -import { McpOAuthService } from '#/agent/mcp/oauth/service'; -import { createMcpOAuthStore } from '#/agent/mcp/oauth/store'; -import { mergeCallerMcpServers, resolveSessionMcpConfig } from '#/agent/mcp/session-config'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IConfigService } from '#/app/config/config'; -import { IPluginService } from '#/app/plugin/plugin'; -import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { ILogService } from '#/_base/log/log'; -import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; - -import { ISessionMcpService } from './sessionMcp'; - -export class SessionMcpService extends Disposable implements ISessionMcpService { - declare readonly _serviceBrand: undefined; - - private mcpManager: McpConnectionManager | undefined; - private mcpInitialLoad: Promise | undefined; - - constructor( - @IBootstrapService private readonly bootstrap: IBootstrapService, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, - @IPluginService private readonly plugins: IPluginService, - @IAtomicDocumentStore private readonly atomicDocs: IAtomicDocumentStore, - @ILogService private readonly log: ILogService, - @ITelemetryService private readonly telemetry: ITelemetryService, - @IConfigService private readonly config: IConfigService, - ) { - super(); - } - - ensureMcpReady(callerServers?: Readonly>): Promise { - if (this.mcpInitialLoad !== undefined) return this.mcpInitialLoad; - const manager = this.connectionManager(); - const initialLoad = this.initializeMcp(manager, callerServers).catch((error: unknown) => { - this.log.error('mcp initial load failed', { error }); - }); - this.mcpInitialLoad = initialLoad; - return initialLoad; - } - - connectionManager(): McpConnectionManager { - if (this.mcpManager !== undefined) return this.mcpManager; - const oauthService = new McpOAuthService({ - store: createMcpOAuthStore(this.atomicDocs), - }); - const manager = new McpConnectionManager({ - log: this.log, - oauthService, - stdioCwd: this.workspace.workDir, - resolveDefaultTimeouts: () => { - const section = this.config.get(MCP_SECTION); - return { - startupTimeoutMs: section?.startupTimeoutMs, - toolTimeoutMs: section?.toolTimeoutMs, - }; - }, - }); - this.mcpManager = manager; - this._register({ dispose: () => void manager.shutdown() }); - return manager; - } - - private async initializeMcp( - manager: McpConnectionManager, - callerServers?: Readonly>, - ): Promise { - await this.config.ready; - await this.connectMcpServers(manager, callerServers); - } - - private async connectMcpServers( - manager: McpConnectionManager, - callerServers?: Readonly>, - ): Promise { - const [base, pluginServers] = await Promise.all([ - resolveSessionMcpConfig({ cwd: this.workspace.workDir, homeDir: this.bootstrap.homeDir }), - this.plugins.enabledMcpServers(), - ]); - const withCaller = mergeCallerMcpServers(base, callerServers); - const servers = { ...withCaller?.servers, ...pluginServers }; - if (Object.keys(servers).length === 0) return; - await manager.connectAll(servers); - this.trackMcpInitialLoad(manager); - } - - private trackMcpInitialLoad(manager: McpConnectionManager): void { - const entries = manager.list().filter((entry) => entry.status !== 'disabled'); - const totalCount = entries.length; - if (totalCount === 0) return; - - const connectedCount = entries.filter((entry) => entry.status === 'connected').length; - if (connectedCount > 0) { - this.telemetry.track2('mcp_connected', { - server_count: connectedCount, - total_count: totalCount, - }); - } - - const failedCount = entries.filter((entry) => entry.status === 'failed').length; - if (failedCount > 0) { - this.telemetry.track2('mcp_failed', { - failed_count: failedCount, - total_count: totalCount, - }); - } - } -} - -registerScopedService( - LifecycleScope.Session, - ISessionMcpService, - SessionMcpService, - ScopeActivation.OnScopeCreated, - 'sessionMcp', -); diff --git a/packages/agent-core-v2/src/session/process/processRunnerService.ts b/packages/agent-core-v2/src/session/process/processRunnerService.ts index 793b8fdff9..cc2aaafa39 100644 --- a/packages/agent-core-v2/src/session/process/processRunnerService.ts +++ b/packages/agent-core-v2/src/session/process/processRunnerService.ts @@ -1,13 +1,18 @@ /** - * `process` domain (L2) — `ISessionProcessRunner` implementation. + * `process` domain (L2) — the default `ISessionProcessRunner` implementation. * * Resolves the default cwd from the session's `ISessionContext` and delegates * the actual host spawn to the App-scope `IHostProcessService`. A per-call * `options.cwd` wins over the seeded cwd. A per-call `options.env` is overlaid * onto `process.env` and passed as the child's complete env bag (the host * replaces the child env with what we pass); when `options.env` is omitted we - * pass `undefined` so the child inherits `process.env` verbatim. Bound at - * Session scope. + * pass `undefined` so the child inherits `process.env` verbatim. + * + * This Session-scope registration is the DEFAULT for scopes built without a + * workspace handler (test hosts, harness agents). Real sessions get the + * handler-shared Workspace-scope runner (`workspaceProcess`) as a scope seed + * from `workspaceHandler`, which shadows this registration — same pattern as + * the other workspace-capability injection contracts. */ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/agentProfileCatalogSeed.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/agentProfileCatalogSeed.ts new file mode 100644 index 0000000000..5e8db0716e --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/agentProfileCatalogSeed.ts @@ -0,0 +1,32 @@ +/** + * `sessionAgentProfileCatalog` domain (L3) — seeded workspace-key contract. + * + * Defines `ISessionAgentProfileCatalogSeed`, the pure-data injection contract + * the Workspace handler hands to every Session scope it creates: ONLY the + * handler's `workspaceId`. The Session-scope `ISessionAgentProfileCatalog` + * uses it to pick this workspace's tagged contributions out of the App-scope + * `IAgentProfileRegistry` (global entries plus the ones registered with this + * key) — the catalog reads the registry directly, so no per-workspace merged + * view is seeded anymore. The key travels as a seed (rather than being + * recomputed from the session's workDir) because the handler's id may be + * folded from an alias spelling of the root. Seeded into the Session scope by + * `workspaceHandler` when the session is materialized. Session-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; + +export interface ISessionAgentProfileCatalogSeed { + readonly _serviceBrand: undefined; + + readonly workspaceKey: string; +} + +export const ISessionAgentProfileCatalogSeed: ServiceIdentifier = + createDecorator('sessionAgentProfileCatalogSeed'); + +export function sessionAgentProfileCatalogSeed( + seed: ISessionAgentProfileCatalogSeed, +): ScopeSeed { + return [[ISessionAgentProfileCatalogSeed as ServiceIdentifier, seed]]; +} diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/explicitFileAgentSource.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/explicitFileAgentSource.ts deleted file mode 100644 index 138b06c785..0000000000 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/explicitFileAgentSource.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * `sessionAgentProfileCatalog` domain (L3) — explicit `IAgentProfileSource` - * producer. - * - * Loads runtime-selected agent files through `hostFs`, resolving paths through - * `workspace` and `bootstrap`. `${base_prompt}` is backed by the user source's - * effective default profile. Bound at Session scope. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; -import { IAgentCatalogRuntimeOptions } from '#/app/agentFileCatalog/agentCatalogRuntimeOptions'; -import { parseAgentFileText } from '#/app/agentFileCatalog/agentFile'; -import { agentProfileFromFile } from '#/app/agentFileCatalog/agentProfileFromFile'; -import { - AGENT_PROFILE_SOURCE_PRIORITY, - type AgentProfileContribution, - type IAgentProfileSource, -} from '#/app/agentFileCatalog/agentProfileSource'; -import { resolveAgentPath } from '#/app/agentFileCatalog/paths'; -import { IUserFileAgentSource } from '#/app/agentFileCatalog/userFileAgentSource'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; - -export interface IExplicitFileAgentSource extends IAgentProfileSource { - readonly _serviceBrand: undefined; -} - -export const IExplicitFileAgentSource: ServiceIdentifier = - createDecorator('explicitFileAgentSource'); - -export class ExplicitFileAgentSource implements IExplicitFileAgentSource { - declare readonly _serviceBrand: undefined; - - readonly id = 'explicit'; - readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.explicit; - readonly fatal = true; - - constructor( - @IAgentCatalogRuntimeOptions private readonly runtimeOptions: IAgentCatalogRuntimeOptions, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, - @IBootstrapService private readonly bootstrap: IBootstrapService, - @IHostFileSystem private readonly fs: IHostFileSystem, - @IUserFileAgentSource private readonly user: IUserFileAgentSource, - ) {} - - async load(): Promise { - const files = this.runtimeOptions.explicitFiles ?? []; - const profiles: AgentProfile[] = []; - for (const file of files) { - const filePath = resolveAgentPath(file, this.workspace.workDir, this.bootstrap.osHomeDir); - const text = await this.fs.readText(filePath); - profiles.push( - agentProfileFromFile(parseAgentFileText({ path: filePath, source: 'explicit', text }), (context) => - this.user.getDefaultProfile().systemPrompt(context), - ), - ); - } - return { profiles }; - } -} - -registerScopedService( - LifecycleScope.Session, - IExplicitFileAgentSource, - ExplicitFileAgentSource, - ScopeActivation.OnScopeCreated, - 'sessionAgentProfileCatalog', -); diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/extraFileAgentSource.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/extraFileAgentSource.ts deleted file mode 100644 index c9d6e8a808..0000000000 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/extraFileAgentSource.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * `sessionAgentProfileCatalog` domain (L3) — extra `IAgentProfileSource` - * producer. - * - * Discovers configured agent profiles through `config`, `workspace`, - * `bootstrap`, and `hostFs`, and reports skipped files through `log`. - * `${base_prompt}` is backed by the user source's effective default profile. - * Bound at Session scope. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import { Disposable } from '#/_base/di/lifecycle'; -import { Emitter, type Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { ILogService } from '#/_base/log/log'; -import { discoverAgentFiles } from '#/app/agentFileCatalog/agentFileDiscovery'; -import { - AGENT_PROFILE_SOURCE_PRIORITY, - profilesFromDiscovery, - type AgentProfileContribution, - type IAgentProfileSource, -} from '#/app/agentFileCatalog/agentProfileSource'; -import { configuredAgentRoots } from '#/app/agentFileCatalog/agentRoots'; -import { - EXTRA_AGENT_DIRS_SECTION, - type ExtraAgentDirsConfig, -} from '#/app/agentFileCatalog/configSection'; -import { IUserFileAgentSource } from '#/app/agentFileCatalog/userFileAgentSource'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IConfigService } from '#/app/config/config'; -import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; - -export interface IExtraFileAgentSource extends IAgentProfileSource { - readonly _serviceBrand: undefined; -} - -export const IExtraFileAgentSource: ServiceIdentifier = - createDecorator('extraFileAgentSource'); - -export class ExtraFileAgentSource extends Disposable implements IExtraFileAgentSource { - declare readonly _serviceBrand: undefined; - - readonly id = 'extra'; - readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.extra; - private readonly onDidChangeEmitter = this._register(new Emitter()); - readonly onDidChange: Event = this.onDidChangeEmitter.event; - - constructor( - @IConfigService private readonly config: IConfigService, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, - @IBootstrapService private readonly bootstrap: IBootstrapService, - @IHostFileSystem private readonly fs: IHostFileSystem, - @ILogService private readonly log: ILogService, - @IUserFileAgentSource private readonly user: IUserFileAgentSource, - ) { - super(); - this._register( - this.config.onDidSectionChange((event) => { - if (event.domain === EXTRA_AGENT_DIRS_SECTION) this.onDidChangeEmitter.fire(); - }), - ); - } - - async load(): Promise { - await this.config.ready; - const dirs = this.config.get(EXTRA_AGENT_DIRS_SECTION) ?? []; - return profilesFromDiscovery( - await discoverAgentFiles( - this.fs, - await configuredAgentRoots( - this.fs, - dirs, - this.workspace.workDir, - this.bootstrap.osHomeDir, - 'extra', - (message, error) => { - this.log.warn(message, error); - }, - ), - (message) => this.log.warn(message), - ), - (context) => this.user.getDefaultProfile().systemPrompt(context), - ); - } -} - -registerScopedService( - LifecycleScope.Session, - IExtraFileAgentSource, - ExtraFileAgentSource, - ScopeActivation.OnScopeCreated, - 'sessionAgentProfileCatalog', -); diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/pluginAgentProfileSource.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/pluginAgentProfileSource.ts deleted file mode 100644 index 42af972661..0000000000 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/pluginAgentProfileSource.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * `sessionAgentProfileCatalog` domain (L3) — plugin `IAgentProfileSource` - * producer. - * - * Discovers agent profiles contributed by enabled plugins (roots from - * `plugin.pluginAgentRoots()`), contributing them at priority 5 (above - * builtin, below user / extra / project / explicit, so every other file - * source wins name collisions). `${base_prompt}` is backed by the user - * source's effective default profile. Re-emits `plugin.onDidReload` as - * `onDidChange` so the catalog re-pulls plugin agents when plugins reload; - * install / enable / remove mutations deliberately do not refresh the - * session catalog — those take effect on the next explicit reload. Bound at - * Session scope. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { ILogService } from '#/_base/log/log'; -import { discoverAgentFiles } from '#/app/agentFileCatalog/agentFileDiscovery'; -import { - AGENT_PROFILE_SOURCE_PRIORITY, - profilesFromDiscovery, - type AgentProfileContribution, - type IAgentProfileSource, -} from '#/app/agentFileCatalog/agentProfileSource'; -import { IUserFileAgentSource } from '#/app/agentFileCatalog/userFileAgentSource'; -import { IPluginService } from '#/app/plugin/plugin'; -import { IHostFileSystem } from '#/os/interface/hostFileSystem'; - -export interface IPluginAgentProfileSource extends IAgentProfileSource { - readonly _serviceBrand: undefined; -} - -export const IPluginAgentProfileSource: ServiceIdentifier = - createDecorator('pluginAgentProfileSource'); - -export class PluginAgentProfileSource implements IPluginAgentProfileSource { - declare readonly _serviceBrand: undefined; - - readonly id = 'plugin'; - readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.plugin; - readonly onDidChange: Event = (listener, thisArg, disposables) => - this.plugins.onDidReload( - () => listener.call(thisArg, undefined as void), - undefined, - disposables, - ); - - constructor( - @IPluginService private readonly plugins: IPluginService, - @IHostFileSystem private readonly fs: IHostFileSystem, - @ILogService private readonly log: ILogService, - @IUserFileAgentSource private readonly user: IUserFileAgentSource, - ) {} - - async load(): Promise { - const roots = await this.plugins.pluginAgentRoots(); - return profilesFromDiscovery( - await discoverAgentFiles(this.fs, roots, (message) => { - this.log.warn(message); - }), - (context) => this.user.getDefaultProfile().systemPrompt(context), - ); - } -} - -registerScopedService( - LifecycleScope.Session, - IPluginAgentProfileSource, - PluginAgentProfileSource, - ScopeActivation.OnScopeCreated, - 'sessionAgentProfileCatalog', -); diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/projectFileAgentSource.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/projectFileAgentSource.ts deleted file mode 100644 index 9f0740870e..0000000000 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/projectFileAgentSource.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * `sessionAgentProfileCatalog` domain (L3) — project `IAgentProfileSource` - * producer. - * - * Discovers project agent profiles through `workspace` and `hostFs`, and - * reports skipped files through `log`. `${base_prompt}` is backed by the user - * source's effective default profile. Bound at Session scope. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { ILogService } from '#/_base/log/log'; -import { discoverAgentFiles } from '#/app/agentFileCatalog/agentFileDiscovery'; -import { - AGENT_PROFILE_SOURCE_PRIORITY, - profilesFromDiscovery, - type AgentProfileContribution, - type IAgentProfileSource, -} from '#/app/agentFileCatalog/agentProfileSource'; -import { projectAgentRoots } from '#/app/agentFileCatalog/agentRoots'; -import { IUserFileAgentSource } from '#/app/agentFileCatalog/userFileAgentSource'; -import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; - -export interface IProjectFileAgentSource extends IAgentProfileSource { - readonly _serviceBrand: undefined; -} - -export const IProjectFileAgentSource: ServiceIdentifier = - createDecorator('projectFileAgentSource'); - -export class ProjectFileAgentSource implements IProjectFileAgentSource { - declare readonly _serviceBrand: undefined; - - readonly id = 'project'; - readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.project; - - constructor( - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, - @IHostFileSystem private readonly fs: IHostFileSystem, - @ILogService private readonly log: ILogService, - @IUserFileAgentSource private readonly user: IUserFileAgentSource, - ) {} - - async load(): Promise { - const roots = await projectAgentRoots( - this.fs, - this.workspace.workDir, - (message, error) => { - this.log.warn(message, error); - }, - ); - return profilesFromDiscovery( - await discoverAgentFiles(this.fs, roots, (message) => this.log.warn(message)), - (context) => this.user.getDefaultProfile().systemPrompt(context), - ); - } -} - -registerScopedService( - LifecycleScope.Session, - IProjectFileAgentSource, - ProjectFileAgentSource, - ScopeActivation.OnScopeCreated, - 'sessionAgentProfileCatalog', -); diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.ts index 7be8ea6535..41fcdc4407 100644 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.ts +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.ts @@ -2,19 +2,37 @@ * `sessionAgentProfileCatalog` domain (L3) — Session-scoped merged agent-profile * catalog contract. * - * Defines the merged read view over the builtin (code-contribution) profiles - * and the file-backed sources (user / extra / project / explicit), merged by - * priority — higher-priority file sources win name collisions, while builtin - * names require an explicit override opt-in. Consumers - * (`IAgentProfileService.bind`, the `Agent` tool, the swarm scheduler) resolve - * profiles through this view instead of the App-scope catalog so file-defined - * agents are spawnable and bindable. Bound at Session scope. + * The Catalog of the agent-profile extension point: a read-only projection + * over the App-scope `IAgentProfileRegistry`, scoped to THIS session — it + * merges the global contributions (builtin / plugin / user) with the ones the + * workspace loaders tagged with this session's seeded workspace key + * (workspace / extra / explicit). Name-level dedup happens HERE, in the + * projection: higher-priority sources win name collisions, while builtin + * names require an explicit `override: true` opt-in to be replaced. + * Consumers (`IAgentProfileService.bind`, the `Agent` tool, the swarm + * scheduler) resolve profiles through this view; `inspect(name)` exposes the + * projection's adjudication (winning source, suppressed candidates) for + * debugging surfaces. Bound at Session scope. */ import { createDecorator } from '#/_base/di/instantiation'; import type { Event } from '#/_base/event'; import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +export interface AgentProfileSuppressedCandidate { + readonly sourceId: string; + readonly priority: number; + readonly reason: 'priority' | 'builtin-override-required'; +} + +export interface AgentProfileInspection { + readonly name: string; + readonly profile: AgentProfile; + readonly sourceId: string; + readonly priority: number; + readonly suppressed: readonly AgentProfileSuppressedCandidate[]; +} + export interface ISessionAgentProfileCatalog { readonly _serviceBrand: undefined; @@ -23,6 +41,7 @@ export interface ISessionAgentProfileCatalog { get(name: string): AgentProfile | undefined; getDefault(): AgentProfile; list(): readonly AgentProfile[]; + inspect(name: string): AgentProfileInspection | undefined; load(): Promise; reload(): Promise; } diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts index a0612d28a2..cb91227d26 100644 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts @@ -2,59 +2,46 @@ * `sessionAgentProfileCatalog` domain (L3) — `ISessionAgentProfileCatalog` * implementation. * - * Merges the builtin (code-contribution) App catalog with the file-backed - * sources (plugin / user / extra / project / explicit) by priority, requiring - * an explicit opt-in before a file replaces a same-name builtin, and - * serializing - * refreshes per source the same way `sessionSkillCatalog` does. The merged - * view always contains the builtin profiles (seeded at construction); file - * profiles appear once `ready` resolves. A rejecting `fatal` source (an - * invalid `--agent-file`) propagates into `ready` so `bind()` / `load()` - * awaiters see the error; a rejecting non-fatal source (a transient fs error - * inside a directory source) degrades to a warning and keeps any previously - * loaded contribution, so directory problems never poison the session. - * `ready` tracks the most recent load pass: `reload()` replaces it, so a - * fatal failure does not wedge the catalog once the underlying problem is - * fixed, and `loadAll` merges whatever loaded even when a fatal source - * rejects mid-pass. The swallowed handler on `ready` keeps an un-awaited - * rejection from crashing the process, and event-driven reloads get the - * same warning treatment. The plain-data state (`contributions`, `merged`) - * is registered into `sessionState` (`ISessionStateService`) and - * read/written through it. - * Bound at Session scope. + * Projects the App-scope `IAgentProfileRegistry` into this session's merged + * profile view. The relevant entries are the global ones (builtin) plus the + * ones tagged with the seeded workspace key (user / plugin / extra / + * workspace / explicit); they are re-merged on every registry change (the + * projection is a cheap full recompute — merge, never incremental patching). + * Merge rules, applied per profile name: candidates are collected from every + * relevant entry (deduped within an entry, highest priority first); the first + * candidate wins, except that replacing a same-name `builtin` profile + * requires `override: true` in the frontmatter — a non-override collision is + * warned about and skipped to the next candidate. `ready` resolves + * immediately: loader readiness is the handler's job — it awaits every + * agent-profile loader of the workspace before publishing the session handle, + * so the registry is already populated when this service is constructed, and + * every later change arrives through `onDidChange`. Bound at Session scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; -import { ILogService } from '#/_base/log/log'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { isError2 } from '#/_base/errors/errors'; -import { defineState } from '#/_base/state/stateRegistry'; +import { ILogService } from '#/_base/log/log'; +import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { - DEFAULT_AGENT_PROFILE_NAME, - IAgentProfileCatalogService, - type AgentProfile, -} from '#/app/agentProfileCatalog/agentProfileCatalog'; -import type { - AgentProfileContribution, - IAgentProfileSource, -} from '#/app/agentFileCatalog/agentProfileSource'; -import { IUserFileAgentSource } from '#/app/agentFileCatalog/userFileAgentSource'; -import { ISessionStateService } from '#/session/state/sessionState'; - -import { IExplicitFileAgentSource } from './explicitFileAgentSource'; -import { IExtraFileAgentSource } from './extraFileAgentSource'; -import { IPluginAgentProfileSource } from './pluginAgentProfileSource'; -import { IProjectFileAgentSource } from './projectFileAgentSource'; -import { ISessionAgentProfileCatalog } from './sessionAgentProfileCatalog'; - -export const agentProfileCatalogContributionsKey = defineState< - Map ->('sessionAgentProfileCatalog.contributions', () => new Map()); -export const agentProfileCatalogMergedKey = defineState>( - 'sessionAgentProfileCatalog.merged', - () => new Map(), -); + IAgentProfileRegistry, + type AgentProfileRegistration, +} from '#/app/agentProfileCatalog/agentProfileRegistry'; +import { BUILTIN_AGENT_PROFILE_SOURCE_ID } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; + +import { ISessionAgentProfileCatalogSeed } from './agentProfileCatalogSeed'; +import { + ISessionAgentProfileCatalog, + type AgentProfileInspection, + type AgentProfileSuppressedCandidate, +} from './sessionAgentProfileCatalog'; + +interface ProfileCandidate { + readonly profile: AgentProfile; + readonly sourceId: string; + readonly priority: number; +} export class SessionAgentProfileCatalogService extends Disposable @@ -62,61 +49,31 @@ export class SessionAgentProfileCatalogService { declare readonly _serviceBrand: undefined; - private readonly sources: readonly IAgentProfileSource[]; - private readonly sourceLoadTails = new Map>(); - private readyPromise: Promise; + private merged = new Map(); + private inspections = new Map(); private readonly onDidChangeEmitter = this._register(new Emitter()); readonly onDidChange: Event = this.onDidChangeEmitter.event; constructor( - @ISessionStateService private readonly states: ISessionStateService, - @IAgentProfileCatalogService private readonly builtin: IAgentProfileCatalogService, - @IPluginAgentProfileSource plugin: IPluginAgentProfileSource, - @IUserFileAgentSource user: IUserFileAgentSource, - @IExtraFileAgentSource extra: IExtraFileAgentSource, - @IProjectFileAgentSource project: IProjectFileAgentSource, - @IExplicitFileAgentSource explicit: IExplicitFileAgentSource, + @IAgentProfileRegistry private readonly registry: IAgentProfileRegistry, + @ISessionAgentProfileCatalogSeed private readonly seed: ISessionAgentProfileCatalogSeed, @ILogService private readonly log: ILogService, ) { super(); - this.states.register(agentProfileCatalogContributionsKey); - this.states.register(agentProfileCatalogMergedKey); - this.sources = [plugin, user, extra, project, explicit].toSorted( - (a, b) => a.priority - b.priority, + this.reproject(); + this._register( + this.registry.onDidChange((change) => { + if (change.workspaceKey !== undefined && change.workspaceKey !== this.seed.workspaceKey) { + return; + } + this.reproject(); + this.onDidChangeEmitter.fire(change.sourceId); + }), ); - for (const s of this.sources) { - if (s.onDidChange) { - this._register( - s.onDidChange(() => { - void this.reloadSource(s.id).catch((error) => { - this.log.warn(`agent profile source "${s.id}" reload failed: ${String(error)}`); - }); - }), - ); - } - } - this.remerge(); - this.readyPromise = this.loadAll(); - void this.readyPromise.catch(() => undefined); - } - - private get contributions(): Map< - string, - { readonly c: AgentProfileContribution; readonly priority: number } - > { - return this.states.get(agentProfileCatalogContributionsKey); - } - - private get merged(): Map { - return this.states.get(agentProfileCatalogMergedKey); - } - - private set merged(value: Map) { - this.states.set(agentProfileCatalogMergedKey, value); } get ready(): Promise { - return this.readyPromise; + return Promise.resolve(); } get(name: string): AgentProfile | undefined { @@ -137,96 +94,110 @@ export class SessionAgentProfileCatalogService return [...this.merged.values()]; } + inspect(name: string): AgentProfileInspection | undefined { + return this.inspections.get(name); + } + async load(): Promise { await this.ready; } async reload(): Promise { - this.readyPromise = this.loadAll(); - void this.readyPromise.catch(() => undefined); - await this.readyPromise; + await this.ready; + this.reproject(); this.onDidChangeEmitter.fire('catalog'); } - private async loadAll(): Promise { - try { - for (const s of this.sources) { - await this.loadSource(s); - } - } finally { - this.remerge(); - } - } - - private async reloadSource(id: string): Promise { - const s = this.sources.find((x) => x.id === id); - if (!s) return; - await this.loadSource(s, true); + private relevantEntries(): AgentProfileRegistration[] { + const key = this.seed.workspaceKey; + return this.registry + .entries() + .filter((e) => e.workspaceKey === undefined || e.workspaceKey === key); } - private loadSource(source: IAgentProfileSource, fireChange = false): Promise { - const previous = this.sourceLoadTails.get(source) ?? Promise.resolve(); - const current = previous - .catch(() => undefined) - .then(async () => { - let contribution: AgentProfileContribution; - try { - contribution = await source.load(); - } catch (error) { - if (source.fatal) throw error; - const at = isError2(error) ? error.details?.['path'] : undefined; - this.log.warn( - `agent profile source "${source.id}" load failed: ${String(error)}${typeof at === 'string' ? ` [${at}]` : ''}`, - ); - return; - } - this.contributions.set(source.id, { c: contribution, priority: source.priority }); - if (fireChange) { - this.remerge(); - this.onDidChangeEmitter.fire(source.id); - } - }); - this.sourceLoadTails.set(source, current); - const clear = () => { - if (this.sourceLoadTails.get(source) === current) { - this.sourceLoadTails.delete(source); + private reproject(): void { + const merged = new Map(); + const inspections = new Map(); + const entries = this.relevantEntries(); + + const builtinEntry = entries.find((e) => e.sourceId === BUILTIN_AGENT_PROFILE_SOURCE_ID); + if (builtinEntry !== undefined) { + for (const profile of builtinEntry.contribution.profiles) { + merged.set(profile.name, profile); + inspections.set(profile.name, { + name: profile.name, + profile, + sourceId: builtinEntry.sourceId, + priority: builtinEntry.priority, + suppressed: [], + }); } - }; - void current.then(clear, clear); - return current; - } - - private remerge(): void { - const m = new Map(); - for (const profile of this.builtin.list()) { - m.set(profile.name, profile); } - const fileProfiles = new Map(); - const ordered = [...this.contributions.values()].toSorted( - (a, b) => b.priority - a.priority, - ); - for (const { c } of ordered) { - const sourceProfiles = new Map(); - for (const profile of c.profiles) sourceProfiles.set(profile.name, profile); - for (const profile of sourceProfiles.values()) { - const candidates = fileProfiles.get(profile.name) ?? []; - candidates.push(profile); - fileProfiles.set(profile.name, candidates); + + const fileCandidates = new Map(); + const ordered = entries + .filter((e) => e.sourceId !== BUILTIN_AGENT_PROFILE_SOURCE_ID) + .toSorted((a, b) => b.priority - a.priority); + for (const entry of ordered) { + const entryProfiles = new Map(); + for (const profile of entry.contribution.profiles) { + entryProfiles.set(profile.name, profile); + } + for (const profile of entryProfiles.values()) { + const candidates = fileCandidates.get(profile.name) ?? []; + candidates.push({ + profile, + sourceId: entry.sourceId, + priority: entry.priority, + }); + fileCandidates.set(profile.name, candidates); } } - for (const candidates of fileProfiles.values()) { - for (const profile of candidates) { - if (m.has(profile.name) && profile.override !== true) { + + for (const candidates of fileCandidates.values()) { + const suppressed: AgentProfileSuppressedCandidate[] = []; + let winner = false; + for (const candidate of candidates) { + if (merged.has(candidate.profile.name) && candidate.profile.override !== true) { this.log.warn( - `agent file profile "${profile.name}" ignored: a same-name builtin profile exists; set "override: true" in the frontmatter to replace it`, + `agent file profile "${candidate.profile.name}" ignored: a same-name builtin profile exists; set "override: true" in the frontmatter to replace it`, ); + suppressed.push({ + sourceId: candidate.sourceId, + priority: candidate.priority, + reason: 'builtin-override-required', + }); continue; } - m.set(profile.name, profile); + merged.set(candidate.profile.name, candidate.profile); + inspections.set(candidate.profile.name, { + name: candidate.profile.name, + profile: candidate.profile, + sourceId: candidate.sourceId, + priority: candidate.priority, + suppressed: [ + ...suppressed, + ...candidates.slice(candidates.indexOf(candidate) + 1).map((rest) => ({ + sourceId: rest.sourceId, + priority: rest.priority, + reason: 'priority' as const, + })), + ], + }); + winner = true; break; } + if (!winner && suppressed.length > 0) { + const name = candidates[0]?.profile.name; + const existing = name === undefined ? undefined : inspections.get(name); + if (existing !== undefined) { + inspections.set(existing.name, { ...existing, suppressed }); + } + } } - this.merged = m; + + this.merged = merged; + this.inspections = inspections; } } diff --git a/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts b/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts index 316e60c287..2b6704029b 100644 --- a/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts +++ b/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts @@ -3,16 +3,15 @@ * * Defines the `ISessionContext` carrying the session's identity, storage * addressing (`sessionId`, `workspaceId`, `sessionDir`, `metaScope`), the - * session's initial working directory (`cwd`), and a `scope(subKey?)` helper - * that returns the session's persistence scope (or a child under it, e.g. - * `scope('agents/main/cron')`). Seeded into the Session scope by - * `sessionLifecycle` when the session is created. + * session's working directory (`cwd`) — frozen at session creation — and a + * `scope(subKey?)` + * helper that returns the session's persistence scope (or a child under it, + * e.g. `scope('agents/main/cron')`). Seeded into the Session scope by + * `workspaceHandler` when the session is created. * - * `cwd` is the working directory frozen at session creation; it is the default - * root the `process` runner spawns in and the seed `workspaceContext` derives - * its mutable `workDir` from. The live, runtime-mutable "current cwd" (changed - * via `chdir`) is owned by `profile` (Agent scope) and `workspaceContext`, not - * here. Pure facts — no store, no IO. Session-scoped. + * `cwd` is the default root the `process` runner spawns in and the seed the + * `workspaceContext` derives its read-only `workDir` from. Pure facts — no + * store, no IO. Session-scoped. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts b/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts deleted file mode 100644 index 6e44c47780..0000000000 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * `sessionFsWatch` domain (L2) — workspace-confined filesystem change feed. - * - * Defines the `ISessionFsWatchService` that turns the os `IHostFsWatchService` - * raw events into a workspace-relative, debounced, `.gitignore`-aware change - * feed (`FsChangeEvent`) for the session. Callers declare the set of - * workspace-relative paths they care about; events outside that subtree are - * dropped. Session-scoped — the scope itself is the session, so no - * `sessionId` is threaded through. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { Event } from '#/_base/event'; - -export type FsChangeKind = 'file' | 'directory' | 'symlink'; - -export type FsChangeAction = 'created' | 'modified' | 'deleted'; - -export interface FsChangeEntry { - path: string; - change: FsChangeAction; - kind: FsChangeKind; - size_delta?: number | undefined; - etag?: string | undefined; -} - -export interface FsChangeEvent { - changes: FsChangeEntry[]; - coalesced_window_ms: number; - truncated?: boolean | undefined; - count?: number | undefined; -} - -export interface ISessionFsWatchService { - readonly _serviceBrand: undefined; - - setWatchedPaths(paths: readonly string[]): void; - - readonly watchedPaths: readonly string[]; - - readonly onDidChangeFiles: Event; -} - -export const ISessionFsWatchService: ServiceIdentifier = - createDecorator('sessionFsWatchService'); diff --git a/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts b/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts index 719230486f..1426342010 100644 --- a/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts +++ b/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts @@ -3,7 +3,7 @@ * * Runs `/init` against the session's main agent: resolves `main` through * `agentLifecycle`, spawns a `coder` subagent bound to the main agent's own - * model / thinking level / cwd (inheriting the main agent's permission mode), + * model / thinking level (inheriting the main agent's permission mode), * drives one init-brief turn via `subagents.run`, and mirrors the run onto the * main agent's record stream (`emitAgentRunSpawned` + `mirrorAgentRun`) so the * UI shows the nested transcript and the `subagent.*` records fire. Once the @@ -33,6 +33,7 @@ import { IAgentSystemReminderService } from '#/agent/systemReminder/systemRemind import { IWireService } from '#/wire/wire'; import { ErrorCodes, Error2 } from '#/errors'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun'; import { ISessionSubagentService } from '#/session/subagent/subagent'; @@ -55,6 +56,7 @@ export class SessionInitService implements ISessionInitService { @IHostFileSystem private readonly fs: IHostFileSystem, @IHostEnvironment private readonly env: IHostEnvironment, @IBootstrapService private readonly bootstrap: IBootstrapService, + @ISessionContext private readonly sessionContext: ISessionContext, ) {} cancelInit(): void { @@ -81,7 +83,6 @@ export class SessionInitService implements ISessionInitService { profile: INIT_PROFILE_NAME, model: own.modelAlias, thinking: own.thinkingLevel, - cwd: own.cwd, }, }); child.accessor.get(IAgentPermissionModeService).setMode(permissionMode); @@ -107,7 +108,7 @@ export class SessionInitService implements ISessionInitService { const agentsMd = await loadAgentsMd( { fs: this.fs, homeDir: this.env.homeDir }, - own.cwd, + this.sessionContext.cwd, this.bootstrap.homeDir, ); main.accessor diff --git a/packages/agent-core-v2/src/session/sessionInstructions/instructionsProvider.ts b/packages/agent-core-v2/src/session/sessionInstructions/instructionsProvider.ts new file mode 100644 index 0000000000..183b6301cb --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionInstructions/instructionsProvider.ts @@ -0,0 +1,35 @@ +/** + * `sessionInstructions` domain (L1) — seeded AGENTS.md provider contract. + * + * Defines `ISessionInstructionsProvider`, the pure-data injection contract + * the Workspace-scope `workspaceInstructions` service hands to every Session + * scope it creates: the workspace's current AGENTS.md snapshot (combined + * content plus the oversize/load warning) and the change event fired when a + * watched instruction file invalidates the snapshot. The contract carries no + * IO — loading and watching live on the workspace side; consumers (the + * agent's `profile` service) read the seed and re-read it off `onDidChange`. + * Seeded into the Session scope by `workspaceHandler` when the session is + * materialized. Session-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import type { Event } from '#/_base/event'; + +export interface ISessionInstructionsProvider { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly agentsMd: string | undefined; + readonly agentsMdWarning: string | undefined; + readonly onDidChange: Event; +} + +export const ISessionInstructionsProvider: ServiceIdentifier = + createDecorator('sessionInstructionsProvider'); + +export function sessionInstructionsProviderSeed( + provider: ISessionInstructionsProvider, +): ScopeSeed { + return [[ISessionInstructionsProvider as ServiceIdentifier, provider]]; +} diff --git a/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts b/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts new file mode 100644 index 0000000000..be9d54975a --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts @@ -0,0 +1,40 @@ +/** + * `sessionLifecycleHooks` domain (L1) — per-session lifecycle hook slots. + * + * Defines the `ISessionLifecycleHooks` seed: one ordered hook-slots instance + * per session, created by the Workspace-scope `workspaceHandler` when it + * materializes the session, seeded into the Session scope, and run by the + * handler around the session's create (`onDidCreateSession`) and close + * (`onWillCloseSession`). Session-scope consumers (e.g. `externalHooks`) + * register against this session-domain contract and never see the Workspace + * domain — the §3.5 seed-channel shape. Also owns the shared + * `SessionCreateSource` / `SessionCloseReason` vocabulary both sides speak. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import type { Hooks } from '#/hooks'; + +export type SessionCreateSource = 'startup' | 'resume' | 'fork'; + +export type SessionCloseReason = 'exit'; + +export interface SessionStartHookEvent { + readonly source: SessionCreateSource; +} + +export interface SessionEndHookEvent { + readonly reason: SessionCloseReason; +} + +export type SessionLifecycleHookSlots = { + readonly onDidCreateSession: SessionStartHookEvent; + readonly onWillCloseSession: SessionEndHookEvent; +}; + +export const ISessionLifecycleHooks: ServiceIdentifier> = + createDecorator>('sessionLifecycleHooks'); + +export function sessionLifecycleHooksSeed(hooks: Hooks): ScopeSeed { + return [[ISessionLifecycleHooks as ServiceIdentifier, hooks]]; +} diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogData.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogData.ts new file mode 100644 index 0000000000..c9e353ca37 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogData.ts @@ -0,0 +1,33 @@ +/** + * `sessionSkillCatalog` domain (L3) — seeded skill-catalog data contract. + * + * Defines `ISessionSkillCatalogData`, the pure-data injection contract the + * Workspace-scope `workspaceSkillCatalog` hands to every Session scope it + * creates: the workspace's merged skill catalog as a live read view plus the + * source-keyed change event. The contract carries no IO — discovery, merging + * and rescanning all live on the workspace side; the Session-scope + * `ISessionSkillCatalog` business view reads this seed and refreshes itself + * off `onDidChange`. Seeded into the Session scope by `workspaceHandler` when + * the session is materialized. Session-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import type { Event } from '#/_base/event'; + +import type { SkillCatalog } from '#/app/skillCatalog/types'; + +export interface ISessionSkillCatalogData { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly catalog: SkillCatalog; + readonly onDidChange: Event; +} + +export const ISessionSkillCatalogData: ServiceIdentifier = + createDecorator('sessionSkillCatalogData'); + +export function sessionSkillCatalogDataSeed(data: ISessionSkillCatalogData): ScopeSeed { + return [[ISessionSkillCatalogData as ServiceIdentifier, data]]; +} diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts index 511ef94579..a6ce059776 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts @@ -1,30 +1,31 @@ /** - * `sessionSkillCatalog` domain (L3) — `ISessionSkillCatalog` sink implementation. + * `sessionSkillCatalog` domain (L3) — `ISessionSkillCatalog` sink + * implementation. * - * Merges builtin, user, explicit, extra, workspace, and plugin skill sources - * by priority, serializing refreshes for each source. Exposes the merged read - * view and accepts ad-hoc contributions through `ISkillCatalogSink`. The - * plain-data state (`contributions`, `merged`) is registered into - * `sessionState` (`ISessionStateService`) and read/written through it. Bound - * at Session scope. + * The Session-scope business view over the workspace's merged skill catalog: + * the discovery/merge/rescan work lives on the Workspace-scope + * `workspaceSkillCatalog` and arrives through the seeded + * `ISessionSkillCatalogData` read view — this service never scans the + * filesystem itself. It re-folds the data snapshot on every seeded change + * event (forwarding the source id) and merges session-local ad-hoc + * contributions (`ISkillCatalogSink`) on top by priority. `reload()` no + * longer re-scans: it re-folds the current seed and re-fires `catalog`. + * The plain-data state (`contributions`, `merged`) is registered into + * `sessionState` (`ISessionStateService`) and read/written through it. + * Bound at Session scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; -import { IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; -import type { ISkillSource, SkillContribution } from '#/app/skillCatalog/skillSource'; +import type { SkillContribution } from '#/app/skillCatalog/skillSource'; import type { SkillCatalog } from '#/app/skillCatalog/types'; -import { IUserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; import { ISessionStateService } from '#/session/state/sessionState'; -import { IPluginSkillSource } from './pluginSkillSource'; -import { IExtraFileSkillSource } from './extraFileSkillSource'; -import { IExplicitFileSkillSource } from './explicitFileSkillSource'; import { ISessionSkillCatalog, type ISkillCatalogSink } from './skillCatalog'; -import { IWorkspaceFileSkillSource } from './workspaceFileSkillSource'; +import { ISessionSkillCatalogData } from './skillCatalogData'; export const skillCatalogContributionsKey = defineState< Map @@ -40,29 +41,25 @@ export class SessionSkillCatalogService { declare readonly _serviceBrand: undefined; - private readonly sources: readonly ISkillSource[]; - private readonly sourceLoadTails = new Map>(); readonly ready: Promise; private readonly onDidChangeEmitter = this._register(new Emitter()); readonly onDidChange: Event = this.onDidChangeEmitter.event; constructor( + @ISessionSkillCatalogData private readonly data: ISessionSkillCatalogData, @ISessionStateService private readonly states: ISessionStateService, - @IBuiltinSkillSource builtin: IBuiltinSkillSource, - @IUserFileSkillSource user: IUserFileSkillSource, - @IExplicitFileSkillSource explicit: IExplicitFileSkillSource, - @IExtraFileSkillSource extra: IExtraFileSkillSource, - @IWorkspaceFileSkillSource workspace: IWorkspaceFileSkillSource, - @IPluginSkillSource plugin: IPluginSkillSource, ) { super(); this.states.register(skillCatalogContributionsKey); this.states.register(skillCatalogMergedKey); - this.sources = [builtin, user, explicit, extra, workspace, plugin].toSorted((a, b) => a.priority - b.priority); - for (const s of this.sources) { - if (s.onDidChange) this._register(s.onDidChange(() => { void this.reloadSource(s.id); })); - } - this.ready = this.loadAll(); + this._register( + this.data.onDidChange((sourceId) => { + this.remerge(); + this.onDidChangeEmitter.fire(sourceId); + }), + ); + this.remerge(); + this.ready = this.data.ready.then(() => this.remerge()); } private get contributions(): Map< @@ -89,7 +86,8 @@ export class SessionSkillCatalogService } async reload(): Promise { - await this.loadAll(); + await this.ready; + this.remerge(); this.onDidChangeEmitter.fire('catalog'); } @@ -105,41 +103,12 @@ export class SessionSkillCatalogService this.onDidChangeEmitter.fire(id); } - private async loadAll(): Promise { - for (const s of this.sources) { - await this.loadSource(s); - } - this.remerge(); - } - - private async reloadSource(id: string): Promise { - const s = this.sources.find((x) => x.id === id); - if (!s) return; - await this.loadSource(s, true); - } - - private loadSource(source: ISkillSource, fireChange = false): Promise { - const previous = this.sourceLoadTails.get(source) ?? Promise.resolve(); - const current = previous.catch(() => undefined).then(async () => { - const contribution = await source.load(); - this.contributions.set(source.id, { c: contribution, priority: source.priority }); - if (fireChange) { - this.remerge(); - this.onDidChangeEmitter.fire(source.id); - } - }); - this.sourceLoadTails.set(source, current); - const clear = () => { - if (this.sourceLoadTails.get(source) === current) { - this.sourceLoadTails.delete(source); - } - }; - void current.then(clear, clear); - return current; - } - private remerge(): void { const m = new InMemorySkillCatalog(); + const base = this.data.catalog; + for (const skill of base.listSkills()) m.register(skill, { replace: true }); + m.addRoots(base.getSkillRoots()); + m.recordSkipped(base.getSkippedByPolicy()); const ordered = [...this.contributions.values()].toSorted((a, b) => a.priority - b.priority); for (const { c } of ordered) { for (const skill of c.skills) m.register(skill, { replace: true }); diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts deleted file mode 100644 index e4398a730b..0000000000 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * `sessionSkillCatalog` domain (L3) — workspace `ISkillSource` producer. - * - * Discovers project skills from the session's current `workDir` - * (`workspaceContext`) through `ISkillDiscovery`, contributing them at priority - * 30 (above user / extra / plugin / builtin). Bound at Session scope so each session reads - * its own workspace root. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import { Disposable } from '#/_base/di/lifecycle'; -import { Emitter, type Event } from '#/_base/event'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { IConfigService } from '#/app/config/config'; -import { - MERGE_ALL_AVAILABLE_SKILLS_SECTION, - type MergeAllAvailableSkillsConfig, -} from '#/app/skillCatalog/configSection'; -import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; -import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import { projectRoots } from '#/app/skillCatalog/skillRoots'; -import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; - -export interface IWorkspaceFileSkillSource extends ISkillSource { - readonly _serviceBrand: undefined; -} - -export const IWorkspaceFileSkillSource: ServiceIdentifier = - createDecorator('workspaceFileSkillSource'); - -export class WorkspaceFileSkillSource extends Disposable implements IWorkspaceFileSkillSource { - declare readonly _serviceBrand: undefined; - - readonly id = 'workspace'; - readonly priority = SKILL_SOURCE_PRIORITY.workspace; - private readonly onDidChangeEmitter = this._register(new Emitter()); - readonly onDidChange: Event = this.onDidChangeEmitter.event; - - constructor( - @ISkillDiscovery private readonly discovery: ISkillDiscovery, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, - @IConfigService private readonly config: IConfigService, - @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, - ) { - super(); - this._register( - this.config.onDidSectionChange((event) => { - if (event.domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) this.onDidChangeEmitter.fire(); - }), - ); - } - - async load(): Promise { - if ((this.runtimeOptions.explicitDirs?.length ?? 0) > 0) { - return { skills: [] }; - } - await this.config.ready; - const mergeAllAvailableSkills = - this.config.get(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true; - return this.discovery.discover(await projectRoots(this.workspace.workDir, { mergeAllAvailableSkills })); - } -} - -registerScopedService( - LifecycleScope.Session, - IWorkspaceFileSkillSource, - WorkspaceFileSkillSource, - ScopeActivation.OnScopeCreated, - 'sessionSkillCatalog', -); diff --git a/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts new file mode 100644 index 0000000000..d089e7e4fb --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts @@ -0,0 +1,32 @@ +/** + * `sessionToolPolicyGate` domain (L1) — seeded workspace tool-veto contract. + * + * Defines `ISessionToolPolicyGate`, the pure-data injection contract the + * Workspace-scope `workspaceToolPolicy` hands to every Session scope it + * creates: the workspace's os-level disabled-tool set as a live read view + * plus its change event. The contract carries no IO — capability probing and + * workspace config live on the workspace side; the Agent-scope `toolPolicy` + * and `toolActivation` read this seed and apply the veto (it outranks every + * Agent-side policy layer). Seeded into the Session scope by + * `workspaceHandler` when the session is materialized; a no-op default + * registration keeps scopes built without a handler (tests) resolvable. + * Session-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import type { Event } from '#/_base/event'; + +export interface ISessionToolPolicyGate { + readonly _serviceBrand: undefined; + + readonly disabledTools: readonly string[]; + readonly onDidChange: Event; +} + +export const ISessionToolPolicyGate: ServiceIdentifier = + createDecorator('sessionToolPolicyGate'); + +export function sessionToolPolicyGateSeed(gate: ISessionToolPolicyGate): ScopeSeed { + return [[ISessionToolPolicyGate as ServiceIdentifier, gate]]; +} diff --git a/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGateService.ts b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGateService.ts new file mode 100644 index 0000000000..37c2daf42d --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGateService.ts @@ -0,0 +1,29 @@ +/** + * `sessionToolPolicyGate` domain (L1) — no-op default `ISessionToolPolicyGate`. + * + * An empty gate (nothing vetoed, never changes) registered at Session scope + * so Session/Agent scopes materialized WITHOUT a workspace handler — test + * hosts, harness agents — still resolve the contract. The handler's seed + * (`sessionToolPolicyGateSeed`) shadows this registration for real sessions, + * the same way every other workspace-resource injection contract works. + */ + +import { Event } from '#/_base/event'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; + +import { ISessionToolPolicyGate } from './sessionToolPolicyGate'; + +export class NoopSessionToolPolicyGate implements ISessionToolPolicyGate { + declare readonly _serviceBrand: undefined; + + readonly disabledTools: readonly string[] = []; + readonly onDidChange = Event.None as Event; +} + +registerScopedService( + LifecycleScope.Session, + ISessionToolPolicyGate, + NoopSessionToolPolicyGate, + ScopeActivation.OnScopeCreated, + 'sessionToolPolicyGate', +); diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts index 00fa9fc11b..9a6525a000 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -164,7 +164,6 @@ export class SessionSwarmService implements ISessionSwarmService { profile: profile.name, model: binding.model, thinking: binding.thinking, - cwd: callerData.cwd, }, labels: subagentLabels(callerAgentId, { swarmItem: options.swarmItem }), }); diff --git a/packages/agent-core-v2/src/session/workspaceCommand/index.ts b/packages/agent-core-v2/src/session/workspaceCommand/index.ts deleted file mode 100644 index 7ca7179092..0000000000 --- a/packages/agent-core-v2/src/session/workspaceCommand/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * `workspaceCommand` domain barrel — re-exports the workspace-command contract - * (`workspaceCommand`) and its scoped service (`workspaceCommandService`). - * Importing this barrel registers the `ISessionWorkspaceCommandService` - * binding into the scope registry. - */ - -export * from './workspaceCommand'; -export * from './workspaceCommandService'; diff --git a/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommand.ts b/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommand.ts deleted file mode 100644 index 1463a2d71c..0000000000 --- a/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommand.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * `workspaceCommand` domain (L6) — workspace mutation command contract. - * - * Defines the `ISessionWorkspaceCommandService` that orchestrates session-level - * workspace mutations (`addAdditionalDir`): persisting workspace-local config - * when asked, updating `ISessionWorkspaceContext`, and mirroring the - * action's stdout into the main agent's context as a `local-command-stdout` - * injection so the agent observes the change. Session-scoped. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; - -export interface AddAdditionalDirInput { - readonly path: string; - readonly persist?: boolean; -} - -export interface WorkspaceAdditionalDirsResult { - readonly projectRoot: string; - readonly configPath: string; - readonly additionalDirs: readonly string[]; - readonly persisted: boolean; -} - -export interface ISessionWorkspaceCommandService { - readonly _serviceBrand: undefined; - - addAdditionalDir(input: AddAdditionalDirInput): Promise; -} - -export const ISessionWorkspaceCommandService: ServiceIdentifier = - createDecorator('sessionWorkspaceCommandService'); diff --git a/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts b/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts deleted file mode 100644 index 7180a0d161..0000000000 --- a/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * `workspaceCommand` domain (L6) — `ISessionWorkspaceCommandService` implementation. - * - * Coordinates session-level workspace mutations: resolves and persists - * project-local config through `projectLocalConfig`, updates - * `workspaceContext`, and mirrors command output into the main agent through - * `agentLifecycle` and `contextMemory`. The plain-data state - * (`pendingMainInjections`) is registered into `sessionState` - * (`ISessionStateService`) and read/written through it. Bound at Session - * scope. - */ - -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; -import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; -import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; -import { ISessionStateService } from '#/session/state/sessionState'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; - -import { - type AddAdditionalDirInput, - ISessionWorkspaceCommandService, - type WorkspaceAdditionalDirsResult, -} from './workspaceCommand'; - -export const workspaceCommandPendingMainInjectionsKey = defineState( - 'workspaceCommand.pendingMainInjections', - () => [], -); - -export class SessionWorkspaceCommandService - extends Disposable - implements ISessionWorkspaceCommandService -{ - declare readonly _serviceBrand: undefined; - private mutationQueue: Promise = Promise.resolve(); - - constructor( - @ISessionStateService private readonly states: ISessionStateService, - @IProjectLocalConfigService - private readonly localConfig: IProjectLocalConfigService, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, - @IAgentLifecycleService private readonly agents: IAgentLifecycleService, - ) { - super(); - this.states.register(workspaceCommandPendingMainInjectionsKey); - this._register( - this.agents.onDidCreate((handle) => { - if (handle.id !== MAIN_AGENT_ID) return; - if (this.pendingMainInjections.length === 0) return; - const pending = this.pendingMainInjections.splice(0); - handle.accessor.get(IAgentContextMemoryService).append(...pending); - }), - ); - } - - private get pendingMainInjections(): ContextMessage[] { - return this.states.get(workspaceCommandPendingMainInjectionsKey); - } - - async addAdditionalDir(input: AddAdditionalDirInput): Promise { - return this.enqueueMutation(() => this.applyAddAdditionalDir(input)); - } - - private async applyAddAdditionalDir( - input: AddAdditionalDirInput, - ): Promise { - const persist = input.persist ?? true; - - if (persist) { - const persisted = await this.localConfig.appendAdditionalDir( - this.workspace.workDir, - input.path, - ); - this.workspace.setAdditionalDirs([ - ...this.workspace.additionalDirs, - ...persisted.additionalDirs, - ]); - this.injectAdditionalDirAdded(input.path, true, persisted.configPath); - return { - projectRoot: persisted.projectRoot, - configPath: persisted.configPath, - additionalDirs: this.workspace.additionalDirs, - persisted: true, - }; - } - - const workspace = await this.localConfig.readAdditionalDirs(this.workspace.workDir); - const resolved = await this.localConfig.resolveAdditionalDirs(this.workspace.workDir, [ - input.path, - ]); - this.workspace.setAdditionalDirs([...this.workspace.additionalDirs, ...resolved]); - this.injectAdditionalDirAdded(input.path, false, workspace.configPath); - return { - projectRoot: workspace.projectRoot, - configPath: workspace.configPath, - additionalDirs: this.workspace.additionalDirs, - persisted: false, - }; - } - - private enqueueMutation(work: () => Promise): Promise { - const run = this.mutationQueue.then(work, work); - this.mutationQueue = run.then(() => undefined, () => undefined); - return run; - } - - private injectAdditionalDirAdded(path: string, persisted: boolean, configPath: string): void { - const stdout = persisted - ? `Added workspace directory:\n ${path}\n Saved to:\n ${configPath}` - : `Added workspace directory:\n ${path}\n For this session only`; - const text = `\n${stdout.trim()}\n`; - const message: ContextMessage = { - role: 'user', - content: [{ type: 'text', text }], - toolCalls: [], - origin: { kind: 'injection', variant: 'local-command-stdout' }, - }; - - const main = this.agents.get(MAIN_AGENT_ID); - if (main !== undefined) { - main.accessor.get(IAgentContextMemoryService).append(message); - return; - } - this.pendingMainInjections.push(message); - } -} - -registerScopedService( - LifecycleScope.Session, - ISessionWorkspaceCommandService, - SessionWorkspaceCommandService, - ScopeActivation.OnScopeCreated, - 'workspaceCommand', -); diff --git a/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts b/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts index 0919627a0d..c2c03bc1fb 100644 --- a/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts +++ b/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts @@ -3,8 +3,12 @@ * * Defines the `ISessionWorkspaceContext` used by the Agent side to resolve relative * paths against the session work directory and to enforce that file/process - * operations stay within the workspace (plus any additional dirs). Pure - * configuration + boundary — it performs no IO. Session-scoped. + * operations stay within the workspace (plus any additional dirs). The view is + * read-only: `workDir` is fixed at session creation (seeded from + * `ISessionContext`); `additionalDirs` mirrors the handler-shared set (seeded + * as `ISessionWorkspaceInfo`) and refreshes when the workspace-level add-dir + * surface changes it. Pure configuration + boundary — it performs no IO. + * Session-scoped. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -16,13 +20,9 @@ export interface ISessionWorkspaceContext { readonly workDir: string; readonly additionalDirs: readonly string[]; - setWorkDir(workDir: string): void; - setAdditionalDirs(dirs: readonly string[]): void; resolve(rel: string): string; isWithin(absPath: string): boolean; assertAllowed(absPath: string, op: PathAccessOperation): string; - addAdditionalDir(dir: string): void; - removeAdditionalDir(dir: string): void; } export const ISessionWorkspaceContext: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts index 4ca6b418de..b260d95c35 100644 --- a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts +++ b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts @@ -2,18 +2,24 @@ * `workspaceContext` domain (L1) — `ISessionWorkspaceContext` implementation. * * Holds the session work directory and additional dirs, resolves relative - * paths, and checks whether a path falls within the workspace. The plain-data - * state (`workDir`, `additionalDirs`) is registered into `sessionState` - * (`ISessionStateService`) and read/written through it. Bound at Session - * scope. + * paths, and checks whether a path falls within the workspace. `workDir` is + * frozen at construction from the `sessionContext` seed (`cwd`); the + * additional dirs are a live read view over the handler-shared set, seeded + * as `ISessionWorkspaceInfo` by the workspace handler — the workspace-level + * add-dir surface (`workspaceDirs`) and its `local.toml` watch refresh this + * view through the seed's change event. The plain-data state (`workDir`, + * `additionalDirs`) is registered into `sessionState` + * (`ISessionStateService`) and read through it. Bound at Session scope. */ import { isAbsolute, relative, resolve } from 'node:path'; +import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionStateService } from '#/session/state/sessionState'; +import { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; import { ISessionWorkspaceContext, type PathAccessOperation } from './workspaceContext'; @@ -23,34 +29,38 @@ export const workspaceContextAdditionalDirsKey = defineState( () => [], ); -export class SessionWorkspaceContextService implements ISessionWorkspaceContext { +export class SessionWorkspaceContextService extends Disposable implements ISessionWorkspaceContext { declare readonly _serviceBrand: undefined; constructor( @ISessionStateService private readonly states: ISessionStateService, @ISessionContext ctx: ISessionContext, + @ISessionWorkspaceInfo workspaceInfo: ISessionWorkspaceInfo, ) { + super(); this.states.register(workspaceContextWorkDirKey); this.states.register(workspaceContextAdditionalDirsKey); - this.setWorkDir(ctx.cwd); + this.states.set(workspaceContextWorkDirKey, resolve(ctx.cwd)); + this.states.set(workspaceContextAdditionalDirsKey, [ + ...new Set(workspaceInfo.additionalDirs.map((d) => resolve(d))), + ]); + this._register( + workspaceInfo.onDidChange(() => { + this.states.set(workspaceContextAdditionalDirsKey, [ + ...new Set(workspaceInfo.additionalDirs.map((d) => resolve(d))), + ]); + }), + ); } private get _workDir(): string { return this.states.get(workspaceContextWorkDirKey); } - private set _workDir(value: string) { - this.states.set(workspaceContextWorkDirKey, value); - } - private get _additionalDirs(): string[] { return this.states.get(workspaceContextAdditionalDirsKey); } - private set _additionalDirs(value: string[]) { - this.states.set(workspaceContextAdditionalDirsKey, value); - } - get workDir(): string { return this._workDir; } @@ -59,14 +69,6 @@ export class SessionWorkspaceContextService implements ISessionWorkspaceContext return this._additionalDirs; } - setWorkDir(workDir: string): void { - this._workDir = resolve(workDir); - } - - setAdditionalDirs(dirs: readonly string[]): void { - this._additionalDirs = [...new Set(dirs.map((d) => resolve(d)))]; - } - resolve(rel: string): string { return isAbsolute(rel) ? resolve(rel) : resolve(this._workDir, rel); } @@ -89,16 +91,6 @@ export class SessionWorkspaceContextService implements ISessionWorkspaceContext } return target; } - - addAdditionalDir(dir: string): void { - const d = resolve(dir); - if (!this._additionalDirs.includes(d)) this._additionalDirs.push(d); - } - - removeAdditionalDir(dir: string): void { - const d = resolve(dir); - this._additionalDirs = this._additionalDirs.filter((x) => x !== d); - } } registerScopedService( diff --git a/packages/agent-core-v2/src/session/workspaceInfo/workspaceInfo.ts b/packages/agent-core-v2/src/session/workspaceInfo/workspaceInfo.ts new file mode 100644 index 0000000000..209397ce1e --- /dev/null +++ b/packages/agent-core-v2/src/session/workspaceInfo/workspaceInfo.ts @@ -0,0 +1,32 @@ +/** + * `workspaceInfo` domain (L1) — seeded workspace-directory data contract. + * + * Defines `ISessionWorkspaceInfo`, the pure-data injection contract the + * Workspace-scope `workspaceDirs` hands to every Session scope it creates: + * the workspace's additional directory set as a live read view plus its + * change event. The contract carries no IO — persistence + * (`.kimi-code/local.toml`), caller-dir merging and file watching all live + * on the workspace side; the Session-scope `workspaceContext` read view + * reads this seed and refreshes itself off `onDidChange`. Seeded into the + * Session scope by `workspaceHandler` when the session is materialized. + * Session-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import type { Event } from '#/_base/event'; + +export interface ISessionWorkspaceInfo { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly additionalDirs: readonly string[]; + readonly onDidChange: Event; +} + +export const ISessionWorkspaceInfo: ServiceIdentifier = + createDecorator('sessionWorkspaceInfo'); + +export function sessionWorkspaceInfoSeed(info: ISessionWorkspaceInfo): ScopeSeed { + return [[ISessionWorkspaceInfo as ServiceIdentifier, info]]; +} diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentCatalogRuntimeOptions.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions.ts similarity index 92% rename from packages/agent-core-v2/src/app/agentFileCatalog/agentCatalogRuntimeOptions.ts rename to packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions.ts index c1eb781edb..217a05636c 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/agentCatalogRuntimeOptions.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions.ts @@ -1,5 +1,5 @@ /** - * `agentFileCatalog` domain (L3) — runtime options for agent-file discovery. + * `workspaceAgentProfileLoader` domain (L3) — runtime options for agent-file discovery. * * Holds process-level runtime overrides: `explicitFiles` mirrors the CLI's * `--agent-file` — individual agent Markdown files loaded as the highest- @@ -47,5 +47,5 @@ registerScopedService( IAgentCatalogRuntimeOptions, AgentCatalogRuntimeOptions, ScopeActivation.OnScopeCreated, - 'agentFileCatalog', + 'workspaceAgentProfileLoader', ); diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/configSection.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/configSection.ts similarity index 89% rename from packages/agent-core-v2/src/app/agentFileCatalog/configSection.ts rename to packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/configSection.ts index 34806dcf00..6472566ced 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/configSection.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/configSection.ts @@ -1,5 +1,5 @@ /** - * `agentFileCatalog` domain (L3) — agent-file config sections. + * `workspaceAgentProfileLoader` domain (L3) — agent-file config sections. * * Registers the top-level config domain `extraAgentDirs`: additional * directories scanned for agent Markdown files. Values stay camelCase in diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader.ts new file mode 100644 index 0000000000..e47c34aaf4 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader.ts @@ -0,0 +1,23 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IExplicitAgentProfileLoader` contract. + * + * The explicit loader of the agent-profile extension point: owns the + * `explicit` contribution in the App-scope `IAgentProfileRegistry` — the + * runtime-selected agent files (`--agent-file`, carried by + * `IAgentCatalogRuntimeOptions`), tagged with this handler's `workspaceId`. + * The loader is `fatal`: an invalid explicit file is an explicit user intent + * that must not be silently dropped, so the rejection propagates into `ready` + * and session materialization fails fast; `reload()` re-arms it once the + * offending file is fixed. Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IExplicitAgentProfileLoader { + readonly _serviceBrand: undefined; + readonly ready: Promise; + reload(): Promise; +} + +export const IExplicitAgentProfileLoader: ServiceIdentifier = + createDecorator('explicitAgentProfileLoader'); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService.ts new file mode 100644 index 0000000000..97a958051f --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService.ts @@ -0,0 +1,80 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IExplicitAgentProfileLoader` implementation. + * + * Loads the runtime-selected agent files through `hostFs`, resolving paths + * against the workspace root (`workspaceContext`) and `bootstrap`. + * `${base_prompt}` is backed by the user loader's effective default profile. + * Bound at Workspace scope. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; +import { IAgentCatalogRuntimeOptions } from '#/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions'; +import { parseAgentFileText } from '#/workspace/workspaceAgentProfileLoader/internal/agentFile'; +import { AgentProfileLoaderBase } from '#/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader'; +import { agentProfileFromFile } from '#/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile'; +import { + AGENT_PROFILE_SOURCE_PRIORITY, + type AgentProfileContribution, +} from '#/app/agentProfileCatalog/agentProfileContribution'; +import { resolveAgentPath } from '#/workspace/workspaceAgentProfileLoader/internal/paths'; +import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { IExplicitAgentProfileLoader } from './explicitAgentProfileLoader'; + +export class ExplicitAgentProfileLoaderService + extends AgentProfileLoaderBase + implements IExplicitAgentProfileLoader +{ + declare readonly _serviceBrand: undefined; + + protected readonly sourceId = 'explicit'; + protected readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.explicit; + protected override readonly fatal = true; + + constructor( + @IAgentCatalogRuntimeOptions private readonly runtimeOptions: IAgentCatalogRuntimeOptions, + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IHostFileSystem private readonly fs: IHostFileSystem, + @ILogService log: ILogService, + @IUserAgentProfileLoader private readonly user: IUserAgentProfileLoader, + @IAgentProfileRegistry registry: IAgentProfileRegistry, + ) { + super(registry, log); + this.start(); + } + + protected override get workspaceKey(): string { + return this.workspace.workspaceId; + } + + protected async load(): Promise { + const files = this.runtimeOptions.explicitFiles ?? []; + const profiles: AgentProfile[] = []; + for (const file of files) { + const filePath = resolveAgentPath(file, this.workspace.cwd, this.bootstrap.osHomeDir); + const text = await this.fs.readText(filePath); + profiles.push( + agentProfileFromFile( + parseAgentFileText({ path: filePath, source: 'explicit', text }), + (context) => this.user.getDefaultProfile().systemPrompt(context), + ), + ); + } + return { profiles }; + } +} + +registerScopedService( + LifecycleScope.Workspace, + IExplicitAgentProfileLoader, + ExplicitAgentProfileLoaderService, + ScopeActivation.OnScopeCreated, + 'workspaceAgentProfileLoader', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader.ts new file mode 100644 index 0000000000..cfb25e6469 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader.ts @@ -0,0 +1,22 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IExtraAgentProfileLoader` contract. + * + * The extra loader of the agent-profile extension point: owns the `extra` + * contribution in the App-scope `IAgentProfileRegistry` — the agent files + * discovered from the configured `extraAgentDirs`, tagged with this handler's + * `workspaceId` (relative configured paths resolve against the workspace + * root, so the contribution is workspace-local even though the config section + * is global). `ready` tracks the most recent discovery pass; `reload()` + * re-discovers and re-registers. Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IExtraAgentProfileLoader { + readonly _serviceBrand: undefined; + readonly ready: Promise; + reload(): Promise; +} + +export const IExtraAgentProfileLoader: ServiceIdentifier = + createDecorator('extraAgentProfileLoader'); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService.ts new file mode 100644 index 0000000000..0715c17377 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService.ts @@ -0,0 +1,98 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IExtraAgentProfileLoader` implementation. + * + * Resolves the configured `extraAgentDirs` through `config`, `workspaceContext`, + * `bootstrap`, and `hostFs`, reporting skipped files through `log`. + * `${base_prompt}` is backed by the user loader's effective default profile. + * Reloads when the `extraAgentDirs` config section changes. Bound at + * Workspace scope. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { discoverAgentFiles } from '#/workspace/workspaceAgentProfileLoader/internal/agentFileDiscovery'; +import { AgentProfileLoaderBase } from '#/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader'; +import { + AGENT_PROFILE_SOURCE_PRIORITY, + type AgentProfileContribution, +} from '#/app/agentProfileCatalog/agentProfileContribution'; +import { profilesFromDiscovery } from './internal/agentProfileFromFile'; +import { configuredAgentRoots } from '#/workspace/workspaceAgentProfileLoader/internal/agentRoots'; +import { + EXTRA_AGENT_DIRS_SECTION, + type ExtraAgentDirsConfig, +} from '#/workspace/workspaceAgentProfileLoader/configSection'; +import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { IExtraAgentProfileLoader } from './extraAgentProfileLoader'; + +export class ExtraAgentProfileLoaderService + extends AgentProfileLoaderBase + implements IExtraAgentProfileLoader +{ + declare readonly _serviceBrand: undefined; + + protected readonly sourceId = 'extra'; + protected readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.extra; + + constructor( + @IConfigService private readonly config: IConfigService, + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IHostFileSystem private readonly fs: IHostFileSystem, + @ILogService log: ILogService, + @IUserAgentProfileLoader private readonly user: IUserAgentProfileLoader, + @IAgentProfileRegistry registry: IAgentProfileRegistry, + ) { + super(registry, log); + this._register( + this.config.onDidSectionChange((event) => { + if (event.domain === EXTRA_AGENT_DIRS_SECTION) { + void this.reload().catch((error) => { + this.log.warn(`agent profile loader "extra" reload failed: ${String(error)}`); + }); + } + }), + ); + this.start(); + } + + protected override get workspaceKey(): string { + return this.workspace.workspaceId; + } + + protected async load(): Promise { + await this.config.ready; + const dirs = this.config.get(EXTRA_AGENT_DIRS_SECTION) ?? []; + return profilesFromDiscovery( + await discoverAgentFiles( + this.fs, + await configuredAgentRoots( + this.fs, + dirs, + this.workspace.cwd, + this.bootstrap.osHomeDir, + 'extra', + (message, error) => { + this.log.warn(message, error); + }, + ), + (message) => this.log.warn(message), + ), + (context) => this.user.getDefaultProfile().systemPrompt(context), + ); + } +} + +registerScopedService( + LifecycleScope.Workspace, + IExtraAgentProfileLoader, + ExtraAgentProfileLoaderService, + ScopeActivation.OnScopeCreated, + 'workspaceAgentProfileLoader', +); diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts similarity index 97% rename from packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts rename to packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts index e11b38c9b3..cacb0cf686 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts @@ -1,5 +1,5 @@ /** - * `agentFileCatalog` domain (L3) — agent-file parsing primitives. + * `workspaceAgentProfileLoader` domain (L3) — agent-file parsing primitives. * * Parses a single agent Markdown file (frontmatter + body) into an * `AgentFileDefinition`. Pure functions with no IO: callers read bytes however @@ -12,7 +12,7 @@ * (Claude Code). */ -import { FrontmatterError, parseFrontmatter } from '#/app/skillCatalog/parser'; +import { FrontmatterError, parseFrontmatter } from '#/_base/text/frontmatter'; import type { AgentFileDefinition, AgentFileSource } from './types'; diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentFileDiscovery.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFileDiscovery.ts similarity index 98% rename from packages/agent-core-v2/src/app/agentFileCatalog/agentFileDiscovery.ts rename to packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFileDiscovery.ts index 4b9a1bc36a..e2ec7b7b5e 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/agentFileDiscovery.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFileDiscovery.ts @@ -1,5 +1,5 @@ /** - * `agentFileCatalog` domain (L3) — filesystem agent-file discovery. + * `workspaceAgentProfileLoader` domain (L3) — filesystem agent-file discovery. * * Discovers and parses agent files through the `hostFs` filesystem boundary. * Invalid files are isolated from the rest of the discovery pass. Failure diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileFromFile.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile.ts similarity index 65% rename from packages/agent-core-v2/src/app/agentFileCatalog/agentProfileFromFile.ts rename to packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile.ts index 7f8d0affe2..741b13acfd 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileFromFile.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile.ts @@ -1,5 +1,5 @@ /** - * `agentFileCatalog` domain (L3) — `AgentFileDefinition` → `AgentProfile` factory. + * `workspaceAgentProfileLoader` domain (L3) — `AgentFileDefinition` → `AgentProfile` factory. * * The file body is a prompt template rendered against the shared variable * table (`systemPromptVars`): `${var}` placeholders substitute live context, @@ -11,15 +11,21 @@ * `IAgentToolPolicyService`; `subagents` passes through as the delegation * allowlist enforced by the `Agent` / `AgentSwarm` tools; `model_preference` * becomes the symbolic default those tools use when spawning the profile. + * `profilesFromDiscovery` packs a whole discovery pass into an + * `AgentProfileContribution`, binding each profile's `${base_prompt}` + * placeholder lazily at render time so it always reflects the effective + * default profile (builtin, or the `SYSTEM.md` override) rather than any + * file-based definition. */ import type { AgentProfile, AgentProfileContext, } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import type { AgentProfileContribution } from '#/app/agentProfileCatalog/agentProfileContribution'; import { renderPromptTemplate } from '#/app/agentProfileCatalog/profile-shared'; -import type { AgentFileDefinition } from './types'; +import type { AgentFileDefinition, AgentFileDiscoveryResult } from './types'; export function agentProfileFromFile( definition: AgentFileDefinition, @@ -41,3 +47,14 @@ export function agentProfileFromFile( renderPromptTemplate(definition.prompt, context, { skillActive }, basePrompt), }; } + +export function profilesFromDiscovery( + result: AgentFileDiscoveryResult, + basePrompt: (context: AgentProfileContext) => string, +): AgentProfileContribution { + return { + profiles: result.agents.map((definition) => agentProfileFromFile(definition, basePrompt)), + skipped: result.skipped, + scannedRoots: result.scannedRoots, + }; +} diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader.ts new file mode 100644 index 0000000000..bd7e74dcd8 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader.ts @@ -0,0 +1,83 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `AgentProfileLoaderBase`, the shared + * loader skeleton of the agent-profile extension point. + * + * A loader owns one source id: it loads an `AgentProfileContribution` and + * registers it into the App-scope `IAgentProfileRegistry` under that id + * (workspace-local loaders additionally tag a `workspaceKey`). The first load + * starts when the subclass constructor calls {@link start} — after its own + * fields are set, since `load()` is virtual. `ready` tracks the most recent + * load pass; `reload()` replaces it, so a `fatal` failure does not wedge the + * loader once the underlying problem is fixed. A rejecting `fatal` loader + * (an invalid `--agent-file`) propagates into `ready` so session + * materialization fails fast; a rejecting non-fatal loader degrades to a + * warning and keeps any previously registered contribution, so directory + * problems never poison the registry. Loads are serialized per loader — a + * refresh never overlaps the previous pass — and the swallowed handler on + * `ready` keeps an un-awaited rejection from crashing the process. + */ + +import { Disposable, MutableDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import type { ILogService } from '#/_base/log/log'; +import type { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; + +import type { AgentProfileContribution } from '#/app/agentProfileCatalog/agentProfileContribution'; + +export abstract class AgentProfileLoaderBase extends Disposable { + protected abstract readonly sourceId: string; + protected abstract readonly priority: number; + protected readonly fatal: boolean = false; + + private readyPromise: Promise = Promise.resolve(); + private tail: Promise = Promise.resolve(); + private readonly registrationHandle = this._register(new MutableDisposable()); + + constructor( + protected readonly registry: IAgentProfileRegistry, + protected readonly log: ILogService, + ) { + super(); + } + + get ready(): Promise { + return this.readyPromise; + } + + /** Starts the first load; subclass constructors call this after their own fields are set. */ + protected start(): void { + this.readyPromise = this.enqueue(); + void this.readyPromise.catch(() => undefined); + } + + async reload(): Promise { + this.readyPromise = this.enqueue(); + void this.readyPromise.catch(() => undefined); + await this.readyPromise; + } + + protected abstract load(): Promise; + + /** Workspace-local loaders override to tag their registration. */ + protected get workspaceKey(): string | undefined { + return undefined; + } + + private enqueue(): Promise { + const current = this.tail.catch(() => undefined).then(() => this.loadAndRegister()); + this.tail = current; + return current; + } + + private async loadAndRegister(): Promise { + try { + const contribution = await this.load(); + this.registrationHandle.value = this.registry.register(this.sourceId, contribution, { + priority: this.priority, + workspaceKey: this.workspaceKey, + }); + } catch (error) { + if (this.fatal) throw error; + this.log.warn(`agent profile loader "${this.sourceId}" load failed: ${String(error)}`); + } + } +} diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentRoots.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentRoots.ts similarity index 81% rename from packages/agent-core-v2/src/app/agentFileCatalog/agentRoots.ts rename to packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentRoots.ts index 1bccdc11aa..ba230da523 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/agentRoots.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentRoots.ts @@ -1,5 +1,5 @@ /** - * `agentFileCatalog` domain (L3) — agent-root resolution primitives. + * `workspaceAgentProfileLoader` domain (L3) — agent-root resolution primitives. * * Resolves user, project, and configured discovery roots through the `hostFs` * filesystem boundary. Pure path probes; no scoped state. @@ -46,6 +46,31 @@ export async function projectAgentRoots( return roots; } +/** + * The project root and the raw project agent-root candidates + * (`.kimi-code/agents` and `.agents/agents` under it) regardless of + * existence — the watch plan for project-agent refreshes, since + * `projectAgentRoots` filters to directories that already exist. + */ +export interface ProjectAgentRootCandidates { + readonly projectRoot: string; + readonly candidates: readonly string[]; +} + +export async function projectAgentRootCandidates( + fs: IHostFileSystem, + workDir: string, + warn?: AgentRootWarn, +): Promise { + const projectRoot = await findProjectRoot(fs, workDir, warn); + return { + projectRoot, + candidates: [...PROJECT_BRAND_DIRS, ...PROJECT_GENERIC_DIRS].map((dir) => + join(projectRoot, dir), + ), + }; +} + export async function configuredAgentRoots( fs: IHostFileSystem, dirs: readonly string[], diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/paths.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/paths.ts similarity index 95% rename from packages/agent-core-v2/src/app/agentFileCatalog/paths.ts rename to packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/paths.ts index 76b9aa92cb..84f77b036e 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/paths.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/paths.ts @@ -1,5 +1,5 @@ /** - * `agentFileCatalog` domain (L3) — shared path primitives for agent-file + * `workspaceAgentProfileLoader` domain (L3) — shared path primitives for agent-file * discovery. * * `~` expansion, base-relative resolution, and `hostFs` type probes used by diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/systemFile.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/systemFile.ts similarity index 96% rename from packages/agent-core-v2/src/app/agentFileCatalog/systemFile.ts rename to packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/systemFile.ts index 3d8685e6b8..f9ad84d4ad 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/systemFile.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/systemFile.ts @@ -1,5 +1,5 @@ /** - * `agentFileCatalog` domain (L3) — `SYSTEM.md` global main-agent prompt override. + * `workspaceAgentProfileLoader` domain (L3) — `SYSTEM.md` global main-agent prompt override. * * `/SYSTEM.md` (default `~/.kimi-code/SYSTEM.md`, moves with * `KIMI_CODE_HOME`) permanently replaces the builtin default profile's system diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/types.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/types.ts similarity index 81% rename from packages/agent-core-v2/src/app/agentFileCatalog/types.ts rename to packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/types.ts index 9187fd2a30..12cad2b2b2 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/types.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/types.ts @@ -1,5 +1,5 @@ /** - * `agentFileCatalog` domain (L3) — agent-file model types. + * `workspaceAgentProfileLoader` domain (L3) — agent-file model types. * * Shared types for the agent-file primitives: the parsed single-file * definition (`AgentFileDefinition`), scan roots (`AgentFileRoot`) tagged with @@ -8,6 +8,9 @@ */ import type { AgentModelPreference } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import type { SkippedAgentFile } from '#/app/agentProfileCatalog/agentProfileContribution'; + +export type { SkippedAgentFile } from '#/app/agentProfileCatalog/agentProfileContribution'; export type AgentFileSource = 'plugin' | 'project' | 'user' | 'extra' | 'explicit'; @@ -30,11 +33,6 @@ export interface AgentFileDefinition { readonly source: AgentFileSource; } -export interface SkippedAgentFile { - readonly path: string; - readonly reason: string; -} - export interface AgentFileDiscoveryResult { readonly agents: readonly AgentFileDefinition[]; readonly skipped: readonly SkippedAgentFile[]; diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader.ts new file mode 100644 index 0000000000..d0c66f18c9 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader.ts @@ -0,0 +1,20 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IPluginAgentProfileLoader` contract. + * + * The plugin loader of the agent-profile extension point: owns the `plugin` + * contribution in the App-scope `IAgentProfileRegistry` — the agent files + * discovered from the enabled plugins' agent roots, tagged with this + * handler's `workspaceId`. `ready` tracks the most recent discovery pass; + * `reload()` re-discovers and re-registers. Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IPluginAgentProfileLoader { + readonly _serviceBrand: undefined; + readonly ready: Promise; + reload(): Promise; +} + +export const IPluginAgentProfileLoader: ServiceIdentifier = + createDecorator('pluginAgentProfileLoader'); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService.ts new file mode 100644 index 0000000000..bbd32f77e9 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService.ts @@ -0,0 +1,79 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IPluginAgentProfileLoader` implementation. + * + * Discovers agent profiles contributed by enabled plugins (roots from the + * App-scope `plugins.pluginAgentRoots()`) and registers them via the shared + * loader skeleton. `${base_prompt}` is backed by the user loader's effective + * default profile. Reloads when plugins reload; install / enable / remove + * mutations deliberately do not re-register — those take effect on the next + * explicit reload. Bound at Workspace scope: agent-file discovery lives in + * the workspace layer alongside every other source. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; +import { IPluginService } from '#/app/plugin/plugin'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { discoverAgentFiles } from './internal/agentFileDiscovery'; +import { AgentProfileLoaderBase } from './internal/agentProfileLoader'; +import { + AGENT_PROFILE_SOURCE_PRIORITY, + type AgentProfileContribution, +} from '#/app/agentProfileCatalog/agentProfileContribution'; +import { profilesFromDiscovery } from './internal/agentProfileFromFile'; +import { IUserAgentProfileLoader } from './userAgentProfileLoader'; +import { IPluginAgentProfileLoader } from './pluginAgentProfileLoader'; + +export class PluginAgentProfileLoaderService + extends AgentProfileLoaderBase + implements IPluginAgentProfileLoader +{ + declare readonly _serviceBrand: undefined; + + protected readonly sourceId = 'plugin'; + protected readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.plugin; + + constructor( + @IPluginService private readonly plugins: IPluginService, + @IHostFileSystem private readonly fs: IHostFileSystem, + @ILogService log: ILogService, + @IUserAgentProfileLoader private readonly user: IUserAgentProfileLoader, + @IAgentProfileRegistry registry: IAgentProfileRegistry, + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + ) { + super(registry, log); + this._register( + this.plugins.onDidReload(() => { + void this.reload().catch((error) => { + this.log.warn(`agent profile loader "plugin" reload failed: ${String(error)}`); + }); + }), + ); + this.start(); + } + + protected override get workspaceKey(): string { + return this.workspace.workspaceId; + } + + protected async load(): Promise { + const roots = await this.plugins.pluginAgentRoots(); + return profilesFromDiscovery( + await discoverAgentFiles(this.fs, roots, (message) => { + this.log.warn(message); + }), + (context) => this.user.getDefaultProfile().systemPrompt(context), + ); + } +} + +registerScopedService( + LifecycleScope.Workspace, + IPluginAgentProfileLoader, + PluginAgentProfileLoaderService, + ScopeActivation.OnScopeCreated, + 'workspaceAgentProfileLoader', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoader.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoader.ts new file mode 100644 index 0000000000..adce0bf1ab --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoader.ts @@ -0,0 +1,26 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IUserAgentProfileLoader` contract. + * + * The user loader of the agent-profile extension point: owns the `user` + * contribution in the App-scope `IAgentProfileRegistry` — the agent files + * discovered from the user agent roots under the os home, plus the + * `/SYSTEM.md` prompt-override profile appended after them — tagged + * with this handler's `workspaceId`. Also exposes the effective default + * profile (the `SYSTEM.md` override when present, else the builtin default, + * refreshed on each load pass) so every sibling loader can back + * `${base_prompt}` with it. `ready` tracks the most recent discovery pass; + * `reload()` re-discovers and re-registers. Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; + +export interface IUserAgentProfileLoader { + readonly _serviceBrand: undefined; + readonly ready: Promise; + getDefaultProfile(): AgentProfile; + reload(): Promise; +} + +export const IUserAgentProfileLoader: ServiceIdentifier = + createDecorator('userAgentProfileLoader'); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService.ts new file mode 100644 index 0000000000..c12b3a61c0 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService.ts @@ -0,0 +1,97 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IUserAgentProfileLoader` implementation. + * + * Discovers user agent profiles through `bootstrap` home paths and `hostFs`, + * reports skipped files through `log`, and appends the `/SYSTEM.md` + * prompt-override profile (synthesized against the builtin default from the + * App builtin loader) after the scanned profiles so it wins same-name + * collisions within this contribution. The user roots are global os + * directories, but per-workspace registration keeps every contribution + * flowing through the same workspace-tagged lane, so the Session catalog + * projection needs no special case. Bound at Workspace scope. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; +import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { discoverAgentFiles } from './internal/agentFileDiscovery'; +import { AgentProfileLoaderBase } from './internal/agentProfileLoader'; +import { + AGENT_PROFILE_SOURCE_PRIORITY, + type AgentProfileContribution, +} from '#/app/agentProfileCatalog/agentProfileContribution'; +import { profilesFromDiscovery } from './internal/agentProfileFromFile'; +import { userAgentRoots } from './internal/agentRoots'; +import { loadSystemMdProfile } from './internal/systemFile'; +import { IUserAgentProfileLoader } from './userAgentProfileLoader'; + +export class UserAgentProfileLoaderService + extends AgentProfileLoaderBase + implements IUserAgentProfileLoader +{ + declare readonly _serviceBrand: undefined; + + protected readonly sourceId = 'user'; + protected readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.user; + + private defaultProfile: AgentProfile; + + constructor( + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IHostFileSystem private readonly fs: IHostFileSystem, + @ILogService log: ILogService, + @IBuiltinAgentProfileLoader private readonly builtin: IBuiltinAgentProfileLoader, + @IAgentProfileRegistry registry: IAgentProfileRegistry, + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + ) { + super(registry, log); + this.defaultProfile = builtin.getDefault(); + this.start(); + } + + protected override get workspaceKey(): string { + return this.workspace.workspaceId; + } + + getDefaultProfile(): AgentProfile { + return this.defaultProfile; + } + + protected async load(): Promise { + const roots = await userAgentRoots( + this.fs, + this.bootstrap.homeDir, + this.bootstrap.osHomeDir, + (message, error) => { + this.log.warn(message, error); + }, + ); + const systemMd = await loadSystemMdProfile( + this.fs, + this.bootstrap.homeDir, + this.builtin.getDefault(), + (message) => this.log.warn(message), + ); + this.defaultProfile = systemMd ?? this.builtin.getDefault(); + const contribution = profilesFromDiscovery( + await discoverAgentFiles(this.fs, roots, (message) => this.log.warn(message)), + (context) => this.defaultProfile.systemPrompt(context), + ); + if (systemMd === undefined) return contribution; + return { ...contribution, profiles: [...contribution.profiles, systemMd] }; + } +} + +registerScopedService( + LifecycleScope.Workspace, + IUserAgentProfileLoader, + UserAgentProfileLoaderService, + ScopeActivation.OnScopeCreated, + 'workspaceAgentProfileLoader', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader.ts new file mode 100644 index 0000000000..077a080fbe --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader.ts @@ -0,0 +1,22 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IWorkspaceAgentProfileLoader` contract. + * + * The workspace loader of the agent-profile extension point: owns the + * `workspace` contribution in the App-scope `IAgentProfileRegistry` — the + * agent files discovered under this handler's project root, tagged with the + * handler's `workspaceId` so concurrent handlers never collide and the + * sessions of THIS workspace project exactly this entry. `ready` tracks the + * most recent discovery pass; `reload()` re-discovers and re-registers. + * Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IWorkspaceAgentProfileLoader { + readonly _serviceBrand: undefined; + readonly ready: Promise; + reload(): Promise; +} + +export const IWorkspaceAgentProfileLoader: ServiceIdentifier = + createDecorator('workspaceAgentProfileLoader'); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService.ts new file mode 100644 index 0000000000..d31299f09c --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService.ts @@ -0,0 +1,109 @@ +/** + * `workspaceAgentProfileLoader` domain (L3) — `IWorkspaceAgentProfileLoader` implementation. + * + * Discovers the workspace's agent files (`.kimi-code/agents`, `.agents/agents` + * under the project root, resolved through `workspaceContext` and `hostFs`) + * and registers them via the shared loader skeleton. `${base_prompt}` is + * backed by the user loader's effective default profile. Watches the project + * agent-root candidates through `hostFsWatch` (watched whether or not they + * exist yet) and reloads debounced, so a project agent-file change + * re-registers this contribution only. Bound at Workspace scope: the scan is + * per handler and the registration dies with it. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { TimeoutTimer } from '#/_base/utils/timer'; +import { subtreeWatchFilter } from '#/_base/utils/paths'; +import { discoverAgentFiles } from '#/workspace/workspaceAgentProfileLoader/internal/agentFileDiscovery'; +import { AgentProfileLoaderBase } from '#/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader'; +import { + AGENT_PROFILE_SOURCE_PRIORITY, + type AgentProfileContribution, +} from '#/app/agentProfileCatalog/agentProfileContribution'; +import { profilesFromDiscovery } from './internal/agentProfileFromFile'; +import { projectAgentRootCandidates, projectAgentRoots } from '#/workspace/workspaceAgentProfileLoader/internal/agentRoots'; +import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { IWorkspaceAgentProfileLoader } from './workspaceAgentProfileLoader'; + +const WATCH_DEBOUNCE_MS = 200; + +export class WorkspaceAgentProfileLoaderService + extends AgentProfileLoaderBase + implements IWorkspaceAgentProfileLoader +{ + declare readonly _serviceBrand: undefined; + + protected readonly sourceId = 'workspace'; + protected readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.workspace; + + private readonly watchDebounce = this._register(new TimeoutTimer()); + private readonly watchReady: Promise; + + constructor( + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + @IHostFileSystem private readonly fs: IHostFileSystem, + @ILogService log: ILogService, + @IUserAgentProfileLoader private readonly user: IUserAgentProfileLoader, + @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, + @IAgentProfileRegistry registry: IAgentProfileRegistry, + ) { + super(registry, log); + this.watchReady = this.watchProjectAgentRoots(); + this.start(); + } + + protected override get workspaceKey(): string { + return this.workspace.workspaceId; + } + + protected async load(): Promise { + // The watch attaches before the first scan returns, so a change landing + // right after the scan cannot slip between the two. + await this.watchReady; + const roots = await projectAgentRoots(this.fs, this.workspace.cwd, (message, error) => { + this.log.warn(message, error); + }); + return profilesFromDiscovery( + await discoverAgentFiles(this.fs, roots, (message) => this.log.warn(message)), + (context) => this.user.getDefaultProfile().systemPrompt(context), + ); + } + + private async watchProjectAgentRoots(): Promise { + // Watch the project root recursively, pruned to the agent-root + // candidates: watching a candidate directory directly never fires when + // its parent (`.kimi-code` / `.agents`) does not exist yet either. + const { projectRoot, candidates } = await projectAgentRootCandidates( + this.fs, + this.workspace.cwd, + (message) => this.log.warn(message), + ); + const handle = this.fsWatch.watch(projectRoot, { + ignored: subtreeWatchFilter(projectRoot, candidates), + }); + this._register(handle); + this._register( + handle.onDidChange(() => { + this.watchDebounce.cancelAndSet(() => { + void this.reload().catch((error) => { + this.log.warn(`agent profile loader "workspace" reload failed: ${String(error)}`); + }); + }, WATCH_DEBOUNCE_MS); + }), + ); + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceAgentProfileLoader, + WorkspaceAgentProfileLoaderService, + ScopeActivation.OnScopeCreated, + 'workspaceAgentProfileLoader', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceContext/workspaceContext.ts b/packages/agent-core-v2/src/workspace/workspaceContext/workspaceContext.ts new file mode 100644 index 0000000000..7d6a99ca12 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceContext/workspaceContext.ts @@ -0,0 +1,56 @@ +/** + * `workspaceContext` domain (L1) — seeded per-handler workspace facts. + * + * Defines the `IWorkspaceContext` carrying the workspace handler's identity + * and storage addressing (`workspaceId`, `persistenceScope` — the handler's + * persistence scope string `sessions/{wd_id}` every session/agent scope + * string derives from), the workspace root (`cwd`) and catalog metadata + * (`meta`), plus the runtime keying pair (`osBackendId` × + * `persistenceBackendId`) that records which os/persistence backends the + * handler binds — both `'local'` until a remote runtime exists (`remoteCwd` + * reserves the remote root slot, never set by the local runtime). Seeded + * into the Workspace scope by `workspaceLifecycle` when the handler is + * materialized. Pure facts — no store, no IO. Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; + +/** The only runtime source today; remote runtimes will add their own. */ +export type WorkspaceSource = 'local'; + +export const LOCAL_OS_BACKEND_ID = 'local'; +export const LOCAL_PERSISTENCE_BACKEND_ID = 'local'; + +/** + * Mirrors the `workspace` catalog's `Workspace` record field-for-field. + * Re-declared structurally so this L1 seed does not import the L2 catalog + * domain; a `Workspace` value is directly assignable here. + */ +export interface WorkspaceMeta { + readonly id: string; + readonly root: string; + readonly name: string; + readonly createdAt: number; + readonly lastOpenedAt: number; +} + +export interface IWorkspaceContext { + readonly _serviceBrand: undefined; + + readonly workspaceId: string; + readonly cwd: string; + readonly source: WorkspaceSource; + readonly remoteCwd?: string; + readonly meta: WorkspaceMeta; + readonly persistenceScope: string; + readonly osBackendId: string; + readonly persistenceBackendId: string; +} + +export const IWorkspaceContext: ServiceIdentifier = + createDecorator('workspaceContext'); + +export function workspaceContextSeed(ctx: IWorkspaceContext): ScopeSeed { + return [[IWorkspaceContext as ServiceIdentifier, ctx]]; +} diff --git a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirs.ts b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirs.ts new file mode 100644 index 0000000000..eabcf1ca23 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirs.ts @@ -0,0 +1,50 @@ +/** + * `workspaceDirs` domain (L3) — Workspace-scoped additional-directory set + * contract. + * + * Defines `IWorkspaceDirs`, the handler-level owner of the workspace's + * `{root, additionalDirs[]}` set: at handler materialization it loads the + * project-local `.kimi-code/local.toml` set; afterwards `addDir` mutations + * (persisted appends or session-caller in-memory unions) and fs watch on + * `local.toml` (cross-process edits) refresh the set, fanning the change + * out to every session of the handler through the `ISessionWorkspaceInfo` + * seed (`sessionInfo()`). The set is shared by all sessions of the + * workspace and persisted entries survive restarts; non-persisted entries + * live in handler memory only. Bound at Workspace scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; + +import type { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; + +export interface WorkspaceAddDirInput { + readonly path: string; + /** Persist to `.kimi-code/local.toml` (default true); false keeps the dir in handler memory only. */ + readonly persist?: boolean; +} + +export interface WorkspaceAdditionalDirsResult { + readonly projectRoot: string; + readonly configPath: string; + readonly additionalDirs: readonly string[]; + readonly persisted: boolean; +} + +export interface IWorkspaceDirs { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly additionalDirs: readonly string[]; + readonly onDidChange: Event; + addDir(input: WorkspaceAddDirInput): Promise; + /** + * Union caller-provided dirs (session create/resume options, resolved + * against `baseDir`) into the shared in-memory set — never persisted. + */ + mergeAdditionalDirs(baseDir: string, dirs: readonly string[]): Promise; + sessionInfo(): ISessionWorkspaceInfo; +} + +export const IWorkspaceDirs: ServiceIdentifier = + createDecorator('workspaceDirs'); diff --git a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts new file mode 100644 index 0000000000..2207d31abc --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts @@ -0,0 +1,202 @@ +/** + * `workspaceDirs` domain (L3) — `IWorkspaceDirs` implementation. + * + * Holds the handler-shared additional-directory set as + * `fileDirs ∪ ephemeralDirs`: `fileDirs` is the project-local + * `.kimi-code/local.toml` set (loaded once per handler through + * `projectLocalConfig`, reloaded debounced when the fs watch sees the file + * change — including writes from OTHER processes), `ephemeralDirs` is the + * in-memory union of non-persisted `addDir` calls and caller-provided dirs + * from session create/resume options (it dies with the handler). Every + * mutation serializes on one tail queue; the change event fires only when + * the combined list actually changed. The set reaches every session of the + * handler through the `ISessionWorkspaceInfo` seed (`sessionInfo()`), a + * live read view over this service. Bound at Workspace scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { TimeoutTimer } from '#/_base/utils/timer'; +import { subtreeWatchFilter } from '#/_base/utils/paths'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import type { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { + IWorkspaceDirs, + type WorkspaceAddDirInput, + type WorkspaceAdditionalDirsResult, +} from './workspaceDirs'; + +const WATCH_DEBOUNCE_MS = 200; + +export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs { + declare readonly _serviceBrand: undefined; + + private fileDirs: readonly string[] = []; + private ephemeralDirs: readonly string[] = []; + private projectRoot: string; + private configPath: string; + readonly ready: Promise; + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + private readonly watchDebounce = this._register(new TimeoutTimer()); + private mutationTail: Promise = Promise.resolve(); + + constructor( + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + @IProjectLocalConfigService private readonly localConfig: IProjectLocalConfigService, + @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, + @ILogService private readonly log: ILogService, + ) { + super(); + this.projectRoot = workspace.cwd; + this.configPath = ''; + this.ready = this.enqueue(() => this.reloadFromDisk()); + void this.ready.then(() => this.watchLocalToml()); + } + + get additionalDirs(): readonly string[] { + return [...new Set([...this.fileDirs, ...this.ephemeralDirs])]; + } + + addDir(input: WorkspaceAddDirInput): Promise { + return this.enqueue(() => this.applyAddDir(input)); + } + + mergeAdditionalDirs(baseDir: string, dirs: readonly string[]): Promise { + if (dirs.length === 0) return Promise.resolve(); + return this.enqueue(async () => { + const resolved = await this.localConfig.resolveAdditionalDirs(baseDir, dirs); + if (this.unionEphemeral(resolved)) { + this.onDidChangeEmitter.fire(); + } + }); + } + + sessionInfo(): ISessionWorkspaceInfo { + const currentDirs = (): readonly string[] => this.additionalDirs; + return { + _serviceBrand: undefined, + ready: this.ready, + onDidChange: this.onDidChange, + get additionalDirs() { + return currentDirs(); + }, + }; + } + + private async applyAddDir(input: WorkspaceAddDirInput): Promise { + const persist = input.persist ?? true; + + if (persist) { + const persisted = await this.localConfig.appendAdditionalDir( + this.workspace.cwd, + input.path, + ); + this.projectRoot = persisted.projectRoot; + this.configPath = persisted.configPath; + const changed = this.setFileDirs(persisted.additionalDirs); + if (changed) { + this.onDidChangeEmitter.fire(); + } + return { + projectRoot: persisted.projectRoot, + configPath: persisted.configPath, + additionalDirs: this.additionalDirs, + persisted: true, + }; + } + + const onDisk = await this.localConfig.readAdditionalDirs(this.workspace.cwd); + this.projectRoot = onDisk.projectRoot; + this.configPath = onDisk.configPath; + const resolved = await this.localConfig.resolveAdditionalDirs(this.workspace.cwd, [ + input.path, + ]); + const changed = this.unionEphemeral(resolved); + if (changed) { + this.onDidChangeEmitter.fire(); + } + return { + projectRoot: onDisk.projectRoot, + configPath: onDisk.configPath, + additionalDirs: this.additionalDirs, + persisted: false, + }; + } + + private async reloadFromDisk(): Promise { + const onDisk = await this.localConfig.readAdditionalDirs(this.workspace.cwd); + this.projectRoot = onDisk.projectRoot; + this.configPath = onDisk.configPath; + if (this.setFileDirs(onDisk.additionalDirs)) { + this.onDidChangeEmitter.fire(); + } + } + + private setFileDirs(dirs: readonly string[]): boolean { + const before = this.additionalDirs; + this.fileDirs = dirs; + return !sameStringList(before, this.additionalDirs); + } + + private unionEphemeral(dirs: readonly string[]): boolean { + const before = this.additionalDirs; + this.ephemeralDirs = [...new Set([...this.ephemeralDirs, ...dirs])]; + return !sameStringList(before, this.additionalDirs); + } + + /** + * Watch the project root recursively, pruned to the `local.toml` + * candidate: watching the file directly never fires when its parent + * `.kimi-code` directory does not exist yet either. + * + * Runs only after `ready` resolves, so `reloadFromDisk` has already + * populated `projectRoot` / `configPath`. + */ + private watchLocalToml(): void { + try { + const handle = this.fsWatch.watch(this.projectRoot, { + recursive: true, + ignored: subtreeWatchFilter(this.projectRoot, [this.configPath]), + }); + this._register(handle); + this._register( + handle.onDidChange(() => { + this.watchDebounce.cancelAndSet(() => { + void this.enqueue(() => this.reloadFromDisk()).catch((error) => { + this.log.warn(`local.toml reload failed: ${String(error)}`); + }); + }, WATCH_DEBOUNCE_MS); + }), + ); + } catch (error) { + this.log.warn(`cannot watch project-local config ${this.configPath}: ${String(error)}`); + } + } + + private enqueue(work: () => Promise): Promise { + const run = this.mutationTail.then(work, work); + this.mutationTail = run.then( + () => undefined, + () => undefined, + ); + return run; + } +} + +function sameStringList(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceDirs, + WorkspaceDirsService, + ScopeActivation.OnScopeCreated, + 'workspaceDirs', +); diff --git a/packages/agent-core-v2/src/session/sessionFs/errors.ts b/packages/agent-core-v2/src/workspace/workspaceFs/errors.ts similarity index 94% rename from packages/agent-core-v2/src/session/sessionFs/errors.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/errors.ts index 058370a2fc..dd5fe32d1d 100644 --- a/packages/agent-core-v2/src/session/sessionFs/errors.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/errors.ts @@ -1,5 +1,5 @@ /** - * `sessionFs` domain error codes. + * `workspaceFs` domain error codes. */ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; diff --git a/packages/agent-core-v2/src/session/sessionFs/fs.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fs.ts similarity index 88% rename from packages/agent-core-v2/src/session/sessionFs/fs.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/fs.ts index f19320310f..6443b326aa 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fs.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fs.ts @@ -1,13 +1,14 @@ /** - * `sessionFs` domain (L2) — wire-shaped filesystem operations. + * `workspaceFs` domain (L3) — wire-shaped filesystem operations. * - * Defines the `ISessionFsService` that backs the fs REST surface: content search, - * content grep, and git status/diff, together with the zod DTO schemas the - * transports validate against. It orchestrates the os `IHostFileSystem` - * (file IO, resolved against the workspace root) plus `ISessionProcessRunner` - * (for `rg` / `git` / `gh`). Git status/diff DTOs live in the `git` domain. - * Session-scoped — the scope itself is the session, so no `sessionId` is - * threaded through. + * Defines the `IWorkspaceFsService` that backs the fs REST surface: content + * search, content grep, and git status/diff, together with the zod DTO + * schemas the transports validate against. It orchestrates the os + * `IHostFileSystem` (file IO, resolved against the workspace root) plus the + * handler-shared `ISessionProcessRunner` (for `rg`). Git status/diff DTOs + * live in the `git` domain. Workspace-scoped — one instance per handler, + * pinned to the handler root (chdir is gone, so the root never changes); the + * edge resolves it through any live session of the workspace. */ import { z } from 'zod'; @@ -180,7 +181,10 @@ export const fsStatManyResponseSchema = z.object({ export type FsStatManyResponse = z.infer; export const fsSearchRequestSchema = z.object({ - query: z.string().min(1), + // An empty query is allowed: the service then lists the workspace root's + // top-level entries (dirs first) instead of fuzzy-matching — the starting + // set for @-mention style file pickers. + query: z.string(), limit: z.number().int().min(1).max(200).default(50), include_globs: z.array(z.string()).optional(), exclude_globs: z.array(z.string()).optional(), @@ -231,7 +235,7 @@ export interface FsDownloadResolved { readonly modifiedAt: Date; } -export interface ISessionFsService { +export interface IWorkspaceFsService { readonly _serviceBrand: undefined; list(req: FsListRequest): Promise; @@ -248,5 +252,5 @@ export interface ISessionFsService { resolveDownload(relPath: string): Promise; } -export const ISessionFsService: ServiceIdentifier = - createDecorator('sessionFsService'); +export const IWorkspaceFsService: ServiceIdentifier = + createDecorator('workspaceFsService'); diff --git a/packages/agent-core-v2/src/session/sessionFs/fsProcess.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsProcess.ts similarity index 95% rename from packages/agent-core-v2/src/session/sessionFs/fsProcess.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/fsProcess.ts index 2eabe9bb43..59c0a53b0c 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsProcess.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsProcess.ts @@ -1,5 +1,5 @@ /** - * `sessionFs` domain (L2) — `runCommand` helper over `ISessionProcessRunner`. + * `workspaceFs` domain (L3) — `runCommand` helper over `ISessionProcessRunner`. * * Collects a child process's full stdout/stderr and exit code through the * Agent's backend-pluggable `ISessionProcessRunner`, with optional `AbortSignal` diff --git a/packages/agent-core-v2/src/session/sessionFs/fsSearch.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsSearch.ts similarity index 98% rename from packages/agent-core-v2/src/session/sessionFs/fsSearch.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/fsSearch.ts index 0c4ed321f4..cc4a207363 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsSearch.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsSearch.ts @@ -1,5 +1,5 @@ /** - * `sessionFs` domain (L2) — pure search/grep helpers. + * `workspaceFs` domain (L3) — pure search/grep helpers. * * Fuzzy filename scoring, glob matching, grep-pattern compilation, and * ripgrep `--json` record parsing. No IO, no DI — plain functions so they can diff --git a/packages/agent-core-v2/src/session/sessionFs/fsService.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts similarity index 88% rename from packages/agent-core-v2/src/session/sessionFs/fsService.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts index 103924b828..d3922cd3ba 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts @@ -1,23 +1,25 @@ /** - * `sessionFs` domain (L2) — `ISessionFsService` implementation. + * `workspaceFs` domain (L3) — `IWorkspaceFsService` implementation. * * Backs the fs REST surface (search / grep / git status / git diff) by * orchestrating the os `IHostFileSystem` (file IO, resolved against the - * workspace root), `ISessionProcessRunner` (`rg`), and `IGitService` (git - * root and execution environment come from the scope, so no `sessionId` is - * threaded through. Git operations are delegated to the App-scoped - * `IGitService`; this service only confines paths and computes repo-relative - * paths before calling it. + * workspace root), the handler-shared `ISessionProcessRunner` (`rg`), and + * `IWorkspaceGitService` (git status/diff bound to the handler root; this + * service only confines paths and computes repo-relative paths before + * calling it). * - * Path confinement applies the lexical `ISessionWorkspaceContext.isWithin` - * check first, then re-verifies the candidate through `IHostFileSystem.realpath` - * (resolving the longest existing prefix, so not-yet-created paths still work): - * a symlink inside the workspace must not steer fs actions to files outside it. - * The plain-data state (`rgResolution`, `realRootsCache`) is registered into - * `sessionState` (`ISessionStateService`) and read/written through it. + * Path confinement applies a lexical within-workspace check first (the + * handler root plus the `workspaceDirs` additional-dir set, mirroring the + * Session-scope `workspaceContext` view semantics), then re-verifies the + * candidate through `IHostFileSystem.realpath` (resolving the longest + * existing prefix, so not-yet-created paths still work): a symlink inside + * the workspace must not steer fs actions to files outside it. The small + * caches (`rgResolution`, `realRootsCache`) are plain per-handler fields. + * Bound at Workspace scope — one instance per handler, shared by every + * session of the workspace. */ -import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { type FsDiffRequest, @@ -63,7 +65,6 @@ const FsWireErrorCode = { import ignore, { type Ignore } from 'ignore'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; import { buildEtag, countLines, @@ -73,14 +74,14 @@ import { guessMime, } from '#/_base/utils/fileMeta'; import { ErrorCodes, Error2, isError2, unwrapErrorCause } from '#/errors'; -import { IGitService } from '#/app/git/git'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IHostFileSystem, type HostDirEntry, type HostFileStat } from '#/os/interface/hostFileSystem'; import { ISessionProcessRunner } from '#/session/process/processRunner'; -import { ISessionStateService } from '#/session/state/sessionState'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { IWorkspaceGitService } from '#/workspace/workspaceGit/workspaceGit'; -import { type FsDownloadResolved, type FsPathResolved, ISessionFsService } from './fs'; +import { type FsDownloadResolved, type FsPathResolved, IWorkspaceFsService } from './fs'; import { readStream, runCommand } from './fsProcess'; import { ensureRgPath, type RgProbe, type RgResolution } from './rgLocator'; import { @@ -103,41 +104,43 @@ const FS_READ_MAX_BYTES = 10 * 1024 * 1024; const HIDDEN_NAME_RE = /^\./; const MACOS_NOISE = new Set(['.DS_Store', '.AppleDouble', '.LSOverride']); -export const sessionFsRgResolutionKey = defineState( - 'sessionFs.rgResolution', - () => undefined, -); -export const sessionFsRealRootsCacheKey = defineState< - { readonly key: string; readonly roots: readonly string[] } | undefined ->('sessionFs.realRootsCache', () => undefined); - -export class SessionFsService implements ISessionFsService { +export class WorkspaceFsService implements IWorkspaceFsService { declare readonly _serviceBrand: undefined; private readonly gitignoreCache = new Map(); + private rgResolution: RgResolution | null | undefined = undefined; + private realRootsCache: { readonly key: string; readonly roots: readonly string[] } | undefined = + undefined; + private readonly workDir: string; constructor( - @ISessionStateService private readonly states: ISessionStateService, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, + @IWorkspaceContext workspace: IWorkspaceContext, + @IWorkspaceDirs private readonly workspaceDirs: IWorkspaceDirs, @IHostFileSystem private readonly hostFs: IHostFileSystem, @ISessionProcessRunner private readonly runner: ISessionProcessRunner, @ITelemetryService private readonly telemetry: ITelemetryService, - @IGitService private readonly git: IGitService, + @IWorkspaceGitService private readonly git: IWorkspaceGitService, ) { - this.states.register(sessionFsRgResolutionKey); - this.states.register(sessionFsRealRootsCacheKey); + this.workDir = resolve(workspace.cwd); } - private get rgResolution(): RgResolution | null | undefined { - return this.states.get(sessionFsRgResolutionKey); + private resolvePathInput(rel: string): string { + return isAbsolute(rel) ? resolve(rel) : resolve(this.workDir, rel); } - private set rgResolution(value: RgResolution | null | undefined) { - this.states.set(sessionFsRgResolutionKey, value); + private isWithinWorkspace(absPath: string): boolean { + const target = resolve(absPath); + if (target === this.workDir) return true; + const rel = relative(this.workDir, target); + if (rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)) return true; + return this.workspaceDirs.additionalDirs.some((dir) => { + const r = relative(resolve(dir), target); + return r === '' || (!r.startsWith('..') && !isAbsolute(r)); + }); } private absOf(rel: string): string { - return rel === '' || rel === '.' ? this.workspace.workDir : join(this.workspace.workDir, rel); + return rel === '' || rel === '.' ? this.workDir : join(this.workDir, rel); } async list(req: FsListRequest): Promise { @@ -341,7 +344,7 @@ export class SessionFsService implements ISessionFsService { } catch (err) { throw mapFsError(err, req.path); } - const name = rel === '.' ? basename(this.workspace.workDir) : basename(abs); + const name = rel === '.' ? basename(this.workDir) : basename(abs); return buildFsEntry(rel, name, st, true); } @@ -358,7 +361,7 @@ export class SessionFsService implements ISessionFsService { resolved.map(async ({ raw, rel, abs }) => { try { const st = await this.hostFs.lstat(abs); - const name = rel === '.' ? basename(this.workspace.workDir) : basename(abs); + const name = rel === '.' ? basename(this.workDir) : basename(abs); entries[raw] = buildFsEntry(rel, name, st, false); } catch { entries[raw] = null; @@ -432,6 +435,35 @@ export class SessionFsService implements ISessionFsService { } async search(req: FsSearchRequest): Promise { + // Empty query: no fuzzy matching — list the workspace root's top-level + // entries (dirs first) so clients can show a starting set for @-mention + // style file pickers. + if (req.query === '') { + const listed = await this.list({ + path: '.', + depth: 1, + limit: req.limit, + show_hidden: false, + follow_gitignore: req.follow_gitignore, + exclude_globs: req.exclude_globs, + sort: 'type_first', + include_git_status: false, + }); + const items = listed.items + .filter( + (entry) => + req.include_globs === undefined || matchesAnyGlob(entry.path, req.include_globs), + ) + .map((entry) => ({ + path: entry.path, + name: entry.name, + kind: entry.kind, + score: 1, + match_positions: [], + })); + return { items, truncated: listed.truncated }; + } + const matcher = req.follow_gitignore ? await this.matcher() : undefined; const candidates: FsSearchHit[] = []; const queryLower = req.query.toLowerCase(); @@ -482,8 +514,6 @@ export class SessionFsService implements ISessionFsService { } async gitStatus(req: FsGitStatusRequest): Promise { - const cwd = this.workspace.workDir; - let filter: Set | undefined; if (req.paths !== undefined && req.paths.length > 0) { filter = new Set(); @@ -492,13 +522,12 @@ export class SessionFsService implements ISessionFsService { } } - return this.git.status(cwd, filter); + return this.git.status(filter); } async diff(req: FsDiffRequest): Promise { - const cwd = this.workspace.workDir; const abs = await this.resolveWithin(req.path); - return this.git.diff(cwd, this.toRel(abs), abs); + return this.git.diff(this.toRel(abs), abs); } private async grepWithRg( @@ -528,7 +557,7 @@ export class SessionFsService implements ISessionFsService { args.push(req.pattern); args.push('.'); - const proc = await this.runner.exec([rgPath, ...args], { cwd: this.workspace.workDir }); + const proc = await this.runner.exec([rgPath, ...args], { cwd: this.workDir }); const acc = new RgJsonAccumulator(req); let killed = false; @@ -688,13 +717,13 @@ export class SessionFsService implements ISessionFsService { } private async matcher(): Promise { - const cwd = this.workspace.workDir; + const cwd = this.workDir; const cached = this.gitignoreCache.get(cwd); if (cached !== undefined) return cached; const ig = ignore(); ig.add('.git/'); try { - const contents = await this.hostFs.readText(join(this.workspace.workDir, '.gitignore')); + const contents = await this.hostFs.readText(join(this.workDir, '.gitignore')); ig.add(contents); } catch { } @@ -705,7 +734,7 @@ export class SessionFsService implements ISessionFsService { private async resolveRg(): Promise { if (this.rgResolution !== undefined) return this.rgResolution; const probe: RgProbe = { - exec: (args) => runCommand(this.runner, args, { cwd: this.workspace.workDir }), + exec: (args) => runCommand(this.runner, args, { cwd: this.workDir }), }; try { this.rgResolution = await ensureRgPath(probe); @@ -715,20 +744,8 @@ export class SessionFsService implements ISessionFsService { return this.rgResolution; } - private get realRootsCache(): - | { readonly key: string; readonly roots: readonly string[] } - | undefined { - return this.states.get(sessionFsRealRootsCacheKey); - } - - private set realRootsCache( - value: { readonly key: string; readonly roots: readonly string[] } | undefined, - ) { - this.states.set(sessionFsRealRootsCacheKey, value); - } - private async realRoots(): Promise { - const dirs = [this.workspace.workDir, ...this.workspace.additionalDirs]; + const dirs = [this.workDir, ...this.workspaceDirs.additionalDirs.map((d) => resolve(d))]; const key = dirs.join('\n'); if (this.realRootsCache?.key === key) return this.realRootsCache.roots; const roots: string[] = []; @@ -778,8 +795,8 @@ export class SessionFsService implements ISessionFsService { details: { path: inputPath, reason: 'dotdot_segment' }, }); } - const abs = this.workspace.resolve(inputPath); - if (!this.workspace.isWithin(abs)) { + const abs = this.resolvePathInput(inputPath); + if (!this.isWithinWorkspace(abs)) { throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" escapes workspace`, { details: { path: inputPath, reason: 'resolved_outside' }, }); @@ -797,7 +814,7 @@ export class SessionFsService implements ISessionFsService { } private toRel(abs: string): string { - const cwd = this.workspace.workDir; + const cwd = this.workDir; if (abs === cwd) return '.'; const rel = relative(cwd, abs); if (rel === '') return '.'; @@ -1027,9 +1044,9 @@ function toWireError(err: unknown): { code: number; msg: string } { } registerScopedService( - LifecycleScope.Session, - ISessionFsService, - SessionFsService, + LifecycleScope.Workspace, + IWorkspaceFsService, + WorkspaceFsService, ScopeActivation.OnScopeCreated, - 'sessionFs', + 'workspaceFs', ); diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fsWatch.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatch.ts new file mode 100644 index 0000000000..2ec869bd39 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatch.ts @@ -0,0 +1,55 @@ +/** + * `workspaceFs` domain (L3) — workspace-confined filesystem change feed. + * + * Defines the `IWorkspaceFsWatchService` that turns the os + * `IHostFsWatchService` raw events into a workspace-relative, debounced, + * `.gitignore`-aware change feed (`FsChangeEvent`) for the whole handler. + * One os watcher on the workspace root is shared by every subscriber — + * subscribers are the sessions of this workspace (via the transport's + * fs-watch bridge) and any Workspace-scope service that wants change + * notifications. Each subscription declares the set of workspace-relative + * paths it cares about; events outside that subtree are dropped, and every + * subscription gets its own debounce window and truncation counters, so a + * per-session feed through a subscription is indistinguishable from the old + * per-session watch service. Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IDisposable } from '#/_base/di/lifecycle'; +import type { Event } from '#/_base/event'; + +export type FsChangeKind = 'file' | 'directory' | 'symlink'; + +export type FsChangeAction = 'created' | 'modified' | 'deleted'; + +export interface FsChangeEntry { + path: string; + change: FsChangeAction; + kind: FsChangeKind; + size_delta?: number | undefined; + etag?: string | undefined; +} + +export interface FsChangeEvent { + changes: FsChangeEntry[]; + coalesced_window_ms: number; + truncated?: boolean | undefined; + count?: number | undefined; +} + +export interface IWorkspaceFsWatchSubscription extends IDisposable { + setWatchedPaths(paths: readonly string[]): void; + + readonly watchedPaths: readonly string[]; + + readonly onDidChangeFiles: Event; +} + +export interface IWorkspaceFsWatchService { + readonly _serviceBrand: undefined; + + subscribe(): IWorkspaceFsWatchSubscription; +} + +export const IWorkspaceFsWatchService: ServiceIdentifier = + createDecorator('workspaceFsWatchService'); diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatchService.ts similarity index 54% rename from packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/fsWatchService.ts index e335f708b7..33b4e709ff 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatchService.ts @@ -1,25 +1,25 @@ /** - * `sessionFsWatch` domain (L2) — `ISessionFsWatchService` implementation. + * `workspaceFs` domain (L3) — `IWorkspaceFsWatchService` implementation. * - * Subscribes to the os `IHostFsWatchService` on the workspace root, confines - * events to the caller-declared subtree and to non-`.gitignore`d paths, - * debounces them into fixed windows and re-exposes them as workspace-relative - * `FsChangeEvent`s. The os watcher is started lazily on the first non-empty - * subscription and stopped when the subscription set becomes empty. The - * plain-data state (`watched`, `pending`, `rawCount`, `truncated`, - * `gitignoreLoaded`) is registered into `sessionState` (`ISessionStateService`) - * and read/written through it. Path confinement is lexical - * (`ISessionWorkspaceContext.isWithin`), matching `sessionFs`. + * Keeps ONE os `IHostFsWatchService` subscription on the handler root and + * fans its raw events out to every `IWorkspaceFsWatchSubscription`: the + * shared leg (the os handle plus the `.gitignore` matcher) runs once per + * handler, the per-subscriber leg (subtree confinement, debounce window, + * overflow truncation) runs once per subscription, so two sessions of the + * same workspace never hang a second os watcher. The os handle starts + * lazily when the first subscription declares a non-empty path set and + * stops when no subscription watches anything. Path confinement is lexical + * (the handler root plus the `workspaceDirs` additional-dir set), matching + * the rest of `workspaceFs`. Bound at Workspace scope. */ -import { isAbsolute, join, relative, sep } from 'node:path'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; import ignore, { type Ignore } from 'ignore'; import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; import { ErrorCodes, Error2 } from '#/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { @@ -27,11 +27,15 @@ import { type IHostFsWatchHandle, IHostFsWatchService, } from '#/os/interface/hostFsWatch'; -import { ISessionStateService } from '#/session/state/sessionState'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import type { FsChangeEntry, FsChangeEvent } from './fsWatch'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; -import { ISessionFsWatchService } from './fsWatch'; +import { + type FsChangeEntry, + type FsChangeEvent, + IWorkspaceFsWatchService, + type IWorkspaceFsWatchSubscription, +} from './fsWatch'; const DEFAULT_DEBOUNCE_MS = 200; const DEFAULT_MAX_CHANGES_PER_WINDOW = 500; @@ -43,117 +47,62 @@ function readPositiveIntEnv(name: string, fallback: number): number { return Number.isFinite(n) && n > 0 ? n : fallback; } -export const sessionFsWatchWatchedKey = defineState>( - 'sessionFsWatch.watched', - () => new Set(), -); -export const sessionFsWatchPendingKey = defineState('sessionFsWatch.pending', () => []); -export const sessionFsWatchRawCountKey = defineState('sessionFsWatch.rawCount', () => 0); -export const sessionFsWatchTruncatedKey = defineState('sessionFsWatch.truncated', () => false); -export const sessionFsWatchGitignoreLoadedKey = defineState( - 'sessionFsWatch.gitignoreLoaded', - () => false, -); - -export class SessionFsWatchService extends Disposable implements ISessionFsWatchService { +export class WorkspaceFsWatchService extends Disposable implements IWorkspaceFsWatchService { declare readonly _serviceBrand: undefined; - private readonly emitter = this._register(new Emitter()); - readonly onDidChangeFiles: Event = this.emitter.event; - + private readonly subscriptions = new Set(); private handle: IHostFsWatchHandle | undefined; private handleSub: IDisposable | undefined; - - private debounceTimer: NodeJS.Timeout | undefined; - - private readonly debounceMs = readPositiveIntEnv( - 'KIMI_CODE_FS_WATCH_DEBOUNCE_MS', - DEFAULT_DEBOUNCE_MS, - ); - private readonly maxChangesPerWindow = readPositiveIntEnv( - 'KIMI_CODE_FS_WATCH_MAX_CHANGES_PER_WINDOW', - DEFAULT_MAX_CHANGES_PER_WINDOW, - ); - + private gitignoreLoaded = false; private readonly matcher: Ignore = ignore().add('.git/'); + private readonly workDir: string; constructor( - @ISessionStateService private readonly states: ISessionStateService, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, + @IWorkspaceContext workspace: IWorkspaceContext, + @IWorkspaceDirs private readonly workspaceDirs: IWorkspaceDirs, @IHostFsWatchService private readonly hostFsWatch: IHostFsWatchService, @IHostFileSystem private readonly hostFs: IHostFileSystem, ) { super(); - this.states.register(sessionFsWatchWatchedKey); - this.states.register(sessionFsWatchPendingKey); - this.states.register(sessionFsWatchRawCountKey); - this.states.register(sessionFsWatchTruncatedKey); - this.states.register(sessionFsWatchGitignoreLoadedKey); - } - - private get watched(): Set { - return this.states.get(sessionFsWatchWatchedKey); + this.workDir = resolve(workspace.cwd); } - private set watched(value: Set) { - this.states.set(sessionFsWatchWatchedKey, value); + subscribe(): IWorkspaceFsWatchSubscription { + const subscription = new WorkspaceFsWatchSubscription(this); + this.subscriptions.add(subscription); + return subscription; } - private get pending(): FsChangeEntry[] { - return this.states.get(sessionFsWatchPendingKey); - } - - private set pending(value: FsChangeEntry[]) { - this.states.set(sessionFsWatchPendingKey, value); - } - - private get rawCount(): number { - return this.states.get(sessionFsWatchRawCountKey); - } - - private set rawCount(value: number) { - this.states.set(sessionFsWatchRawCountKey, value); - } - - private get truncated(): boolean { - return this.states.get(sessionFsWatchTruncatedKey); - } - - private set truncated(value: boolean) { - this.states.set(sessionFsWatchTruncatedKey, value); - } - - private get gitignoreLoaded(): boolean { - return this.states.get(sessionFsWatchGitignoreLoadedKey); - } - - private set gitignoreLoaded(value: boolean) { - this.states.set(sessionFsWatchGitignoreLoadedKey, value); - } - - get watchedPaths(): readonly string[] { - return Array.from(this.watched); - } - - setWatchedPaths(paths: readonly string[]): void { + /** Subscription → service: confinement validation + rel normalization. */ + normalizeWatchedPaths(paths: readonly string[]): Set { const next = new Set(); for (const p of paths) { const abs = this.resolveWithin(p); next.add(this.toRel(abs)); } - this.watched = next; - if (next.size === 0) { - this.teardownHandle(); - this.clearWindow(); - return; + return next; + } + + /** Subscription → service: a subscription's path set changed (or it disposed). */ + syncHandle(): void { + for (const sub of this.subscriptions) { + if (sub.hasPaths()) { + this.ensureHandle(); + return; + } } - this.ensureHandle(); + this.teardownHandle(); + } + + dropSubscription(subscription: WorkspaceFsWatchSubscription): void { + this.subscriptions.delete(subscription); + this.syncHandle(); } private ensureHandle(): void { if (this.handle !== undefined) return; this.loadGitignore(); - const handle = this.hostFsWatch.watch(this.workspace.workDir, { recursive: true }); + const handle = this.hostFsWatch.watch(this.workDir, { recursive: true }); this.handle = handle; this.handleSub = handle.onDidChange((e) => this.onRaw(e)); } @@ -168,14 +117,12 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch private loadGitignore(): void { if (this.gitignoreLoaded) return; this.gitignoreLoaded = true; - void this.hostFs - .readText(join(this.workspace.workDir, '.gitignore')) - .then( - (content) => { - this.matcher.add(content); - }, - () => undefined, - ); + void this.hostFs.readText(join(this.workDir, '.gitignore')).then( + (content) => { + this.matcher.add(content); + }, + () => undefined, + ); } private onRaw(e: HostFsChange): void { @@ -183,8 +130,111 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch if (rel === '.') return; const probe = e.kind === 'directory' ? `${rel}/` : rel; if (this.matcher.ignores(probe)) return; - if (!isUnderAny(rel, this.watched)) return; + for (const sub of this.subscriptions) { + sub.onRawChange(rel, e); + } + } + override dispose(): void { + // `sub.dispose()` removes the subscription from the set; deleting the + // current element mid-iteration is safe for JS Sets. + for (const sub of this.subscriptions) { + sub.dispose(); + } + this.subscriptions.clear(); + this.teardownHandle(); + super.dispose(); + } + + private resolveWithin(inputPath: string): string { + if (inputPath === '' || inputPath === '/') { + throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" rejected (empty)`, { + details: { path: inputPath, reason: 'empty' }, + }); + } + if (isAbsolute(inputPath)) { + throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" rejected (absolute)`, { + details: { path: inputPath, reason: 'absolute' }, + }); + } + const segments = inputPath.split(/[/\\]+/); + if (segments.some((s) => s === '..')) { + throw new Error2( + ErrorCodes.FS_PATH_ESCAPES, + `path "${inputPath}" rejected (dotdot segment)`, + { details: { path: inputPath, reason: 'dotdot_segment' } }, + ); + } + const abs = isAbsolute(inputPath) ? resolve(inputPath) : resolve(this.workDir, inputPath); + if (!this.isWithinWorkspace(abs)) { + throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" escapes workspace`, { + details: { path: inputPath, reason: 'resolved_outside' }, + }); + } + return abs; + } + + private isWithinWorkspace(absPath: string): boolean { + const target = resolve(absPath); + if (target === this.workDir) return true; + const rel = relative(this.workDir, target); + if (rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)) return true; + return this.workspaceDirs.additionalDirs.some((dir) => { + const r = relative(resolve(dir), target); + return r === '' || (!r.startsWith('..') && !isAbsolute(r)); + }); + } + + private toRel(abs: string): string { + const cwd = this.workDir; + if (abs === cwd) return '.'; + const rel = relative(cwd, abs); + if (rel === '') return '.'; + return rel.split(sep).join('/'); + } +} + +class WorkspaceFsWatchSubscription implements IWorkspaceFsWatchSubscription { + private readonly emitter = new Emitter(); + readonly onDidChangeFiles: Event = this.emitter.event; + + private watched = new Set(); + private pending: FsChangeEntry[] = []; + private rawCount = 0; + private truncated = false; + private debounceTimer: NodeJS.Timeout | undefined; + private disposed = false; + + private readonly debounceMs = readPositiveIntEnv( + 'KIMI_CODE_FS_WATCH_DEBOUNCE_MS', + DEFAULT_DEBOUNCE_MS, + ); + private readonly maxChangesPerWindow = readPositiveIntEnv( + 'KIMI_CODE_FS_WATCH_MAX_CHANGES_PER_WINDOW', + DEFAULT_MAX_CHANGES_PER_WINDOW, + ); + + constructor(private readonly owner: WorkspaceFsWatchService) {} + + get watchedPaths(): readonly string[] { + return Array.from(this.watched); + } + + hasPaths(): boolean { + return !this.disposed && this.watched.size > 0; + } + + setWatchedPaths(paths: readonly string[]): void { + if (this.disposed) return; + this.watched = this.owner.normalizeWatchedPaths(paths); + if (this.watched.size === 0) { + this.clearWindow(); + } + this.owner.syncHandle(); + } + + onRawChange(rel: string, e: HostFsChange): void { + if (this.disposed || !isUnderAny(rel, this.watched)) return; this.pending.push({ path: rel, change: e.action, kind: e.kind }); this.rawCount += 1; if (this.pending.length > this.maxChangesPerWindow) { @@ -200,7 +250,7 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch private flush(): void { this.debounceTimer = undefined; - if (this.rawCount === 0) return; + if (this.disposed || this.rawCount === 0) return; const truncated = this.truncated; const count = this.rawCount; const changes = truncated ? [] : this.pending; @@ -226,46 +276,12 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch this.truncated = false; } - override dispose(): void { + dispose(): void { + if (this.disposed) return; + this.disposed = true; this.clearWindow(); - this.teardownHandle(); - super.dispose(); - } - - private resolveWithin(inputPath: string): string { - if (inputPath === '' || inputPath === '/') { - throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" rejected (empty)`, { - details: { path: inputPath, reason: 'empty' }, - }); - } - if (isAbsolute(inputPath)) { - throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" rejected (absolute)`, { - details: { path: inputPath, reason: 'absolute' }, - }); - } - const segments = inputPath.split(/[/\\]+/); - if (segments.some((s) => s === '..')) { - throw new Error2( - ErrorCodes.FS_PATH_ESCAPES, - `path "${inputPath}" rejected (dotdot segment)`, - { details: { path: inputPath, reason: 'dotdot_segment' } }, - ); - } - const abs = this.workspace.resolve(inputPath); - if (!this.workspace.isWithin(abs)) { - throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" escapes workspace`, { - details: { path: inputPath, reason: 'resolved_outside' }, - }); - } - return abs; - } - - private toRel(abs: string): string { - const cwd = this.workspace.workDir; - if (abs === cwd) return '.'; - const rel = relative(cwd, abs); - if (rel === '') return '.'; - return rel.split(sep).join('/'); + this.emitter.dispose(); + this.owner.dropSubscription(this); } } @@ -279,9 +295,9 @@ function isUnderAny(rel: string, parents: ReadonlySet): boolean { } registerScopedService( - LifecycleScope.Session, - ISessionFsWatchService, - SessionFsWatchService, + LifecycleScope.Workspace, + IWorkspaceFsWatchService, + WorkspaceFsWatchService, ScopeActivation.OnScopeCreated, - 'sessionFsWatch', + 'workspaceFs', ); diff --git a/packages/agent-core-v2/src/session/sessionFs/rgLocator.ts b/packages/agent-core-v2/src/workspace/workspaceFs/rgLocator.ts similarity index 84% rename from packages/agent-core-v2/src/session/sessionFs/rgLocator.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/rgLocator.ts index 178514afc1..a5d763076d 100644 --- a/packages/agent-core-v2/src/session/sessionFs/rgLocator.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/rgLocator.ts @@ -1,12 +1,12 @@ /** - * `sessionFs` domain — shared ripgrep (`rg`) binary locator. + * `workspaceFs` domain — shared ripgrep (`rg`) binary locator. * - * Single place that decides which `rg` the Glob and Grep paths run. The lookup - * mirrors v1's `ensureRgPath` intent (bundled-or-system, graceful degradation) - * but is driven through a caller-supplied {@link RgProbe} so it works against - * whatever execution environment the caller has — Glob probes through the - * session `ISessionProcessRunner`, Grep through the shared runner as well. - * Both run `rg --version` and treat exit code 0 as "available". + * Single place that decides which `rg` the fs search/grep paths run. The + * lookup mirrors v1's `ensureRgPath` intent (bundled-or-system, graceful + * degradation) but is driven through a caller-supplied {@link RgProbe} so it + * works against whatever execution environment the caller has — the fs + * surface probes through the handler-shared `ISessionProcessRunner`, running + * `rg --version` and treating exit code 0 as "available". * * Lookup order (first hit wins): * 1. System `rg` on the execution-environment PATH (`rg --version`). diff --git a/packages/agent-core-v2/src/session/sessionFs/runRg.ts b/packages/agent-core-v2/src/workspace/workspaceFs/runRg.ts similarity index 85% rename from packages/agent-core-v2/src/session/sessionFs/runRg.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/runRg.ts index 46ab223428..ccb6051191 100644 --- a/packages/agent-core-v2/src/session/sessionFs/runRg.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/runRg.ts @@ -1,17 +1,13 @@ /** - * `sessionFs` domain — shared ripgrep subprocess plumbing. + * `workspaceFs` domain — shared ripgrep subprocess plumbing. * - * Single place that knows how Glob spawns `rg` through the session - * `ISessionProcessRunner`: timeout / abort handling, capped stdout / stderr - * draining, two-phase kill with process disposal, and the EAGAIN retry - * predicate. Mode-specific argument building and output parsing stay in the - * tools themselves. - * - * Ported from v1 (`packages/agent-core/src/tools/support/run-rg.ts`) onto the - * v2 `ISessionProcessRunner`. Grep keeps its own `runCommand` path in - * `fsService` (it streams JSON and has a pure-node fallback); this helper is - * shared in the sense that the previously inline Glob plumbing now lives in one - * reusable module under the same `sessionFs` domain as Grep's search code. + * Timeout / abort handling, capped stdout / stderr draining, two-phase kill + * with process disposal, and the EAGAIN retry predicate for spawning `rg` + * through the handler-shared `ISessionProcessRunner`. Ported from v1 + * (`packages/agent-core/src/tools/support/run-rg.ts`). The fs surface's Grep + * keeps its own `runCommand` path in `fsService` (it streams JSON and has a + * pure-node fallback); this helper is the reusable module for callers that + * want the simpler buffered shape. */ import type { Readable } from 'node:stream'; diff --git a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts new file mode 100644 index 0000000000..615838088c --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts @@ -0,0 +1,22 @@ +/** + * `workspaceGit` domain (L2) — handler-root-bound git facade contract. + * + * Defines the `IWorkspaceGitService`, a thin facade over the App-scope + * `IGitService` pinned to this handler's workspace root: callers pass + * repo-relative paths only, never a `cwd`. The PR-status cache stays in the + * App-scope `IGitService`, keyed by `cwd` with a 60 s TTL — one entry per + * workspace root, which is exactly per-handler ownership. Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { FsDiffResponse, FsGitStatusResponse } from '#/app/git/git'; + +export interface IWorkspaceGitService { + readonly _serviceBrand: undefined; + + status(pathFilter?: ReadonlySet): Promise; + diff(relPath: string, absPath: string): Promise; +} + +export const IWorkspaceGitService: ServiceIdentifier = + createDecorator('workspaceGitService'); diff --git a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts new file mode 100644 index 0000000000..45695c827c --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts @@ -0,0 +1,39 @@ +/** + * `workspaceGit` domain (L2) — `IWorkspaceGitService` implementation. + * + * Delegates every call to the App-scope `IGitService` with `cwd` pinned to + * the handler's workspace root (`IWorkspaceContext.cwd`). Owns no state: the + * PR-status cache (keyed by `cwd`, 60 s TTL) already lives in the App-scope + * service, which makes it per-handler in effect. Bound at Workspace scope. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { type FsDiffResponse, type FsGitStatusResponse, IGitService } from '#/app/git/git'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { IWorkspaceGitService } from './workspaceGit'; + +export class WorkspaceGitService implements IWorkspaceGitService { + declare readonly _serviceBrand: undefined; + + constructor( + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + @IGitService private readonly git: IGitService, + ) {} + + status(pathFilter?: ReadonlySet): Promise { + return this.git.status(this.workspace.cwd, pathFilter); + } + + diff(relPath: string, absPath: string): Promise { + return this.git.diff(this.workspace.cwd, relPath, absPath); + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceGitService, + WorkspaceGitService, + ScopeActivation.OnScopeCreated, + 'workspaceGit', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceHandler/addressing.ts b/packages/agent-core-v2/src/workspace/workspaceHandler/addressing.ts new file mode 100644 index 0000000000..83cc605a94 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceHandler/addressing.ts @@ -0,0 +1,30 @@ +/** + * `workspaceHandler` domain (L6) — persistence addressing along the handler chain. + * + * Pure functions deriving the persistence scope strings and on-disk + * directories from the handler's `persistenceScope` (`sessions/{wd_id}`): + * session = `{handlerScope}/{session_id}`, agent = + * `{sessionScope}/agents/{agent_id}`. Under the local/local runtime these + * are byte-identical to the layout the pre-Workspace engine wrote, so v1 + * readers (`session_index.jsonl`, snapshot readers) keep working unchanged. + * Own no scoped state. + */ + +import { join } from 'pathe'; + +/** The handler's persistence scope: `sessions/{wd_id}`. */ +export function workspacePersistenceScope(sessionsScope: string, workspaceId: string): string { + return join(sessionsScope, workspaceId); +} + +export function sessionScopeOf(handlerScope: string, sessionId: string): string { + return `${handlerScope}/${sessionId}`; +} + +export function sessionDirOf(homeDir: string, handlerScope: string, sessionId: string): string { + return join(homeDir, sessionScopeOf(handlerScope, sessionId)); +} + +export function agentScopeOf(sessionScope: string, agentId: string): string { + return `${sessionScope}/agents/${agentId}`; +} diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandler.ts similarity index 51% rename from packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts rename to packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandler.ts index c57bb8d214..a97aa1c9a1 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandler.ts @@ -1,31 +1,42 @@ /** - * `sessionLifecycle` domain (L6) — creates and tracks sessions at the process root. + * `workspaceHandler` domain (L6) — per-handler session lifecycle contract. * - * Defines the public contract of session lifecycle: the `CreateSessionOptions`, - * `ForkSessionOptions`, `CreateChildSessionOptions`, and the - * `ISessionLifecycleService` used to create sessions (`create`), look up the - * live ones (`get` / `list`), close them (`close`), archive/restore them, - * fork them (`fork`), and fork-then-tag them as direct children (`createChild`). Announces - * lifecycle transitions through ordered hook slots plus - * `onDidCreateSession` / `onDidCloseSession` / `onDidArchiveSession` / - * `onDidForkSession`. App-scoped — a single - * process-wide instance owns the live session scope tree. Persisted - * sessions (open or closed) are the `sessionIndex` read model; per-session - * behaviour lives in the Session-scoped domains. + * Defines the public contract of one workspace handler: the + * `CreateSessionOptions`, `ForkSessionOptions`, `CreateChildSessionOptions`, + * `ResumeSessionOptions`, and the `IWorkspaceHandlerService` used to create + * sessions (`create`), look up the live ones (`get` / `list`), close them + * (`close`), archive/restore them, fork them (`fork`), and fork-then-tag + * them as direct children (`createChild`) — always as child scopes of THIS + * handler's Workspace scope, so a handler owns exactly the sessions of one + * workspace and fork never crosses handlers. Announces lifecycle transitions + * through `onDidCreateSession` / `onDidCloseSession` / `onDidArchiveSession` + * / `onDidForkSession`; the ordered hook slots are per-session seeds + * (`sessionLifecycleHooks`). Workspace-scoped — one instance per + * materialized handler. Persisted sessions (open or closed) are the + * `sessionIndex` read model; per-session behaviour lives in the + * Session-scoped domains. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { ISessionScopeHandle } from '#/_base/di/scope'; import type { Event } from '#/_base/event'; -import type { McpServerConfig } from '#/agent/mcp/config-schema'; import type { BindAgentInput } from '#/agent/profile/profile'; -import type { Hooks } from '#/hooks'; +import type { + SessionCloseReason, + SessionCreateSource, +} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; + +export type { SessionCloseReason, SessionCreateSource }; export interface CreateSessionOptions { readonly sessionId?: string; readonly workDir: string; + /** + * Caller-provided additional workspace directories, resolved against + * `workDir` and unioned into the handler's SHARED in-memory set (every + * session of this workspace sees them; never persisted). + */ readonly additionalDirs?: readonly string[]; - readonly mcpServers?: Readonly>; readonly mainAgentBinding?: BindAgentInput; } @@ -36,6 +47,15 @@ export interface ForkSessionOptions { readonly metadata?: Record; } +export interface ResumeSessionOptions { + /** + * Caller-provided additional workspace directories, re-resolved against the + * session workDir and unioned into the handler's shared in-memory set (same + * semantics as `CreateSessionOptions.additionalDirs`). + */ + readonly additionalDirs?: readonly string[]; +} + export interface CreateChildSessionOptions { readonly sourceSessionId: string; readonly newSessionId?: string; @@ -53,21 +73,12 @@ export interface SessionClosedEvent { readonly sessionId: string; } -export type SessionCreateSource = 'startup' | 'resume' | 'fork'; - -export type SessionCloseReason = 'exit'; - export interface SessionWillCloseEvent { readonly sessionId: string; readonly handle: ISessionScopeHandle; readonly reason: SessionCloseReason; } -export type SessionLifecycleHooks = { - readonly onDidCreateSession: SessionCreatedEvent; - readonly onWillCloseSession: SessionWillCloseEvent; -}; - export interface SessionArchivedEvent { readonly sessionId: string; } @@ -78,18 +89,17 @@ export interface SessionForkedEvent { readonly handle: ISessionScopeHandle; } -export interface ISessionLifecycleService { +export interface IWorkspaceHandlerService { readonly _serviceBrand: undefined; readonly onDidCreateSession: Event; readonly onDidCloseSession: Event; readonly onDidArchiveSession: Event; readonly onDidForkSession: Event; - readonly hooks: Hooks; create(opts: CreateSessionOptions): Promise; get(sessionId: string): ISessionScopeHandle | undefined; list(): readonly ISessionScopeHandle[]; - resume(sessionId: string): Promise; + resume(sessionId: string, opts?: ResumeSessionOptions): Promise; close(sessionId: string): Promise; archive(sessionId: string): Promise; restore(sessionId: string): Promise; @@ -97,5 +107,5 @@ export interface ISessionLifecycleService { createChild(opts: CreateChildSessionOptions): Promise; } -export const ISessionLifecycleService: ServiceIdentifier = - createDecorator('sessionLifecycleService'); +export const IWorkspaceHandlerService: ServiceIdentifier = + createDecorator('workspaceHandlerService'); diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts similarity index 61% rename from packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts rename to packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts index accd49cfd9..09d18a235f 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts @@ -1,34 +1,58 @@ /** - * `sessionLifecycle` domain (L6) — `ISessionLifecycleService` implementation. + * `workspaceHandler` domain (L6) — `IWorkspaceHandlerService` implementation. * - * Owns the process-wide registry of open Session child scopes, creating them - * through the DI scope tree and seeding each with its identity and storage - * addressing, running lifecycle hook slots, and tearing them down on - * close/archive — archiving flags the session's `sessionMetadata`, removes - * its `agentLifecycle` agents, restoring clears the archived flag, and - * broadcasts through `event`; session start and resume failures are reported - * through `telemetry`. Each Session scope receives a telemetry view bound to - * its session id, while failures before a scope is available use an ephemeral - * context view. + * Owns the registry of THIS handler's open Session child scopes, creating + * them through the DI scope tree (children of the handler's Workspace + * scope) and seeding each with its identity, storage addressing derived + * from the handler's `workspaceContext.persistenceScope`, and a per-session + * `sessionLifecycleHooks` slots instance it runs around create/close, + * tearing sessions down on close/archive — archiving flags the session's + * `sessionMetadata`, removes its `agentLifecycle` agents, restoring clears + * the archived flag, and broadcasts through `event`; session start and + * resume failures are reported through `telemetry`. Each Session scope + * receives a telemetry view bound to its session id, while failures before + * a scope is available use an ephemeral context view. Closing a session + * never touches the handler itself. + * Every Session scope is also seeded with the handler's shared workspace + * resources as pure-data read views (the injection contracts): + * `sessionSkillCatalogData` (the merged skill catalog), + * `sessionAgentProfileCatalogSeed` (ONLY the handler's workspace id — the + * Session-scope catalog reads the App-scope agent-profile registry directly + * and picks out the contributions tagged with this key), + * `sessionInstructionsProvider` (the AGENTS.md snapshot), + * `sessionMcpHandle` (the one shared MCP connection manager), + * `sessionWorkspaceInfo` (the shared additional-directory set — caller + * `additionalDirs` options union into it at materialization; the + * `workspaceDirs` service owns persistence and the `local.toml` watch), and + * `sessionToolPolicyGate` (the workspace's os-level tool veto) — discovery, + * watching and connecting all live on the Workspace-scope services; session + * consumers read the seeds and refresh off their change events. * Materializes the session's initial metadata on - * creation by resolving `sessionMetadata`. Bound at App scope. Persisted - * sessions are discovered through the `sessionIndex` read model, and workspace - * roots are remembered through `workspace`. On create / fork the + * creation by resolving `sessionMetadata`. Bound at Workspace scope. + * Persisted sessions are discovered through the `sessionIndex` read model. + * On create / fork the * session is also appended to the shared `session_index.jsonl` so v1 clients * (TUI, export) can discover sessions created by the v2 engine; the entry is - * indexed under the registry-resolved workspace id — the same id seeding the + * indexed under the handler's workspace id — the same id seeding the * session's storage scope — so an alias spelling of the workDir cannot split * the session into a bucket v1 readers never look in. Fork flushes * live Agent wire journals, normalizes a missing protocol envelope, and - * appends the fork boundary before restoring the target Agent. On - * materialize, the session's metadata, tool policy, and agent-profile catalog - * are awaited before the handle is published — agent-file discovery is local- + * appends the fork boundary before restoring the target Agent; fork is + * confined to this handler (source and target share the workspace bucket). + * On + * materialize, the agent-profile loaders' `ready` is awaited + * before the handle is published — agent-file discovery is local- * fs and cheap, and a resumed session's first turn must see file-defined - * agent types in the `Agent` tool description; the catalog's `ready` only - * rejects for a fatal explicit-source error, exactly the case that should + * agent types in the `Agent` tool description; only the `fatal` explicit + * loader rejects, exactly the case that should * fail fast, and on that failure the half-materialized handle is disposed - * instead of poisoning the session cache (the skill catalog, by contrast, is - * kicked fire-and-forget). The session-level services whose subscriptions + * instead of poisoning the session cache, and the explicit loader is re-armed + * with a fire-and-forget `reload()` so a fixed agent file unblocks later + * creates + * (the workspace skill catalog, by contrast, is kicked fire-and-forget). + * The handler's shared MCP manager (file + plugin servers only — sessions + * cannot contribute servers) is awaited before create/resume returns. The + * session-level services whose subscriptions * must exist before the first agent / turn (external hooks, cron, the * secondary-model startup warning) opt into `OnScopeCreated` activation. */ @@ -62,8 +86,6 @@ import { ISessionIndex, PARENT_SESSION_ID_KEY, } from '#/app/sessionIndex/sessionIndex'; -import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; -import { IWorkspaceService } from '#/app/workspace/workspace'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ErrorCodes, Error2, isError2 } from '#/errors'; import { createHooks } from '#/hooks'; @@ -73,40 +95,65 @@ import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; -import { ISessionMcpService } from '#/session/mcp/sessionMcp'; +import { sessionMcpHandleSeed } from '#/session/mcp/sessionMcpHandle'; import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata'; import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext'; +import { sessionAgentProfileCatalogSeed } from '#/session/sessionAgentProfileCatalog/agentProfileCatalogSeed'; +import { sessionInstructionsProviderSeed } from '#/session/sessionInstructions/instructionsProvider'; +import { sessionWorkspaceInfoSeed } from '#/session/workspaceInfo/workspaceInfo'; +import { + ISessionLifecycleHooks, + sessionLifecycleHooksSeed, + type SessionLifecycleHookSlots, +} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; -import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; -import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; +import { ISessionProcessRunner } from '#/session/process/processRunner'; +import { sessionSkillCatalogDataSeed } from '#/session/sessionSkillCatalog/skillCatalogData'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { sessionToolPolicyGateSeed } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { IWireService } from '#/wire/wire'; import { AGENT_WIRE_RECORD_KEY, createWireMetadataRecord, type WireRecord, } from '#/wire/record'; - +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; +import { + IExplicitAgentProfileLoader, +} from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader'; +import { + IExtraAgentProfileLoader, +} from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader'; +import { + IWorkspaceAgentProfileLoader, +} from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; +import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; +import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; + +import { agentScopeOf, sessionDirOf, sessionScopeOf } from './addressing'; import { type CreateChildSessionOptions, type CreateSessionOptions, type ForkSessionOptions, + type ResumeSessionOptions, type SessionArchivedEvent, type SessionClosedEvent, type SessionCreatedEvent, type SessionForkedEvent, - type SessionLifecycleHooks, type SessionWillCloseEvent, - ISessionLifecycleService, -} from './sessionLifecycle'; + IWorkspaceHandlerService, +} from './workspaceHandler'; type MaterializeSessionOptions = Omit & { readonly sessionId: string; - readonly workspaceId?: string; }; -export class SessionLifecycleService extends Disposable implements ISessionLifecycleService { +export class WorkspaceHandlerService extends Disposable implements IWorkspaceHandlerService { declare readonly _serviceBrand: undefined; private readonly sessions = new Map(); private readonly _onDidCreateSession = this._register(new Emitter()); @@ -117,14 +164,11 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec readonly onDidArchiveSession: Event = this._onDidArchiveSession.event; private readonly _onDidForkSession = this._register(new Emitter()); readonly onDidForkSession: Event = this._onDidForkSession.event; - readonly hooks = createHooks([ - 'onDidCreateSession', - 'onWillCloseSession', - ]); private readonly resuming = new Map>(); constructor( @IInstantiationService private readonly instantiation: IInstantiationService, + @IWorkspaceContext private readonly workspaceContext: IWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, @IConfigService private readonly config: IConfigService, @IHostEnvironment private readonly hostEnv: IHostEnvironment, @@ -133,15 +177,36 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec @IAtomicDocumentStore private readonly docs: IAtomicDocumentStore, @IHostFileSystem private readonly hostFs: IHostFileSystem, @ICronTaskPersistence private readonly cronStore: ICronTaskPersistence, - @IWorkspaceService private readonly workspaces: IWorkspaceService, - @IProjectLocalConfigService - private readonly projectLocalConfig: IProjectLocalConfigService, @IEventService private readonly event: IEventService, @ITelemetryService private readonly telemetry: ITelemetryService, + @IWorkspaceSkillCatalog private readonly skillCatalog: IWorkspaceSkillCatalog, + @IWorkspaceAgentProfileLoader + private readonly workspaceAgentProfileLoader: IWorkspaceAgentProfileLoader, + @IExtraAgentProfileLoader + private readonly extraAgentProfileLoader: IExtraAgentProfileLoader, + @IExplicitAgentProfileLoader + private readonly explicitAgentProfileLoader: IExplicitAgentProfileLoader, + @IUserAgentProfileLoader + private readonly userAgentProfileLoader: IUserAgentProfileLoader, + @IPluginAgentProfileLoader + private readonly pluginAgentProfileLoader: IPluginAgentProfileLoader, + @IWorkspaceInstructionsService private readonly instructions: IWorkspaceInstructionsService, + @IWorkspaceMcpService private readonly mcp: IWorkspaceMcpService, + @IWorkspaceDirs private readonly workspaceDirs: IWorkspaceDirs, + @IWorkspaceToolPolicy private readonly toolPolicy: IWorkspaceToolPolicy, + @ISessionProcessRunner private readonly processRunner: ISessionProcessRunner, ) { super(); } + private get workspaceId(): string { + return this.workspaceContext.workspaceId; + } + + private get handlerScope(): string { + return this.workspaceContext.persistenceScope; + } + async create(opts: CreateSessionOptions): Promise { const sessionId = opts.sessionId ?? createSessionId(); const handle = await this.materializeSession({ ...opts, sessionId }); @@ -157,15 +222,12 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const planAgent = main ?? (await ensureMainAgent(handle)); await planAgent.accessor.get(IAgentPlanService).enter(); } - // Index the session under the workspace id the registry actually resolved - // (the same one seeding the session's storage scope), not a recomputed + // Index the session under the handler's workspace id (the same one + // seeding the session's storage scope), not a recomputed // `encodeWorkDirKey` — with root folding the two can diverge. - await this.appendSessionIndexEntry( - sessionId, - opts.workDir, - handle.accessor.get(ISessionContext).workspaceId, - ); + await this.appendSessionIndexEntry(sessionId, opts.workDir); } catch (error) { + // Roll back ONLY the session directory — the handler stays live. const sessionDir = handle.accessor.get(ISessionContext).sessionDir; this.sessions.delete(sessionId); await this.drainAgents(handle).catch(() => {}); @@ -178,11 +240,18 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } private async materializeSession(opts: MaterializeSessionOptions): Promise { - const workspace = await this.workspaces.createOrTouch(opts.workDir); - const workspaceId = opts.workspaceId ?? workspace.id; - const sessionScope = this.bootstrap.sessionScope(workspaceId, opts.sessionId); - const sessionDir = this.bootstrap.sessionDir(workspaceId, opts.sessionId); + const workspaceId = this.workspaceId; + const sessionScope = sessionScopeOf(this.handlerScope, opts.sessionId); + const sessionDir = sessionDirOf(this.bootstrap.homeDir, this.handlerScope, opts.sessionId); const metaScope = sessionScope; + // Caller-provided dirs join the handler's SHARED in-memory set (union + // across all sessions of this workspace, §6.1) — the workspace dirs + // service owns the local.toml set and its watch; sessions read the + // combined view through the `ISessionWorkspaceInfo` seed below. Await + // the initial local.toml load first so the seed starts from the + // assembled set. + await this.workspaceDirs.ready; + await this.workspaceDirs.mergeAdditionalDirs(opts.workDir, opts.additionalDirs ?? []); const ctx: ISessionContext = { _serviceBrand: undefined, sessionId: opts.sessionId, @@ -193,12 +262,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec scope: (subKey?: string): string => subKey === undefined || subKey === '' ? sessionScope : `${sessionScope}/${subKey}`, }; - const localWorkspaceDirs = await this.projectLocalConfig.readAdditionalDirs(opts.workDir); - const callerAdditionalDirs = await this.projectLocalConfig.resolveAdditionalDirs( - opts.workDir, - opts.additionalDirs ?? [], - ); - const additionalDirs = [...localWorkspaceDirs.additionalDirs, ...callerAdditionalDirs]; + const hooks = createHooks([ + 'onDidCreateSession', + 'onWillCloseSession', + ]); await this.hostEnv.ready; const handle = createScopedChildHandle( this.instantiation, @@ -207,21 +274,47 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec { extra: [ ...sessionContextSeed(ctx), + ...sessionLifecycleHooksSeed(hooks), [ITelemetryService, this.telemetry.withContext({ sessionId: opts.sessionId })], + // Workspace resource seeds (the §3.5 injection contracts): the + // handler's shared skill / agent-profile catalogs, AGENTS.md + // snapshot, and MCP manager reach the session as pure-data read + // views; refreshes fan out through their change events. + ...sessionSkillCatalogDataSeed(this.skillCatalog.sessionData()), + ...sessionAgentProfileCatalogSeed({ + _serviceBrand: undefined, + workspaceKey: workspaceId, + }), + ...sessionInstructionsProviderSeed(this.instructions.sessionProvider()), + ...sessionMcpHandleSeed(this.mcp.sessionHandle()), + ...sessionWorkspaceInfoSeed(this.workspaceDirs.sessionInfo()), + ...sessionToolPolicyGateSeed(this.toolPolicy.sessionGate()), + // The handler-shared Workspace-scope process runner shadows the + // Session-scope default registration in every session of this + // workspace (same seed pattern as the contracts above). + [ISessionProcessRunner, this.processRunner], ], }, ) as ISessionScopeHandle; - if (additionalDirs.length > 0) { - handle.accessor.get(ISessionWorkspaceContext).setAdditionalDirs(additionalDirs); - } try { await handle.accessor.get(ISessionMetadata).ready; await handle.accessor.get(ISessionToolPolicy).ready; - void handle.accessor.get(ISessionSkillCatalog).ready; - await handle.accessor.get(ISessionAgentProfileCatalog).ready; - await handle.accessor.get(ISessionMcpService).ensureMcpReady(opts.mcpServers); + void this.skillCatalog.ready; + await Promise.all([ + this.workspaceAgentProfileLoader.ready, + this.extraAgentProfileLoader.ready, + this.explicitAgentProfileLoader.ready, + this.userAgentProfileLoader.ready, + this.pluginAgentProfileLoader.ready, + ]); + await this.mcp.ready; } catch (error) { handle.dispose(); + // Re-arm the explicit agent-profile loader after a fatal rejection: + // its `ready` tracks the latest load pass, so without a reload the + // handler would keep rejecting every later session create even after + // the user fixes the offending agent file. + void this.explicitAgentProfileLoader.reload().catch(() => undefined); throw error; } this.sessions.set(opts.sessionId, handle); @@ -229,18 +322,15 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } /** - * Append one entry to the v1-compatible `session_index.jsonl`. `workspaceId` - * must be the SAME id the session was materialized with (registry-resolved, - * possibly folded from an alias spelling) — recomputing + * Append one entry to the v1-compatible `session_index.jsonl`. The entry's + * `sessionDir` derives from THIS handler's persistence scope, so the bucket + * is structurally the same workspace id the session was materialized with + * (possibly folded from an alias spelling) — recomputing * `encodeWorkDirKey(workDir)` here could mint a different bucket and orphan * the session for v1 readers. */ - private async appendSessionIndexEntry( - sessionId: string, - workDir: string, - workspaceId: string, - ): Promise { - const sessionDir = this.bootstrap.sessionDir(workspaceId, sessionId); + private async appendSessionIndexEntry(sessionId: string, workDir: string): Promise { + const sessionDir = sessionDirOf(this.bootstrap.homeDir, this.handlerScope, sessionId); this.appendLogStore.append('', 'session_index.jsonl', { sessionId, sessionDir, @@ -250,7 +340,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } private async announceCreated(event: SessionCreatedEvent): Promise { - await this.hooks.onDidCreateSession.run(event); + await event.handle.accessor + .get(ISessionLifecycleHooks) + .onDidCreateSession.run({ source: event.source }); this._onDidCreateSession.fire(event); event.handle.accessor .get(ITelemetryService) @@ -262,12 +354,12 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec return this.sessions.get(sessionId); } - resume(sessionId: string): Promise { + resume(sessionId: string, opts?: ResumeSessionOptions): Promise { const inflight = this.resuming.get(sessionId); if (inflight !== undefined) return inflight; const live = this.sessions.get(sessionId); if (live !== undefined) return Promise.resolve(live); - const promise = this.doResume(sessionId) + const promise = this.doResume(sessionId, opts) .catch((error: unknown) => { this.telemetry .withContext({ sessionId }) @@ -281,21 +373,22 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec return promise; } - private async doResume(sessionId: string): Promise { + private async doResume( + sessionId: string, + opts?: ResumeSessionOptions, + ): Promise { const live = this.sessions.get(sessionId); if (live !== undefined) return live; const summary = await this.index.get(sessionId); - if (summary === undefined) return undefined; - const workspace = - summary.cwd === undefined ? await this.workspaces.get(summary.workspaceId) : undefined; - const workDir = summary.cwd ?? workspace?.root; - if (workDir === undefined) return undefined; + // A handler resumes only its own workspace's sessions. + if (summary === undefined || summary.workspaceId !== this.workspaceId) return undefined; + const workDir = summary.cwd ?? this.workspaceContext.cwd; const handle = await this.materializeSession({ sessionId, workDir, - workspaceId: summary.workspaceId, + additionalDirs: opts?.additionalDirs, }); const agents = handle.accessor.get(IAgentLifecycleService); if (agents.get(MAIN_AGENT_ID) === undefined) { @@ -347,7 +440,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } private async announceWillClose(event: SessionWillCloseEvent): Promise { - await this.hooks.onWillCloseSession.run(event); + await event.handle.accessor + .get(ISessionLifecycleHooks) + .onWillCloseSession.run({ reason: event.reason }); } private async drainAgents(handle: ISessionScopeHandle): Promise { @@ -362,13 +457,13 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const sourceHandle = this.sessions.get(sourceId); const indexSummary = await this.index.get(sourceId); - if (sourceHandle === undefined && indexSummary === undefined) { + if ( + (sourceHandle === undefined && indexSummary === undefined) || + (indexSummary !== undefined && indexSummary.workspaceId !== this.workspaceId) + ) { + // Fork never crosses handlers: the source must live in this workspace. throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sourceId} does not exist`); } - const workspaceId = - sourceHandle !== undefined - ? sourceHandle.accessor.get(ISessionContext).workspaceId - : indexSummary!.workspaceId; // Fork is unconditional — it never rejects on the source being busy. // Copying a live journal yields a torn prefix (a turn cut mid-flight), @@ -381,15 +476,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec let target: ISessionScopeHandle | undefined; let targetSessionDir: string | undefined; try { - const workspace = await this.workspaces.get(workspaceId); - if (workspace === undefined) { - throw new Error2(ErrorCodes.WORKSPACE_NOT_FOUND, `workspace ${workspaceId} does not exist`); - } - const sourceMeta = sourceHandle !== undefined ? await sourceHandle.accessor.get(ISessionMetadata).read() - : await this.readMetaFromDisk(workspaceId, sourceId); + : await this.readMetaFromDisk(sourceId); targetId = opts.newSessionId ?? createSessionId(); if (this.sessions.has(targetId) || (await this.index.get(targetId)) !== undefined) { @@ -399,15 +489,15 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ); } - targetSessionDir = this.bootstrap.sessionDir(workspaceId, targetId); + targetSessionDir = sessionDirOf(this.bootstrap.homeDir, this.handlerScope, targetId); await this.copySessionFiles( - this.bootstrap.sessionDir(workspaceId, sourceId), + sessionDirOf(this.bootstrap.homeDir, this.handlerScope, sourceId), targetSessionDir, ); target = await this.materializeSession({ sessionId: targetId, - workDir: workspace.root, + workDir: this.workspaceContext.cwd, }); const targetCtx = target.accessor.get(ISessionContext); const targetMeta = target.accessor.get(ISessionMetadata); @@ -417,10 +507,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec for (const agentId of agentIds) { await this.copyAgentWire({ sourceHandle, - sourceWorkspaceId: workspaceId, sourceSessionId: sourceId, agentId, - targetWorkspaceId: targetCtx.workspaceId, targetSessionId: targetCtx.sessionId, }); } @@ -435,7 +523,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec custom: forkCustomMetadata(sourceMeta?.custom, opts.metadata), }); - await this.duplicateCronTasks(workspaceId, sourceId, targetId); + await this.duplicateCronTasks(sourceId, targetId); for (const agentId of agentIds) { const sourceAgent = sourceAgents[agentId]!; @@ -446,7 +534,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec }); } - await this.appendSessionIndexEntry(targetId, workspace.root, targetCtx.workspaceId); + await this.appendSessionIndexEntry(targetId, this.workspaceContext.cwd); this._onDidForkSession.fire({ sourceSessionId: sourceId, sessionId: targetId, @@ -498,10 +586,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private async copyAgentWire(args: { readonly sourceHandle: ISessionScopeHandle | undefined; - readonly sourceWorkspaceId: string; readonly sourceSessionId: string; readonly agentId: string; - readonly targetWorkspaceId: string; readonly targetSessionId: string; }): Promise { if (args.sourceHandle !== undefined) { @@ -515,11 +601,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const records = await collect( this.appendLogStore.read( - this.bootstrap.agentScope( - args.sourceWorkspaceId, - args.sourceSessionId, - args.agentId, - ), + agentScopeOf(sessionScopeOf(this.handlerScope, args.sourceSessionId), args.agentId), AGENT_WIRE_RECORD_KEY, ), ); @@ -531,11 +613,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec records.push(forkedRecord()); await this.appendLogStore.rewrite( - this.bootstrap.agentScope( - args.targetWorkspaceId, - args.targetSessionId, - args.agentId, - ), + agentScopeOf(sessionScopeOf(this.handlerScope, args.targetSessionId), args.agentId), AGENT_WIRE_RECORD_KEY, records, ); @@ -584,12 +662,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } } - private async duplicateCronTasks( - workspaceId: string, - sourceId: string, - targetId: string, - ): Promise { - const tasks = await this.cronStore.list({ workspaceId }); + private async duplicateCronTasks(sourceId: string, targetId: string): Promise { + const tasks = await this.cronStore.list({ workspaceId: this.workspaceId }); for (const task of tasks) { if (task.tags?.[CRON_SESSION_TAG] !== sourceId) continue; const clone: CronTask = { @@ -597,27 +671,21 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec id: ulid(), tags: { ...task.tags, [CRON_SESSION_TAG]: targetId }, }; - await this.cronStore.save(workspaceId, clone); + await this.cronStore.save(this.workspaceId, clone); } } - private async readMetaFromDisk( - workspaceId: string, - sessionId: string, - ): Promise { - return this.docs.get( - this.bootstrap.sessionScope(workspaceId, sessionId), - 'state.json', - ); + private async readMetaFromDisk(sessionId: string): Promise { + return this.docs.get(sessionScopeOf(this.handlerScope, sessionId), 'state.json'); } } registerScopedService( - LifecycleScope.App, - ISessionLifecycleService, - SessionLifecycleService, + LifecycleScope.Workspace, + IWorkspaceHandlerService, + WorkspaceHandlerService, ScopeActivation.OnScopeCreated, - 'sessionLifecycle', + 'workspaceHandler', ); async function collect(iterable: AsyncIterable): Promise { diff --git a/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructions.ts b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructions.ts new file mode 100644 index 0000000000..d88aab8233 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructions.ts @@ -0,0 +1,36 @@ +/** + * `workspaceInstructions` domain (L4) — Workspace-scoped AGENTS.md service + * contract. + * + * Defines `IWorkspaceInstructionsService`, the handler-level owner of the + * workspace's AGENTS.md instruction snapshot: loaded once at handler + * materialization through the `profile` domain's pure loader, then + * invalidated by fs watch on every candidate instruction file and reloaded + * debounced. `sessionProvider()` projects the snapshot into the + * `ISessionInstructionsProvider` seed every Session scope of this handler + * receives, so agent system prompts read the shared snapshot and refresh off + * its change event instead of re-reading the files per prompt build. Bound + * at Workspace scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; +import type { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; + +export interface WorkspaceInstructionsSnapshot { + readonly agentsMd: string | undefined; + readonly agentsMdWarning: string | undefined; +} + +export interface IWorkspaceInstructionsService { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly snapshot: WorkspaceInstructionsSnapshot; + readonly onDidChange: Event; + reload(): Promise; + sessionProvider(): ISessionInstructionsProvider; +} + +export const IWorkspaceInstructionsService: ServiceIdentifier = + createDecorator('workspaceInstructionsService'); diff --git a/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts new file mode 100644 index 0000000000..91962bbd75 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts @@ -0,0 +1,148 @@ +/** + * `workspaceInstructions` domain (L4) — `IWorkspaceInstructionsService` + * implementation. + * + * Loads the workspace root's AGENTS.md hierarchy at construction through the + * `profile` domain's pure loader (over the os `hostFs`, the host home dir, + * and the `bootstrap` brand dir), then watches the loader's probe set + * (`agentsMdWatchRoots` — brand / user-generic / project-root→leaf chain, + * each plan root watched recursively and pruned to its candidates so files + * created later inside not-yet-existing directories are still caught) + * through `hostFsWatch` and reloads debounced; the change event fires only + * when the combined content or warning actually changed. The snapshot is shared by every session of + * the handler through the `ISessionInstructionsProvider` seed + * (`sessionProvider()`), a live read view over this service. Bound at + * Workspace scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { TimeoutTimer } from '#/_base/utils/timer'; +import { subtreeWatchFilter } from '#/_base/utils/paths'; +import { agentsMdWatchRoots, loadAgentsMdForRoots } from '#/agent/profile/context'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import type { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { + IWorkspaceInstructionsService, + type WorkspaceInstructionsSnapshot, +} from './workspaceInstructions'; + +const WATCH_DEBOUNCE_MS = 200; + +export class WorkspaceInstructionsService + extends Disposable + implements IWorkspaceInstructionsService +{ + declare readonly _serviceBrand: undefined; + + private current: WorkspaceInstructionsSnapshot = { + agentsMd: undefined, + agentsMdWarning: undefined, + }; + readonly ready: Promise; + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + private readonly watchDebounce = this._register(new TimeoutTimer()); + private reloadTail: Promise = Promise.resolve(); + + constructor( + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + @IHostFileSystem private readonly fs: IHostFileSystem, + @IHostEnvironment private readonly env: IHostEnvironment, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, + @ILogService private readonly log: ILogService, + ) { + super(); + this.ready = this.reload(); + void this.watchCandidateFiles(); + } + + get snapshot(): WorkspaceInstructionsSnapshot { + return this.current; + } + + reload(): Promise { + const tail = this.reloadTail.catch(() => undefined).then(async () => { + const result = await loadAgentsMdForRoots( + { fs: this.fs, homeDir: this.env.homeDir }, + this.bootstrap.homeDir, + [this.workspace.cwd], + ); + const next: WorkspaceInstructionsSnapshot = { + agentsMd: result.content, + agentsMdWarning: result.warning, + }; + if ( + next.agentsMd !== this.current.agentsMd || + next.agentsMdWarning !== this.current.agentsMdWarning + ) { + this.current = next; + this.onDidChangeEmitter.fire(); + } + }); + this.reloadTail = tail; + return tail; + } + + sessionProvider(): ISessionInstructionsProvider { + const currentAgentsMd = (): string | undefined => this.current.agentsMd; + const currentWarning = (): string | undefined => this.current.agentsMdWarning; + return { + _serviceBrand: undefined, + ready: this.ready, + onDidChange: this.onDidChange, + get agentsMd() { + return currentAgentsMd(); + }, + get agentsMdWarning() { + return currentWarning(); + }, + }; + } + + private async watchCandidateFiles(): Promise { + // Watch each plan root recursively, pruned to its candidate files: + // watching a candidate file directly never fires when its parent + // directory (`.kimi-code` / `.agents`) does not exist yet either. + const plan = await agentsMdWatchRoots( + { fs: this.fs, homeDir: this.env.homeDir }, + this.workspace.cwd, + this.bootstrap.homeDir, + ); + for (const { root, candidates } of plan) { + try { + const handle = this.fsWatch.watch(root, { + ignored: subtreeWatchFilter(root, candidates), + }); + this._register(handle); + this._register( + handle.onDidChange(() => { + this.watchDebounce.cancelAndSet(() => { + void this.reload().catch((error) => { + this.log.warn(`AGENTS.md reload failed: ${String(error)}`); + }); + }, WATCH_DEBOUNCE_MS); + }), + ); + } catch (error) { + this.log.warn(`cannot watch instruction root ${root}: ${String(error)}`); + } + } + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceInstructionsService, + WorkspaceInstructionsService, + ScopeActivation.OnScopeCreated, + 'workspaceInstructions', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcp.ts b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcp.ts new file mode 100644 index 0000000000..615bf5ab00 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcp.ts @@ -0,0 +1,39 @@ +/** + * `workspaceMcp` domain (L5) — Workspace-scoped MCP subsystem contract. + * + * Defines `IWorkspaceMcpService`, the handler-level owner of the workspace's + * ONE shared `McpConnectionManager`: connected at handler materialization + * from the `workspaceMcpConfig` domain's effective server snapshot and + * incrementally reconciled as its change events arrive. Sessions cannot + * contribute MCP servers: there is no caller-supplied server channel on + * session create/resume. Every session of the handler receives the manager + * through the `ISessionMcpHandle` seed (`sessionHandle()`); no per-session + * MCP connections exist. Bound at Workspace scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { McpConnectionManager } from '#/mcpCore/connection-manager'; +import type { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; + +export interface IWorkspaceMcpService { + readonly _serviceBrand: undefined; + + /** + * The handler's initial connect (the config domain's effective snapshot). + * Settles once the initial connection attempt finishes; per-server failures + * are reflected in MCP status entries rather than rejecting this promise. + */ + readonly ready: Promise; + + /** + * The handler's shared connection manager. Always available, independent + * of the initial connect's progress; global timeout defaults come from the + * config domain's tunables at each (re)connect. + */ + connectionManager(): McpConnectionManager; + + sessionHandle(): ISessionMcpHandle; +} + +export const IWorkspaceMcpService: ServiceIdentifier = + createDecorator('workspaceMcpService'); diff --git a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts new file mode 100644 index 0000000000..cb48c8ba47 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts @@ -0,0 +1,143 @@ +/** + * `workspaceMcp` domain (L5) — `IWorkspaceMcpService` implementation. + * + * Owns the handler-wide `McpConnectionManager` (built at construction, + * shared by every session of the workspace) and nothing else: where the + * server set comes from is the `workspaceMcpConfig` domain's business. This + * service drives the initial connect from the config domain's snapshot, + * applies its reconciled change events incrementally (serialized on a + * mutation tail, always after the initial connect settles), feeds the + * manager's global timeout defaults from the config domain's tunables at + * each (re)connect, and reports connection telemetry for the initial load. + * An outright initial-load or change-apply failure is logged (per-server + * failures are status entries). The manager (and its stdio child processes, + * whose cwd is the handler root) lives as long as the handler — i.e. the + * process — so a stateful stdio server is shared by concurrent sessions of + * the workspace rather than owned by one session. Bound at Workspace scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; + +import { McpConnectionManager } from '#/mcpCore/connection-manager'; +import { McpOAuthService } from '#/mcpCore/oauth/service'; +import { IMcpOAuthStore } from '#/app/mcpConfig/oauthStore'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { + IWorkspaceMcpConfigService, + type McpServersChange, +} from '#/workspace/workspaceMcpConfig/workspaceMcpConfig'; + +import { IWorkspaceMcpService } from './workspaceMcp'; + +export class WorkspaceMcpService extends Disposable implements IWorkspaceMcpService { + declare readonly _serviceBrand: undefined; + + private readonly manager: McpConnectionManager; + readonly ready: Promise; + /** Serializes change applications against each other. */ + private mutationTail: Promise = Promise.resolve(); + + constructor( + @IWorkspaceContext workspace: IWorkspaceContext, + @IWorkspaceMcpConfigService private readonly mcpConfig: IWorkspaceMcpConfigService, + @IMcpOAuthStore oauthStore: IMcpOAuthStore, + @ILogService private readonly log: ILogService, + @ITelemetryService private readonly telemetry: ITelemetryService, + ) { + super(); + const oauthService = new McpOAuthService({ store: oauthStore }); + this.manager = new McpConnectionManager({ + log: this.log, + oauthService, + stdioCwd: workspace.cwd, + resolveDefaultTimeouts: () => this.mcpConfig.tunables(), + }); + this._register({ dispose: () => void this.manager.shutdown() }); + this._register( + this.mcpConfig.onDidChange((change) => { + this.scheduleApply(change); + }), + ); + this.ready = this.initialize().catch((error: unknown) => { + this.log.error('mcp initial load failed', { error }); + }); + } + + connectionManager(): McpConnectionManager { + return this.manager; + } + + sessionHandle(): ISessionMcpHandle { + return { + _serviceBrand: undefined, + ready: this.ready, + connectionManager: this.manager, + }; + } + + private mutate(work: () => Promise): Promise { + const tail = this.mutationTail.catch(() => undefined).then(work); + this.mutationTail = tail; + return tail; + } + + private async initialize(): Promise { + await this.mcpConfig.ready; + const servers = this.mcpConfig.servers(); + if (Object.keys(servers).length === 0) return; + await this.manager.connectAll(servers); + this.trackMcpInitialLoad(); + } + + /** Applies a change after the initial connect settles, serialized. */ + private scheduleApply(change: McpServersChange): void { + void this.ready + .then(() => this.mutate(() => this.apply(change))) + .catch((error) => { + this.log.warn(`mcp server change apply failed: ${String(error)}`); + }); + } + + private async apply(change: McpServersChange): Promise { + for (const name of change.remove) { + await this.manager.remove(name); + } + for (const [name, config] of Object.entries(change.upsert)) { + await this.manager.connect(name, config); + } + } + + private trackMcpInitialLoad(): void { + const entries = this.manager.list().filter((entry) => entry.status !== 'disabled'); + const totalCount = entries.length; + if (totalCount === 0) return; + + const connectedCount = entries.filter((entry) => entry.status === 'connected').length; + if (connectedCount > 0) { + this.telemetry.track2('mcp_connected', { + server_count: connectedCount, + total_count: totalCount, + }); + } + + const failedCount = entries.filter((entry) => entry.status === 'failed').length; + if (failedCount > 0) { + this.telemetry.track2('mcp_failed', { + failed_count: failedCount, + total_count: totalCount, + }); + } + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceMcpService, + WorkspaceMcpService, + ScopeActivation.OnScopeCreated, + 'workspaceMcp', +); diff --git a/packages/agent-core-v2/src/agent/mcp/config-loader.ts b/packages/agent-core-v2/src/workspace/workspaceMcpConfig/config-loader.ts similarity index 62% rename from packages/agent-core-v2/src/agent/mcp/config-loader.ts rename to packages/agent-core-v2/src/workspace/workspaceMcpConfig/config-loader.ts index 2aef7d1481..50d536f41b 100644 --- a/packages/agent-core-v2/src/agent/mcp/config-loader.ts +++ b/packages/agent-core-v2/src/workspace/workspaceMcpConfig/config-loader.ts @@ -1,8 +1,25 @@ -import { readFile, stat } from 'node:fs/promises'; +/** + * `workspaceMcpConfig` domain (L5) — MCP JSON config discovery and loading. + * + * Resolves the three MCP config files for a cwd (user `mcp.json` under the + * kimi home, project-root `.mcp.json` — the root discovered through the + * `git` domain's work-tree probe — and `.kimi-code/mcp.json` under the cwd) + * and loads them with user < project-root < project precedence, normalizing + * relative stdio `cwd` entries against the project-root file's directory. + * `includeProject: false` skips the two project-level files and loads the + * user file only — the workspace-trust gate: the project files ship with + * the checkout, so an untrusted workspace must never see them. All + * filesystem access goes through the os `IHostFileSystem`, supplied by + * the caller. Pure functions — no scoped state. + */ + import { dirname, isAbsolute, join, normalize, resolve } from 'pathe'; +import { findGitWorkTree } from '#/app/git/workTree'; import { resolveKimiHome } from '#/app/bootstrap/bootstrap'; -import { McpServerConfigSchema, type McpServerConfig } from './config-schema'; +import { OsFsErrors, HostFsError } from '#/os/interface/hostFsErrors'; +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { McpServerConfigSchema, type McpServerConfig } from '#/mcpCore/config-schema'; import { ErrorCodes, Error2 } from '#/errors'; import { z } from 'zod'; @@ -17,12 +34,14 @@ export interface McpJsonPaths { } export interface ResolveMcpJsonPathsInput { + readonly fs: IHostFileSystem; readonly cwd: string; readonly homeDir?: string; } export async function resolveMcpJsonPaths(input: ResolveMcpJsonPathsInput): Promise { - const projectRoot = await findProjectRoot(input.cwd); + const start = normalize(input.cwd); + const projectRoot = (await findGitWorkTree(input.fs, start))?.root ?? start; return { user: join(resolveKimiHome(input.homeDir), 'mcp.json'), @@ -32,55 +51,40 @@ export async function resolveMcpJsonPaths(input: ResolveMcpJsonPathsInput): Prom } export interface LoadMcpServersInput { + readonly fs: IHostFileSystem; readonly cwd: string; readonly homeDir?: string; + /** Defaults to true; false loads the user file only (untrusted workspace). */ + readonly includeProject?: boolean; } export async function loadMcpServers( input: LoadMcpServersInput, ): Promise> { - const paths = await resolveMcpJsonPaths({ cwd: input.cwd, homeDir: input.homeDir }); + const paths = await resolveMcpJsonPaths(input); + if (input.includeProject === false) { + return readMcpJson(input.fs, paths.user); + } const [user, projectRoot, project] = await Promise.all([ - readMcpJson(paths.user), - readMcpJson(paths.projectRoot, { stdioCwdBase: dirname(paths.projectRoot) }), - readMcpJson(paths.project), + readMcpJson(input.fs, paths.user), + readMcpJson(input.fs, paths.projectRoot, { stdioCwdBase: dirname(paths.projectRoot) }), + readMcpJson(input.fs, paths.project), ]); return { ...user, ...projectRoot, ...project }; } -async function findProjectRoot(cwd: string): Promise { - const start = normalize(cwd); - let current = start; - - while (true) { - if (await pathExists(join(current, '.git'))) return current; - const parent = dirname(current); - if (parent === current) return start; - current = parent; - } -} - -async function pathExists(filePath: string): Promise { - try { - await stat(filePath); - return true; - } catch (error: unknown) { - if (isPathMissing(error)) return false; - throw error; - } -} - interface ReadMcpJsonOptions { readonly stdioCwdBase?: string; } async function readMcpJson( + fs: IHostFileSystem, filePath: string, options: ReadMcpJsonOptions = {}, ): Promise> { let text: string; try { - text = await readFile(filePath, 'utf-8'); + text = await fs.readText(filePath); } catch (error: unknown) { if (isFileNotFound(error)) return {}; throw new Error2(ErrorCodes.CONFIG_INVALID, `Failed to read ${filePath}: ${describeError(error)}`, { @@ -131,17 +135,7 @@ function resolvePath(base: string, value: string): string { } function isFileNotFound(error: unknown): boolean { - return getErrorCode(error) === 'ENOENT'; -} - -function isPathMissing(error: unknown): boolean { - const code = getErrorCode(error); - return code === 'ENOENT' || code === 'ENOTDIR'; -} - -function getErrorCode(error: unknown): unknown { - if (typeof error !== 'object' || error === null || !('code' in error)) return undefined; - return (error as { code: unknown }).code; + return error instanceof HostFsError && error.code === OsFsErrors.codes.OS_FS_NOT_FOUND; } function describeError(error: unknown): string { diff --git a/packages/agent-core-v2/src/workspace/workspaceMcpConfig/workspaceMcpConfig.ts b/packages/agent-core-v2/src/workspace/workspaceMcpConfig/workspaceMcpConfig.ts new file mode 100644 index 0000000000..96cfac0b0f --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceMcpConfig/workspaceMcpConfig.ts @@ -0,0 +1,76 @@ +/** + * `workspaceMcpConfig` domain (L5) — Workspace-scoped MCP server-config owner + * contract. + * + * Defines `IWorkspaceMcpConfigService`, the single source of truth for "which + * MCP servers should this workspace run": it resolves the MCP config files + * (user `mcp.json`, project-root `.mcp.json`, `.kimi-code/mcp.json`) and the + * enabled plugins' contributions — on a name collision the file config wins — + * with the two project-level files gated by `workspaceTrust` (an untrusted + * workspace gets the user file and plugin contributions only), then tracks + * both sources (fs watch on the config files, + * `plugins.onDidReload`) and publishes the reconciled effective set as a + * snapshot plus already-diffed change events. Consumers never read config + * files, the plugin registry, or the `[mcp]` config section themselves: the + * global timeout preferences are exposed here as {@link tunables} too, so the + * connection side has exactly one configuration dependency. The domain holds + * no connection state and never talks to an MCP server; writing config files + * stays out of the engine (the node-sdk global store and the `/mcp-config` + * skill are the external write paths). Bound at Workspace scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; + +import type { McpServerConfig } from '#/mcpCore/config-schema'; + +/** + * One reconciled change of the effective server set. `upsert` entries are new + * or fingerprint-changed and ask the consumer to (re)connect; `remove` names + * vanished from the effective set and ask it to disconnect. Entries whose + * merged value did not actually change (e.g. a same-named fallback with an + * identical config) produce no event. + */ +export interface McpServersChange { + readonly upsert: Readonly>; + readonly remove: readonly string[]; +} + +/** The global `[mcp]` timeout preferences, as resolved by `config`. */ +export interface McpTunables { + readonly startupTimeoutMs?: number; + readonly toolTimeoutMs?: number; +} + +export interface IWorkspaceMcpConfigService { + readonly _serviceBrand: undefined; + + /** + * The initial resolve (config files + plugin contributions). Settles once + * the first effective snapshot is available; a load failure is logged and + * resolves to an empty snapshot rather than rejecting. + */ + readonly ready: Promise; + + /** + * The current effective server set (file sources win name collisions). + * Meaningful after {@link ready}; immutable — replaced wholesale on change. + */ + servers(): Readonly>; + + /** + * The global `[mcp]` timeout preferences, resolved live from `config` at + * each call so a mid-session edit applies to the next (re)connect. + */ + tunables(): McpTunables; + + /** + * Fires when the effective set changes after the initial resolve (a + * watched config file changed, or plugins reloaded). Never fires before + * {@link ready} settles; empty diffs are suppressed. + */ + readonly onDidChange: Event; +} + +export const IWorkspaceMcpConfigService: ServiceIdentifier = + createDecorator('workspaceMcpConfigService'); diff --git a/packages/agent-core-v2/src/workspace/workspaceMcpConfig/workspaceMcpConfigService.ts b/packages/agent-core-v2/src/workspace/workspaceMcpConfig/workspaceMcpConfigService.ts new file mode 100644 index 0000000000..f732f2ef33 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceMcpConfig/workspaceMcpConfigService.ts @@ -0,0 +1,252 @@ +/** + * `workspaceMcpConfig` domain (L5) — `IWorkspaceMcpConfigService` + * implementation. + * + * Resolves the handler's effective MCP server set from exactly two sources — + * the MCP config files (`resolveMcpJsonPaths`: user `mcp.json`, project-root + * `.mcp.json`, `.kimi-code/mcp.json` — read through the os `hostFs`) and the + * enabled plugins; on a name collision the file config wins, and when one + * source's server vanishes the same-named entry from the other source takes + * over. The two project-level files are gated by `workspaceTrust`: while the + * workspace is untrusted they are skipped (the user file and plugin + * contributions still load), and a trust flip triggers the same reload path + * as a file edit, so trusting connects the project servers and untrusting + * drops them. The config files are watched (the user file directly, the + * project root recursively pruned to the two project candidates) and plugin + * contributions follow `plugins.onDidReload`; every re-resolve recomputes the + * merged view and publishes the fingerprint diff through `onDidChange`, so a + * config edit or a plugin installed, enabled or reloaded AFTER the handler + * materialized still reaches the connection side. Reloads are debounced and + * serialized on a mutation tail; an outright initial-load or reload failure + * is logged, leaving the last published snapshot in place. The initial + * resolve waits for `config.ready` so the file/plugin read and the `[mcp]` + * section read are deterministic. Bound at Workspace scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Emitter } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { TimeoutTimer } from '#/_base/utils/timer'; +import { subtreeWatchFilter } from '#/_base/utils/paths'; +import { dirname } from 'pathe'; + +import type { McpServerConfig } from '#/mcpCore/config-schema'; +import { MCP_SECTION, type McpSection } from '#/app/mcpConfig/configSection'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { IPluginService } from '#/app/plugin/plugin'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceTrust } from '#/workspace/workspaceTrust/workspaceTrust'; + +import { loadMcpServers, resolveMcpJsonPaths } from './config-loader'; +import { + IWorkspaceMcpConfigService, + type McpServersChange, + type McpTunables, +} from './workspaceMcpConfig'; + +const WATCH_DEBOUNCE_MS = 200; + +export class WorkspaceMcpConfigService extends Disposable implements IWorkspaceMcpConfigService { + declare readonly _serviceBrand: undefined; + + readonly ready: Promise; + /** Serializes watch/plugin reloads against each other. */ + private mutationTail: Promise = Promise.resolve(); + /** File-sourced server configs by name (the winning source on collisions). */ + private fileServers = new Map(); + /** Plugin-sourced server configs by name. */ + private pluginServers = new Map(); + /** The last published merged view; replaced wholesale on every change. */ + private current: Readonly> = {}; + private readonly watchDebounce = this._register(new TimeoutTimer()); + private readonly changeEmitter = this._register(new Emitter()); + readonly onDidChange = this.changeEmitter.event; + + constructor( + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IPluginService private readonly plugins: IPluginService, + @ILogService private readonly log: ILogService, + @IConfigService private readonly config: IConfigService, + @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, + @IHostFileSystem private readonly fs: IHostFileSystem, + @IWorkspaceTrust private readonly trust: IWorkspaceTrust, + ) { + super(); + this.ready = this.initialize().catch((error: unknown) => { + this.log.error('mcp config initial load failed', { error }); + }); + this._register( + this.plugins.onDidReload(() => { + void this.reloadPluginServers().catch((error) => { + this.log.warn(`mcp plugin reload failed: ${String(error)}`); + }); + }), + ); + this._register( + this.trust.onDidChange(() => { + void this.reloadFileServers().catch((error) => { + this.log.warn(`mcp trust reload failed: ${String(error)}`); + }); + }), + ); + void this.watchConfigFiles(); + } + + servers(): Readonly> { + return this.current; + } + + tunables(): McpTunables { + const section = this.config.get(MCP_SECTION); + return { + startupTimeoutMs: section?.startupTimeoutMs, + toolTimeoutMs: section?.toolTimeoutMs, + }; + } + + private mutate(work: () => Promise): Promise { + const tail = this.mutationTail.catch(() => undefined).then(work); + this.mutationTail = tail; + return tail; + } + + private async initialize(): Promise { + await this.config.ready; + await this.trust.ready; + const [fileServers, pluginServers] = await Promise.all([ + loadMcpServers({ + fs: this.fs, + cwd: this.workspace.cwd, + homeDir: this.bootstrap.homeDir, + includeProject: this.trust.isTrusted(), + }), + this.plugins.enabledMcpServers(), + ]); + this.fileServers = new Map(Object.entries(fileServers)); + this.pluginServers = new Map(Object.entries(pluginServers)); + this.current = this.merged(); + } + + /** The effective set: file config wins on a name collision. */ + private merged(): Record { + return { ...Object.fromEntries(this.pluginServers), ...Object.fromEntries(this.fileServers) }; + } + + private async watchConfigFiles(): Promise { + const paths = await resolveMcpJsonPaths({ + fs: this.fs, + cwd: this.workspace.cwd, + homeDir: this.bootstrap.homeDir, + }); + // The user file's parent (the kimi home) always exists, so a direct + // watch is reliable; the project candidates' parents may not exist yet, + // so the project root is watched recursively and pruned to them. + this.watchPaths([paths.user]); + const projectRoot = dirname(paths.projectRoot); + const handle = this.fsWatch.watch(projectRoot, { + ignored: subtreeWatchFilter(projectRoot, [paths.projectRoot, paths.project]), + }); + this._register(handle); + this._register( + handle.onDidChange(() => { + this.scheduleFileReload(); + }), + ); + } + + private watchPaths(paths: readonly string[]): void { + for (const path of paths) { + const handle = this.fsWatch.watch(path); + this._register(handle); + this._register( + handle.onDidChange(() => { + this.scheduleFileReload(); + }), + ); + } + } + + private scheduleFileReload(): void { + this.watchDebounce.cancelAndSet(() => { + void this.reloadFileServers().catch((error) => { + this.log.warn(`mcp config reload failed: ${String(error)}`); + }); + }, WATCH_DEBOUNCE_MS); + } + + private async reloadFileServers(): Promise { + await this.ready; + await this.mutate(async () => { + const fresh = await loadMcpServers({ + fs: this.fs, + cwd: this.workspace.cwd, + homeDir: this.bootstrap.homeDir, + includeProject: this.trust.isTrusted(), + }); + this.fileServers = new Map(Object.entries(fresh)); + this.publishIfChanged(); + }); + } + + private async reloadPluginServers(): Promise { + await this.ready; + await this.mutate(async () => { + const fresh = await this.plugins.enabledMcpServers(); + this.pluginServers = new Map(Object.entries(fresh)); + this.publishIfChanged(); + }); + } + + /** + * Recomputes the merged view against the last published snapshot and fires + * the fingerprint diff; a change that leaves the effective set untouched + * (e.g. a vanished source shadowed by an identical same-named entry) + * produces no event. + */ + private publishIfChanged(): void { + const next = this.merged(); + const upsert: Record = {}; + const remove: string[] = []; + for (const [name, config] of Object.entries(next)) { + const previous = this.current[name]; + if (previous === undefined || fingerprintConfig(previous) !== fingerprintConfig(config)) { + upsert[name] = config; + } + } + for (const name of Object.keys(this.current)) { + if (!Object.hasOwn(next, name)) remove.push(name); + } + this.current = next; + if (Object.keys(upsert).length === 0 && remove.length === 0) return; + this.changeEmitter.fire({ upsert, remove }); + } +} + +function fingerprintConfig(config: McpServerConfig): string { + return JSON.stringify(sortKeysDeep(config)); +} + +function sortKeysDeep(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeysDeep); + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value) + .toSorted(([a], [b]) => a.localeCompare(b)) + .map(([key, nested]) => [key, sortKeysDeep(nested)]), + ); + } + return value; +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceMcpConfigService, + WorkspaceMcpConfigService, + ScopeActivation.OnScopeCreated, + 'workspaceMcpConfig', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceProcess/workspaceProcessRunnerService.ts b/packages/agent-core-v2/src/workspace/workspaceProcess/workspaceProcessRunnerService.ts new file mode 100644 index 0000000000..c2329f7f5e --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceProcess/workspaceProcessRunnerService.ts @@ -0,0 +1,66 @@ +/** + * `workspaceProcess` domain (L2) — `ISessionProcessRunner` implementation. + * + * Resolves the default cwd from the handler's `IWorkspaceContext` (chdir is + * gone, so the workspace root is the one fixed default) and delegates the + * actual host spawn to the App-scope `IHostProcessService`. A per-call + * `options.cwd` wins over the handler root. A per-call `options.env` is + * overlaid onto `process.env` and passed as the child's complete env bag (the + * host replaces the child env with what we pass); when `options.env` is + * omitted we pass `undefined` so the child inherits `process.env` verbatim. + * + * Bound at Workspace scope — one runner per handler, shared by every session + * of the workspace. The contract (`ISessionProcessRunner`) stays in the + * session domain so Session/Agent consumers keep importing it without + * crossing the Workspace-tier import ban; the Workspace-scope registration + * reaches them through ordinary parent-scope resolution. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IHostProcessService } from '#/os/interface/hostProcess'; +import { type IProcess, ISessionProcessRunner, type ProcessExecOptions } from '#/session/process/processRunner'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +export class WorkspaceProcessRunnerService implements ISessionProcessRunner { + declare readonly _serviceBrand: undefined; + + constructor( + @IWorkspaceContext private readonly ctx: IWorkspaceContext, + @IHostProcessService private readonly hostProcess: IHostProcessService, + ) {} + + async exec(args: readonly string[], options?: ProcessExecOptions): Promise { + const command = args[0]; + if (command === undefined) { + throw new Error( + 'WorkspaceProcessRunnerService.exec(): at least one argument (the command to run) is required.', + ); + } + const restArgs = args.slice(1); + + const cwd = options?.cwd ?? this.ctx.cwd; + const env = this._buildExecEnv(options?.env); + + return this.hostProcess.spawn(command, restArgs, { cwd, env }); + } + + private _buildExecEnv( + invocationEnv: Record | undefined, + ): Record | undefined { + if (invocationEnv === undefined) { + return undefined; + } + return { + ...(process.env as Record), + ...invocationEnv, + }; + } +} + +registerScopedService( + LifecycleScope.Workspace, + ISessionProcessRunner, + WorkspaceProcessRunnerService, + ScopeActivation.OnScopeCreated, + 'workspaceProcess', +); diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/explicitFileSkillSource.ts similarity index 72% rename from packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts rename to packages/agent-core-v2/src/workspace/workspaceSkillCatalog/explicitFileSkillSource.ts index d2096c7443..9966d98a6b 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/explicitFileSkillSource.ts @@ -1,11 +1,11 @@ /** - * `sessionSkillCatalog` domain (L3) — explicit `ISkillSource` producer. + * `workspaceSkillCatalog` domain (L3) — explicit `ISkillSource` producer. * * Mirrors v1 SDK `skillDirs`: when runtime options provide `explicitDirs`, this * source contributes those directories as the user source, resolving relative - * paths against the session project root. When no explicit dirs are configured, - * it yields nothing so default user / project discovery remains active. Bound at - * Session scope so each session resolves paths against its own workDir. + * paths against the workspace root. When no explicit dirs are configured, + * it yields nothing so default user / project discovery remains active. Bound + * at Workspace scope so every session of the handler shares one scan. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -14,8 +14,12 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { configuredRoots } from '#/app/skillCatalog/skillRoots'; import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { + SKILL_SOURCE_PRIORITY, + type ISkillSource, + type SkillContribution, +} from '#/app/skillCatalog/skillSource'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; export interface IExplicitFileSkillSource extends ISkillSource { readonly _serviceBrand: undefined; @@ -33,7 +37,7 @@ export class ExplicitFileSkillSource implements IExplicitFileSkillSource { constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, + @IWorkspaceContext private readonly workspace: IWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, ) {} @@ -43,15 +47,15 @@ export class ExplicitFileSkillSource implements IExplicitFileSkillSource { return { skills: [] }; } return this.discovery.discover( - await configuredRoots(explicitDirs, this.workspace.workDir, this.bootstrap.osHomeDir, 'user'), + await configuredRoots(explicitDirs, this.workspace.cwd, this.bootstrap.osHomeDir, 'user'), ); } } registerScopedService( - LifecycleScope.Session, + LifecycleScope.Workspace, IExplicitFileSkillSource, ExplicitFileSkillSource, ScopeActivation.OnScopeCreated, - 'sessionSkillCatalog', + 'workspaceSkillCatalog', ); diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/extraFileSkillSource.ts similarity index 72% rename from packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts rename to packages/agent-core-v2/src/workspace/workspaceSkillCatalog/extraFileSkillSource.ts index 6d8d834c3b..f9588a297e 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/extraFileSkillSource.ts @@ -1,11 +1,13 @@ /** - * `sessionSkillCatalog` domain (L3) — extra `ISkillSource` producer. + * `workspaceSkillCatalog` domain (L3) — extra `ISkillSource` producer. * * Discovers user-configured extra skill directories (`extraSkillDirs`) through * `ISkillDiscovery`, contributing them at priority 10 (above plugin / builtin, - * below user / workspace). Relative paths resolve against the session project - * root; `~` and `~/...` resolve against the bootstrap home dir. Bound at Session - * scope so each session reads its own workspace root. + * below user / workspace). Relative paths resolve against the workspace root; + * `~` and `~/...` resolve against the bootstrap home dir. Re-fires + * `onDidChange` when the `extraSkillDirs` config section changes so the + * catalog re-scans THIS source only. Bound at Workspace scope so every + * session of the handler shares one scan. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -20,8 +22,12 @@ import { } from '#/app/skillCatalog/configSection'; import { configuredRoots } from '#/app/skillCatalog/skillRoots'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { + SKILL_SOURCE_PRIORITY, + type ISkillSource, + type SkillContribution, +} from '#/app/skillCatalog/skillSource'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; export interface IExtraFileSkillSource extends ISkillSource { readonly _serviceBrand: undefined; @@ -41,7 +47,7 @@ export class ExtraFileSkillSource extends Disposable implements IExtraFileSkillS constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @IConfigService private readonly config: IConfigService, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, + @IWorkspaceContext private readonly workspace: IWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, ) { super(); @@ -56,15 +62,15 @@ export class ExtraFileSkillSource extends Disposable implements IExtraFileSkillS await this.config.ready; const extraSkillDirs = this.config.get(EXTRA_SKILL_DIRS_SECTION) ?? []; return this.discovery.discover( - await configuredRoots(extraSkillDirs, this.workspace.workDir, this.bootstrap.osHomeDir, 'extra'), + await configuredRoots(extraSkillDirs, this.workspace.cwd, this.bootstrap.osHomeDir, 'extra'), ); } } registerScopedService( - LifecycleScope.Session, + LifecycleScope.Workspace, IExtraFileSkillSource, ExtraFileSkillSource, ScopeActivation.OnScopeCreated, - 'sessionSkillCatalog', + 'workspaceSkillCatalog', ); diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/pluginSkillSource.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/pluginSkillSource.ts similarity index 76% rename from packages/agent-core-v2/src/session/sessionSkillCatalog/pluginSkillSource.ts rename to packages/agent-core-v2/src/workspace/workspaceSkillCatalog/pluginSkillSource.ts index 36e021ab39..0988d7c365 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/pluginSkillSource.ts +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/pluginSkillSource.ts @@ -1,20 +1,24 @@ /** - * `sessionSkillCatalog` domain (L3) — plugin `ISkillSource` producer. + * `workspaceSkillCatalog` domain (L3) — plugin `ISkillSource` producer. * * Discovers skills contributed by enabled plugins through `ISkillDiscovery` * (roots from `plugin.pluginSkillRoots()`), contributing them at priority 5 * (above builtin, below extra / user / workspace, so project, user and extra * skills win name collisions). Re-emits `plugin.onDidReload` as `onDidChange` - * so the sink re-pulls plugin skills when plugins reload; install / enable / - * remove mutations deliberately do not refresh the session catalog — those - * take effect on the next explicit reload. Bound at Session scope. + * so the catalog re-pulls plugin skills when plugins reload. Bound at + * Workspace scope so every session of the handler shares one scan. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; +import { + PLUGIN_SKILL_SOURCE_ID, + SKILL_SOURCE_PRIORITY, + type ISkillSource, + type SkillContribution, +} from '#/app/skillCatalog/skillSource'; import { IPluginService } from '#/app/plugin/plugin'; export interface IPluginSkillSource extends ISkillSource { @@ -24,7 +28,7 @@ export interface IPluginSkillSource extends ISkillSource { export const IPluginSkillSource: ServiceIdentifier = createDecorator('pluginSkillSource'); -export const PLUGIN_SKILL_SOURCE_ID = 'plugin'; +export { PLUGIN_SKILL_SOURCE_ID }; export class PluginSkillSource implements IPluginSkillSource { declare readonly _serviceBrand: undefined; @@ -49,9 +53,9 @@ export class PluginSkillSource implements IPluginSkillSource { } registerScopedService( - LifecycleScope.Session, + LifecycleScope.Workspace, IPluginSkillSource, PluginSkillSource, ScopeActivation.OnScopeCreated, - 'sessionSkillCatalog', + 'workspaceSkillCatalog', ); diff --git a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/rootFileSkillSource.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/rootFileSkillSource.ts new file mode 100644 index 0000000000..ecb98c6ed9 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/rootFileSkillSource.ts @@ -0,0 +1,115 @@ +/** + * `workspaceSkillCatalog` domain (L3) — workspace-root `ISkillSource` + * producer. + * + * Discovers project skills from the handler's workspace root + * (`workspaceContext.cwd`) through `ISkillDiscovery`, contributing them at + * priority 30 (above user / extra / plugin / builtin). Watches the project + * skill-root candidates (`.kimi-code/skills`, `.agents/skills` under the + * project root, watched whether or not they exist yet) through + * `hostFsWatch` and re-fires `onDidChange` debounced, so the catalog + * re-scans THIS source only when project skill files change. Renamed from + * the Session-scope `IWorkspaceFileSkillSource` (now + * `IWorkspaceRootSkillSource`) to avoid the collision called out in the + * workspace-domain plan. Bound at Workspace scope so every session of the + * handler shares one scan. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { TimeoutTimer } from '#/_base/utils/timer'; +import { subtreeWatchFilter } from '#/_base/utils/paths'; +import { IConfigService } from '#/app/config/config'; +import { + MERGE_ALL_AVAILABLE_SKILLS_SECTION, + type MergeAllAvailableSkillsConfig, +} from '#/app/skillCatalog/configSection'; +import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; +import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; +import { projectRoots, projectSkillRootCandidates } from '#/app/skillCatalog/skillRoots'; +import { + SKILL_SOURCE_PRIORITY, + type ISkillSource, + type SkillContribution, +} from '#/app/skillCatalog/skillSource'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +export const WORKSPACE_ROOT_SKILL_SOURCE_ID = 'workspace'; + +const WATCH_DEBOUNCE_MS = 200; + +export interface IWorkspaceRootSkillSource extends ISkillSource { + readonly _serviceBrand: undefined; +} + +export const IWorkspaceRootSkillSource: ServiceIdentifier = + createDecorator('workspaceRootSkillSource'); + +export class WorkspaceRootSkillSource extends Disposable implements IWorkspaceRootSkillSource { + declare readonly _serviceBrand: undefined; + + readonly id = WORKSPACE_ROOT_SKILL_SOURCE_ID; + readonly priority = SKILL_SOURCE_PRIORITY.workspace; + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + private readonly watchDebounce = this._register(new TimeoutTimer()); + private readonly watchReady: Promise; + + constructor( + @ISkillDiscovery private readonly discovery: ISkillDiscovery, + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + @IConfigService private readonly config: IConfigService, + @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, + @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, + ) { + super(); + this._register( + this.config.onDidSectionChange((event) => { + if (event.domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) this.onDidChangeEmitter.fire(); + }), + ); + this.watchReady = this.watchProjectSkillRoots(); + } + + async load(): Promise { + // The watch attaches before the first scan returns, so a change landing + // right after the scan cannot slip between the two. + await this.watchReady; + if ((this.runtimeOptions.explicitDirs?.length ?? 0) > 0) { + return { skills: [] }; + } + await this.config.ready; + const mergeAllAvailableSkills = + this.config.get(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true; + return this.discovery.discover( + await projectRoots(this.workspace.cwd, { mergeAllAvailableSkills }), + ); + } + + private async watchProjectSkillRoots(): Promise { + // Watch the project root recursively, pruned to the skill-root + // candidates: watching a candidate directory directly never fires when + // its parent (`.kimi-code` / `.agents`) does not exist yet either. + const { projectRoot, candidates } = await projectSkillRootCandidates(this.workspace.cwd); + const handle = this.fsWatch.watch(projectRoot, { + ignored: subtreeWatchFilter(projectRoot, candidates), + }); + this._register(handle); + this._register( + handle.onDidChange(() => { + this.watchDebounce.cancelAndSet(() => this.onDidChangeEmitter.fire(), WATCH_DEBOUNCE_MS); + }), + ); + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceRootSkillSource, + WorkspaceRootSkillSource, + ScopeActivation.OnScopeCreated, + 'workspaceSkillCatalog', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalog.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalog.ts new file mode 100644 index 0000000000..87043d2099 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalog.ts @@ -0,0 +1,33 @@ +/** + * `workspaceSkillCatalog` domain (L3) — Workspace-scoped skill catalog + * contract. + * + * Defines `IWorkspaceSkillCatalog`, the handler-level owner of skill + * discovery and merging: at handler materialization it loads every source + * (builtin / user / explicit / extra / workspace-root / plugin) and merges by + * priority; afterwards single sources refresh incrementally (fs watch on the + * project skill dirs, config section changes, plugin reloads) — never a full + * rescan. `sessionData()` projects the merged view into the + * `ISessionSkillCatalogData` seed every Session scope of this handler + * receives. Bound at Workspace scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; + +import type { SkillCatalog } from '#/app/skillCatalog/types'; +import type { ISessionSkillCatalogData } from '#/session/sessionSkillCatalog/skillCatalogData'; + +export interface IWorkspaceSkillCatalog { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly catalog: SkillCatalog; + readonly onDidChange: Event; + load(): Promise; + reload(): Promise; + sessionData(): ISessionSkillCatalogData; +} + +export const IWorkspaceSkillCatalog: ServiceIdentifier = + createDecorator('workspaceSkillCatalog'); diff --git a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts new file mode 100644 index 0000000000..148c1bb534 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts @@ -0,0 +1,144 @@ +/** + * `workspaceSkillCatalog` domain (L3) — `IWorkspaceSkillCatalog` + * implementation. + * + * Merges builtin, user, explicit, extra, workspace-root, and plugin skill + * sources by priority ONCE per handler, serializing refreshes for each + * source; afterwards a source's `onDidChange` (fs watch / config section / + * plugin reload) re-scans that source alone and re-fires the merged change + * event — no full rescan ever leaves the build-time load. The merged view is + * shared by every session of the handler through the + * `ISessionSkillCatalogData` seed (`sessionData()`), a live read view over + * this service. Bound at Workspace scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; +import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; +import type { ISkillSource, SkillContribution } from '#/app/skillCatalog/skillSource'; +import type { SkillCatalog } from '#/app/skillCatalog/types'; +import { IUserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; +import type { ISessionSkillCatalogData } from '#/session/sessionSkillCatalog/skillCatalogData'; + +import { IExplicitFileSkillSource } from './explicitFileSkillSource'; +import { IExtraFileSkillSource } from './extraFileSkillSource'; +import { IPluginSkillSource } from './pluginSkillSource'; +import { IWorkspaceRootSkillSource } from './rootFileSkillSource'; +import { IWorkspaceSkillCatalog } from './workspaceSkillCatalog'; + +export class WorkspaceSkillCatalogService extends Disposable implements IWorkspaceSkillCatalog { + declare readonly _serviceBrand: undefined; + + private readonly sources: readonly ISkillSource[]; + private readonly contributions = new Map< + string, + { readonly c: SkillContribution; readonly priority: number } + >(); + private merged = new InMemorySkillCatalog(); + private readonly sourceLoadTails = new Map>(); + readonly ready: Promise; + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + + constructor( + @IBuiltinSkillSource builtin: IBuiltinSkillSource, + @IUserFileSkillSource user: IUserFileSkillSource, + @IExplicitFileSkillSource explicit: IExplicitFileSkillSource, + @IExtraFileSkillSource extra: IExtraFileSkillSource, + @IWorkspaceRootSkillSource workspace: IWorkspaceRootSkillSource, + @IPluginSkillSource plugin: IPluginSkillSource, + ) { + super(); + this.sources = [builtin, user, explicit, extra, workspace, plugin].toSorted( + (a, b) => a.priority - b.priority, + ); + for (const s of this.sources) { + if (s.onDidChange) + this._register( + s.onDidChange(() => { + void this.reloadSource(s.id); + }), + ); + } + this.ready = this.loadAll(); + } + + get catalog(): SkillCatalog { + return this.merged; + } + + async reload(): Promise { + await this.loadAll(); + this.onDidChangeEmitter.fire('catalog'); + } + + async load(): Promise { + await this.ready; + } + + sessionData(): ISessionSkillCatalogData { + const currentCatalog = (): SkillCatalog => this.merged; + return { + _serviceBrand: undefined, + ready: this.ready, + onDidChange: this.onDidChange, + get catalog() { + return currentCatalog(); + }, + }; + } + + private async loadAll(): Promise { + for (const s of this.sources) { + await this.loadSource(s); + } + this.remerge(); + } + + private async reloadSource(id: string): Promise { + const s = this.sources.find((x) => x.id === id); + if (!s) return; + await this.loadSource(s, true); + } + + private loadSource(source: ISkillSource, fireChange = false): Promise { + const previous = this.sourceLoadTails.get(source) ?? Promise.resolve(); + const current = previous.catch(() => undefined).then(async () => { + const contribution = await source.load(); + this.contributions.set(source.id, { c: contribution, priority: source.priority }); + if (fireChange) { + this.remerge(); + this.onDidChangeEmitter.fire(source.id); + } + }); + this.sourceLoadTails.set(source, current); + const clear = () => { + if (this.sourceLoadTails.get(source) === current) { + this.sourceLoadTails.delete(source); + } + }; + void current.then(clear, clear); + return current; + } + + private remerge(): void { + const m = new InMemorySkillCatalog(); + const ordered = [...this.contributions.values()].toSorted((a, b) => a.priority - b.priority); + for (const { c } of ordered) { + for (const skill of c.skills) m.register(skill, { replace: true }); + m.addRoots(c.scannedRoots ?? []); + m.recordSkipped(c.skipped ?? []); + } + this.merged = m; + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceSkillCatalog, + WorkspaceSkillCatalogService, + ScopeActivation.OnScopeCreated, + 'workspaceSkillCatalog', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicy.ts b/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicy.ts new file mode 100644 index 0000000000..d889678f1c --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicy.ts @@ -0,0 +1,30 @@ +/** + * `workspaceToolPolicy` domain (L2) — os-level tool enable/disable contract. + * + * Defines the `IWorkspaceToolPolicy`, the Workspace-scope owner of the + * tool-veto set that outranks every Agent-side policy layer (profile × + * `[tools]` config × session denylist): a tool the workspace disables never + * activates and can never execute, no matter what the upper layers allow. + * The set derives from the handler's runtime capabilities — the + * `IWorkspaceContext.osBackendId` keying pair records which os backend the + * handler binds; a runtime whose backend lacks a capability (e.g. no PTY) + * contributes the dependent tool names here. The set reaches every session + * of the handler through the `ISessionToolPolicyGate` seed + * (`sessionGate()`), a live read view over this service. Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; +import type { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; + +export interface IWorkspaceToolPolicy { + readonly _serviceBrand: undefined; + + disabledTools(): readonly string[]; + readonly onDidChange: Event; + + sessionGate(): ISessionToolPolicyGate; +} + +export const IWorkspaceToolPolicy: ServiceIdentifier = + createDecorator('workspaceToolPolicy'); diff --git a/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicyService.ts b/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicyService.ts new file mode 100644 index 0000000000..d63ecaee94 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicyService.ts @@ -0,0 +1,75 @@ +/** + * `workspaceToolPolicy` domain (L2) — `IWorkspaceToolPolicy` implementation. + * + * Computes the os-level disabled-tool set from the runtime capabilities the + * handler binds (`IWorkspaceContext.osBackendId`). The local runtime carries + * the full node os backend (fs / process / PTY / watch), so it vetoes + * nothing; runtimes with a reduced os backend land their own capability + * mapping (or seed their own `IWorkspaceToolPolicy`) when they arrive. A + * workspace-level tools config does not exist yet — when one does, it joins + * the capability set here and fires `onDidChange`. Bound at Workspace scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { Event } from '#/_base/event'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import type { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; +import { + IWorkspaceContext, + LOCAL_OS_BACKEND_ID, +} from '#/workspace/workspaceContext/workspaceContext'; + +import { IWorkspaceToolPolicy } from './workspaceToolPolicy'; + +/** + * The os-level veto set for one os backend. Pure so the capability mapping is + * unit-testable without a scope. The local backend has every capability, so + * it disables nothing. + * + * An UNKNOWN backend also disables nothing — that fail-open is a deliberate + * choice: the veto is a safety override, and guessing restrictions for a + * backend nobody has mapped would break tools on a runtime that may be fully + * capable. The cost is the opposite silence: a NEW runtime that forgets to + * extend this mapping ships with no os-level veto. When a new os backend + * lands, extend this mapping (or seed its own `IWorkspaceToolPolicy`) as + * part of the runtime bring-up. + */ +export function computeCapabilityDisabledTools(osBackendId: string): readonly string[] { + if (osBackendId === LOCAL_OS_BACKEND_ID) return []; + return []; +} + +export class WorkspaceToolPolicyService extends Disposable implements IWorkspaceToolPolicy { + declare readonly _serviceBrand: undefined; + + private readonly disabled: readonly string[]; + readonly onDidChange = Event.None as Event; + + constructor(@IWorkspaceContext workspace: IWorkspaceContext) { + super(); + this.disabled = computeCapabilityDisabledTools(workspace.osBackendId); + } + + disabledTools(): readonly string[] { + return this.disabled; + } + + sessionGate(): ISessionToolPolicyGate { + const current = (): readonly string[] => this.disabledTools(); + return { + _serviceBrand: undefined, + onDidChange: this.onDidChange, + get disabledTools() { + return current(); + }, + }; + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceToolPolicy, + WorkspaceToolPolicyService, + ScopeActivation.OnScopeCreated, + 'workspaceToolPolicy', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrust.ts b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrust.ts new file mode 100644 index 0000000000..13001afc2d --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrust.ts @@ -0,0 +1,45 @@ +/** + * `workspaceTrust` domain (L2) — per-workspace trust-state contract. + * + * Defines `IWorkspaceTrust`, the Workspace-scope owner of one yes/no fact: + * has the user trusted this workspace. Trust gates everything the + * workspace's own files may ask the engine to run before those files are + * read as instructions; the first consumer is `workspaceMcpConfig`, which + * skips the project-level MCP config files (project-root `.mcp.json` and + * `.kimi-code/mcp.json`) while the workspace is untrusted, so a freshly + * cloned repo cannot auto-start MCP servers. The marker is recorded OUTSIDE + * the workspace (see the impl for the storage layout) so a malicious + * checkout cannot mark itself trusted, and every handler of the same root + * resolves to the same record. Trust flips only through the explicit + * `trust()` / `untrust()` calls — the engine has no interactive prompt. + * Workspace-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; + +export interface WorkspaceTrustChange { + readonly trusted: boolean; +} + +export interface IWorkspaceTrust { + readonly _serviceBrand: undefined; + + /** Settles once the persisted marker has been read; never rejects. */ + readonly ready: Promise; + + /** Awaits {@link ready} and returns the trust state — the RPC-friendly read. */ + get(): Promise; + + /** Meaningful after {@link ready}; untrusted until proven otherwise. */ + isTrusted(): boolean; + + /** Idempotent; fires {@link onDidChange} only on a real flip. */ + trust(): Promise; + untrust(): Promise; + + readonly onDidChange: Event; +} + +export const IWorkspaceTrust: ServiceIdentifier = + createDecorator('workspaceTrust'); diff --git a/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts new file mode 100644 index 0000000000..bbcc3282be --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts @@ -0,0 +1,95 @@ +/** + * `workspaceTrust` domain (L2) — `IWorkspaceTrust` implementation. + * + * Persists the trust marker through the `persistence` domain's + * `IAtomicDocumentStore` under the `workspace-trust` scope, one document per + * workspace keyed by `encodeWorkDirKey(root)` — the same id the workspace + * registry mints for the root — with the raw root kept in the value for + * inspection. The document's presence IS the trusted state: `trust()` + * writes it, `untrust()` deletes it. The record lives under the kimi home, + * never inside the workspace, so a checked-out tree cannot pre-trust + * itself. The flag is read once through `ready` and every later mutation + * goes through this service, so the view is in-process: another process + * flipping the same record is picked up only on restart (a `docs.watch` + * sync can join when a second writer exists). A read failure resolves to + * untrusted. Bound at Workspace scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Emitter } from '#/_base/event'; +import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { IWorkspaceTrust, type WorkspaceTrustChange } from './workspaceTrust'; + +const TRUST_SCOPE = 'workspace-trust'; + +interface TrustRecord { + readonly root: string; + readonly trustedAt: number; +} + +export class WorkspaceTrustService extends Disposable implements IWorkspaceTrust { + declare readonly _serviceBrand: undefined; + + readonly ready: Promise; + private readonly root: string; + private readonly storeKey: string; + private trusted = false; + private readonly changeEmitter = this._register(new Emitter()); + readonly onDidChange = this.changeEmitter.event; + + constructor( + @IWorkspaceContext workspace: IWorkspaceContext, + @IAtomicDocumentStore private readonly docs: IAtomicDocumentStore, + ) { + super(); + this.root = workspace.cwd; + this.storeKey = encodeWorkDirKey(workspace.cwd); + this.ready = this.initialize(); + } + + isTrusted(): boolean { + return this.trusted; + } + + async get(): Promise { + await this.ready; + return this.trusted; + } + + async trust(): Promise { + if (this.trusted) return; + await this.docs.set(TRUST_SCOPE, this.storeKey, { + root: this.root, + trustedAt: Date.now(), + }); + this.trusted = true; + this.changeEmitter.fire({ trusted: true }); + } + + async untrust(): Promise { + if (!this.trusted) return; + await this.docs.delete(TRUST_SCOPE, this.storeKey); + this.trusted = false; + this.changeEmitter.fire({ trusted: false }); + } + + private async initialize(): Promise { + try { + this.trusted = (await this.docs.get(TRUST_SCOPE, this.storeKey)) !== undefined; + } catch { + this.trusted = false; + } + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceTrust, + WorkspaceTrustService, + ScopeActivation.OnDemand, + 'workspaceTrust', +); diff --git a/packages/agent-core-v2/test/_base/contribution/registry.test.ts b/packages/agent-core-v2/test/_base/contribution/registry.test.ts new file mode 100644 index 0000000000..bfaef5501e --- /dev/null +++ b/packages/agent-core-v2/test/_base/contribution/registry.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { ContributionRegistry } from '#/_base/contribution/registry'; + +interface TestContribution { + readonly items: readonly string[]; +} + +describe('ContributionRegistry', () => { + it('stores one entry per sourceId and replaces on re-register', () => { + const registry = new ContributionRegistry(); + registry.register('a', { items: ['1'] }, { priority: 10 }); + registry.register('a', { items: ['2'] }, { priority: 20 }); + + const entries = registry.entries(); + expect(entries).toHaveLength(1); + expect(entries[0]).toEqual({ sourceId: 'a', priority: 20, contribution: { items: ['2'] } }); + registry.dispose(); + }); + + it('defaults priority to 0 and exposes entries with metadata', () => { + const registry = new ContributionRegistry(); + registry.register('a', { items: ['1'] }); + registry.register('b', { items: ['2'] }, { priority: 5 }); + + expect(registry.get('a')?.priority).toBe(0); + expect(registry.get('b')?.priority).toBe(5); + expect(registry.entries().map((e) => e.sourceId)).toEqual(['a', 'b']); + registry.dispose(); + }); + + it('unregister removes the entry and is idempotent', () => { + const registry = new ContributionRegistry(); + registry.register('a', { items: ['1'] }); + registry.unregister('a'); + registry.unregister('a'); + + expect(registry.entries()).toHaveLength(0); + expect(registry.get('a')).toBeUndefined(); + registry.dispose(); + }); + + it('handle dispose unregisters only the entry it registered', () => { + const registry = new ContributionRegistry(); + const stale = registry.register('a', { items: ['old'] }); + registry.register('a', { items: ['new'] }); + + stale.dispose(); + + expect(registry.get('a')?.contribution.items).toEqual(['new']); + registry.dispose(); + }); + + it('handle dispose is idempotent', () => { + const registry = new ContributionRegistry(); + const handle = registry.register('a', { items: ['1'] }); + handle.dispose(); + handle.dispose(); + + expect(registry.entries()).toHaveLength(0); + registry.dispose(); + }); + + it('fires onDidChange with the sourceId on register, re-register, and unregister', () => { + const registry = new ContributionRegistry(); + const seen: string[] = []; + registry.onDidChange((sourceId) => seen.push(sourceId)); + + registry.register('a', { items: ['1'] }); + registry.register('a', { items: ['2'] }); + registry.unregister('a'); + registry.unregister('missing'); + + expect(seen).toEqual(['a', 'a', 'a']); + registry.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/_base/text/frontmatter.test.ts b/packages/agent-core-v2/test/_base/text/frontmatter.test.ts new file mode 100644 index 0000000000..5e813093ef --- /dev/null +++ b/packages/agent-core-v2/test/_base/text/frontmatter.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { FrontmatterError, parseFrontmatter } from '#/_base/text/frontmatter'; + +describe('parseFrontmatter', () => { + it('parses yaml frontmatter and body', () => { + const { data, body } = parseFrontmatter('---\nname: foo\n---\nbody text'); + expect(data).toEqual({ name: 'foo' }); + expect(body).toBe('body text'); + }); + + it('returns null data when there is no frontmatter', () => { + const { data, body } = parseFrontmatter('just body'); + expect(data).toBeNull(); + expect(body).toBe('just body'); + }); + + it('throws when the closing fence is missing', () => { + expect(() => parseFrontmatter('---\nname: foo')).toThrow(FrontmatterError); + }); +}); diff --git a/packages/agent-core-v2/test/agent/mcp/mcp.test.ts b/packages/agent-core-v2/test/agent/mcp/mcp.test.ts index d1962f12f4..dd9ca41a7e 100644 --- a/packages/agent-core-v2/test/agent/mcp/mcp.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/mcp.test.ts @@ -11,13 +11,13 @@ import { Event } from '#/_base/event'; import { abortError } from '#/_base/utils/abort'; import { type DomainEvent, IEventBus } from '#/app/event/eventBus'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import type { McpConnectionManager, McpServerEntry } from '#/agent/mcp/connection-manager'; +import type { McpConnectionManager, McpServerEntry } from '#/mcpCore/connection-manager'; import { IAgentMcpService } from '#/agent/mcp/mcp'; import { AgentMcpService } from '#/agent/mcp/mcpService'; -import { ISessionMcpService } from '#/session/mcp/sessionMcp'; +import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import type { McpOAuthService } from '#/agent/mcp/oauth/service'; -import type { MCPClient, MCPToolDefinition } from '#/agent/mcp/types'; +import type { McpOAuthService } from '#/mcpCore/oauth/service'; +import type { MCPClient, MCPToolDefinition } from '#/mcpCore/types'; import { IWireService } from '#/wire/wire'; import type { WireRecord } from '#/wire/record'; import { McpDiscoveryModel } from '#/agent/mcp/mcpDiscoveryOps'; @@ -37,7 +37,7 @@ import { stubLoopWithHooks } from '../loop/stubs'; import { stubToolResultTruncationService } from '../toolResultTruncation/stubs'; import { recordingWireLog, registerTestAgentWire } from '../../wire/stubs'; -import { discoverTools, executeTool, fakeMcpClient } from './stubs'; +import { discoverTools, executeTool, fakeMcpClient } from '../../mcpCore/stubs'; const MCP_OUTPUT_TRUNCATED_TEXT = '\n\n[Output truncated: exceeded 100000 character limit. ' + @@ -220,10 +220,11 @@ describe('AgentMcpService', () => { }); function createService(manager: FakeMcpManager): AgentMcpService { - ix.stub(ISessionMcpService, { - ensureMcpReady: () => Promise.resolve(), - connectionManager: () => manager as unknown as McpConnectionManager, - }); + ix.stub(ISessionMcpHandle, { + _serviceBrand: undefined, + ready: Promise.resolve(), + connectionManager: manager as unknown as McpConnectionManager, + } satisfies ISessionMcpHandle); ix.stub(ISessionContext, { sessionDir: '/tmp/kimi-code-mcp-test' }); const svc = ix.createInstance(AgentMcpService); disposables.add(svc); diff --git a/packages/agent-core-v2/test/agent/mcp/output.test.ts b/packages/agent-core-v2/test/agent/mcp/output.test.ts index e051fd31d9..c2f0443dab 100644 --- a/packages/agent-core-v2/test/agent/mcp/output.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/output.test.ts @@ -9,7 +9,7 @@ import { describe, expect, test } from 'vitest'; import type { ITelemetryService, TelemetryProperties } from '#/app/telemetry/telemetry'; import { convertMCPContentBlock, mcpResultToExecutableOutput } from '#/agent/mcp/output'; import { createMcpTool } from '#/agent/mcp/tools/mcp'; -import type { MCPClient, MCPContentBlock, MCPToolResult } from '#/agent/mcp/types'; +import type { MCPClient, MCPContentBlock, MCPToolResult } from '#/mcpCore/types'; import type { ToolExecution } from '#/tool/toolContract'; import { sniffImageDimensions } from '#/agent/media/file-type'; diff --git a/packages/agent-core-v2/test/agent/mcp/session-config.test.ts b/packages/agent-core-v2/test/agent/mcp/session-config.test.ts deleted file mode 100644 index 9ad619d41b..0000000000 --- a/packages/agent-core-v2/test/agent/mcp/session-config.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { mergeCallerMcpServers, type SessionMcpConfig } from '#/agent/mcp/session-config'; -import type { McpServerConfig } from '#/agent/mcp/config-schema'; - -const stdio = (command: string): McpServerConfig => ({ - transport: 'stdio', - command, -}); - -const http = (url: string): McpServerConfig => ({ - transport: 'http', - url, -}); - -describe('mergeCallerMcpServers', () => { - it('returns base unchanged when callerServers is undefined', () => { - const base: SessionMcpConfig = { servers: { fs: stdio('fs') } }; - expect(mergeCallerMcpServers(base, undefined)).toBe(base); - }); - - it('returns base unchanged when callerServers is empty', () => { - const base: SessionMcpConfig = { servers: { fs: stdio('fs') } }; - expect(mergeCallerMcpServers(base, {})).toBe(base); - }); - - it('returns undefined when both base and callerServers are absent', () => { - expect(mergeCallerMcpServers(undefined, undefined)).toBeUndefined(); - expect(mergeCallerMcpServers(undefined, {})).toBeUndefined(); - }); - - it('promotes a caller-only payload into a fresh SessionMcpConfig when base is undefined', () => { - const callerServers = { docs: http('https://mcp.example.com') }; - expect(mergeCallerMcpServers(undefined, callerServers)).toEqual({ - servers: { docs: http('https://mcp.example.com') }, - }); - }); - - it('layers caller on top of base with caller winning on key collision', () => { - const base: SessionMcpConfig = { - servers: { - shared: stdio('disk-version'), - diskOnly: stdio('disk-only'), - }, - }; - const callerServers = { - shared: stdio('caller-version'), - callerOnly: http('https://caller.example.com'), - }; - expect(mergeCallerMcpServers(base, callerServers)).toEqual({ - servers: { - shared: stdio('caller-version'), - diskOnly: stdio('disk-only'), - callerOnly: http('https://caller.example.com'), - }, - }); - }); -}); diff --git a/packages/agent-core-v2/test/agent/mcp/tools/auth.test.ts b/packages/agent-core-v2/test/agent/mcp/tools/auth.test.ts index bfa958df77..7b07717e97 100644 --- a/packages/agent-core-v2/test/agent/mcp/tools/auth.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/tools/auth.test.ts @@ -1,11 +1,11 @@ import { MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE } from '#/agent/mcp/tools/auth'; import { describe, expect, it } from 'vitest'; -import { AlreadyAuthorizedError, type BeginAuthorizationResult, type McpOAuthService } from '#/agent/mcp/oauth/service'; +import { AlreadyAuthorizedError, type BeginAuthorizationResult, type McpOAuthService } from '#/mcpCore/oauth/service'; import { createMcpAuthTool } from '#/agent/mcp/tools/auth'; import type { ToolUpdate } from '#/tool/toolContract'; -import { executeTool } from '../stubs'; +import { executeTool } from '../../../mcpCore/stubs'; function fakeOAuthService( begin: ( diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts index 47e133f418..a95a124f32 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts @@ -25,7 +25,10 @@ import { type PermissionRule, } from '#/agent/permissionRules/permissionRules'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IGitService } from '#/app/git/git'; +import { findGitWorkTree } from '#/app/git/workTree'; import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { ToolAccesses, type ToolAccesses as ToolAccessList } from '#/tool/toolContract'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; @@ -34,6 +37,8 @@ import { recordingTelemetry } from '../../app/telemetry/stubs'; const signal = new AbortController().signal; +const hostFs = new HostFileSystem(); + describe('AgentPermissionPolicyService chain', () => { let disposables: DisposableStore; let ix: TestInstantiationService; @@ -59,9 +64,10 @@ describe('AgentPermissionPolicyService chain', () => { rules: () => rules, sessionApprovalRulePatterns: () => sessionApprovalRulePatterns, })); - reg.defineInstance(ISessionWorkspaceContext, workspace); + reg.defineInstance(ISessionWorkspaceContext, workspace.stub); reg.defineInstance(IHostEnvironment, kaosStub()); reg.defineInstance(ITelemetryService, recordingTelemetry([])); + reg.definePartialInstance(IGitService, { findWorkTree: async () => null }); reg.define(IAgentPermissionPolicyService, AgentPermissionPolicyService); }, strict: true, @@ -196,9 +202,12 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { makeAgentScopeContext({ agentId: 'main', agentScope: '' }), ); reg.definePartialInstance(IAgentPermissionRulesService, permissionRulesStub()); - reg.defineInstance(ISessionWorkspaceContext, workspace); + reg.defineInstance(ISessionWorkspaceContext, workspace.stub); reg.defineInstance(IHostEnvironment, kaosStub()); reg.defineInstance(ITelemetryService, recordingTelemetry([])); + reg.definePartialInstance(IGitService, { + findWorkTree: (cwd: string) => findGitWorkTree(hostFs, cwd), + }); reg.define(IAgentPermissionPolicyService, AgentPermissionPolicyService); }, strict: true, @@ -475,32 +484,26 @@ function stringArg( return typeof value === 'string' ? value : fallback; } -function workspaceStub(initialWorkDir: string): ISessionWorkspaceContext { - let workDir = initialWorkDir; +function workspaceStub(initialWorkDir: string): { + readonly stub: ISessionWorkspaceContext; + addAdditionalDir(dir: string): void; +} { let additionalDirs: string[] = []; - return { + const stub: ISessionWorkspaceContext = { _serviceBrand: undefined, - get workDir() { - return workDir; - }, + workDir: initialWorkDir, get additionalDirs() { return additionalDirs; }, - setWorkDir: (nextWorkDir) => { - workDir = nextWorkDir; - }, - setAdditionalDirs: (dirs) => { - additionalDirs = [...dirs]; - }, resolve: (path) => path, isWithin: () => true, assertAllowed: (path) => path, + }; + return { + stub, addAdditionalDir: (dir) => { if (!additionalDirs.includes(dir)) additionalDirs = [...additionalDirs, dir]; }, - removeAdditionalDir: (dir) => { - additionalDirs = additionalDirs.filter((candidate) => candidate !== dir); - }, }; } diff --git a/packages/agent-core-v2/test/agent/plan/plan.test.ts b/packages/agent-core-v2/test/agent/plan/plan.test.ts index 8977ccdc6e..56f2671a5a 100644 --- a/packages/agent-core-v2/test/agent/plan/plan.test.ts +++ b/packages/agent-core-v2/test/agent/plan/plan.test.ts @@ -9,7 +9,7 @@ import { tmpdir } from 'node:os'; // deny rules adjudicate. import '#/agent/plan/planService'; import type { ToolCall } from '#/kosong/contract/message'; -import { dirname, isAbsolute, join } from 'pathe'; +import { dirname, join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; @@ -181,9 +181,7 @@ describe('Plan service', () => { it('enters plan mode without starting a model turn and prepares the plan directory', async () => { const mkdir = vi.fn().mockResolvedValue(undefined); const writeText = vi.fn().mockResolvedValue(0); - const cwd = await makeTempDir('kimi-plan-entry-'); useFakes(createPlanFakes({ mkdir, writeText })); - profile.update({ cwd }); await ctx.rpc.enterPlan({}); await delay(10); @@ -198,11 +196,9 @@ describe('Plan service', () => { }); it('derives the plan path from the agent homedir on enter and restore', async () => { - const cwd = await makeTempDir('kimi-plan-path-'); useFakes(createPlanFakes({ writeText: vi.fn(async (_path: string, _content: string): Promise => {}), })); - profile.update({ cwd }); await plan.enter('stable-plan'); const livePath = await expectActivePlanPath(); @@ -225,25 +221,10 @@ describe('Plan service', () => { expect(await expectActivePlanPath()).toBe(livePath); }); - it('keeps the plan path under the agent homedir when the profile cwd is empty', async () => { - useFakes(createPlanFakes({ - writeText: vi.fn(async (_path: string, _content: string): Promise => {}), - })); - profile.update({ cwd: '' }); - - await plan.enter('homedir-plan'); - - const planPath = await expectActivePlanPath(); - expect(isAbsolute(planPath)).toBe(true); - expect(planPath).toBe(expectedPlanPath('homedir-plan')); - }); - it('enters plan mode through the EnterPlanMode tool and reminds the next step', async () => { - const cwd = await makeTempDir('kimi-plan-tool-entry-'); const { fakes } = createPlanFileFakes(); useFakes(fakes); useTools(['EnterPlanMode']); - profile.update({ cwd }); await ctx.rpc.setPermission({ mode: 'yolo' }); const enterPlanModeCall: ToolCall = { @@ -266,10 +247,8 @@ describe('Plan service', () => { describe('plan clear', () => { it('empties the current plan file without leaving plan mode', async () => { - const cwd = await makeTempDir('kimi-plan-clear-'); const { files, writeText, fakes } = createPlanFileFakes(); useFakes(fakes); - profile.update({ cwd }); await plan.enter('test-plan', false); const planPath = await expectActivePlanPath(); @@ -313,10 +292,8 @@ describe('Plan service', () => { }); it('snapshots the current plan file into a versioned blob with a reference record', async () => { - const cwd = await makeTempDir('kimi-plan-revision-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); - profile.update({ cwd }); await plan.enter('rev-plan', false); const planPath = await expectActivePlanPath(); @@ -339,10 +316,8 @@ describe('Plan service', () => { }); it('increments the version on every recording and keeps earlier blobs', async () => { - const cwd = await makeTempDir('kimi-plan-revision-twice-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); - profile.update({ cwd }); await plan.enter('rev-plan', false); const planPath = await expectActivePlanPath(); @@ -357,10 +332,8 @@ describe('Plan service', () => { }); it('mints the next version from the replayed counter', async () => { - const cwd = await makeTempDir('kimi-plan-revision-restore-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); - profile.update({ cwd }); await plan.enter('rev-plan', false); const planPath = await expectActivePlanPath(); @@ -385,11 +358,9 @@ describe('Plan service', () => { }); it('records a revision when ExitPlanMode submits the plan', async () => { - const cwd = await makeTempDir('kimi-plan-submit-revision-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); useTools(['ExitPlanMode']); - profile.update({ cwd }); await ctx.rpc.setPermission({ mode: 'auto' }); await plan.enter('submit-plan', false); @@ -414,11 +385,9 @@ describe('Plan service', () => { }); it('records the next version when a revised plan is resubmitted', async () => { - const cwd = await makeTempDir('kimi-plan-revise-revision-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); useTools(['ExitPlanMode']); - profile.update({ cwd }); await ctx.rpc.setPermission({ mode: 'manual' }); await plan.enter('revise-plan', false); @@ -458,10 +427,8 @@ describe('Plan service', () => { }); it('does not record a revision on clear or on plan file writes', async () => { - const cwd = await makeTempDir('kimi-plan-no-revision-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); - profile.update({ cwd }); await plan.enter('quiet-plan', false); const planPath = await expectActivePlanPath(); @@ -476,11 +443,9 @@ describe('Plan service', () => { describe('plan exit tool', () => { it('reads the current plan file and exits plan mode directly in auto mode', async () => { - const cwd = await makeTempDir('kimi-plan-exit-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); useTools(['ExitPlanMode']); - profile.update({ cwd }); await ctx.rpc.setPermission({ mode: 'auto' }); await plan.enter('test-plan', false); @@ -508,11 +473,9 @@ describe('Plan service', () => { }); it('stops the turn and stays in plan mode when the user rejects the plan', async () => { - const cwd = await makeTempDir('kimi-plan-reject-exit-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); useTools(['ExitPlanMode']); - profile.update({ cwd }); await ctx.rpc.setPermission({ mode: 'manual' }); await plan.enter('reject-plan', false); @@ -542,7 +505,6 @@ describe('Plan service', () => { const exec = vi.fn(() => { throw new Error('Bash should not execute after plan rejection'); }); - const cwd = await makeTempDir('kimi-plan-reject-skip-tool-'); const { files, fakes: baseFakes } = createPlanFileFakes(undefined); const fakes: PlanFakes = { fs: baseFakes.fs, @@ -550,7 +512,6 @@ describe('Plan service', () => { }; useFakes(fakes); useTools(['ExitPlanMode', 'Bash']); - profile.update({ cwd }); await ctx.rpc.setPermission({ mode: 'yolo' }); await plan.enter('reject-and-exit-plan', false); @@ -590,11 +551,9 @@ describe('Plan service', () => { }); it('refuses to exit when the current plan file is empty', async () => { - const cwd = await makeTempDir('kimi-plan-empty-exit-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); useTools(['ExitPlanMode']); - profile.update({ cwd }); await ctx.rpc.setPermission({ mode: 'yolo' }); await plan.enter('empty-plan', false); @@ -622,11 +581,9 @@ describe('Plan service', () => { describe('plan exit tool options', () => { it('keeps options for approval when an option omits the optional description', async () => { - const cwd = await makeTempDir('kimi-plan-options-exit-'); const { files, fakes } = createPlanFileFakes(); useFakes(fakes); useTools(['ExitPlanMode']); - profile.update({ cwd }); await ctx.rpc.setPermission({ mode: 'manual' }); await plan.enter('options-plan', false); @@ -673,9 +630,7 @@ describe('Plan service', () => { files.set(path, content); }); useFakes(createPlanFakes({ readText, writeText })); - const cwd = await makeTempDir('kimi-plan-write-tool-'); useTools([toolName]); - profile.update({ cwd }); await plan.enter('test-plan', false); const planPath = await expectActivePlanPath(); @@ -714,9 +669,7 @@ describe('Plan service', () => { files.set(path, content); }); useFakes(createPlanFakes({ writeText })); - const cwd = await makeTempDir('kimi-plan-deny-write-'); useTools(['Write']); - profile.update({ cwd }); permissionRules.addRules([ { decision: 'deny', diff --git a/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts b/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts index ba2570c73f..0a67d1b4e1 100644 --- a/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts +++ b/packages/agent-core-v2/test/agent/profile/apply-profile.test.ts @@ -11,7 +11,7 @@ import { IPluginService } from '#/app/plugin/plugin'; import type { EnabledPluginSystemPrompt } from '#/app/plugin/types'; import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; -import { PLUGIN_SKILL_SOURCE_ID } from '#/session/sessionSkillCatalog/pluginSkillSource'; +import { PLUGIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource'; import { appService, @@ -104,7 +104,6 @@ describe('AgentProfileService.applyProfile', () => { it('refreshes the active profile system prompt exactly without resetting active tools', async () => { await writeFile(join(workDir, 'AGENTS.md'), 'old instructions', 'utf-8'); const { profile: svc } = buildContext(); - svc.update({ cwd: workDir }); await svc.applyProfile(exactProfile); svc.update({ activeToolNames: ['Read'] }); await writeFile(join(workDir, 'AGENTS.md'), 'new instructions', 'utf-8'); diff --git a/packages/agent-core-v2/test/agent/profile/binding.test.ts b/packages/agent-core-v2/test/agent/profile/binding.test.ts index 8e2f4545c9..3b78e42048 100644 --- a/packages/agent-core-v2/test/agent/profile/binding.test.ts +++ b/packages/agent-core-v2/test/agent/profile/binding.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; @@ -7,7 +7,9 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite import { Event } from '#/_base/event'; import { ConfigTarget, IConfigService } from '#/app/config/config'; import { TOOLS_SECTION } from '#/agent/toolPolicy/configSection'; -import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { AgentProfileRegistryService } from '#/app/agentProfileCatalog/agentProfileRegistryService'; +import { BuiltinAgentProfileLoaderService } from '#/app/agentProfileCatalog/builtinAgentProfileLoaderService'; import { registerAgentProfile } from '#/app/agentProfileCatalog/contribution'; import type { ToolCall } from '#/kosong/contract/message'; import { IAgentProfileService, type ResolvedAgentProfile } from '#/agent/profile/profile'; @@ -19,6 +21,7 @@ import { IAtomicDocumentStore, type IAtomicDocumentStore as AtomicDocumentStore import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { IWireService } from '#/wire/wire'; import type { ExecutableTool, ToolExecution, ToolResult, ToolSource } from '#/tool/toolContract'; @@ -91,10 +94,11 @@ describe('AgentProfileService.bind', () => { } it('binds a profile + model atomically and becomes runnable', async () => { - const { ctx: context, profile: svc } = buildContext(); + const { profile: svc } = buildContext(); - const catalog = context.get(IAgentProfileCatalogService); + const catalog = new BuiltinAgentProfileLoaderService(new AgentProfileRegistryService()); expect(catalog.get(DEFAULT_AGENT_PROFILE_NAME)).toBeDefined(); + catalog.dispose(); expect(svc.isRunnable()).toBe(false); @@ -136,7 +140,6 @@ describe('AgentProfileService.bind', () => { profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL, thinking: 'low', - cwd: homeDir, }); await ctx.get(IWireService).flush(); @@ -144,7 +147,6 @@ describe('AgentProfileService.bind', () => { expect(records).toHaveLength(1); expect(records[0]).toMatchObject({ type: 'profile.bind', - cwd: homeDir, profileName: DEFAULT_AGENT_PROFILE_NAME, modelAlias: MOCK_MODEL, thinkingEffort: 'on', @@ -197,6 +199,23 @@ describe('AgentProfileService.bind', () => { }); }); + it('refreshes the system prompt from the session cwd after a default bind', async () => { + const workDir = await mkdtemp(join(tmpdir(), 'kimi-bind-work-')); + try { + await writeFile(join(workDir, 'AGENTS.md'), 'v1 instructions', 'utf-8'); + ctx = createTestAgent(hostEnvironmentServices(homeDir), { cwd: workDir }); + const svc = ctx.get(IAgentProfileService); + await svc.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL }); + + await writeFile(join(workDir, 'AGENTS.md'), 'v2 instructions', 'utf-8'); + await svc.refreshSystemPrompt(); + + expect(svc.getSystemPrompt()).toContain('v2 instructions'); + } finally { + await rm(workDir, { recursive: true, force: true }); + } + }); + it('setModel applies the default profile when none is bound yet', async () => { const { profile: svc } = buildContext(); @@ -806,6 +825,57 @@ describe('AgentToolPolicyService executor enforcement', () => { expect(probe.calls).toBe(0); }); + // Phase-4 behavior contract: the workspace (os-level) veto — seeded as + // `ISessionToolPolicyGate` — blocks direct execution just like the classic + // layers, and it wins over every one of them. + it('blocks a direct builtin call through the workspace tool-policy gate', async () => { + ctx = createTestAgent( + hostEnvironmentServices(homeDir), + sessionService(ISessionToolPolicyGate, { + _serviceBrand: undefined, + disabledTools: ['PolicyProbe'], + onDidChange: Event.None as Event, + } satisfies ISessionToolPolicyGate), + ); + await ctx.get(IAgentProfileService).bind({ + profile: DEFAULT_AGENT_PROFILE_NAME, + model: MOCK_MODEL, + }); + const probe = new PolicyProbeTool('PolicyProbe'); + ctx.get(IAgentToolRegistryService).register(probe); + + const result = await executeDirectToolCall(ctx, 'PolicyProbe'); + + expect(result).toMatchObject({ + isError: true, + output: 'Tool "PolicyProbe" is disabled by the active tool policy', + }); + expect(probe.calls).toBe(0); + }); + + // The prompt projection goes through the same workspace veto: a profile + // whose prompt renders `skillActive` must see the Skill tool as inactive + // when the gate disables it (profileService's `isToolActiveForProfile`). + it('applies the workspace gate in the prompt projection (skillActive)', async () => { + registerAgentProfile({ + name: 'gate-skill-active', + tools: ['Read', 'Skill'], + systemPrompt: (context) => `skill-active:${String(context.skillActive)}`, + }); + ctx = createTestAgent( + hostEnvironmentServices(homeDir), + sessionService(ISessionToolPolicyGate, { + _serviceBrand: undefined, + disabledTools: ['Skill'], + onDidChange: Event.None as Event, + } satisfies ISessionToolPolicyGate), + ); + const profileService = ctx.get(IAgentProfileService); + await profileService.bind({ profile: 'gate-skill-active', model: MOCK_MODEL }); + + expect(profileService.data().systemPrompt).toBe('skill-active:false'); + }); + it('does not reject select_tools, the policy-gated disclosure loading entry', async () => { ctx = createTestAgent(hostEnvironmentServices(homeDir)); // The default profile's allowlist does not name select_tools; the guard diff --git a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts index b00106a87e..5becafb842 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; +import { Event } from '#/_base/event'; import { IAgentProfileService } from '#/agent/profile/profile'; import { AgentProfileService } from '#/agent/profile/profileService'; import { ActiveToolsModel, ProfileModel } from '#/agent/profile/profileOps'; @@ -21,13 +22,13 @@ import { AgentStateService } from '#/agent/state/agentStateService'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IHostIdentity } from '#/app/hostIdentity/hostIdentity'; -import { IPluginService } from '#/app/plugin/plugin'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { IWireService } from '#/wire/wire'; @@ -213,10 +214,6 @@ function buildHost(key: string): { host.stub(IHostEnvironment, stubUnused()); host.stub(IHostFileSystem, stubUnused()); host.stub(IHostIdentity, stubUnused()); - host.stub(IPluginService, { - _serviceBrand: undefined, - onDidReload: () => ({ dispose: () => {} }), - }); host.stub(IBootstrapService, stubUnused()); host.stub(ISessionContext, createSessionContextStub()); host.stub(ISessionWorkspaceContext, stubUnused()); @@ -225,6 +222,13 @@ function buildHost(key: string): { _serviceBrand: undefined, onDidChange: () => ({ dispose: () => {} }), }); + host.stub(ISessionInstructionsProvider, { + _serviceBrand: undefined, + ready: Promise.resolve(), + agentsMd: undefined, + agentsMdWarning: undefined, + onDidChange: Event.None as Event, + } satisfies ISessionInstructionsProvider); host.stub(ISessionToolPolicy, { _serviceBrand: undefined, ready: Promise.resolve(), @@ -308,14 +312,12 @@ describe('AgentProfileService (wire-backed config.update)', () => { it('persists and replays an allowlist reset to unrestricted', async () => { svc.applyBindingSnapshot({ - cwd: '/work', profileName: 'restricted', thinkingLevel: 'off', systemPrompt: 'restricted', activeToolNames: ['Read'], }); svc.applyBindingSnapshot({ - cwd: '/work', profileName: 'unrestricted', thinkingLevel: 'off', systemPrompt: 'unrestricted', @@ -334,31 +336,22 @@ describe('AgentProfileService (wire-backed config.update)', () => { replay.ix.dispose(); }); - it('chdir and emitStatusUpdated run live-only and are silent during replay', async () => { - let chdirCalls = 0; + it('emitStatusUpdated runs live-only and is silent during replay', async () => { let statusEmits = 0; svc.configure({ - chdir: () => { - chdirCalls += 1; - }, emitStatusUpdated: () => { statusEmits += 1; }, }); - svc.update({ cwd: '/work', profileName: DEFAULT_AGENT_PROFILE_NAME }); - expect(chdirCalls).toBe(1); + svc.update({ profileName: DEFAULT_AGENT_PROFILE_NAME }); expect(statusEmits).toBe(1); const records = await readRecords(); const host = buildHost('profile-replay'); - let replayChdir = 0; let replayEmits = 0; host.svc.configure({ - chdir: () => { - replayChdir += 1; - }, emitStatusUpdated: () => { replayEmits += 1; }, @@ -370,9 +363,7 @@ describe('AgentProfileService (wire-backed config.update)', () => { testWireScope(SCOPE, 'profile-replay'), records, ); - expect(modelOf(host.wire).cwd).toBe('/work'); expect(modelOf(host.wire).profileName).toBe(DEFAULT_AGENT_PROFILE_NAME); - expect(replayChdir).toBe(0); expect(replayEmits).toBe(0); const written: WireRecord[] = []; diff --git a/packages/agent-core-v2/test/agent/task/taskManager.test.ts b/packages/agent-core-v2/test/agent/task/taskManager.test.ts index c51f2358d6..09d77da803 100644 --- a/packages/agent-core-v2/test/agent/task/taskManager.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskManager.test.ts @@ -21,6 +21,7 @@ import { } from '#/agent/tools/agent/subagent-task'; import { ProcessTask } from '#/agent/tools/os/bash/process-task'; import { isUserCancellation, userCancellationReason } from '#/_base/utils/abort'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { configServices, createTestAgent, @@ -1276,11 +1277,16 @@ describe('AgentTaskService', () => { it('getTask on an unknown id does not create persisted state', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-mgr-missing-')); try { - const { manager, persistence } = createAgentTaskService({ sessionDir }); + const { ctx, manager, persistence } = createAgentTaskService({ sessionDir }); expect(manager.getTask('bash-bogusss0')).toBeUndefined(); expect(await persistence!.listTasks()).toEqual([]); + // The session scope's initial metadata write is kicked at creation but + // not awaited by the (synchronous) harness; settle it before the + // cleanup below removes the home dir, the same way session + // materialization awaits metadata readiness in production. + await ctx.get(ISessionMetadata).ready; } finally { await rm(sessionDir, { recursive: true, force: true }); } diff --git a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts index 2ed9a38989..68ec8ae171 100644 --- a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts +++ b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts @@ -9,6 +9,7 @@ import { } from '#/_base/di/scope'; import { createServices } from '#/_base/di/test'; import { IEventBus } from '#/app/event/eventBus'; +import { Event } from '#/_base/event'; import { IAgentProfileService, type ProfileData } from '#/agent/profile/profile'; import { IAgentToolActivationService } from '#/agent/toolActivation/toolActivation'; import { AgentToolActivationService } from '#/agent/toolActivation/toolActivationService'; @@ -20,6 +21,7 @@ import { } from '#/agent/toolRegistry/toolContribution'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import type { AgentTool, ToolExecution } from '#/tool/toolContract'; class StubTool implements AgentTool { @@ -68,6 +70,7 @@ describe('AgentToolActivationService', () => { activeToolNames?: readonly string[]; disallowedTools?: readonly string[]; } = {}; + const gateData: { disabledTools: readonly string[] } = { disabledTools: [] }; function createActivationHost() { disposables = new DisposableStore(); @@ -80,6 +83,13 @@ describe('AgentToolActivationService', () => { reg.definePartialInstance(IEventBus, { subscribe: () => toDisposable(() => {}), }); + reg.defineInstance(ISessionToolPolicyGate, { + _serviceBrand: undefined, + get disabledTools() { + return gateData.disabledTools; + }, + onDidChange: Event.None as Event, + } satisfies ISessionToolPolicyGate); reg.define(IAgentToolRegistryService, AgentToolRegistryService); reg.define(IAgentToolActivationService, AgentToolActivationService); reg.define(IAlphaTool, AlphaTool); @@ -98,6 +108,7 @@ describe('AgentToolActivationService', () => { _clearAgentToolContributionsForTests(); delete profileData.activeToolNames; delete profileData.disallowedTools; + gateData.disabledTools = []; }); afterEach(() => { @@ -173,6 +184,38 @@ describe('AgentToolActivationService', () => { expect(gammaConstructions).toBe(0); }); + // Phase-4 behavior contract: the workspace (os-level) veto outranks the + // profile — a workspace-disabled tool never activates, so it never lands + // in `registry.list()` (and therefore never reaches the model's schema). + it('honors the workspace tool-policy veto before the profile', async () => { + gateData.disabledTools = ['Beta']; + registerAgentToolService(IAlphaTool, AlphaTool, { name: 'Alpha' }); + registerAgentToolService(IBetaTool, BetaTool, { name: 'Beta' }); + const ix = createActivationHost(); + + await ix.get(IAgentToolActivationService).activate(); + + const registry = ix.get(IAgentToolRegistryService); + expect(registry.resolve('Alpha')).toBeInstanceOf(AlphaTool); + expect(registry.resolve('Beta')).toBeUndefined(); + expect(registry.list().map((t) => t.name)).not.toContain('Beta'); + expect(betaConstructions).toBe(0); + }); + + it('lets the workspace veto win over a profile allowlist', async () => { + profileData.activeToolNames = ['Alpha', 'Beta']; + gateData.disabledTools = ['Beta']; + registerAgentToolService(IAlphaTool, AlphaTool, { name: 'Alpha' }); + registerAgentToolService(IBetaTool, BetaTool, { name: 'Beta' }); + const ix = createActivationHost(); + + await ix.get(IAgentToolActivationService).activate(); + + const registry = ix.get(IAgentToolRegistryService); + expect(registry.resolve('Alpha')).toBeInstanceOf(AlphaTool); + expect(registry.resolve('Beta')).toBeUndefined(); + }); + it('is idempotent and picks up newly allowed tools on re-activation', async () => { profileData.activeToolNames = ['Alpha']; registerAgentToolService(IAlphaTool, AlphaTool, { name: 'Alpha' }); diff --git a/packages/agent-core-v2/test/agent/toolPolicy/evaluate.test.ts b/packages/agent-core-v2/test/agent/toolPolicy/evaluate.test.ts index a21952d8b0..723393adcc 100644 --- a/packages/agent-core-v2/test/agent/toolPolicy/evaluate.test.ts +++ b/packages/agent-core-v2/test/agent/toolPolicy/evaluate.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { findInactiveToolPatterns, isToolActive, + isToolActiveComposed, literalToolNames, } from '#/agent/toolPolicy/evaluate'; @@ -61,3 +62,51 @@ describe('literalToolNames', () => { ).toEqual(['Read']); }); }); + +describe('isToolActiveComposed workspace veto', () => { + it('lets the workspace layer veto a tool every other layer allows', () => { + expect( + isToolActiveComposed( + { + workspaceDisabledTools: ['Bash'], + profile: { tools: ['Bash', 'Read'] }, + global: { enabled: ['Bash', 'Read'] }, + sessionDisabledTools: [], + }, + 'Bash', + ), + ).toBe(false); + expect( + isToolActiveComposed( + { + workspaceDisabledTools: ['Bash'], + profile: { tools: ['Bash', 'Read'] }, + }, + 'Read', + ), + ).toBe(true); + }); + + it('applies the workspace veto to MCP tools by glob', () => { + expect( + isToolActiveComposed( + { workspaceDisabledTools: ['mcp__blocked__*'], profile: {} }, + 'mcp__blocked__write', + 'mcp', + ), + ).toBe(false); + }); + + it('stays inactive when any classic layer also denies', () => { + expect( + isToolActiveComposed( + { + workspaceDisabledTools: ['Bash'], + profile: {}, + sessionDisabledTools: ['Bash'], + }, + 'Bash', + ), + ).toBe(false); + }); +}); diff --git a/packages/agent-core-v2/test/app/agentProfileCatalog/agentProfileRegistry.test.ts b/packages/agent-core-v2/test/app/agentProfileCatalog/agentProfileRegistry.test.ts new file mode 100644 index 0000000000..0a73ec574b --- /dev/null +++ b/packages/agent-core-v2/test/app/agentProfileCatalog/agentProfileRegistry.test.ts @@ -0,0 +1,132 @@ +/** + * Scenario: the App-scope agent-profile registry service. + * + * Exercises `AgentProfileRegistryService` directly: the (sourceId, + * workspaceKey) storage-key encoding (one global entry per source id, with + * same-id workspace-local entries coexisting across handlers), the scoped + * `unregister` / dispose-handle semantics, the `entries()` metadata, and the + * decoded `onDidChange` payload. The generic contribution registry + * underneath is covered by `test/_base/contribution/registry.test.ts`; this + * suite only pins the service layer's workspaceKey dimension. Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/app/agentProfileCatalog/agentProfileRegistry.test.ts`. + */ + +import { describe, expect, it } from 'vitest'; + +import { AgentProfileRegistryService } from '#/app/agentProfileCatalog/agentProfileRegistryService'; +import type { AgentProfileContribution } from '#/app/agentProfileCatalog/agentProfileContribution'; + +function contribution(marker: string): AgentProfileContribution { + return { profiles: [{ name: marker, systemPrompt: () => marker }] }; +} + +describe('AgentProfileRegistryService', () => { + it('replaces a same-sourceId global registration', () => { + const registry = new AgentProfileRegistryService(); + registry.register('user', contribution('v1')); + registry.register('user', contribution('v2')); + + const entries = registry.entries(); + expect(entries).toHaveLength(1); + expect(entries[0]?.contribution.profiles[0]?.name).toBe('v2'); + registry.dispose(); + }); + + it('keeps same-sourceId entries with different workspaceKeys coexisting', () => { + const registry = new AgentProfileRegistryService(); + registry.register('workspace', contribution('global')); + registry.register('workspace', contribution('wd_a'), { workspaceKey: 'wd_a' }); + registry.register('workspace', contribution('wd_b'), { workspaceKey: 'wd_b' }); + // Re-registering one key replaces only that key's entry. + registry.register('workspace', contribution('wd_a-v2'), { workspaceKey: 'wd_a' }); + + const entries = registry.entries(); + expect(entries).toHaveLength(3); + const byKey = new Map(entries.map((entry) => [entry.workspaceKey, entry])); + expect(byKey.get(undefined)?.contribution.profiles[0]?.name).toBe('global'); + expect(byKey.get('wd_a')?.contribution.profiles[0]?.name).toBe('wd_a-v2'); + expect(byKey.get('wd_b')?.contribution.profiles[0]?.name).toBe('wd_b'); + registry.dispose(); + }); + + it('unregister(sourceId, workspaceKey) removes only the matching entry', () => { + const registry = new AgentProfileRegistryService(); + registry.register('workspace', contribution('wd_a'), { workspaceKey: 'wd_a' }); + registry.register('workspace', contribution('wd_b'), { workspaceKey: 'wd_b' }); + registry.register('user', contribution('global')); + + registry.unregister('workspace', 'wd_a'); + expect(registry.entries().map((entry) => entry.workspaceKey)).toEqual(['wd_b', undefined]); + + registry.unregister('workspace', 'wd_b'); + expect(registry.entries().map((entry) => entry.sourceId)).toEqual(['user']); + + registry.unregister('user'); + expect(registry.entries()).toHaveLength(0); + registry.dispose(); + }); + + it('a dispose handle removes only the entry it registered', () => { + const registry = new AgentProfileRegistryService(); + const stale = registry.register('workspace', contribution('old'), { workspaceKey: 'wd_a' }); + registry.register('workspace', contribution('new'), { workspaceKey: 'wd_a' }); + + stale.dispose(); + + const entries = registry.entries(); + expect(entries).toHaveLength(1); + expect(entries[0]?.contribution.profiles[0]?.name).toBe('new'); + registry.dispose(); + }); + + it('exposes sourceId, priority, workspaceKey, and contribution through entries()', () => { + const registry = new AgentProfileRegistryService(); + const pluginContribution = contribution('plugin-p'); + const workspaceContribution = contribution('ws-p'); + registry.register('plugin', pluginContribution, { priority: 5 }); + registry.register('workspace', workspaceContribution, { + priority: 30, + workspaceKey: 'wd_a', + }); + + const entries = registry.entries(); + expect(entries).toHaveLength(2); + expect(entries[0]).toEqual({ + sourceId: 'plugin', + priority: 5, + workspaceKey: undefined, + contribution: pluginContribution, + }); + expect(entries[1]).toEqual({ + sourceId: 'workspace', + priority: 30, + workspaceKey: 'wd_a', + contribution: workspaceContribution, + }); + // Priority defaults to 0 when omitted. + registry.register('user', contribution('user-p')); + expect(registry.entries()[2]?.priority).toBe(0); + registry.dispose(); + }); + + it('fires onDidChange with the decoded { sourceId, workspaceKey } payload', () => { + const registry = new AgentProfileRegistryService(); + const seen: { readonly sourceId: string; readonly workspaceKey?: string }[] = []; + const subscription = registry.onDidChange((change) => seen.push(change)); + + registry.register('user', contribution('global')); + registry.register('workspace', contribution('wd_a'), { workspaceKey: 'wd_a' }); + registry.unregister('workspace', 'wd_a'); + // Unregistering a missing entry stays silent. + registry.unregister('workspace', 'wd_b'); + + expect(seen).toStrictEqual([ + { sourceId: 'user', workspaceKey: undefined }, + { sourceId: 'workspace', workspaceKey: 'wd_a' }, + { sourceId: 'workspace', workspaceKey: 'wd_a' }, + ]); + subscription.dispose(); + registry.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts b/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts index 0866164e83..65d78a2f08 100644 --- a/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts +++ b/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts @@ -34,7 +34,7 @@ describe('BootstrapService (scoped)', () => { const svc = host.app.accessor.get(IBootstrapService); expect(svc.homeDir).toBe('/tmp/kimi-home'); expect(svc.configPath).toBe('/tmp/kimi-home/config.toml'); - expect(svc.sessionsDir).toBe('/tmp/kimi-home/sessions'); + expect(svc.scope('sessions')).toBe('sessions'); host.dispose(); }); diff --git a/packages/agent-core-v2/test/app/bootstrap/stubs.ts b/packages/agent-core-v2/test/app/bootstrap/stubs.ts index b5a097969c..147b465d34 100644 --- a/packages/agent-core-v2/test/app/bootstrap/stubs.ts +++ b/packages/agent-core-v2/test/app/bootstrap/stubs.ts @@ -19,10 +19,9 @@ export const stubClientIdentity = { } as const; export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv = {}): IBootstrapService { - const sessionsScope = 'sessions'; const scopes: Record = { config: '', - sessions: sessionsScope, + sessions: 'sessions', blobs: 'blobs', store: 'store', logs: 'logs', @@ -30,9 +29,6 @@ export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv credentials: 'credentials', cron: 'cron', }; - const sessionScope = (wsId: string, sId: string): string => `${sessionsScope}/${wsId}/${sId}`; - const agentScope = (wsId: string, sId: string, aId: string): string => - `${sessionScope(wsId, sId)}/agents/${aId}`; return { _serviceBrand: undefined, platform: 'linux', @@ -50,10 +46,6 @@ export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv logsDir: `${homeDir}/logs`, getEnv: (name) => env[name], scope: (name) => scopes[name], - sessionScope, - agentScope, - sessionDir: (wsId, sId) => `${homeDir}/${sessionScope(wsId, sId)}`, - agentHomedir: (wsId, sId, aId) => `${homeDir}/${agentScope(wsId, sId, aId)}`, }; } diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index f6842a525e..92d74232a7 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -84,14 +84,14 @@ import { } from '#/app/auth/configSection'; import { SECONDARY_DERIVED_MODEL_ID } from '#/app/kosongConfig/secondaryModelOverlay'; import { type SecondaryModelConfig } from '#/app/kosongConfig/configSection'; -import '#/agent/mcp/configSection'; +import '#/app/mcpConfig/configSection'; import { MCP_SECTION, MCP_STARTUP_TIMEOUT_ENV, MCP_TOOL_TIMEOUT_ENV, McpSectionSchema, type McpSection, -} from '#/agent/mcp/configSection'; +} from '#/app/mcpConfig/configSection'; import { ILogService } from '#/_base/log/log'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -239,11 +239,13 @@ describe('Agent config', () => { created_at: 1, }, { - type: 'config.update', + type: 'profile.bind', cwd: '/restored-cwd', modelAlias: 'restored-model', profileName: 'restored-profile', + thinkingEffort: 'off', systemPrompt: 'Restored prompt.', + disallowedTools: [], }, { type: 'tools.set_active_tools', @@ -252,7 +254,6 @@ describe('Agent config', () => { ]); expect(profile.data()).toMatchObject({ - cwd: '/restored-cwd', modelAlias: 'restored-model', profileName: 'restored-profile', systemPrompt: 'Restored prompt.', @@ -260,7 +261,7 @@ describe('Agent config', () => { }); }); - it('config.update with cwd initializes builtin tools', async () => { + it('config.update initializes builtin tools', async () => { const tools = await ctx.rpc.getTools({}); expect(toolNames(tools)).toEqual( diff --git a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts index f447eed466..bf76062689 100644 --- a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts +++ b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts @@ -46,10 +46,10 @@ import { IPluginService } from '#/app/plugin/plugin'; import { IHostProcessService } from '#/os/interface/hostProcess'; import { HostProcessService } from '#/os/backends/node-local/hostProcessService'; import { - ISessionLifecycleService, - type SessionLifecycleHooks, -} from '#/app/sessionLifecycle/sessionLifecycle'; -import { createHooks } from '#/hooks'; + ISessionLifecycleHooks, + type SessionLifecycleHookSlots, +} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; +import { createHooks, type Hooks } from '#/hooks'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { type AgentTaskHooks, @@ -182,33 +182,11 @@ function stubSessionContext(): ISessionContext { }; } -function stubSessionLifecycle(): ISessionLifecycleService { - return { - _serviceBrand: undefined, - hooks: createHooks([ - 'onDidCreateSession', - 'onWillCloseSession', - ]), - onDidCreateSession: Event.None as ISessionLifecycleService['onDidCreateSession'], - onDidCloseSession: Event.None as ISessionLifecycleService['onDidCloseSession'], - onDidArchiveSession: Event.None as ISessionLifecycleService['onDidArchiveSession'], - onDidForkSession: Event.None as ISessionLifecycleService['onDidForkSession'], - create: async () => { - throw new Error('not implemented'); - }, - get: () => undefined, - list: () => [], - resume: async () => undefined, - close: async () => {}, - archive: async () => {}, - restore: async () => undefined, - fork: async () => { - throw new Error('not implemented'); - }, - createChild: async () => { - throw new Error('not implemented'); - }, - }; +function stubSessionLifecycleHooks(): Hooks { + return createHooks([ + 'onDidCreateSession', + 'onWillCloseSession', + ]); } describe('IExternalHooksRunnerService integration', () => { @@ -512,7 +490,7 @@ describe('IExternalHooksRunnerService integration', () => { ? 'sessions/workspace-1/session-1' : `sessions/workspace-1/session-1/${subKey}`, }); - reg.defineInstance(ISessionLifecycleService, stubSessionLifecycle()); + reg.defineInstance(ISessionLifecycleHooks, stubSessionLifecycleHooks()); reg.definePartialInstance(ISessionSubagentService, { hooks: createHooks(['onWillStartAgentTask']), onDidStopAgentTask: stopAgentTask.event, @@ -825,11 +803,10 @@ describe('IExternalHooksRunnerService integration', () => { const disposables = new DisposableStore(); let ix: TestInstantiationService | undefined; try { - const lifecycle = stubSessionLifecycle(); + const lifecycleHooks = stubSessionLifecycleHooks(); const path = hookLogPath(); const command = appendHookLogCommand(path); const cwd = mkdtempSync(join(tmpdir(), 'session-external-hooks-cwd-')); - const handle = {} as ISessionScopeHandle; ix = createServices(disposables, { strict: true, @@ -847,7 +824,7 @@ describe('IExternalHooksRunnerService integration', () => { ? 'sessions/workspace-1/session-1' : `sessions/workspace-1/session-1/${subKey}`, }); - reg.defineInstance(ISessionLifecycleService, lifecycle); + reg.defineInstance(ISessionLifecycleHooks, lifecycleHooks); reg.definePartialInstance(ISessionSubagentService, { hooks: createHooks(['onWillStartAgentTask']), onDidStopAgentTask: Event.None as Event, @@ -874,31 +851,10 @@ describe('IExternalHooksRunnerService integration', () => { ix.set(ISessionExternalHooksService, new SyncDescriptor(SessionExternalHooksService)); ix.get(ISessionExternalHooksService); - await lifecycle.hooks.onDidCreateSession.run({ - sessionId: 'session-1', - handle, - source: 'startup', - }); - await lifecycle.hooks.onDidCreateSession.run({ - sessionId: 'session-1', - handle, - source: 'resume', - }); - await lifecycle.hooks.onDidCreateSession.run({ - sessionId: 'session-1', - handle, - source: 'fork', - }); - await lifecycle.hooks.onDidCreateSession.run({ - sessionId: 'other-session', - handle, - source: 'startup', - }); - await lifecycle.hooks.onWillCloseSession.run({ - sessionId: 'session-1', - handle, - reason: 'exit', - }); + await lifecycleHooks.onDidCreateSession.run({ source: 'startup' }); + await lifecycleHooks.onDidCreateSession.run({ source: 'resume' }); + await lifecycleHooks.onDidCreateSession.run({ source: 'fork' }); + await lifecycleHooks.onWillCloseSession.run({ reason: 'exit' }); expect(readHookLog(path)).toEqual([ { diff --git a/packages/agent-core-v2/test/app/gateway/gateway.test.ts b/packages/agent-core-v2/test/app/gateway/gateway.test.ts index e2194cd5ed..f5fe1623b4 100644 --- a/packages/agent-core-v2/test/app/gateway/gateway.test.ts +++ b/packages/agent-core-v2/test/app/gateway/gateway.test.ts @@ -11,7 +11,8 @@ import { IRestGateway } from '#/app/gateway/gateway'; import { RestGateway } from '#/app/gateway/gatewayService'; import { ILogService } from '#/_base/log/log'; import { IAgentPromptService } from '#/agent/prompt/prompt'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; import { IAgentLoopService } from '#/agent/loop/loop'; import { createHooks } from '#/hooks'; import { stubLog } from '../../_base/log/stubs'; @@ -87,12 +88,34 @@ describe('RestGateway', () => { dispose: () => {}, }; - ix.stub(ISessionLifecycleService, { + const handlerService: IWorkspaceHandlerService = { _serviceBrand: undefined, + onDidCreateSession: () => ({ dispose: () => {} }), + onDidCloseSession: () => ({ dispose: () => {} }), + onDidArchiveSession: () => ({ dispose: () => {} }), + onDidForkSession: () => ({ dispose: () => {} }), create: () => Promise.resolve(sessionHandle), - get: (id) => (id === 's1' ? sessionHandle : undefined), + get: (id: string) => (id === 's1' ? sessionHandle : undefined), list: () => [sessionHandle], + resume: () => Promise.resolve(sessionHandle), close: () => Promise.resolve(), + archive: () => Promise.resolve(), + restore: () => Promise.resolve(sessionHandle), + fork: () => Promise.resolve(sessionHandle), + createChild: () => Promise.resolve(sessionHandle), + }; + const handlerHandle = { + id: 'wd_stub', + kind: LifecycleScope.Workspace, + accessor: makeAccessor([[IWorkspaceHandlerService, handlerService]]), + dispose: () => {}, + } as const; + ix.stub(IWorkspaceLifecycleService, { + _serviceBrand: undefined, + onDidMaterializeHandler: () => ({ dispose: () => {} }), + handlerFor: () => Promise.resolve(handlerHandle), + handlers: { list: () => [handlerHandle] }, + sessions: { list: () => ['s1'] }, }); ix.stub(ILogService, stubLog()); ix.set(IRestGateway, new SyncDescriptor(RestGateway)); diff --git a/packages/agent-core-v2/test/app/git/gitService.test.ts b/packages/agent-core-v2/test/app/git/gitService.test.ts index ca7a569a03..39432cebba 100644 --- a/packages/agent-core-v2/test/app/git/gitService.test.ts +++ b/packages/agent-core-v2/test/app/git/gitService.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -9,11 +9,13 @@ import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { IGitService } from '#/app/git/git'; import { GitService } from '#/app/git/gitService'; +import { findGitWorkTree } from '#/app/git/workTree'; import { ErrorCodes } from '#/errors'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { HostProcessService } from '#/os/backends/node-local/hostProcessService'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IHostProcessService } from '#/os/interface/hostProcess'; +import { normalize } from 'pathe'; function git(cwd: string, ...args: string[]): string { return execFileSync('git', args, { @@ -135,4 +137,83 @@ describe('GitService', () => { ).rejects.toMatchObject({ code: ErrorCodes.FS_PATH_NOT_FOUND }); }); }); + + describe('findWorkTree', () => { + it('finds the repo root from a nested subdirectory', async () => { + mkdirSync(join(repo, 'a', 'b'), { recursive: true }); + + const result = await service.findWorkTree(join(repo, 'a', 'b')); + + expect(result).toEqual({ + root: normalize(repo), + dotGitPath: normalize(join(repo, '.git')), + controlDirPath: normalize(join(repo, '.git')), + }); + }); + + it('returns null when no ancestor holds a .git entry', async () => { + const plain = mkdtempSync(join(tmpdir(), 'git-service-plain-')); + try { + await expect(service.findWorkTree(plain)).resolves.toBeNull(); + } finally { + rmSync(plain, { recursive: true, force: true }); + } + }); + + it('resolves an absolute gitdir pointer in a .git file', async () => { + const wt = mkdtempSync(join(tmpdir(), 'git-service-wt-')); + try { + const control = join(repo, '.git', 'worktrees', 'wt'); + writeFileSync(join(wt, '.git'), `gitdir: ${control}\n`); + + const result = await service.findWorkTree(wt); + + expect(result?.root).toBe(normalize(wt)); + expect(result?.dotGitPath).toBe(normalize(join(wt, '.git'))); + expect(result?.controlDirPath).toBe(normalize(control)); + } finally { + rmSync(wt, { recursive: true, force: true }); + } + }); + + it('resolves a relative gitdir pointer against the marker parent', async () => { + const wt = mkdtempSync(join(tmpdir(), 'git-service-wt-')); + try { + writeFileSync(join(wt, '.git'), 'gitdir: ../gitdir-target\n'); + + const result = await service.findWorkTree(wt); + + expect(result?.controlDirPath).toBe(normalize(join(wt, '..', 'gitdir-target'))); + } finally { + rmSync(wt, { recursive: true, force: true }); + } + }); + + it('parses a BOM-prefixed gitdir pointer', async () => { + const wt = mkdtempSync(join(tmpdir(), 'git-service-wt-')); + try { + writeFileSync(join(wt, '.git'), '\uFEFFgitdir: ../target\n'); + + const result = await service.findWorkTree(wt); + + expect(result?.controlDirPath).toBe(normalize(join(wt, '..', 'target'))); + } finally { + rmSync(wt, { recursive: true, force: true }); + } + }); + + it('skips a .git file without a gitdir pointer and keeps walking up', async () => { + const inner = join(repo, 'inner'); + mkdirSync(inner, { recursive: true }); + writeFileSync(join(inner, '.git'), 'not a pointer\n'); + + const result = await service.findWorkTree(inner); + + expect(result?.root).toBe(normalize(repo)); + }); + + it('returns null for a relative cwd', async () => { + await expect(findGitWorkTree(new HostFileSystem(), 'some/relative/path')).resolves.toBeNull(); + }); + }); }); diff --git a/packages/agent-core-v2/test/app/mcpConfig/oauthStore.test.ts b/packages/agent-core-v2/test/app/mcpConfig/oauthStore.test.ts new file mode 100644 index 0000000000..6462016324 --- /dev/null +++ b/packages/agent-core-v2/test/app/mcpConfig/oauthStore.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { createMcpOAuthStore } from '#/app/mcpConfig/oauthStore'; +import type { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; + +describe('createMcpOAuthStore', () => { + it('round-trips JSON data through the credentials/mcp scope', async () => { + const calls: Array<{ op: string; scope: string; key: string; value?: unknown }> = []; + const docs: Pick = { + async get(scope: string, key: string): Promise { + calls.push({ op: 'get', scope, key }); + return { hello: 'world' } as T; + }, + async set(scope, key, value) { + calls.push({ op: 'set', scope, key, value }); + }, + async delete(scope, key) { + calls.push({ op: 'delete', scope, key }); + }, + }; + const store = createMcpOAuthStore(docs as unknown as IAtomicDocumentStore); + + await expect(store.read('foo.json')).resolves.toEqual({ hello: 'world' }); + await store.write('foo.json', { token: 'abc' }); + await store.remove('foo.json'); + + expect(calls).toEqual([ + { op: 'get', scope: 'credentials/mcp', key: 'foo.json' }, + { op: 'set', scope: 'credentials/mcp', key: 'foo.json', value: { token: 'abc' } }, + { op: 'delete', scope: 'credentials/mcp', key: 'foo.json' }, + ]); + }); + + it('returns undefined when the underlying document store read fails', async () => { + const store = createMcpOAuthStore({ + get: async () => { + throw new Error('corrupt json'); + }, + set: async () => {}, + delete: async () => {}, + } as unknown as IAtomicDocumentStore); + + await expect(store.read('bad.json')).resolves.toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts b/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts index eb102235dd..5d23169b6a 100644 --- a/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts +++ b/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { toDisposable } from '#/_base/di/lifecycle'; import { type IAgentScopeHandle, type ISessionScopeHandle, LifecycleScope } from '#/_base/di/scope'; +import { TestInstantiationService } from '#/_base/di/test'; import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; @@ -10,7 +11,8 @@ import type { ContentPart } from '#/kosong/contract/message'; import { type IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IWireService } from '#/wire/wire'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionCronService } from '#/session/cron/sessionCronService'; @@ -72,10 +74,25 @@ function buildService(opts: { dispose: () => {}, } as unknown as ISessionScopeHandle; - const lifecycle = { - resume: (sessionId: string) => - Promise.resolve(sessionId === opts.summary.id ? sessionHandle : undefined), - } as unknown as ISessionLifecycleService; + const workspaceLifecycle = { + handlerFor: () => + Promise.resolve({ + id: opts.summary.workspaceId, + kind: LifecycleScope.Workspace, + accessor: { + get: (token: unknown): unknown => { + if (token === IWorkspaceHandlerService) { + return { + resume: (sessionId: string) => + Promise.resolve(sessionId === opts.summary.id ? sessionHandle : undefined), + }; + } + throw new Error('unexpected workspace service access'); + }, + }, + dispose: () => {}, + }), + } as unknown as IWorkspaceLifecycleService; const index = { get: (sessionId: string) => Promise.resolve(sessionId === opts.summary.id ? opts.summary : undefined), @@ -93,7 +110,10 @@ function buildService(opts: { acquire: () => toDisposable(() => {}), }; - return new MessageLegacyService(lifecycle, index, appendLog); + const instantiation = new TestInstantiationService(); + instantiation.stub(ISessionIndex, index); + instantiation.stub(IWorkspaceLifecycleService, workspaceLifecycle); + return new MessageLegacyService(instantiation, index, appendLog); } describe('MessageLegacyService', () => { diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 7cfdab0be9..dca3deefc7 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -43,13 +43,10 @@ import { } from '#/app/sessionExport/sessionExportService'; import { writeExportZip } from '#/app/sessionExport/zip'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; -import { - ISessionLifecycleService, - type SessionLifecycleHooks, -} from '#/app/sessionLifecycle/sessionLifecycle'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; import { IWorkspaceService } from '#/app/workspace/workspace'; import { Error2 } from '#/errors'; -import { createHooks } from '#/hooks'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; @@ -900,30 +897,50 @@ function registerSessionExportServices( get: async () => options.summary, countActive: async () => (options.summary === undefined || options.summary.archived ? 0 : 1), }); - reg.defineInstance(ISessionLifecycleService, { + reg.defineInstance(IWorkspaceLifecycleService, { _serviceBrand: undefined, - onDidCreateSession: noopEvent, - onDidCloseSession: noopEvent, - onDidArchiveSession: noopEvent, - onDidForkSession: noopEvent, - hooks: createHooks([ - 'onDidCreateSession', - 'onWillCloseSession', - ]), - create: async () => { - throw new Error('create should not be called by session export'); + onDidMaterializeHandler: noopEvent, + handlerFor: async () => { + throw new Error('handlerFor should not be called by session export'); }, - get: () => options.lifecycleHandle, - list: () => (options.lifecycleHandle === undefined ? [] : [options.lifecycleHandle]), - resume: async () => options.lifecycleHandle, - close: async () => {}, - archive: async () => {}, - restore: async () => options.lifecycleHandle, - fork: async () => { - throw new Error('fork should not be called by session export'); + handlers: { + list: () => [ + { + id: 'ws_live', + kind: LifecycleScope.Workspace, + accessor: accessorFrom([ + [ + IWorkspaceHandlerService, + { + _serviceBrand: undefined, + onDidCreateSession: noopEvent, + onDidCloseSession: noopEvent, + onDidArchiveSession: noopEvent, + onDidForkSession: noopEvent, + create: async () => { + throw new Error('create should not be called by session export'); + }, + get: () => options.lifecycleHandle, + list: () => (options.lifecycleHandle === undefined ? [] : [options.lifecycleHandle]), + resume: async () => options.lifecycleHandle, + close: async () => {}, + archive: async () => {}, + restore: async () => options.lifecycleHandle, + fork: async () => { + throw new Error('fork should not be called by session export'); + }, + createChild: async () => { + throw new Error('createChild should not be called by session export'); + }, + } satisfies IWorkspaceHandlerService, + ], + ]), + dispose: () => {}, + }, + ], }, - createChild: async () => { - throw new Error('createChild should not be called by session export'); + sessions: { + list: () => (options.lifecycleHandle === undefined ? [] : [options.lifecycleHandle.id]), }, }); reg.defineInstance(IWorkspaceService, { diff --git a/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts b/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts index fe4eae06a4..f24257df05 100644 --- a/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts +++ b/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts @@ -22,7 +22,9 @@ import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { UNKNOWN_CAPABILITY } from '#/kosong/contract/capability'; import { ISessionLegacyService } from '#/app/sessionLegacy/sessionLegacy'; import { SessionLegacyService } from '#/app/sessionLegacy/sessionLegacyService'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; +import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; import { IAgentActivityView } from '#/agent/activityView/activityView'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -42,6 +44,43 @@ function accessor( }; } +/** Stub the index → handler → session-lifecycle chain for one live session. */ +function stubSessionChain(ix: TestInstantiationService, session: ISessionScopeHandle): void { + const handler = { + id: 'wd', + kind: LifecycleScope.Workspace, + accessor: accessor([ + [ + IWorkspaceHandlerService, + { + resume: () => Promise.resolve(session), + get: () => session, + }, + ], + ]), + dispose: () => {}, + } as const; + ix.stub(ISessionIndex, { + get: (id: string) => + Promise.resolve( + id === session.id + ? { + id: session.id, + workspaceId: 'wd', + cwd: '/workspace', + createdAt: 1, + updatedAt: 1, + archived: false, + } + : undefined, + ), + }); + ix.stub(IWorkspaceLifecycleService, { + handlerFor: () => Promise.resolve(handler), + handlers: { list: () => [handler] }, + }); +} + describe('Session legacy status (best-effort runtime state)', () => { let disposables: DisposableStore; let ix: TestInstantiationService; @@ -104,10 +143,7 @@ describe('Session legacy status (best-effort runtime state)', () => { ]), dispose: () => {}, }; - ix.stub(ISessionLifecycleService, { - resume: () => Promise.resolve(session), - get: () => session, - }); + stubSessionChain(ix, session); ix.set(ISessionLegacyService, new SyncDescriptor(SessionLegacyService)); const status = await ix.get(ISessionLegacyService).status('session-test'); @@ -182,10 +218,7 @@ describe('Session legacy status (best-effort runtime state)', () => { ]), dispose: () => {}, }; - ix.stub(ISessionLifecycleService, { - resume: () => Promise.resolve(session), - get: () => session, - }); + stubSessionChain(ix, session); ix.set(ISessionLegacyService, new SyncDescriptor(SessionLegacyService)); const status = await ix.get(ISessionLegacyService).status('session-capped'); @@ -231,10 +264,7 @@ describe('Session legacy status (best-effort runtime state)', () => { ]), dispose: () => {}, }; - ix.stub(ISessionLifecycleService, { - resume: () => Promise.resolve(session), - get: () => session, - }); + stubSessionChain(ix, session); ix.set(ISessionLegacyService, new SyncDescriptor(SessionLegacyService)); await ix.get(ISessionLegacyService).updateProfile('session-test', { diff --git a/packages/agent-core-v2/test/app/skillCatalog/parser.test.ts b/packages/agent-core-v2/test/app/skillCatalog/parser.test.ts index f623b02692..116dcc156f 100644 --- a/packages/agent-core-v2/test/app/skillCatalog/parser.test.ts +++ b/packages/agent-core-v2/test/app/skillCatalog/parser.test.ts @@ -1,31 +1,11 @@ import { describe, expect, it } from 'vitest'; import { - FrontmatterError, SkillParseError, UnsupportedSkillTypeError, - parseFrontmatter, parseSkillText, } from '#/app/skillCatalog/parser'; -describe('parseFrontmatter', () => { - it('parses yaml frontmatter and body', () => { - const { data, body } = parseFrontmatter('---\nname: foo\n---\nbody text'); - expect(data).toEqual({ name: 'foo' }); - expect(body).toBe('body text'); - }); - - it('returns null data when there is no frontmatter', () => { - const { data, body } = parseFrontmatter('just body'); - expect(data).toBeNull(); - expect(body).toBe('just body'); - }); - - it('throws when the closing fence is missing', () => { - expect(() => parseFrontmatter('---\nname: foo')).toThrow(FrontmatterError); - }); -}); - describe('parseSkillText', () => { it('parses a directory skill with required fields', () => { const skill = parseSkillText({ diff --git a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts new file mode 100644 index 0000000000..49adc95bfe --- /dev/null +++ b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts @@ -0,0 +1,523 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + LifecycleScope, + ScopeActivation, + _clearScopedRegistryForTests, + registerScopedService, +} from '#/_base/di/scope'; +import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test'; +import { Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; +import { IEventService } from '#/app/event/event'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; +import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import type { McpConnectionManager } from '#/mcpCore/connection-manager'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { ISessionProcessRunner } from '#/session/process/processRunner'; +import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; +import { IWorkspaceAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; +import { IExtraAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader'; +import { IExplicitAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader'; +import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; +import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; +import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; +import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; +import { Error2, ErrorCodes } from '#/errors'; +import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService'; +import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; +import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; +import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs'; +import { stubLog } from '../../_base/log/stubs'; + +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { WorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycleService'; +import { + followWorkspaceHandlers, + getLiveSessionById, + resumeSessionById, +} from '#/app/workspaceLifecycle/sessionLookup'; + +function bootstrapStub(): IBootstrapService { + return { + homeDir: '/tmp', + scope: (name: string) => name, + } as unknown as IBootstrapService; +} + +function hostEnvironmentStub(): IHostEnvironment { + return { + _serviceBrand: undefined, + osKind: 'Linux', + osArch: 'x86_64', + osVersion: 'test', + shellName: 'bash', + shellPath: '/bin/bash', + pathClass: 'posix', + homeDir: '/home', + ready: Promise.resolve(), + }; +} + +/** Catalog stub that mints `encodeWorkDirKey` ids and records createOrTouch calls. */ +function catalogStub() { + const workspaces = new Map(); + const createOrTouch = vi.fn((root: string, name?: string) => { + const id = encodeWorkDirKey(root); + const existing = workspaces.get(id); + const workspace: Workspace = + existing ?? { id, root, name: name ?? 'proj', createdAt: 1, lastOpenedAt: 1 }; + workspaces.set(id, workspace); + return Promise.resolve(workspace); + }); + const service: IWorkspaceService = { + _serviceBrand: undefined, + list: () => Promise.resolve([...workspaces.values()]), + get: (id) => Promise.resolve(workspaces.get(id)), + createOrTouch, + update: () => Promise.resolve(undefined), + delete: () => Promise.resolve(), + }; + return { service, createOrTouch }; +} + +function sessionIndexStub(): ISessionIndex { + return { + _serviceBrand: undefined, + list: () => Promise.resolve({ items: [], total: 0, hasMore: false }), + get: () => Promise.resolve(undefined), + countActive: () => Promise.resolve(0), + }; +} + +function agentProfileLoaderStub(): IWorkspaceAgentProfileLoader { + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + reload: () => Promise.resolve(), + }; +} + +function userAgentProfileLoaderStub(): IUserAgentProfileLoader { + return { + ...agentProfileLoaderStub(), + getDefaultProfile: () => { + throw new Error('not implemented'); + }, + }; +} + +function sessionStubs(): ReturnType[] { + return [ + stubPair(ISessionMetadata, { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChangeMetadata: () => ({ dispose: () => {} }), + read: () => Promise.resolve({} as never), + update: () => Promise.resolve(), + setTitle: () => Promise.resolve(), + setArchived: () => Promise.resolve(), + registerAgent: () => Promise.resolve(), + } satisfies ISessionMetadata), + stubPair(ISessionToolPolicy, { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChange: () => ({ dispose: () => {} }), + disabledTools: () => [], + setDisabledTools: () => Promise.resolve(), + } satisfies ISessionToolPolicy), + stubPair(ISessionProcessRunner, { + _serviceBrand: undefined, + exec: () => Promise.reject(new Error('process exec is not supported in this test')), + } satisfies ISessionProcessRunner), + stubPair(IWorkspaceSkillCatalog, (() => { + const catalog = { + getSkill: () => undefined, + getPluginSkill: () => undefined, + renderSkillPrompt: () => '', + listSkills: () => [], + listInvocableSkills: () => [], + getSkillRoots: () => [], + getSkippedByPolicy: () => [], + getModelSkillListing: () => '', + }; + const onDidChange = () => ({ dispose: () => {} }); + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + catalog, + onDidChange, + load: () => Promise.resolve(), + reload: () => Promise.resolve(), + sessionData: () => ({ + _serviceBrand: undefined, + ready: Promise.resolve(), + catalog, + onDidChange, + }), + } as unknown as IWorkspaceSkillCatalog; + })()), + stubPair(IWorkspaceAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IExtraAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IExplicitAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IUserAgentProfileLoader, userAgentProfileLoaderStub()), + stubPair(IPluginAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IWorkspaceInstructionsService, (() => { + const onDidChange = () => ({ dispose: () => {} }); + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + snapshot: { agentsMd: undefined, agentsMdWarning: undefined }, + onDidChange, + reload: () => Promise.resolve(), + sessionProvider: () => ({ + _serviceBrand: undefined, + ready: Promise.resolve(), + agentsMd: undefined, + agentsMdWarning: undefined, + onDidChange, + }), + } as unknown as IWorkspaceInstructionsService; + })()), + stubPair(IWorkspaceMcpService, { + _serviceBrand: undefined, + ready: Promise.resolve(), + connectionManager: () => { + throw new Error('not implemented'); + }, + sessionHandle: () => ({ + _serviceBrand: undefined, + ready: Promise.resolve(), + get connectionManager(): McpConnectionManager { + throw new Error('not implemented'); + }, + }), + } as unknown as IWorkspaceMcpService), + stubPair(IAgentLifecycleService, (() => { + const main = { + id: 'main', + kind: LifecycleScope.Agent, + accessor: { + get: () => { + throw new Error('unexpected main agent service access'); + }, + }, + dispose: () => {}, + } as const; + return { + _serviceBrand: undefined, + onDidCreate: () => ({ dispose: () => {} }), + onDidDispose: () => ({ dispose: () => {} }), + create: () => Promise.reject(new Error('not implemented')), + fork: () => Promise.reject(new Error('not implemented')), + get: (id: string) => (id === 'main' ? main : undefined), + list: () => [], + remove: () => Promise.resolve(), + broadcastPermissionMode: () => {}, + } as unknown as IAgentLifecycleService; + })()), + ]; +} + +describe('WorkspaceLifecycleService', () => { + let host: ScopedTestHost | undefined; + let telemetryRecords: TelemetryRecord[]; + let createOrTouchSpy: ReturnType; + + beforeEach(() => { + telemetryRecords = []; + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.App, + IWorkspaceLifecycleService, + WorkspaceLifecycleService, + ScopeActivation.OnDemand, + 'workspaceLifecycle', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceHandlerService, + WorkspaceHandlerService, + ScopeActivation.OnScopeCreated, + 'workspaceHandler', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceToolPolicy, + WorkspaceToolPolicyService, + ScopeActivation.OnScopeCreated, + 'workspaceToolPolicy', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceDirs, + WorkspaceDirsService, + ScopeActivation.OnScopeCreated, + 'workspaceDirs', + ); + registerScopedService( + LifecycleScope.App, + IHostFileSystem, + HostFileSystem, + ScopeActivation.OnDemand, + 'hostFs', + ); + }); + + afterEach(() => { + host?.dispose(); + host = undefined; + }); + + function build(extra: ReturnType[] = []): IWorkspaceLifecycleService { + const catalog = catalogStub(); + createOrTouchSpy = catalog.createOrTouch; + host = createScopedTestHost([ + stubPair(IBootstrapService, bootstrapStub()), + stubPair(IHostEnvironment, hostEnvironmentStub()), + stubPair(IWorkspaceService, catalog.service), + stubPair(ISessionIndex, sessionIndexStub()), + stubPair(IConfigService, { get: () => undefined } as unknown as IConfigService), + stubPair(IAppendLogStore, { + _serviceBrand: undefined, + append: () => {}, + read: async function* () {}, + rewrite: () => Promise.resolve(), + flush: () => Promise.resolve(), + close: () => Promise.resolve(), + acquire: () => ({ dispose: () => {} }), + } satisfies IAppendLogStore), + stubPair(IAtomicDocumentStore, { + _serviceBrand: undefined, + get: () => Promise.resolve(undefined), + set: () => Promise.resolve(), + delete: () => Promise.resolve(), + list: () => Promise.resolve([]), + watch: () => () => ({ dispose: () => {} }), + acquire: () => ({ dispose: () => {} }), + } as unknown as IAtomicDocumentStore), + stubPair(IEventService, { + _serviceBrand: undefined, + onDidPublish: () => ({ dispose: () => {} }), + publish: () => {}, + subscribe: () => ({ dispose: () => {} }), + } satisfies IEventService), + stubPair(IProjectLocalConfigService, { + _serviceBrand: undefined, + readAdditionalDirs: (workDir: string) => + Promise.resolve({ + projectRoot: workDir, + configPath: `${workDir}/.kimi-code/local.toml`, + additionalDirs: [], + }), + resolveAdditionalDirs: (_base: string, dirs: readonly string[]) => + Promise.resolve([...dirs]), + appendAdditionalDir: () => Promise.reject(new Error('not implemented')), + } satisfies IProjectLocalConfigService), + stubPair(IHostFsWatchService, { + _serviceBrand: undefined, + watch: () => ({ onDidChange: Event.None, dispose: () => {} }), + } as unknown as IHostFsWatchService), + stubPair(ILogService, stubLog()), + stubPair(ITelemetryService, recordingTelemetry(telemetryRecords)), + stubPair(ICronTaskPersistence, { + _serviceBrand: undefined, + get: () => Promise.resolve(undefined), + list: () => Promise.resolve([]), + save: () => Promise.resolve(), + delete: () => Promise.resolve(), + } satisfies ICronTaskPersistence), + ...sessionStubs(), + ...extra, + ]); + return host.app.accessor.get(IWorkspaceLifecycleService); + } + + it('handlerFor materializes a handler seeded with the workspace facts', async () => { + const lifecycle = build(); + const handler = await lifecycle.handlerFor({ root: '/tmp/proj' }); + + expect(handler.kind).toBe(LifecycleScope.Workspace); + const ctx = handler.accessor.get(IWorkspaceContext); + expect(ctx).toMatchObject({ + workspaceId: encodeWorkDirKey('/tmp/proj'), + cwd: '/tmp/proj', + source: 'local', + persistenceScope: `sessions/${encodeWorkDirKey('/tmp/proj')}`, + osBackendId: 'local', + persistenceBackendId: 'local', + }); + expect(lifecycle.handlers.list()).toEqual([handler]); + }); + + it('handlerFor joins concurrent materializations of the same workspace', async () => { + const lifecycle = build(); + + const [a, b] = await Promise.all([ + lifecycle.handlerFor({ root: '/tmp/proj' }), + lifecycle.handlerFor({ root: '/tmp/proj' }), + ]); + + expect(a).toBe(b); + expect(lifecycle.handlers.list()).toHaveLength(1); + }); + + it('two concurrent sessions of one workspace share a single handler', async () => { + const lifecycle = build(); + const workspaceId = encodeWorkDirKey('/tmp/proj'); + + const [handlerA, handlerB] = await Promise.all([ + lifecycle.handlerFor({ root: '/tmp/proj' }), + lifecycle.handlerFor({ workspaceId, root: '/tmp/proj' }), + ]); + expect(handlerA).toBe(handlerB); + + const sessions = handlerA.accessor.get(IWorkspaceHandlerService); + const [s1, s2] = await Promise.all([ + sessions.create({ sessionId: 's1', workDir: '/tmp/proj' }), + sessions.create({ sessionId: 's2', workDir: '/tmp/proj' }), + ]); + + expect(s1.id).toBe('s1'); + expect(s2.id).toBe('s2'); + expect(lifecycle.handlers.list()).toEqual([handlerA]); + expect(lifecycle.sessions.list(workspaceId).toSorted()).toEqual(['s1', 's2']); + }); + + it('handlerFor by workspaceId returns the live handler without re-touching the catalog', async () => { + const lifecycle = build(); + const handler = await lifecycle.handlerFor({ root: '/tmp/proj' }); + expect(createOrTouchSpy).toHaveBeenCalledTimes(1); + + const again = await lifecycle.handlerFor({ workspaceId: encodeWorkDirKey('/tmp/proj') }); + + expect(again).toBe(handler); + // A live handler is returned as-is — no catalog write beyond the initial + // materialization. + expect(createOrTouchSpy).toHaveBeenCalledTimes(1); + }); + + it('handlerFor by unknown workspaceId without a root hint throws workspace.not_found', async () => { + const lifecycle = build(); + + await expect(lifecycle.handlerFor({ workspaceId: 'wd_missing' })).rejects.toMatchObject({ + code: ErrorCodes.WORKSPACE_NOT_FOUND, + }); + expect(lifecycle.handlers.list()).toHaveLength(0); + }); + + it('a failed handler materialization does not disturb live handlers', async () => { + const lifecycle = build(); + const first = await lifecycle.handlerFor({ root: '/tmp/proj' }); + + const workspaces = host!.app.accessor.get(IWorkspaceService); + const original = workspaces.createOrTouch.bind(workspaces); + vi.spyOn(workspaces, 'createOrTouch').mockImplementation((root: string, name?: string) => + root === '/tmp/broken' ? Promise.reject(new Error('disk gone')) : original(root, name), + ); + + await expect(lifecycle.handlerFor({ root: '/tmp/broken' })).rejects.toThrow('disk gone'); + expect(lifecycle.handlers.list()).toEqual([first]); + expect(first.accessor.get(IWorkspaceContext).workspaceId).toBe(encodeWorkDirKey('/tmp/proj')); + }); + + it('sessions.list is empty for a non-materialized workspace', () => { + const lifecycle = build(); + expect(lifecycle.sessions.list('wd_missing')).toEqual([]); + }); + + describe('sessionLookup', () => { + function indexWith(summary: { + readonly id: string; + readonly workspaceId: string; + readonly cwd?: string; + }): ReturnType { + return stubPair(ISessionIndex, { + ...sessionIndexStub(), + get: (id: string) => Promise.resolve(id === summary.id ? { ...summary, createdAt: 1, updatedAt: 1, archived: false } : undefined), + } as unknown as ISessionIndex); + } + + it('resumeSessionById routes index → handlerFor → handler resume', async () => { + const workspaceId = encodeWorkDirKey('/tmp/proj'); + const lifecycle = build([indexWith({ id: 's1', workspaceId, cwd: '/tmp/proj' })]); + + const handle = await resumeSessionById(host!.app.accessor, 's1'); + + expect(handle?.id).toBe('s1'); + expect(lifecycle.handlers.list()).toHaveLength(1); + expect(getLiveSessionById(host!.app.accessor, 's1')).toBe(handle); + }); + + it('resumeSessionById returns undefined for an unknown session', async () => { + build(); + await expect(resumeSessionById(host!.app.accessor, 'nope')).resolves.toBeUndefined(); + }); + + it('resumeSessionById reports session_load_failed when the index read fails', async () => { + build([ + stubPair(ISessionIndex, { + ...sessionIndexStub(), + get: () => + Promise.reject(new Error2(ErrorCodes.SESSION_NOT_FOUND, 'index read failed')), + } as unknown as ISessionIndex), + ]); + + await expect(resumeSessionById(host!.app.accessor, 's1')).rejects.toMatchObject({ + code: ErrorCodes.SESSION_NOT_FOUND, + }); + expect(telemetryRecords).toContainEqual({ + event: 'session_load_failed', + properties: { sessionId: 's1', reason: ErrorCodes.SESSION_NOT_FOUND }, + }); + }); + + it('getLiveSessionById finds only live sessions', async () => { + const lifecycle = build(); + const handler = await lifecycle.handlerFor({ root: '/tmp/proj' }); + const sessions = handler.accessor.get(IWorkspaceHandlerService); + await sessions.create({ sessionId: 's1', workDir: '/tmp/proj' }); + + expect(getLiveSessionById(host!.app.accessor, 's1')?.id).toBe('s1'); + expect(getLiveSessionById(host!.app.accessor, 'other')).toBeUndefined(); + }); + + it('followWorkspaceHandlers subscribes present and future handlers', async () => { + const lifecycle = build(); + const closed: string[] = []; + const sub = followWorkspaceHandlers(host!.app.accessor, (service) => + service.onDidCloseSession((event) => closed.push(event.sessionId)), + ); + + const first = await lifecycle.handlerFor({ root: '/tmp/proj' }); + await first.accessor.get(IWorkspaceHandlerService).create({ sessionId: 's1', workDir: '/tmp/proj' }); + // Materialized AFTER the follow subscription — still observed. + const second = await lifecycle.handlerFor({ root: '/tmp/other' }); + await second.accessor.get(IWorkspaceHandlerService).create({ sessionId: 's2', workDir: '/tmp/other' }); + + await first.accessor.get(IWorkspaceHandlerService).close('s1'); + await second.accessor.get(IWorkspaceHandlerService).close('s2'); + + expect(closed.toSorted()).toEqual(['s1', 's2']); + sub.dispose(); + }); + }); +}); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 4a4a25174b..1faa14ee23 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -24,8 +24,14 @@ import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; import { CronTaskPersistenceService } from '#/app/cron/cronTaskPersistenceService'; import { IAgentGoalService } from '#/agent/goal/goal'; import { AgentGoalService } from '#/agent/goal/goalService'; -import { ISessionMcpService } from '#/session/mcp/sessionMcp'; -import type { McpConnectionManager } from '#/agent/mcp/connection-manager'; +import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; +import { McpConnectionManager } from '#/mcpCore/connection-manager'; +import { loadAgentsMdForRoots, type LoadedAgentsMd } from '#/agent/profile/context'; +import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; +import { ISessionAgentProfileCatalogSeed } from '#/session/sessionAgentProfileCatalog/agentProfileCatalogSeed'; +import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; +import { ISessionSkillCatalogData } from '#/session/sessionSkillCatalog/skillCatalogData'; import type { PermissionData, PermissionMode } from '#/agent/permissionPolicy/types'; import type { PermissionRule } from '#/agent/permissionRules/permissionRules'; import { IAgentPlanService, type PlanData } from '#/agent/plan/plan'; @@ -142,6 +148,11 @@ import { type ScopeSeed, type ServiceIdentifier, } from '#/index'; +import { createHooks } from '#/hooks'; +import { + ISessionLifecycleHooks, + type SessionLifecycleHookSlots, +} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; import { IEventBus } from '#/app/event/eventBus'; import { IWireService } from '#/wire/wire'; import { WireService } from '#/wire/wireService'; @@ -680,13 +691,13 @@ export function cronServices(): TestAgentServiceOverride { export function mcpServices(options: { readonly manager?: McpConnectionManager; }): TestAgentServiceOverride { - // `AgentMcpService` now resolves the session's shared manager through - // `ISessionMcpService`; tests inject a fake manager by stubbing that service. - return sessionService(ISessionMcpService, { + // `AgentMcpService` resolves the workspace's shared manager through the + // seeded `ISessionMcpHandle`; tests inject a fake manager by stubbing it. + return sessionService(ISessionMcpHandle, { _serviceBrand: undefined, - ensureMcpReady: () => Promise.resolve(), - connectionManager: () => options.manager!, - } satisfies ISessionMcpService); + ready: Promise.resolve(), + connectionManager: options.manager!, + } satisfies ISessionMcpHandle); } export function skillServices( @@ -1144,7 +1155,7 @@ export class AgentTestContext { const agentTelemetry = this.root.accessor .get(ITelemetryService) .withContext({ agent_id: agentId }); - const sessionScope = bootstrap.sessionScope(workspaceId, sessionId); + const sessionScope = `${bootstrap.scope('sessions')}/${workspaceId}/${sessionId}`; this.session = this.root.createChild(LifecycleScope.Session, sessionId, { extra: collectScopeSeed( [ @@ -1153,15 +1164,51 @@ export class AgentTestContext { _serviceBrand: undefined, sessionId, workspaceId, - sessionDir: bootstrap.sessionDir(workspaceId, sessionId), + sessionDir: `${bootstrap.homeDir}/${sessionScope}`, metaScope: `${sessionScope}/session-meta`, cwd: this.cwd, scope: (subKey?: string): string => subKey === undefined || subKey === '' ? sessionScope : `${sessionScope}/${subKey}`, }); + reg.defineInstance( + ISessionLifecycleHooks, + createHooks([ + 'onDidCreateSession', + 'onWillCloseSession', + ]), + ); reg.defineInstance(ISessionInteractionService, this.createInteractionService()); reg.defineInstance(ISessionApprovalService, this.createApprovalService()); reg.defineInstance(ISessionQuestionService, this.createQuestionService()); + // Workspace-resource injection contracts (the seeds the real + // handler hands each session): the harness has no Workspace + // scope, so it seeds equivalents directly — an empty skill + // catalog, the workspace key the Session agent-profile catalog + // reads the App registry with, a live-read AGENTS.md + // provider, and a no-server MCP manager. Tests replace them + // through the usual service overrides. + reg.defineInstance(ISessionSkillCatalogData, { + _serviceBrand: undefined, + ready: Promise.resolve(), + catalog: new InMemorySkillCatalog(), + onDidChange: Event.None as Event, + } satisfies ISessionSkillCatalogData); + reg.defineInstance(ISessionAgentProfileCatalogSeed, { + _serviceBrand: undefined, + workspaceKey: workspaceId, + } satisfies ISessionAgentProfileCatalogSeed); + reg.defineInstance(ISessionInstructionsProvider, this.createInstructionsProvider()); + reg.defineInstance(ISessionMcpHandle, { + _serviceBrand: undefined, + ready: Promise.resolve(), + connectionManager: new McpConnectionManager(), + } satisfies ISessionMcpHandle); + reg.defineInstance(ISessionWorkspaceInfo, { + _serviceBrand: undefined, + ready: Promise.resolve(), + additionalDirs: [], + onDidChange: Event.None as Event, + } satisfies ISessionWorkspaceInfo); reg.defineInstance(IAgentLifecycleService, { _serviceBrand: undefined, onDidCreate: Event.None as Event, @@ -1234,7 +1281,7 @@ export class AgentTestContext { reg.defineDescriptor(IAgentGoalService, new SyncDescriptor(AgentGoalService)); reg.defineDescriptor(IAgentSkillService, new SyncDescriptor(AgentSkillService)); reg.defineDescriptor(IAgentUserToolService, new SyncDescriptor(AgentUserToolService)); - const agentScope = bootstrap.agentScope(workspaceId, sessionId, agentId); + const agentScope = `${sessionScope}/agents/${agentId}`; reg.defineInstance(IAgentScopeContext, { _serviceBrand: undefined, agentId, @@ -1249,14 +1296,6 @@ export class AgentTestContext { ), }); - this.get(IAgentProfileService).configure({ - cwd: () => this.cwd, - chdir: async (nextCwd: string) => { - this.cwd = nextCwd; - workspace.setWorkDir(nextCwd); - }, - }); - this.initializeRestorableServices(); // Resolve the activity view so its constructor subscriptions publish // `agent.activity.updated` — production ignites it in agentLifecycle. @@ -1372,7 +1411,6 @@ export class AgentTestContext { this.configureRuntimeModel(provider, modelCapabilities); const profile = this.get(IAgentProfileService); profile.update({ - cwd: process.cwd(), modelAlias: provider.model, systemPrompt: DEFAULT_TEST_SYSTEM_PROMPT, thinkingLevel: 'off', @@ -1716,12 +1754,11 @@ export class AgentTestContext { async expectResumeMatches(): Promise { await this.waitForSessionMetadata(); await this.drainWirePersistence(); - const profile = this.get(IAgentProfileService); const configSnapshot = structuredClone(this.get(IConfigService).getAll() as KimiConfig); let wireHistory = await this.wireHistory(); let resumedThroughRecord = wireHistory.length; const resumed = createTestAgent( - { autoConfigure: false, cwd: profile.data().cwd }, + { autoConfigure: false, cwd: this.cwd }, ...this.serviceOverrides, configServices(() => configSnapshot), llmGenerateServices(failOnResumeGenerate), @@ -1854,6 +1891,37 @@ export class AgentTestContext { this.snapshots.respondPending(method, id, result); } + /** + * The harness AGENTS.md provider: no Workspace scope and no fs watch exist + * here, so `ready` re-reads the instruction files on every await and the + * getters expose the freshest load — the same freshness the old per-prompt + * disk read gave prompt builds. + */ + private createInstructionsProvider(): ISessionInstructionsProvider { + const fs = this.root.accessor.get(IHostFileSystem); + const env = this.root.accessor.get(IHostEnvironment); + const bootstrapService = this.root.accessor.get(IBootstrapService); + const cwd = this.cwd; + let current: LoadedAgentsMd = { content: '', warning: undefined }; + return { + _serviceBrand: undefined, + get ready(): Promise { + return loadAgentsMdForRoots({ fs, homeDir: env.homeDir }, bootstrapService.homeDir, [ + cwd, + ]).then((result) => { + current = result; + }); + }, + get agentsMd() { + return current.content; + }, + get agentsMdWarning() { + return current.warning; + }, + onDidChange: Event.None as Event, + }; + } + private createInteractionService(): ISessionInteractionService { const pending = new Map(); function createTestInteraction( @@ -2106,8 +2174,8 @@ function createWorkspaceContextStub( initialWorkDir: string, initialAdditionalDirs: readonly string[], ): ISessionWorkspaceContext { - let workDir = resolve(initialWorkDir); - let additionalDirs = initialAdditionalDirs.map((dir) => resolve(dir)); + const workDir = resolve(initialWorkDir); + const additionalDirs = initialAdditionalDirs.map((dir) => resolve(dir)); const isWithin = (absPath: string): boolean => { const target = resolve(absPath); if (target === workDir) return true; @@ -2120,18 +2188,8 @@ function createWorkspaceContextStub( }; return { _serviceBrand: undefined, - get workDir() { - return workDir; - }, - get additionalDirs() { - return additionalDirs; - }, - setWorkDir: (next) => { - workDir = resolve(next); - }, - setAdditionalDirs: (dirs) => { - additionalDirs = dirs.map((dir) => resolve(dir)); - }, + workDir, + additionalDirs, resolve: (path) => (isAbsolute(path) ? resolve(path) : resolve(workDir, path)), isWithin, assertAllowed: (absPath: string, op: PathAccessOperation) => { @@ -2141,14 +2199,6 @@ function createWorkspaceContextStub( } return target; }, - addAdditionalDir: (dir) => { - const resolved = resolve(dir); - if (!additionalDirs.includes(resolved)) additionalDirs = [...additionalDirs, resolved]; - }, - removeAdditionalDir: (dir) => { - const resolved = resolve(dir); - additionalDirs = additionalDirs.filter((candidate) => candidate !== resolved); - }, }; } @@ -2324,7 +2374,7 @@ function configStateSnapshot(ctx: AgentTestContext): ResumeStateSnapshot['config const providerConfig = model === undefined ? undefined : ctx.get(IProviderService).get(model.providerName); return { - cwd: data.cwd, + cwd: ctx.get(ISessionContext).cwd, activeToolNames: data.activeToolNames, provider: providerConfig, profileName: data.profileName, diff --git a/packages/agent-core-v2/test/agent/mcp/client-http.test.ts b/packages/agent-core-v2/test/mcpCore/client-http.test.ts similarity index 99% rename from packages/agent-core-v2/test/agent/mcp/client-http.test.ts rename to packages/agent-core-v2/test/mcpCore/client-http.test.ts index f10dfebc6a..2d7d5775e1 100644 --- a/packages/agent-core-v2/test/agent/mcp/client-http.test.ts +++ b/packages/agent-core-v2/test/mcpCore/client-http.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { ErrorCodes, Error2 } from '#/errors'; -import { buildMcpHttpHeaders, HttpMcpClient, isTerminalTransportError } from '#/agent/mcp/client-http'; +import { buildMcpHttpHeaders, HttpMcpClient, isTerminalTransportError } from '#/mcpCore/client-http'; import { startInProcessHttpMcpServer } from './stubs'; diff --git a/packages/agent-core-v2/test/agent/mcp/client-sse.test.ts b/packages/agent-core-v2/test/mcpCore/client-sse.test.ts similarity index 96% rename from packages/agent-core-v2/test/agent/mcp/client-sse.test.ts rename to packages/agent-core-v2/test/mcpCore/client-sse.test.ts index 378619b6d2..1c98d1ee7b 100644 --- a/packages/agent-core-v2/test/agent/mcp/client-sse.test.ts +++ b/packages/agent-core-v2/test/mcpCore/client-sse.test.ts @@ -1,7 +1,7 @@ import { SseError } from '@modelcontextprotocol/sdk/client/sse.js'; import { afterEach, describe, expect, it } from 'vitest'; -import { SseMcpClient, isTerminalSseTransportError } from '#/agent/mcp/client-sse'; +import { SseMcpClient, isTerminalSseTransportError } from '#/mcpCore/client-sse'; import { startInProcessSseMcpServer } from './stubs'; diff --git a/packages/agent-core-v2/test/agent/mcp/client-stdio.test.ts b/packages/agent-core-v2/test/mcpCore/client-stdio.test.ts similarity index 99% rename from packages/agent-core-v2/test/agent/mcp/client-stdio.test.ts rename to packages/agent-core-v2/test/mcpCore/client-stdio.test.ts index 9594884313..7092f0428e 100644 --- a/packages/agent-core-v2/test/agent/mcp/client-stdio.test.ts +++ b/packages/agent-core-v2/test/mcpCore/client-stdio.test.ts @@ -5,7 +5,7 @@ import { join } from 'pathe'; import { describe, expect, it } from 'vitest'; import { Error2 } from '#/errors'; -import { mergeStdioEnv, StdioMcpClient } from '#/agent/mcp/client-stdio'; +import { mergeStdioEnv, StdioMcpClient } from '#/mcpCore/client-stdio'; import { crashAfterConnectFixture, diff --git a/packages/agent-core-v2/test/agent/mcp/connection-manager.test.ts b/packages/agent-core-v2/test/mcpCore/connection-manager.test.ts similarity index 85% rename from packages/agent-core-v2/test/agent/mcp/connection-manager.test.ts rename to packages/agent-core-v2/test/mcpCore/connection-manager.test.ts index fe561192f3..c6bd4f92aa 100644 --- a/packages/agent-core-v2/test/agent/mcp/connection-manager.test.ts +++ b/packages/agent-core-v2/test/mcpCore/connection-manager.test.ts @@ -1,10 +1,11 @@ /** - * Scenario: MCP connection lifecycle, timeout defaults, and session config readiness. + * Scenario: MCP connection lifecycle and timeout defaults. * - * Exercises the real connection manager and resolves the real session MCP - * service through DI. Stdio MCP processes are the external boundary; timeout - * forwarding tests stub only the MCP SDK client boundary. - * Run with `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * Exercises the real connection manager; stdio MCP processes are the + * external boundary, and timeout forwarding tests stub only the MCP SDK + * client boundary. The workspace-level initialization scenarios live in + * `test/workspace/workspaceMcp/`. Run with + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run * test/agent/mcp/connection-manager.test.ts`. */ @@ -28,23 +29,10 @@ import type { import { z } from 'zod'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DisposableStore } from '#/_base/di/lifecycle'; -import { createServices } from '#/_base/di/test'; -import { ILogService } from '#/_base/log/log'; import { Error2 } from '#/errors'; -import { McpConnectionManager, type McpServerEntry } from '#/agent/mcp/connection-manager'; -import { MCP_SECTION, type McpSection } from '#/agent/mcp/configSection'; -import { McpOAuthService } from '#/agent/mcp/oauth/service'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IConfigService } from '#/app/config/config'; -import { IPluginService } from '#/app/plugin/plugin'; -import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; -import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; -import { ISessionMcpService } from '#/session/mcp/sessionMcp'; -import { SessionMcpService } from '#/session/mcp/sessionMcpService'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; - -import { stubLog } from '../../_base/log/stubs'; +import { McpConnectionManager, type McpServerEntry } from '#/mcpCore/connection-manager'; +import { McpOAuthService } from '#/mcpCore/oauth/service'; + import { closeServer, crashAfterConnectFixture, @@ -830,96 +818,3 @@ describe('McpConnectionManager', () => { } }, 15000); }); - -describe('Session MCP initialization', () => { - let cwd: string; - let homeDir: string; - let disposables: DisposableStore; - let manager: McpConnectionManager | undefined; - - beforeEach(() => { - cwd = mkdtempSync(join(tmpdir(), 'kimi-session-mcp-cwd-')); - homeDir = mkdtempSync(join(tmpdir(), 'kimi-session-mcp-home-')); - disposables = new DisposableStore(); - manager = undefined; - }); - - afterEach(async () => { - await manager?.shutdown(); - disposables.dispose(); - await Promise.all([ - rm(cwd, { recursive: true, force: true }), - rm(homeDir, { recursive: true, force: true }), - ]); - }); - - function createSessionMcpService(ready: Promise, mcpSection?: McpSection) { - const ix = createServices(disposables, { - strict: true, - additionalServices: (reg) => { - reg.definePartialInstance(IBootstrapService, { homeDir }); - reg.definePartialInstance(ISessionWorkspaceContext, { workDir: cwd }); - reg.definePartialInstance(IPluginService, { - enabledMcpServers: async () => ({}), - }); - reg.definePartialInstance(IAtomicDocumentStore, {}); - reg.defineInstance(ILogService, stubLog()); - reg.defineInstance(ITelemetryService, noopTelemetryService); - reg.definePartialInstance(IConfigService, { - ready, - get: ((domain: string): T => - (domain === MCP_SECTION ? mcpSection : undefined) as T), - }); - reg.define(ISessionMcpService, SessionMcpService); - }, - }); - return ix.get(ISessionMcpService); - } - - it('exposes the connection manager before config is ready and starts connecting once ready', async () => { - let resolveConfigReady!: () => void; - const ready = new Promise((resolve) => { - resolveConfigReady = resolve; - }); - const service = createSessionMcpService(ready); - // The manager is available synchronously, independent of config readiness. - manager = service.connectionManager(); - expect(manager.list()).toEqual([]); - - const initialLoad = service.ensureMcpReady({ - alpha: { - transport: 'stdio', - command: process.execPath, - args: [stdioFixture], - }, - }); - try { - // The initial connect is gated on config.ready: no entry exists yet. - await sleep(50); - expect(manager.list()).toEqual([]); - - resolveConfigReady(); - await initialLoad; - expect(manager.get('alpha')?.status).toBe('connected'); - } finally { - resolveConfigReady(); - await initialLoad; - } - }, 15000); - - it('times out tool calls using the session MCP timeout preference', async () => { - const service = createSessionMcpService(Promise.resolve(), { toolTimeoutMs: 1 }); - await service.ensureMcpReady({ - slowTool: { - transport: 'stdio', - command: process.execPath, - args: [slowToolStdioFixture], - env: { KIMI_TEST_MCP_TOOL_DELAY_MS: '300' }, - }, - }); - manager = service.connectionManager(); - const client = manager.resolved('slowTool')?.client; - if (client === undefined) throw new Error('expected a connected client'); - await expect(client.callTool('slow_echo', { text: 'hi' })).rejects.toThrow(/timed out/i); - }, 15000); -}); diff --git a/packages/agent-core-v2/test/agent/mcp/fixtures/crash-after-connect-stdio-server.mjs b/packages/agent-core-v2/test/mcpCore/fixtures/crash-after-connect-stdio-server.mjs similarity index 100% rename from packages/agent-core-v2/test/agent/mcp/fixtures/crash-after-connect-stdio-server.mjs rename to packages/agent-core-v2/test/mcpCore/fixtures/crash-after-connect-stdio-server.mjs diff --git a/packages/agent-core-v2/test/agent/mcp/fixtures/cwd-stdio-server.mjs b/packages/agent-core-v2/test/mcpCore/fixtures/cwd-stdio-server.mjs similarity index 100% rename from packages/agent-core-v2/test/agent/mcp/fixtures/cwd-stdio-server.mjs rename to packages/agent-core-v2/test/mcpCore/fixtures/cwd-stdio-server.mjs diff --git a/packages/agent-core-v2/test/agent/mcp/fixtures/hanging-list-stdio-server.mjs b/packages/agent-core-v2/test/mcpCore/fixtures/hanging-list-stdio-server.mjs similarity index 100% rename from packages/agent-core-v2/test/agent/mcp/fixtures/hanging-list-stdio-server.mjs rename to packages/agent-core-v2/test/mcpCore/fixtures/hanging-list-stdio-server.mjs diff --git a/packages/agent-core-v2/test/agent/mcp/fixtures/mock-stdio-server.mjs b/packages/agent-core-v2/test/mcpCore/fixtures/mock-stdio-server.mjs similarity index 100% rename from packages/agent-core-v2/test/agent/mcp/fixtures/mock-stdio-server.mjs rename to packages/agent-core-v2/test/mcpCore/fixtures/mock-stdio-server.mjs diff --git a/packages/agent-core-v2/test/agent/mcp/fixtures/slow-stdio-server.mjs b/packages/agent-core-v2/test/mcpCore/fixtures/slow-stdio-server.mjs similarity index 100% rename from packages/agent-core-v2/test/agent/mcp/fixtures/slow-stdio-server.mjs rename to packages/agent-core-v2/test/mcpCore/fixtures/slow-stdio-server.mjs diff --git a/packages/agent-core-v2/test/agent/mcp/fixtures/slow-tool-stdio-server.mjs b/packages/agent-core-v2/test/mcpCore/fixtures/slow-tool-stdio-server.mjs similarity index 100% rename from packages/agent-core-v2/test/agent/mcp/fixtures/slow-tool-stdio-server.mjs rename to packages/agent-core-v2/test/mcpCore/fixtures/slow-tool-stdio-server.mjs diff --git a/packages/agent-core-v2/test/agent/mcp/fixtures/stderr-then-exit-stdio-server.mjs b/packages/agent-core-v2/test/mcpCore/fixtures/stderr-then-exit-stdio-server.mjs similarity index 100% rename from packages/agent-core-v2/test/agent/mcp/fixtures/stderr-then-exit-stdio-server.mjs rename to packages/agent-core-v2/test/mcpCore/fixtures/stderr-then-exit-stdio-server.mjs diff --git a/packages/agent-core-v2/test/agent/mcp/oauth/store.test.ts b/packages/agent-core-v2/test/mcpCore/oauth/store.test.ts similarity index 69% rename from packages/agent-core-v2/test/agent/mcp/oauth/store.test.ts rename to packages/agent-core-v2/test/mcpCore/oauth/store.test.ts index c7046a29d3..b0d78e8dbc 100644 --- a/packages/agent-core-v2/test/agent/mcp/oauth/store.test.ts +++ b/packages/agent-core-v2/test/mcpCore/oauth/store.test.ts @@ -1,14 +1,9 @@ import type { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; import { describe, expect, it } from 'vitest'; -import { McpOAuthClientProvider } from '#/agent/mcp/oauth/provider'; -import { McpOAuthService } from '#/agent/mcp/oauth/service'; -import { - createMcpOAuthStore, - mcpOAuthStoreKey, - sanitizeStoreKey, -} from '#/agent/mcp/oauth/store'; -import type { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { McpOAuthClientProvider } from '#/mcpCore/oauth/provider'; +import { McpOAuthService } from '#/mcpCore/oauth/service'; +import { mcpOAuthStoreKey, sanitizeStoreKey } from '#/mcpCore/oauth/store'; import { createMemoryMcpOAuthStore } from '../stubs'; @@ -31,47 +26,6 @@ describe('sanitizeStoreKey', () => { }); }); -describe('createMcpOAuthStore', () => { - it('round-trips JSON data through the credentials/mcp scope', async () => { - const calls: Array<{ op: string; scope: string; key: string; value?: unknown }> = []; - const docs: Pick = { - async get(scope: string, key: string): Promise { - calls.push({ op: 'get', scope, key }); - return { hello: 'world' } as T; - }, - async set(scope, key, value) { - calls.push({ op: 'set', scope, key, value }); - }, - async delete(scope, key) { - calls.push({ op: 'delete', scope, key }); - }, - }; - const store = createMcpOAuthStore(docs as unknown as IAtomicDocumentStore); - - await expect(store.read('foo.json')).resolves.toEqual({ hello: 'world' }); - await store.write('foo.json', { token: 'abc' }); - await store.remove('foo.json'); - - expect(calls).toEqual([ - { op: 'get', scope: 'credentials/mcp', key: 'foo.json' }, - { op: 'set', scope: 'credentials/mcp', key: 'foo.json', value: { token: 'abc' } }, - { op: 'delete', scope: 'credentials/mcp', key: 'foo.json' }, - ]); - }); - - it('returns undefined when the underlying document store read fails', async () => { - const store = createMcpOAuthStore({ - get: async () => { - throw new Error('corrupt json'); - }, - set: async () => {}, - delete: async () => {}, - } as unknown as IAtomicDocumentStore); - - await expect(store.read('bad.json')).resolves.toBeUndefined(); - }); -}); - describe('MCP OAuth credential identity', () => { it('isolates tokens for the same server name on different URLs', async () => { const store = createMemoryMcpOAuthStore(); diff --git a/packages/agent-core-v2/test/agent/mcp/stubs.ts b/packages/agent-core-v2/test/mcpCore/stubs.ts similarity index 98% rename from packages/agent-core-v2/test/agent/mcp/stubs.ts rename to packages/agent-core-v2/test/mcpCore/stubs.ts index 4dea9aa1d9..2e11f25c74 100644 --- a/packages/agent-core-v2/test/agent/mcp/stubs.ts +++ b/packages/agent-core-v2/test/mcpCore/stubs.ts @@ -8,8 +8,8 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ import type { Tool as KosongTool } from '#/kosong/contract/tool'; import { z } from 'zod'; -import type { McpOAuthStore } from '#/agent/mcp/oauth/store'; -import type { MCPClient, MCPToolDefinition } from '#/agent/mcp/types'; +import type { McpOAuthStore } from '#/mcpCore/oauth/store'; +import type { MCPClient, MCPToolDefinition } from '#/mcpCore/types'; import type { ExecutableTool, ExecutableToolContext, diff --git a/packages/agent-core-v2/test/agent/mcp/tool-naming.test.ts b/packages/agent-core-v2/test/mcpCore/tool-naming.test.ts similarity index 94% rename from packages/agent-core-v2/test/agent/mcp/tool-naming.test.ts rename to packages/agent-core-v2/test/mcpCore/tool-naming.test.ts index 815f22e381..76466e5076 100644 --- a/packages/agent-core-v2/test/agent/mcp/tool-naming.test.ts +++ b/packages/agent-core-v2/test/mcpCore/tool-naming.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { isMcpToolName, qualifyMcpToolName, sanitizeMcpNamePart } from '#/agent/mcp/tool-naming'; +import { isMcpToolName } from '#/tool/toolContract'; +import { qualifyMcpToolName, sanitizeMcpNamePart } from '#/mcpCore/tool-naming'; describe('sanitizeMcpNamePart', () => { it('passes alphanumeric, underscore, and dash through unchanged', () => { diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts index 868354b4aa..85f0e06b60 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts @@ -32,6 +32,8 @@ import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem, type HostFileStat } from '#/os/interface/hostFileSystem'; import { IHostProcessService, type IHostProcess } from '#/os/interface/hostProcess'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; +import { Event } from '#/_base/event'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { type GrepInput, @@ -311,6 +313,11 @@ describe('GrepTool', () => { reg.define(IGrepTool, ProductionGrepTool); reg.define(IAgentToolRegistryService, AgentToolRegistryService); reg.define(IAgentToolActivationService, AgentToolActivationService); + reg.defineInstance(ISessionToolPolicyGate, { + _serviceBrand: undefined, + disabledTools: [], + onDidChange: Event.None as Event, + } satisfies ISessionToolPolicyGate); reg.definePartialInstance(IAgentProfileService, { data: () => ({}) as unknown as ProfileData, }); diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index 8ba39f86d5..56e216d17b 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -14,11 +14,10 @@ import { Disposable, DisposableStore } from '#/_base/di/lifecycle'; import { type ISessionScopeHandle, LifecycleScope } from '#/_base/di/scope'; import { TestInstantiationService } from '#/_base/di/test'; import { Event } from '#/_base/event'; -import { type McpServerConfig } from '#/agent/mcp/config-schema'; import { IAgentProfileService } from '#/agent/profile/profile'; import '#/agent/profile/profileService'; import { IAgentMcpService } from '#/agent/mcp/mcp'; -import { McpConnectionManager } from '#/agent/mcp/connection-manager'; +import { McpConnectionManager } from '#/mcpCore/connection-manager'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import '#/agent/permissionMode/permissionModeOps'; import { IAgentStateService } from '#/agent/state/agentState'; @@ -26,8 +25,10 @@ import { AgentStateService } from '#/agent/state/agentStateService'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { AgentLifecycleService } from '#/session/agentLifecycle/agentLifecycleService'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; -import { ISessionMcpService } from '#/session/mcp/sessionMcp'; -import { SessionMcpService } from '#/session/mcp/sessionMcpService'; +import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; +import { McpOAuthService } from '#/mcpCore/oauth/service'; +import { createMcpOAuthStore } from '#/app/mcpConfig/oauthStore'; import { ISessionSubagentService } from '#/session/subagent/subagent'; import { SessionSubagentService } from '#/session/subagent/subagentService'; import '#/agent/mcp/mcpService'; @@ -35,7 +36,6 @@ import '#/wire/wireService'; import { IAgentTaskService } from '#/agent/task/task'; import { ISessionCronService } from '#/session/cron/sessionCronService'; import '#/agent/toolDedupe/toolDedupeService'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import '#/app/event/eventBusService'; @@ -58,6 +58,7 @@ import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { _clearAgentToolContributionsForTests } from '#/agent/toolRegistry/toolContribution'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import '#/agent/toolActivation/toolActivationService'; @@ -176,7 +177,11 @@ describe('AgentLifecycleService', () => { workspaceId: 'ws_test', sessionDir: '/tmp/kimi-agentLifecycle-test', metaScope: 'test', - }); + scope: (subKey?: string) => + subKey === undefined || subKey === '' + ? 'sessions/ws_test/sess_test' + : `sessions/ws_test/sess_test/${subKey}`, + } as unknown as ISessionContext); ix.stub(ISessionMetadata, { _serviceBrand: undefined, ready: Promise.resolve(), @@ -191,10 +196,6 @@ describe('AgentLifecycleService', () => { _serviceBrand: undefined, homeDir: '/tmp/kimi-agentLifecycle-home', cwd: '/tmp/kimi-agentLifecycle-home', - agentScope: (_ws: string, _session: string, agentId: string) => - `test/agents/${agentId}`, - agentHomedir: (workspaceId: string, sessionId: string, agentId: string) => - `/tmp/kimi-agentLifecycle-home/sessions/${workspaceId}/${sessionId}/agents/${agentId}`, } as unknown as IBootstrapService); ix.stub(ISessionWorkspaceContext, { _serviceBrand: undefined, @@ -207,7 +208,7 @@ describe('AgentLifecycleService', () => { get: (() => undefined) as IConfigService['get'], onDidSectionChange: (() => ({ dispose: () => {} })) as IConfigService['onDidSectionChange'], } as unknown as IConfigService); - ix.stub(IAtomicDocumentStore, { + const atomicDocsStore: IAtomicDocumentStore = { _serviceBrand: undefined, get: async (scope: string, key: string): Promise => atomicDocs.get(`${scope}/${key}`) as T | undefined, @@ -223,7 +224,8 @@ describe('AgentLifecycleService', () => { .map((key) => key.slice(scope.length + 1)), watch: () => Event.None as Event, acquire: () => ({ dispose: () => {} }), - } satisfies IAtomicDocumentStore); + }; + ix.stub(IAtomicDocumentStore, atomicDocsStore); ix.stub(ILogService, noopLog); ix.stub(IAgentPluginService, { _serviceBrand: undefined, @@ -328,6 +330,11 @@ describe('AgentLifecycleService', () => { disabledTools: () => [], setDisabledTools: () => Promise.resolve(), } as unknown as ISessionToolPolicy); + ix.stub(ISessionToolPolicyGate, { + _serviceBrand: undefined, + disabledTools: [], + onDidChange: Event.None as Event, + } satisfies ISessionToolPolicyGate); permissionModeSetMode = vi.fn(); ix.stub(IAgentPermissionModeService, { _serviceBrand: undefined, @@ -335,7 +342,25 @@ describe('AgentLifecycleService', () => { setMode: permissionModeSetMode, onDidChangeMode: Event.None, } as unknown as IAgentPermissionModeService); - ix.set(ISessionMcpService, new SyncDescriptor(SessionMcpService)); + ix.stub(ISessionInstructionsProvider, { + _serviceBrand: undefined, + ready: Promise.resolve(), + agentsMd: undefined, + agentsMdWarning: undefined, + onDidChange: Event.None as Event, + } satisfies ISessionInstructionsProvider); + // The session's MCP readiness arrives through the seeded + // `ISessionMcpHandle`; the default handle carries an OAuth-wired manager + // over the test atomic document store so the agent mirror's OAuth + // surface stays exercisable. + ix.stub(ISessionMcpHandle, { + _serviceBrand: undefined, + ready: Promise.resolve(), + connectionManager: new McpConnectionManager({ + log: noopLog, + oauthService: new McpOAuthService({ store: createMcpOAuthStore(atomicDocsStore) }), + }), + } satisfies ISessionMcpHandle); stopAllOnExit = vi.fn(async () => []); ix.stub(IAgentTaskService, { _serviceBrand: undefined, @@ -617,39 +642,16 @@ describe('AgentLifecycleService', () => { ]); }); - it('waits for MCP config resolution and initial connect before returning an agent', async () => { - let resolvePluginServersRequested!: () => void; - const pluginServersRequested = new Promise((resolve) => { - resolvePluginServersRequested = resolve; - }); - let resolvePluginServers: - | ((servers: Record) => void) - | undefined; - const pluginServers = new Promise>((resolve) => { - resolvePluginServers = resolve; - }); - ix.stub(IPluginService, { - ...pluginServiceStub, - enabledMcpServers: () => { - resolvePluginServersRequested(); - return pluginServers; - }, - } as unknown as IPluginService); - - let resolveConnectStarted!: () => void; - const connectStarted = new Promise((resolve) => { - resolveConnectStarted = resolve; + it('waits for the MCP handle readiness before returning an agent', async () => { + let releaseReady!: () => void; + const ready = new Promise((resolve) => { + releaseReady = resolve; }); - let resolveConnect: (() => void) | undefined; - const connected = new Promise((resolve) => { - resolveConnect = resolve; - }); - const connectAll = vi - .spyOn(McpConnectionManager.prototype, 'connectAll') - .mockImplementation(() => { - resolveConnectStarted(); - return connected; - }); + ix.stub(ISessionMcpHandle, { + _serviceBrand: undefined, + ready, + connectionManager: new McpConnectionManager({ log: noopLog }), + } satisfies ISessionMcpHandle); const svc = ix.get(IAgentLifecycleService); let settled = false; @@ -657,49 +659,18 @@ describe('AgentLifecycleService', () => { settled = true; }); - await pluginServersRequested; - expect(settled).toBe(false); - expect(connectAll).not.toHaveBeenCalled(); - - resolvePluginServers?.({ - delayed: { transport: 'stdio', command: process.execPath }, + // The wire seal + registerAgent complete first; the create call then + // parks on the seeded MCP readiness promise. + await vi.waitFor(() => { + expect(registerAgent).toHaveBeenCalled(); }); - await connectStarted; - expect(connectAll).toHaveBeenCalledTimes(1); expect(settled).toBe(false); - resolveConnect?.(); + releaseReady(); await create; expect(settled).toBe(true); }); - it('merges caller-supplied MCP servers into the initial connect (file < caller < plugin)', async () => { - ix.stub(IPluginService, { - ...pluginServiceStub, - enabledMcpServers: async () => ({ - shared: { transport: 'stdio', command: 'plugin-version' }, - }), - } as unknown as IPluginService); - const connectAll = vi - .spyOn(McpConnectionManager.prototype, 'connectAll') - .mockResolvedValue(undefined); - - const sessionMcp = ix.get(ISessionMcpService); - await sessionMcp.ensureMcpReady({ - shared: { transport: 'stdio', command: 'caller-version' }, - callerOnly: { transport: 'http', url: 'https://caller.example.com' }, - }); - - expect(connectAll).toHaveBeenCalledTimes(1); - expect(connectAll).toHaveBeenCalledWith({ - shared: { transport: 'stdio', command: 'plugin-version' }, - callerOnly: { transport: 'http', url: 'https://caller.example.com' }, - }); - - await sessionMcp.ensureMcpReady({ ignored: { transport: 'stdio', command: 'ignored' } }); - expect(connectAll).toHaveBeenCalledTimes(1); - }); - it('exposes the in-flight handle and joins it after bootstrap', async () => { let releaseRegister!: () => void; let registerStarted!: () => void; @@ -766,7 +737,6 @@ describe('AgentLifecycleService', () => { const svc = ix.get(IAgentLifecycleService); const source = await svc.create({ agentId: 'main' }); source.accessor.get(IAgentProfileService).applyBindingSnapshot({ - cwd: '/work', profileName: 'deleted-profile', thinkingLevel: 'high', systemPrompt: 'original prompt', @@ -778,7 +748,6 @@ describe('AgentLifecycleService', () => { const child = await svc.fork('main', { agentId: 'forked' }); expect(child.accessor.get(IAgentProfileService).data()).toMatchObject({ - cwd: '/work', profileName: 'deleted-profile', thinkingLevel: 'high', systemPrompt: 'original prompt', diff --git a/packages/agent-core-v2/test/session/sessionFs/gitContext.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/profile/gitContext.test.ts similarity index 99% rename from packages/agent-core-v2/test/session/sessionFs/gitContext.test.ts rename to packages/agent-core-v2/test/session/agentLifecycle/profile/gitContext.test.ts index 7e92fb462e..26a01dd517 100644 --- a/packages/agent-core-v2/test/session/sessionFs/gitContext.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/profile/gitContext.test.ts @@ -6,7 +6,7 @@ import { collectGitContext, parseProjectName, sanitizeRemoteUrl, -} from '#/session/sessionFs/gitContext'; +} from '#/session/agentLifecycle/profile/gitContext'; import type { ILogger } from '#/_base/log/log'; import type { IProcess, ISessionProcessRunner } from '#/session/process/processRunner'; diff --git a/packages/agent-core-v2/test/session/cron/cron-fire-steer.e2e.test.ts b/packages/agent-core-v2/test/session/cron/cron-fire-steer.e2e.test.ts new file mode 100644 index 0000000000..2be71a2da0 --- /dev/null +++ b/packages/agent-core-v2/test/session/cron/cron-fire-steer.e2e.test.ts @@ -0,0 +1,138 @@ +/** + * Cron fire steer-turn context e2e: when a scheduled cron task fires and + * steers a NEW turn on the idle main agent, the provider request must carry + * the full conversation context — in particular the earlier CronCreate tool + * result (which holds the `id: ` line the model was told about). + * + * Wiring: testAgent harness with a scripted provider. The harness builds the + * Session scope with a stub `IAgentLifecycleService` (no `create`), so this + * test overrides it with a registry stub that resolves `main` to the harness + * agent and fires `onDidCreate` the way production does — that is what binds + * `SessionCronServiceImpl` to the main agent (cron tools + wire restore + * hook). The cron clock is file-driven (`clock: file:...`) and ticking is + * manual (`manualTick: true`) so the fire is deterministic. + * + * Run: ../../node_modules/.bin/vitest run test/session/cron/cron-fire-steer.e2e.test.ts + */ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { Emitter, Event } from '#/_base/event'; +import type { ServiceIdentifier } from '#/_base/di/instantiation'; +import { LifecycleScope, type IAgentScopeHandle } from '#/_base/di/scope'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import type { CronConfig } from '#/app/cron/configSection'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionCronService } from '#/session/cron/sessionCronService'; + +import { createTestAgent, sessionService, type TestAgentContext } from '../../harness'; + +function textOf(message: ContextMessage): string { + return message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); +} + +describe('cron-fired steer turn context', () => { + let ctx: TestAgentContext; + let clockFile: string; + let onDidCreate: Emitter; + let mainHandle: IAgentScopeHandle | undefined; + + beforeEach(async () => { + const dir = mkdtempSync(join(tmpdir(), 'cron-steer-')); + clockFile = join(dir, 'clock.txt'); + writeFileSync(clockFile, String(Date.now())); + + onDidCreate = new Emitter(); + const lifecycleStub: IAgentLifecycleService = { + _serviceBrand: undefined, + onDidCreate: onDidCreate.event, + onDidDispose: Event.None as Event, + create: () => Promise.reject(new Error('not supported in this test')), + fork: () => Promise.reject(new Error('not supported in this test')), + get: (agentId) => (agentId === 'main' ? mainHandle : undefined), + list: () => (mainHandle === undefined ? [] : [mainHandle]), + broadcastPermissionMode: () => {}, + remove: () => Promise.resolve(), + }; + ctx = createTestAgent(sessionService(IAgentLifecycleService, lifecycleStub)); + + // Bind the cron service to the harness's main agent the way production + // does once AgentLifecycleService.create resolves the main handle. + const accessor = { + get: (id: ServiceIdentifier): T => ctx.get(id), + }; + mainHandle = { id: 'main', kind: LifecycleScope.Agent, accessor, dispose: () => {} }; + onDidCreate.fire(mainHandle); + + // Deterministic cron: file-driven wall clock, manual ticks, no jitter. + const cronConfig: CronConfig = { + debug: false, + noJitter: true, + noStale: false, + disabled: false, + manualTick: true, + clock: `file:${clockFile}`, + }; + // The harness KimiConfig index signature is readonly: replace the whole + // config object (the harness mutation idiom) instead of index-writing. + ctx.kimiConfig = { ...ctx.kimiConfig, cron: cronConfig }; + // Run the wire restore pipeline so the cron service's onDidRestore hook + // picks up the file clock and starts the (manual) scheduler. + await ctx.restorePersisted(); + + await ctx.rpc.setPermission({ mode: 'yolo' }); + }); + + afterEach(async () => { + onDidCreate.dispose(); + await ctx.dispose(); + }); + + it('carries earlier tool results into the cron-fired steer turn request', async () => { + ctx.mockNextResponse({ + type: 'function', + id: 'call_cron_1', + name: 'CronCreate', + arguments: JSON.stringify({ cron: '* * * * *', prompt: 'fire me', recurring: true }), + }); + ctx.mockNextResponse({ type: 'text', text: 'scheduled' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'remind me every minute' }] }); + await ctx.untilTurnEnd(); + + // Sanity: the CronCreate tool result landed in the conversation context. + const toolMessages = ctx.contextData().history.filter((m) => m.role === 'tool'); + expect(toolMessages).toHaveLength(1); + const jobId = textOf(toolMessages[0]!).match(/^id: (\S+)$/m)?.[1]; + expect(jobId).toBeDefined(); + + // Fire: push the wall clock past the next minute boundary and tick. + ctx.mockNextResponse({ type: 'text', text: 'cron turn done' }); + writeFileSync(clockFile, String(Date.now() + 120_000)); + await ctx.get(ISessionCronService).tick(); + await ctx.get(IAgentLoopService).settled(); + + // The steer turn ran exactly one more request. + expect(ctx.llmCalls.length).toBe(3); + const fireRequest = ctx.llmCalls.at(-1)!; + + // (1) The cron fire prompt is there as the latest user message. + const lastUser = fireRequest.history.filter((m) => m.role === 'user').at(-1); + const lastUserText = lastUser?.content + .map((part) => (part.type === 'text' ? part.text : '')) + .join('') ?? ''; + expect(lastUserText).toContain('fire me'); + + // (2) The earlier CronCreate tool result is still in the request — + // this is the regression assertion. + const requestToolTexts = fireRequest.history + .filter((m) => m.role === 'tool') + .flatMap((m) => m.content) + .map((part) => (part.type === 'text' ? part.text : '')); + expect(requestToolTexts.some((text) => text.includes(`id: ${jobId!}`))).toBe(true); + }); +}); diff --git a/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts b/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts index ccb7df17e8..e3afb19220 100644 --- a/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts @@ -1,718 +1,251 @@ /** - * Scenario: session agent-profile catalog — file-source discovery, priority - * merge, explicit fatal semantics, and config-driven reload. Exercises the - * real scoped catalog and source services against real temp directories. - * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * Scenario: the Session-scope agent-profile catalog projection over the + * App-scope `IAgentProfileRegistry`. + * + * Exercises `SessionAgentProfileCatalogService` directly (no DI scope host): + * a hand-driven `AgentProfileRegistryService` plus a stub log verify the + * projection rules — relevant-entry filtering by the seeded workspace key, + * priority-ordered name dedup, the builtin-override rule, change-event + * fan-out, and the read surface (`get` / `list` / `getDefault` / `inspect`). + * Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run * test/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.test.ts`. */ -import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { describe, expect, it } from 'vitest'; -import { join } from 'pathe'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { createScopedTestHost, stubPair } from '#/_base/di/test'; -import { - LifecycleScope, - ScopeActivation, - _clearScopedRegistryForTests, - registerScopedService, -} from '#/_base/di/scope'; -import { Emitter, type Event } from '#/_base/event'; -import { ILogService } from '#/_base/log/log'; import { DEFAULT_AGENT_PROFILE_NAME, - IAgentProfileCatalogService, + type AgentProfile, } from '#/app/agentProfileCatalog/agentProfileCatalog'; -import { AgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalogService'; -import { IAgentCatalogRuntimeOptions } from '#/app/agentFileCatalog/agentCatalogRuntimeOptions'; -import { EXTRA_AGENT_DIRS_SECTION } from '#/app/agentFileCatalog/configSection'; -import { - IUserFileAgentSource, - UserFileAgentSource, -} from '#/app/agentFileCatalog/userFileAgentSource'; -import type { AgentFileRoot } from '#/app/agentFileCatalog/types'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IConfigService } from '#/app/config/config'; -import { IPluginService } from '#/app/plugin/plugin'; -import type { ReloadSummary } from '#/app/plugin/types'; -import '#/index'; -import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; -import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { - ExplicitFileAgentSource, - IExplicitFileAgentSource, -} from '#/session/sessionAgentProfileCatalog/explicitFileAgentSource'; -import { - ExtraFileAgentSource, - IExtraFileAgentSource, -} from '#/session/sessionAgentProfileCatalog/extraFileAgentSource'; -import { - IPluginAgentProfileSource, - PluginAgentProfileSource, -} from '#/session/sessionAgentProfileCatalog/pluginAgentProfileSource'; -import { - IProjectFileAgentSource, - ProjectFileAgentSource, -} from '#/session/sessionAgentProfileCatalog/projectFileAgentSource'; -import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; +import { BUILTIN_AGENT_PROFILE_SOURCE_ID } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; +import { AgentProfileRegistryService } from '#/app/agentProfileCatalog/agentProfileRegistryService'; import { SessionAgentProfileCatalogService } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService'; -import { ISessionStateService } from '#/session/state/sessionState'; -import { SessionStateService } from '#/session/state/sessionStateService'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; - -import { stubBootstrap } from '../../app/bootstrap/stubs'; +import { AGENT_PROFILE_SOURCE_PRIORITY } from '#/app/agentProfileCatalog/agentProfileContribution'; -function configStub(): IConfigService & { - setExtraAgentDirs(dirs: readonly string[]): void; - fireSectionChange(domain: string): void; -} { - let extraAgentDirs: readonly string[] = []; - const sectionChangeListeners: Array<(event: unknown) => void> = []; - return { - _serviceBrand: undefined, - ready: Promise.resolve(), - onDidChangeConfiguration: () => ({ dispose: () => {} }), - onDidSectionChange: (listener: (event: unknown) => void) => { - sectionChangeListeners.push(listener); - return { dispose: () => {} }; - }, - get: (domain: string) => - domain === EXTRA_AGENT_DIRS_SECTION ? [...extraAgentDirs] : undefined, - inspect: () => ({ - value: undefined, - defaultValue: undefined, - userValue: undefined, - memoryValue: undefined, - }), - getAll: () => ({}), - set: async () => {}, - replace: async () => {}, - reload: async () => {}, - diagnostics: () => [], - setExtraAgentDirs: (dirs: readonly string[]) => { - extraAgentDirs = [...dirs]; - }, - fireSectionChange: (domain: string) => { - for (const listener of sectionChangeListeners) { - listener({ domain, source: 'set', value: undefined, previousValue: undefined }); - } - }, - } as unknown as IConfigService & { - setExtraAgentDirs(dirs: readonly string[]): void; - fireSectionChange(domain: string): void; - }; -} +import { stubLog } from '../../_base/log/stubs'; -function workspaceStub(workDir: string): ISessionWorkspaceContext { - return { - _serviceBrand: undefined, - workDir, - additionalDirs: [], - setWorkDir: () => {}, - setAdditionalDirs: () => {}, - resolve: (rel: string) => rel, - isWithin: () => true, - assertAllowed: (p: string) => p, - addAdditionalDir: () => {}, - removeAdditionalDir: () => {}, - }; -} +const WORKSPACE_KEY = 'wd_a'; -function pluginStub( - agentRoots: readonly AgentFileRoot[] = [], - reloadEmitter?: Emitter, -): IPluginService { +function profile(name: string, options?: { readonly override?: boolean }): AgentProfile { return { - _serviceBrand: undefined, - onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }), - listPlugins: async () => [], - installPlugin: async () => ({ id: '' }) as never, - setPluginEnabled: async () => {}, - setPluginMcpServerEnabled: async () => {}, - removePlugin: async () => {}, - reloadPlugins: async () => ({ added: [], removed: [], errors: [] }), - getPluginInfo: async () => { - throw new Error('getPluginInfo is not used by these tests'); - }, - listPluginCommands: async () => [], - checkUpdates: async () => [], - pluginSkillRoots: async () => [], - pluginAgentRoots: async () => agentRoots, - enabledSessionStarts: async () => [], - enabledSystemPrompts: async () => [], - enabledMcpServers: async () => ({}), - enabledHooks: async () => [], + name, + override: options?.override, + systemPrompt: () => `prompt:${name}`, }; } -function agentMd(name: string, description: string, override = false): string { - const overrideLine = override ? 'override: true\n' : ''; - return `---\nname: ${name}\ndescription: ${description}\n${overrideLine}---\n\nYou are ${name}.\n`; +function makeCatalog(workspaceKey: string = WORKSPACE_KEY) { + const registry = new AgentProfileRegistryService(); + const catalog = new SessionAgentProfileCatalogService( + registry, + { _serviceBrand: undefined, workspaceKey }, + stubLog(), + ); + return { registry, catalog }; } -interface Fixture { - readonly homeDir: string; - readonly osHomeDir: string; - readonly workDir: string; - readonly extraDir: string; -} +describe('SessionAgentProfileCatalogService (registry projection)', () => { + it('projects global entries and own-workspace entries, filtering other workspace keys', () => { + const { registry, catalog } = makeCatalog(); + const globalProfile = profile('global-p'); + const ownProfile = profile('own-ws-p'); + registry.register('user', { profiles: [globalProfile] }); + registry.register('workspace', { profiles: [ownProfile] }, { workspaceKey: 'wd_a' }); + registry.register('workspace', { profiles: [profile('other-ws-p')] }, { workspaceKey: 'wd_b' }); + + expect(catalog.get('global-p')).toBe(globalProfile); + expect(catalog.get('own-ws-p')).toBe(ownProfile); + expect(catalog.get('other-ws-p')).toBeUndefined(); + catalog.dispose(); + registry.dispose(); + }); + + it('excludes same-name profiles of other workspace keys from the merge entirely', () => { + const { registry, catalog } = makeCatalog(); + const userProfile = profile('shared'); + const ownProfile = profile('shared'); + registry.register('user', { profiles: [userProfile] }, { + priority: AGENT_PROFILE_SOURCE_PRIORITY.user, + }); + // A higher-priority entry from ANOTHER workspace must not even become a + // candidate: it neither wins the name nor shows up as suppressed. + registry.register('workspace', { profiles: [profile('shared')] }, { + priority: AGENT_PROFILE_SOURCE_PRIORITY.explicit, + workspaceKey: 'wd_b', + }); + registry.register('workspace', { profiles: [ownProfile] }, { + priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, + workspaceKey: 'wd_a', + }); + + expect(catalog.get('shared')).toBe(ownProfile); + expect(catalog.inspect('shared')).toEqual({ + name: 'shared', + profile: ownProfile, + sourceId: 'workspace', + priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, + suppressed: [ + { sourceId: 'user', priority: AGENT_PROFILE_SOURCE_PRIORITY.user, reason: 'priority' }, + ], + }); + catalog.dispose(); + registry.dispose(); + }); + + it('lets the higher-priority source win a name collision and reports the suppressed candidate', () => { + const { registry, catalog } = makeCatalog(); + const lowProfile = profile('x'); + const highProfile = profile('x'); + registry.register('user', { profiles: [lowProfile] }, { + priority: AGENT_PROFILE_SOURCE_PRIORITY.user, + }); + registry.register('workspace', { profiles: [highProfile] }, { + priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, + workspaceKey: 'wd_a', + }); + + expect(catalog.get('x')).toBe(highProfile); + expect(catalog.inspect('x')).toEqual({ + name: 'x', + profile: highProfile, + sourceId: 'workspace', + priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, + suppressed: [ + { sourceId: 'user', priority: AGENT_PROFILE_SOURCE_PRIORITY.user, reason: 'priority' }, + ], + }); + catalog.dispose(); + registry.dispose(); + }); + + it('keeps the builtin profile when a same-name file profile lacks override: true', () => { + const { registry, catalog } = makeCatalog(); + const builtinProfile = profile(DEFAULT_AGENT_PROFILE_NAME); + const fileProfile = profile(DEFAULT_AGENT_PROFILE_NAME); + registry.register(BUILTIN_AGENT_PROFILE_SOURCE_ID, { profiles: [builtinProfile] }, { + priority: AGENT_PROFILE_SOURCE_PRIORITY.builtin, + }); + registry.register('workspace', { profiles: [fileProfile] }, { + priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, + workspaceKey: 'wd_a', + }); + + expect(catalog.get(DEFAULT_AGENT_PROFILE_NAME)).toBe(builtinProfile); + expect(catalog.inspect(DEFAULT_AGENT_PROFILE_NAME)).toEqual({ + name: DEFAULT_AGENT_PROFILE_NAME, + profile: builtinProfile, + sourceId: BUILTIN_AGENT_PROFILE_SOURCE_ID, + priority: AGENT_PROFILE_SOURCE_PRIORITY.builtin, + suppressed: [ + { + sourceId: 'workspace', + priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, + reason: 'builtin-override-required', + }, + ], + }); + catalog.dispose(); + registry.dispose(); + }); + + it('lets a file profile with override: true replace the same-name builtin', () => { + const { registry, catalog } = makeCatalog(); + const builtinProfile = profile(DEFAULT_AGENT_PROFILE_NAME); + const overrideProfile = profile(DEFAULT_AGENT_PROFILE_NAME, { override: true }); + registry.register(BUILTIN_AGENT_PROFILE_SOURCE_ID, { profiles: [builtinProfile] }); + registry.register('workspace', { profiles: [overrideProfile] }, { + priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, + workspaceKey: 'wd_a', + }); + + expect(catalog.get(DEFAULT_AGENT_PROFILE_NAME)).toBe(overrideProfile); + expect(catalog.inspect(DEFAULT_AGENT_PROFILE_NAME)).toEqual({ + name: DEFAULT_AGENT_PROFILE_NAME, + profile: overrideProfile, + sourceId: 'workspace', + priority: AGENT_PROFILE_SOURCE_PRIORITY.workspace, + suppressed: [], + }); + catalog.dispose(); + registry.dispose(); + }); + + it('re-projects and fires the source id on relevant registry changes, ignoring other keys', () => { + const { registry, catalog } = makeCatalog(); + const seen: string[] = []; + const subscription = catalog.onDidChange((sourceId) => seen.push(sourceId)); + + const ownProfile = profile('own-ws-p'); + registry.register('workspace', { profiles: [ownProfile] }, { workspaceKey: 'wd_a' }); + expect(catalog.get('own-ws-p')).toBe(ownProfile); + + const globalProfile = profile('global-p'); + registry.register('user', { profiles: [globalProfile] }); + expect(catalog.get('global-p')).toBe(globalProfile); + + // Changes tagged with another workspace key are ignored entirely: no + // re-projection, no event. + registry.register('workspace', { profiles: [profile('other-ws-p')] }, { workspaceKey: 'wd_b' }); + registry.unregister('workspace', 'wd_b'); + expect(catalog.get('other-ws-p')).toBeUndefined(); + + registry.unregister('user'); + expect(catalog.get('global-p')).toBeUndefined(); + + expect(seen).toEqual(['workspace', 'user', 'user']); + subscription.dispose(); + catalog.dispose(); + registry.dispose(); + }); + + it("fires 'catalog' on reload", async () => { + const { registry, catalog } = makeCatalog(); + const seen: string[] = []; + const subscription = catalog.onDidChange((sourceId) => seen.push(sourceId)); + + await catalog.reload(); + + expect(seen).toEqual(['catalog']); + subscription.dispose(); + catalog.dispose(); + registry.dispose(); + }); + + it('resolves ready immediately (loader readiness is the workspace handler’s job)', async () => { + const { registry, catalog } = makeCatalog(); -async function withFixture(run: (fixture: Fixture) => Promise): Promise { - const root = await mkdtemp(join(tmpdir(), 'agent-profile-catalog-')); - try { - const make = async (dir: string): Promise => { - const p = join(root, dir); - await mkdir(p, { recursive: true }); - return realpath(p); - }; - const [homeDir, osHomeDir, workDir, extraDir] = await Promise.all([ - make('kimi-home'), - make('os-home'), - make('work'), - make('extra-agents'), - ]); - await run({ homeDir, osHomeDir, workDir, extraDir }); - } finally { - await rm(root, { recursive: true, force: true }); - } -} - -async function writeAgent(dir: string, fileName: string, content: string): Promise { - await mkdir(dir, { recursive: true }); - const filePath = join(dir, fileName); - await writeFile(filePath, content); - return filePath; -} - -function logStub(warnings?: string[]): ILogService { - return { - _serviceBrand: undefined, - warn: (message: unknown) => { - warnings?.push(String(message)); - }, - info: () => {}, - error: () => {}, - debug: () => {}, - trace: () => {}, - setLevel: () => {}, - } as unknown as ILogService; -} - -function makeSession( - fixture: Fixture, - opts?: { - readonly extraAgentDirs?: readonly string[]; - readonly explicitFiles?: readonly string[]; - readonly logWarnings?: string[]; - readonly userSource?: IUserFileAgentSource; - readonly explicitSource?: IExplicitFileAgentSource; - readonly pluginAgentRoots?: readonly AgentFileRoot[]; - readonly pluginReloadEmitter?: Emitter; - }, -) { - const config = configStub(); - if (opts?.extraAgentDirs !== undefined) config.setExtraAgentDirs(opts.extraAgentDirs); - const runtimeOptions = { - _serviceBrand: undefined, - explicitFiles: opts?.explicitFiles, - } as unknown as IAgentCatalogRuntimeOptions; - const host = createScopedTestHost([ - stubPair(IBootstrapService, { - ...stubBootstrap(fixture.homeDir), - osHomeDir: fixture.osHomeDir, - }), - stubPair(IConfigService, config), - stubPair(IAgentCatalogRuntimeOptions, runtimeOptions), - stubPair(ILogService, logStub()), - stubPair( - IPluginService, - pluginStub(opts?.pluginAgentRoots ?? [], opts?.pluginReloadEmitter), - ), - ...(opts?.userSource ? [stubPair(IUserFileAgentSource, opts.userSource)] : []), - ]); - const session = host.child(LifecycleScope.Session, 's1', [ - stubPair(ISessionWorkspaceContext, workspaceStub(fixture.workDir)), - stubPair(ILogService, logStub(opts?.logWarnings)), - ...(opts?.explicitSource ? [stubPair(IExplicitFileAgentSource, opts.explicitSource)] : []), - ]); - return { host, session, config }; -} - -function waitForEvent(event: Event): Promise { - return new Promise((resolve) => { - const disposable = event(() => { - disposable.dispose(); - resolve(); - }); - }); -} - -describe('SessionAgentProfileCatalogService', () => { - beforeEach(() => { - // `import '#/index'` fills the registry with the whole product graph, - // including OnScopeCreated services with unstubbed dependencies, so clear - // it and re-register only the real services this suite constructs; their - // other dependencies are seeded as stubs by `makeSession`. Builtin agent - // profile contributions accumulate in a separate module-level list at - // import time and are unaffected by the clear. - _clearScopedRegistryForTests(); - registerScopedService( - LifecycleScope.Session, - ISessionStateService, - SessionStateService, - ScopeActivation.OnScopeCreated, - 'state', - ); - registerScopedService( - LifecycleScope.App, - IAgentProfileCatalogService, - AgentProfileCatalogService, - ); - registerScopedService(LifecycleScope.App, IUserFileAgentSource, UserFileAgentSource); - registerScopedService(LifecycleScope.App, IHostFileSystem, HostFileSystem); - registerScopedService( - LifecycleScope.Session, - ISessionAgentProfileCatalog, - SessionAgentProfileCatalogService, - ); - registerScopedService( - LifecycleScope.Session, - IExplicitFileAgentSource, - ExplicitFileAgentSource, + await expect(catalog.ready).resolves.toBeUndefined(); + await expect(catalog.load()).resolves.toBeUndefined(); + catalog.dispose(); + registry.dispose(); + }); + + it('serves the read surface and throws from getDefault without the default profile', () => { + const { registry, catalog } = makeCatalog(); + expect(catalog.get('missing')).toBeUndefined(); + expect(catalog.inspect('missing')).toBeUndefined(); + expect(catalog.list()).toEqual([]); + expect(() => catalog.getDefault()).toThrow( + `Default agent profile "${DEFAULT_AGENT_PROFILE_NAME}" is not registered`, ); - registerScopedService(LifecycleScope.Session, IExtraFileAgentSource, ExtraFileAgentSource); - registerScopedService( - LifecycleScope.Session, - IProjectFileAgentSource, - ProjectFileAgentSource, - ); - registerScopedService( - LifecycleScope.Session, - IPluginAgentProfileSource, - PluginAgentProfileSource, - ); - }); - - it('lists builtin profiles when no agent directories exist', async () => { - await withFixture(async (fixture) => { - const { host, session } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.get(DEFAULT_AGENT_PROFILE_NAME)).toBeDefined(); - expect(catalog.getDefault().name).toBe(DEFAULT_AGENT_PROFILE_NAME); - expect(catalog.list().length).toBeGreaterThan(0); - host.dispose(); - }); - }); - - it('merges user and project agents; project wins on name collision', async () => { - await withFixture(async (fixture) => { - await writeAgent(join(fixture.homeDir, 'agents'), 'shared.md', agentMd('shared', 'from user')); - await writeAgent(join(fixture.homeDir, 'agents'), 'user-only.md', agentMd('user-only', 'user agent')); - await writeAgent( - join(fixture.workDir, '.kimi-code', 'agents'), - 'shared.md', - agentMd('shared', 'from project'), - ); - const { host, session } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.get('shared')?.description).toBe('from project'); - expect(catalog.get('user-only')?.description).toBe('user agent'); - host.dispose(); - }); - }); - - it('orders sources user < extra < project < explicit', async () => { - await withFixture(async (fixture) => { - await writeAgent(join(fixture.homeDir, 'agents'), 'shared.md', agentMd('shared', 'from user')); - await writeAgent(join(fixture.homeDir, 'agents'), 'user-extra.md', agentMd('user-extra', 'from user')); - await writeAgent(fixture.extraDir, 'shared.md', agentMd('shared', 'from extra')); - await writeAgent(fixture.extraDir, 'user-extra.md', agentMd('user-extra', 'from extra')); - await writeAgent( - join(fixture.workDir, '.kimi-code', 'agents'), - 'shared.md', - agentMd('shared', 'from project'), - ); - const explicitFile = await writeAgent( - fixture.workDir, - 'explicit.md', - agentMd('shared', 'from explicit'), - ); - const { host, session } = makeSession(fixture, { - extraAgentDirs: [fixture.extraDir], - explicitFiles: [explicitFile], - }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.get('shared')?.description).toBe('from explicit'); - expect(catalog.get('user-extra')?.description).toBe('from extra'); - host.dispose(); - }); - }); - - it('merges plugin agents below user; user wins on name collision', async () => { - await withFixture(async (fixture) => { - const pluginAgentsDir = join(fixture.extraDir, 'plugin-agents'); - await writeAgent(pluginAgentsDir, 'shared.md', agentMd('shared', 'from plugin')); - await writeAgent(pluginAgentsDir, 'plugin-only.md', agentMd('plugin-only', 'from plugin')); - await writeAgent(join(fixture.homeDir, 'agents'), 'shared.md', agentMd('shared', 'from user')); - const { host, session } = makeSession(fixture, { - pluginAgentRoots: [{ path: pluginAgentsDir, source: 'plugin' }], - }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.get('shared')?.description).toBe('from user'); - expect(catalog.get('plugin-only')?.description).toBe('from plugin'); - host.dispose(); - }); - }); - - it('reloads the plugin source when plugins reload', async () => { - await withFixture(async (fixture) => { - const pluginAgentsDir = join(fixture.extraDir, 'plugin-agents'); - await mkdir(pluginAgentsDir, { recursive: true }); - const reloadEmitter = new Emitter(); - const { host, session } = makeSession(fixture, { - pluginAgentRoots: [{ path: pluginAgentsDir, source: 'plugin' }], - pluginReloadEmitter: reloadEmitter, - }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - expect(catalog.get('late')).toBeUndefined(); - - await writeAgent(pluginAgentsDir, 'late.md', agentMd('late', 'late plugin agent')); - const changed = waitForEvent(catalog.onDidChange); - reloadEmitter.fire({ added: [], removed: [], errors: [] }); - await changed; - - expect(catalog.get('late')?.description).toBe('late plugin agent'); - host.dispose(); - }); - }); - - it('fails ready when an explicit agent file is invalid', async () => { - await withFixture(async (fixture) => { - const bad = await writeAgent( - fixture.workDir, - 'bad.md', - '---\nname: bad\n---\n\nbody\n', - ); - const { host, session } = makeSession(fixture, { explicitFiles: [bad] }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - - await expect(catalog.load()).rejects.toThrow(/description/i); - host.dispose(); - }); - }); - - it('fails ready when an explicit agent file does not exist', async () => { - await withFixture(async (fixture) => { - const { host, session } = makeSession(fixture, { - explicitFiles: [join(fixture.workDir, 'missing.md')], - }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - - await expect(catalog.load()).rejects.toMatchObject({ code: 'os.fs.not_found' }); - host.dispose(); - }); - }); - - it('recovers ready after a reload fixes a previously fatal explicit file', async () => { - await withFixture(async (fixture) => { - const bad = await writeAgent( - fixture.workDir, - 'bad.md', - '---\nname: bad\n---\n\nbody\n', - ); - const { host, session } = makeSession(fixture, { explicitFiles: [bad] }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await expect(catalog.load()).rejects.toThrow(/description/i); - - await writeFile(bad, agentMd('fixed', 'fixed agent')); - await catalog.reload(); - - await expect(catalog.load()).resolves.toBeUndefined(); - expect(catalog.get('fixed')?.description).toBe('fixed agent'); - host.dispose(); - }); - }); - - it('resolves relative explicit files against the session workDir', async () => { - await withFixture(async (fixture) => { - await writeAgent( - join(fixture.workDir, 'agents'), - 'solo.md', - agentMd('solo', 'relative explicit'), - ); - const { host, session } = makeSession(fixture, { explicitFiles: ['agents/solo.md'] }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.get('solo')?.description).toBe('relative explicit'); - host.dispose(); - }); - }); - - it('reloads the extra source when extraAgentDirs changes', async () => { - await withFixture(async (fixture) => { - await writeAgent(fixture.extraDir, 'from-extra.md', agentMd('from-extra', 'extra agent')); - const { host, session, config } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - expect(catalog.get('from-extra')).toBeUndefined(); - - config.setExtraAgentDirs([fixture.extraDir]); - const changed = waitForEvent(catalog.onDidChange); - config.fireSectionChange(EXTRA_AGENT_DIRS_SECTION); - await changed; - - expect(catalog.get('from-extra')?.description).toBe('extra agent'); - host.dispose(); - }); - }); - it('skips invalid project files and still loads valid ones', async () => { - await withFixture(async (fixture) => { - await writeAgent( - join(fixture.workDir, '.kimi-code', 'agents'), - 'bad.md', - '---\nname: bad\n---\n\nbody\n', - ); - await writeAgent(join(fixture.workDir, '.kimi-code', 'agents'), 'good.md', agentMd('good', 'valid')); - const { host, session } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.get('good')?.description).toBe('valid'); - host.dispose(); - }); - }); - - it('keeps the builtin default when a same-name file does not opt in to override', async () => { - await withFixture(async (fixture) => { - await writeAgent( - join(fixture.workDir, '.kimi-code', 'agents'), - 'agent.md', - agentMd('agent', 'project default override'), - ); - const { host, session } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.getDefault().description).not.toBe('project default override'); - host.dispose(); - }); - }); - - it('lets a file profile explicitly override the builtin default', async () => { - await withFixture(async (fixture) => { - await writeAgent( - join(fixture.workDir, '.kimi-code', 'agents'), - 'agent.md', - agentMd('agent', 'project default override', true), - ); - const { host, session } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.getDefault().description).toBe('project default override'); - host.dispose(); - }); - }); - - it('falls back to a valid lower-priority builtin override', async () => { - await withFixture(async (fixture) => { - await writeAgent( - join(fixture.homeDir, 'agents'), - 'agent.md', - agentMd('agent', 'user default override', true), - ); - await writeAgent( - join(fixture.workDir, '.kimi-code', 'agents'), - 'agent.md', - agentMd('agent', 'project default without override'), - ); - const { host, session } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.getDefault().description).toBe('user default override'); - expect(catalog.getDefault().description).not.toBe('project default without override'); - host.dispose(); - }); - }); - - it('keeps builtin profiles and warns when a non-fatal source fails to load', async () => { - await withFixture(async (fixture) => { - const logWarnings: string[] = []; - const failingUserSource = { - _serviceBrand: undefined, - id: 'user', - priority: 10, - load: () => Promise.reject(new Error('disk gone')), - } as unknown as IUserFileAgentSource; - const { host, session } = makeSession(fixture, { - logWarnings, - userSource: failingUserSource, - }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - - await catalog.load(); - - expect(catalog.get(DEFAULT_AGENT_PROFILE_NAME)).toBeDefined(); - expect(logWarnings.some((w) => w.includes('"user"'))).toBe(true); - host.dispose(); - }); - }); - - it('keeps the previous contribution when a source reload fails', async () => { - await withFixture(async (fixture) => { - const logWarnings: string[] = []; - const emitter = new Emitter(); - let fail = false; - const fileProfile = { - name: 'file-agent', - description: 'from file', - systemPrompt: () => 'x', - }; - const userSource = { - _serviceBrand: undefined, - id: 'user', - priority: 10, - onDidChange: emitter.event, - load: () => - fail - ? Promise.reject(new Error('disk gone')) - : Promise.resolve({ profiles: [fileProfile] }), - } as unknown as IUserFileAgentSource; - const { host, session } = makeSession(fixture, { logWarnings, userSource }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - expect(catalog.get('file-agent')?.description).toBe('from file'); - - fail = true; - emitter.fire(); - await vi.waitFor(() => { - expect(logWarnings.some((w) => w.includes('load failed'))).toBe(true); - }); - - expect(catalog.get('file-agent')?.description).toBe('from file'); - host.dispose(); - }); - }); - - it('warns and keeps stale data when a fatal source reload fails', async () => { - await withFixture(async (fixture) => { - const logWarnings: string[] = []; - const emitter = new Emitter(); - let fail = false; - const explicitProfile = { - name: 'exp-agent', - description: 'explicit', - systemPrompt: () => 'x', - }; - const explicitSource = { - _serviceBrand: undefined, - id: 'explicit', - priority: 40, - fatal: true, - onDidChange: emitter.event, - load: () => - fail - ? Promise.reject(new Error('file deleted mid-session')) - : Promise.resolve({ profiles: [explicitProfile] }), - } as unknown as IExplicitFileAgentSource; - const { host, session } = makeSession(fixture, { logWarnings, explicitSource }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - expect(catalog.get('exp-agent')?.description).toBe('explicit'); - - fail = true; - emitter.fire(); - await vi.waitFor(() => { - expect(logWarnings.some((w) => w.includes('reload failed'))).toBe(true); - }); - - expect(catalog.get('exp-agent')?.description).toBe('explicit'); - host.dispose(); - }); - }); - - it('replaces the builtin default system prompt with user-level SYSTEM.md', async () => { - await withFixture(async (fixture) => { - await writeFile( - join(fixture.homeDir, 'SYSTEM.md'), - 'You are a custom main agent. cwd=${cwd} unknown=${nope}', - ); - const { host, session } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - const prompt = catalog.getDefault().systemPrompt({ cwd: '/work/dir' }); - expect(prompt).toContain('You are a custom main agent.'); - expect(prompt).toContain('cwd=/work/dir'); - expect(prompt).toContain('unknown=${nope}'); - host.dispose(); - }); - }); - - it('lets SYSTEM.md win over a same-name scanned user agent file', async () => { - await withFixture(async (fixture) => { - await writeAgent( - join(fixture.homeDir, 'agents'), - 'agent.md', - agentMd('agent', 'user agents dir default', true), - ); - await writeFile(join(fixture.homeDir, 'SYSTEM.md'), 'system md prompt'); - const { host, session } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.getDefault().systemPrompt({})).toContain('system md prompt'); - host.dispose(); - }); - }); - - it('lets a same-name project agent file win over user-level SYSTEM.md', async () => { - await withFixture(async (fixture) => { - await writeFile(join(fixture.homeDir, 'SYSTEM.md'), 'system md prompt'); - await writeAgent( - join(fixture.workDir, '.kimi-code', 'agents'), - 'agent.md', - agentMd('agent', 'project default override', true), - ); - const { host, session } = makeSession(fixture); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.getDefault().description).toBe('project default override'); - expect(catalog.getDefault().systemPrompt({})).not.toContain('system md prompt'); - host.dispose(); - }); - }); - - it('lets an explicit agent file win over user-level SYSTEM.md', async () => { - await withFixture(async (fixture) => { - await writeFile(join(fixture.homeDir, 'SYSTEM.md'), 'system md prompt'); - const explicitFile = await writeAgent( - fixture.workDir, - 'explicit.md', - agentMd('agent', 'explicit default override', true), - ); - const { host, session } = makeSession(fixture, { explicitFiles: [explicitFile] }); - const catalog = session.accessor.get(ISessionAgentProfileCatalog); - await catalog.load(); - - expect(catalog.getDefault().description).toBe('explicit default override'); - expect(catalog.getDefault().systemPrompt({})).not.toContain('system md prompt'); - host.dispose(); - }); + const defaultProfile = profile(DEFAULT_AGENT_PROFILE_NAME); + const coderProfile = profile('coder'); + registry.register(BUILTIN_AGENT_PROFILE_SOURCE_ID, { profiles: [defaultProfile] }); + registry.register('user', { profiles: [coderProfile] }); + + expect(catalog.getDefault()).toBe(defaultProfile); + expect(catalog.get('coder')).toBe(coderProfile); + expect(catalog.list()).toEqual([defaultProfile, coderProfile]); + expect(catalog.inspect(DEFAULT_AGENT_PROFILE_NAME)).toEqual({ + name: DEFAULT_AGENT_PROFILE_NAME, + profile: defaultProfile, + sourceId: BUILTIN_AGENT_PROFILE_SOURCE_ID, + priority: 0, + suppressed: [], + }); + catalog.dispose(); + registry.dispose(); }); }); diff --git a/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts b/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts deleted file mode 100644 index 1889c456ce..0000000000 --- a/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * `sessionFsWatch` domain (L2) — verifies confinement to the declared subtree, - * workspace-relative path mapping, debounce coalescing, window truncation, - * `.gitignore` filtering and handle lifecycle, using a fake os watcher. - */ - -import { isAbsolute, join, relative, resolve } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { LifecycleScope } from '#/_base/di/scope'; -import { createScopedTestHost, stubPair } from '#/_base/di/test'; -import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { - type HostFsChange, - type IHostFsWatchHandle, - IHostFsWatchService, -} from '#/os/interface/hostFsWatch'; -import { ISessionStateService } from '#/session/state/sessionState'; -import { SessionStateService } from '#/session/state/sessionStateService'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import type { FsChangeEvent } from '#/session/sessionFs/fsWatch'; - -import { ISessionFsWatchService } from '#/session/sessionFs/fsWatch'; -import { SessionFsWatchService } from '#/session/sessionFs/fsWatchService'; - -const WORK_DIR = '/repo'; - -void SessionFsWatchService; - -function stubWorkspace(): ISessionWorkspaceContext { - return { - _serviceBrand: undefined, - workDir: WORK_DIR, - additionalDirs: [], - setWorkDir: () => {}, - setAdditionalDirs: () => {}, - resolve: (rel) => (isAbsolute(rel) ? rel : resolve(WORK_DIR, rel)), - isWithin: (abs) => { - const r = relative(WORK_DIR, abs); - return r === '' || (!r.startsWith('..') && !isAbsolute(r)); - }, - assertAllowed: (abs) => abs, - addAdditionalDir: () => {}, - removeAdditionalDir: () => {}, - }; -} - -interface FakeWatch { - readonly service: IHostFsWatchService; - readonly watchCalls: string[]; - fire: (rel: string, action: HostFsChange['action'], kind?: HostFsChange['kind']) => void; - readonly disposed: () => boolean; -} - -function fakeHostFsWatch(): FakeWatch { - const watchCalls: string[] = []; - let listener: ((e: HostFsChange) => void) | undefined; - let disposed = false; - const handle: IHostFsWatchHandle = { - onDidChange: (l) => { - listener = l; - return { dispose: () => (listener = undefined) }; - }, - dispose: () => { - disposed = true; - listener = undefined; - }, - }; - const service: IHostFsWatchService = { - _serviceBrand: undefined, - watch: (path) => { - watchCalls.push(path); - disposed = false; - return handle; - }, - }; - return { - service, - watchCalls, - fire: (rel, action, kind = 'file') => - listener?.({ path: join(WORK_DIR, rel), action, kind }), - disposed: () => disposed, - }; -} - -function fakeHostFs(gitignore?: string): IHostFileSystem { - return { - _serviceBrand: undefined, - readText: async (p: string) => { - if (gitignore !== undefined && p === join(WORK_DIR, '.gitignore')) return gitignore; - const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException; - err.code = 'ENOENT'; - throw err; - }, - } as unknown as IHostFileSystem; -} - -interface Harness { - readonly svc: ISessionFsWatchService; - readonly watch: FakeWatch; - readonly events: FsChangeEvent[]; -} - -function makeSession(gitignore?: string): Harness { - const watch = fakeHostFsWatch(); - const host = createScopedTestHost(); - const session = host.child(LifecycleScope.Session, 's1', [ - stubPair(ISessionStateService, new SessionStateService()), - stubPair(ISessionWorkspaceContext, stubWorkspace()), - stubPair(IHostFsWatchService, watch.service), - stubPair(IHostFileSystem, fakeHostFs(gitignore)), - ]); - const svc = session.accessor.get(ISessionFsWatchService); - const events: FsChangeEvent[] = []; - svc.onDidChangeFiles((e) => events.push(e)); - disposers.push(() => host.dispose()); - return { svc, watch, events }; -} - -const disposers: Array<() => void> = []; - -describe('SessionFsWatchService', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { - for (const d of disposers.splice(0)) d(); - vi.useRealTimers(); - }); - - it('starts the os watcher on the workspace root for a non-empty subscription', () => { - const { svc, watch } = makeSession(); - svc.setWatchedPaths(['src']); - expect(watch.watchCalls).toEqual([WORK_DIR]); - expect(svc.watchedPaths).toEqual(['src']); - }); - - it('drops events outside the subscribed subtree', () => { - const { svc, watch, events } = makeSession(); - svc.setWatchedPaths(['src']); - - watch.fire('src/a.ts', 'created'); - watch.fire('lib/b.ts', 'created'); - vi.advanceTimersByTime(200); - - expect(events).toHaveLength(1); - expect(events[0]?.changes).toEqual([{ path: 'src/a.ts', change: 'created', kind: 'file' }]); - }); - - it('coalesces changes within a window into one event', () => { - const { svc, watch, events } = makeSession(); - svc.setWatchedPaths(['.']); - - watch.fire('a.ts', 'created'); - watch.fire('b.ts', 'modified'); - watch.fire('c.ts', 'deleted'); - vi.advanceTimersByTime(200); - - expect(events).toHaveLength(1); - expect(events[0]?.coalesced_window_ms).toBe(200); - expect(events[0]?.changes).toHaveLength(3); - }); - - it('marks the event truncated when the window overflows', () => { - const { svc, watch, events } = makeSession(); - svc.setWatchedPaths(['.']); - - for (let i = 0; i < 501; i++) watch.fire(`f${i}.ts`, 'created'); - vi.advanceTimersByTime(200); - - expect(events).toHaveLength(1); - expect(events[0]?.truncated).toBe(true); - expect(events[0]?.changes).toEqual([]); - expect(events[0]?.count).toBe(501); - }); - - it('filters out `.gitignore`d paths once loaded', async () => { - const { svc, watch, events } = makeSession('dist/\n'); - svc.setWatchedPaths(['.']); - await Promise.resolve(); - await Promise.resolve(); - - watch.fire('dist/x.js', 'created'); - watch.fire('src/keep.ts', 'created'); - vi.advanceTimersByTime(200); - - expect(events).toHaveLength(1); - expect(events[0]?.changes.map((c) => c.path)).toEqual(['src/keep.ts']); - }); - - it('rejects paths that escape the workspace', () => { - const { svc } = makeSession(); - expect(() => svc.setWatchedPaths(['../x'])).toThrowError(/escapes workspace|rejected/); - expect(() => svc.setWatchedPaths(['/abs'])).toThrowError(/rejected/); - }); - - it('disposes the os handle when the subscription set becomes empty', () => { - const { svc, watch } = makeSession(); - svc.setWatchedPaths(['src']); - expect(watch.disposed()).toBe(false); - svc.setWatchedPaths([]); - expect(watch.disposed()).toBe(true); - }); - - it('does not fire after the service is disposed', () => { - const { svc, watch, events } = makeSession(); - svc.setWatchedPaths(['.']); - watch.fire('a.ts', 'created'); - (svc as unknown as { dispose: () => void }).dispose(); - vi.advanceTimersByTime(200); - expect(events).toHaveLength(0); - }); -}); diff --git a/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts b/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts index 044f5778e6..85d256eeef 100644 --- a/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts +++ b/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts @@ -16,6 +16,7 @@ import { IAgentSystemReminderService } from '#/agent/systemReminder/systemRemind import { IWireService } from '#/wire/wire'; import { ErrorCodes, Error2 } from '#/errors'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionInitService } from '#/session/sessionInit/sessionInit'; import { SessionInitService } from '#/session/sessionInit/sessionInitService'; import { ISessionSubagentService } from '#/session/subagent/subagent'; @@ -68,7 +69,7 @@ describe('SessionInitService', () => { const eventBus = { publish: vi.fn((event: unknown) => events.push(event)) }; const telemetry = { track: vi.fn(), track2: vi.fn() }; const profile = { - data: () => ({ modelAlias: 'mock-model', thinkingLevel: 'off', cwd: WORK_DIR }), + data: () => ({ modelAlias: 'mock-model', thinkingLevel: 'off' }), }; const permissionMode = { mode: 'auto', setMode: vi.fn() }; @@ -122,6 +123,10 @@ describe('SessionInitService', () => { _serviceBrand: undefined, homeDir: '/home/brand', } as unknown as IBootstrapService); + ix.stub(ISessionContext, { + _serviceBrand: undefined, + cwd: WORK_DIR, + } as unknown as ISessionContext); ix.set(ISessionInitService, new SyncDescriptor(SessionInitService)); }); @@ -133,7 +138,7 @@ describe('SessionInitService', () => { expect(create).toHaveBeenCalledTimes(1); expect(create.mock.calls[0]![0]).toMatchObject({ - binding: { profile: 'coder', model: 'mock-model', thinking: 'off', cwd: WORK_DIR }, + binding: { profile: 'coder', model: 'mock-model', thinking: 'off' }, }); expect(run).toHaveBeenCalledTimes(1); diff --git a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts index d552fb6c0c..840b05b200 100644 --- a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts @@ -1,787 +1,140 @@ /** - * Scenario: session skill-source discovery, merge, and plugin refresh. + * Scenario: the Session-scope skill-catalog view over the seeded workspace data. * - * Exercises the real scoped catalog and source services with filesystem or - * in-memory discovery boundaries, including controlled concurrent refreshes. - * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * Exercises `SessionSkillCatalogService` against a controlled + * `ISessionSkillCatalogData` seed: snapshot forwarding, change-event + * fan-out, session-local ad-hoc sink contributions, and the no-rescan + * `reload()`. Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run * test/session/sessionSkillCatalog/skillCatalog.test.ts`. */ -import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; - -import { join } from 'pathe'; import { beforeEach, describe, expect, it } from 'vitest'; import { createScopedTestHost, stubPair } from '#/_base/di/test'; import { _clearScopedRegistryForTests, LifecycleScope, - ScopeActivation, registerScopedService, } from '#/_base/di/scope'; -import { Emitter, type Event } from '#/_base/event'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IPluginService } from '#/app/plugin/plugin'; -import { PluginService } from '#/app/plugin/pluginService'; -import type { ReloadSummary } from '#/app/plugin/types'; -import { IProviderService } from '#/kosong/provider/provider'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import { IConfigService } from '#/app/config/config'; -import { - EXTRA_SKILL_DIRS_SECTION, - MERGE_ALL_AVAILABLE_SKILLS_SECTION, -} from '#/app/skillCatalog/configSection'; -import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; -import { BuiltinSkillSource, IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; -import { IUserFileSkillSource, UserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; -import { InMemorySkillDiscovery } from '#/app/skillCatalog/inMemorySkillDiscovery'; -import type { SkillContribution } from '#/app/skillCatalog/skillSource'; -import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { Emitter } from '#/_base/event'; +import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; +import type { SkillCatalog } from '#/app/skillCatalog/types'; +import { ISessionSkillCatalog, type ISkillCatalogSink } from '#/session/sessionSkillCatalog/skillCatalog'; +import { ISessionSkillCatalogData } from '#/session/sessionSkillCatalog/skillCatalogData'; import { SessionSkillCatalogService } from '#/session/sessionSkillCatalog/skillCatalogService'; -import { ExplicitFileSkillSource, IExplicitFileSkillSource } from '#/session/sessionSkillCatalog/explicitFileSkillSource'; -import { ExtraFileSkillSource, IExtraFileSkillSource } from '#/session/sessionSkillCatalog/extraFileSkillSource'; -import { IWorkspaceFileSkillSource, WorkspaceFileSkillSource } from '#/session/sessionSkillCatalog/workspaceFileSkillSource'; -import { IPluginSkillSource, PluginSkillSource } from '#/session/sessionSkillCatalog/pluginSkillSource'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; -import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import type { SkillRoot } from '#/app/skillCatalog/types'; -import { stubBootstrap } from '../../app/bootstrap/stubs'; import { stubSkill } from '../../app/skillCatalog/stubs'; -import { stubProviderService } from '../../app/provider/stubs'; -const bootstrapStub = stubBootstrap('/home'); - -function configStub(): IConfigService & { - setExtraSkillDirs(dirs: readonly string[]): void; - setMergeAllAvailableSkills(value: boolean): void; - fireSectionChange(domain: string): void; +function dataSeed(initial: InMemorySkillCatalog): { + readonly data: ISessionSkillCatalogData; + readonly changes: Emitter; + replace(next: InMemorySkillCatalog): void; } { - let extraSkillDirs: readonly string[] = []; - let mergeAllAvailableSkills = true; - const sectionChangeListeners: Array<(event: unknown) => void> = []; + let current: SkillCatalog = initial; + const changes = new Emitter(); return { - _serviceBrand: undefined, - ready: Promise.resolve(), - onDidChangeConfiguration: () => ({ dispose: () => {} }), - onDidSectionChange: (listener: (event: unknown) => void) => { - sectionChangeListeners.push(listener); - return { dispose: () => {} }; - }, - get: (domain: string) => { - if (domain === EXTRA_SKILL_DIRS_SECTION) return [...extraSkillDirs]; - if (domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) return mergeAllAvailableSkills; - return undefined; - }, - inspect: () => ({ value: undefined, defaultValue: undefined, userValue: undefined, memoryValue: undefined }), - getAll: () => ({}), - set: async () => {}, - replace: async () => {}, - reload: async () => {}, - diagnostics: () => [], - setExtraSkillDirs: (dirs: readonly string[]) => { - extraSkillDirs = [...dirs]; - }, - setMergeAllAvailableSkills: (value: boolean) => { - mergeAllAvailableSkills = value; - }, - fireSectionChange: (domain: string) => { - for (const listener of sectionChangeListeners) { - listener({ domain, source: 'set', value: undefined, previousValue: undefined }); - } + changes, + data: { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChange: changes.event, + get catalog() { + return current; + }, }, - } as unknown as IConfigService & { - setExtraSkillDirs(dirs: readonly string[]): void; - setMergeAllAvailableSkills(value: boolean): void; - fireSectionChange(domain: string): void; - }; -} - -function pluginStub( - skillRoots: readonly SkillRoot[] = [], - reloadEmitter?: Emitter, -): IPluginService { - return { - _serviceBrand: undefined, - onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }), - listPlugins: async () => [], - installPlugin: async () => ({ id: '' }) as never, - setPluginEnabled: async () => {}, - setPluginMcpServerEnabled: async () => {}, - removePlugin: async () => {}, - reloadPlugins: async () => ({ added: [], removed: [], errors: [] }), - getPluginInfo: async () => { - throw new Error('getPluginInfo is not used by these tests'); + replace(next: InMemorySkillCatalog) { + current = next; }, - listPluginCommands: async () => [], - checkUpdates: async () => [], - pluginSkillRoots: async () => skillRoots, - pluginAgentRoots: async () => [], - enabledSessionStarts: async () => [], - enabledSystemPrompts: async () => [], - enabledMcpServers: async () => ({}), - enabledHooks: async () => [], }; } -function workspaceStub(workDir: string): { - readonly stub: ISessionWorkspaceContext; - setWorkDir(dir: string): void; -} { - let current = workDir; - const stub = { - _serviceBrand: undefined, - get workDir() { - return current; - }, - additionalDirs: [] as readonly string[], - setWorkDir: (dir: string) => { - current = dir; - }, - setAdditionalDirs: () => {}, - resolve: (rel: string) => rel, - isWithin: () => true, - assertAllowed: (p: string) => p, - addAdditionalDir: () => {}, - removeAdditionalDir: () => {}, - } satisfies ISessionWorkspaceContext; - return { stub, setWorkDir: (dir) => { current = dir; } }; -} - -function makeHost( - store: ISkillDiscovery, - ws: ISessionWorkspaceContext, - pluginRoots: readonly SkillRoot[] = [], - explicitDirs?: readonly string[], - pluginReloadEmitter?: Emitter, -) { - const config = configStub(); - const runtimeOptions = { - _serviceBrand: undefined, - explicitDirs, - } as unknown as ISkillCatalogRuntimeOptions; - const host = createScopedTestHost([ - stubPair(ISkillDiscovery, store), - stubPair(IBootstrapService, bootstrapStub), - stubPair(IConfigService, config), - stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), - stubPair(IPluginService, pluginStub(pluginRoots, pluginReloadEmitter)), - ]); - const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionWorkspaceContext, ws)]); - return { host, session, config }; -} - -function waitForEvents(event: Event, count: number): Promise { - return new Promise((resolve) => { - let received = 0; - const disposable = event(() => { - received += 1; - if (received === count) { - disposable.dispose(); - resolve(); - } - }); - }); +function catalogOf(...skills: readonly ReturnType[]): InMemorySkillCatalog { + const catalog = new InMemorySkillCatalog(); + for (const skill of skills) catalog.register(skill, { replace: true }); + return catalog; } -function deferred(): { - readonly promise: Promise; - readonly resolve: (value: T) => void; -} { - let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; -} - -async function withSkillCatalogWorkspace( - run: (fixture: { readonly workDir: string; readonly skillRoot: string }) => Promise, -): Promise { - const workDir = await mkdtemp(join(tmpdir(), 'skill-catalog-')); - const skillRoot = join(workDir, '.kimi-code', 'skills'); - await mkdir(skillRoot, { recursive: true }); - try { - await run({ workDir, skillRoot: await realpath(skillRoot) }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } -} - -describe('SessionSkillCatalogService', () => { +describe('SessionSkillCatalogService (seed view)', () => { beforeEach(() => { - // Keep the scoped registry limited to the catalog chain these tests - // exercise so unrelated OnScopeCreated registrations do not run; every - // other dependency arrives as a seeded stub via `createScopedTestHost`. _clearScopedRegistryForTests(); registerScopedService( LifecycleScope.Session, ISessionStateService, SessionStateService, - ScopeActivation.OnScopeCreated, - 'state', ); - registerScopedService(LifecycleScope.App, IBuiltinSkillSource, BuiltinSkillSource); - registerScopedService(LifecycleScope.App, IUserFileSkillSource, UserFileSkillSource); - registerScopedService(LifecycleScope.App, IPluginService, PluginService); registerScopedService(LifecycleScope.Session, ISessionSkillCatalog, SessionSkillCatalogService); - registerScopedService(LifecycleScope.Session, IExplicitFileSkillSource, ExplicitFileSkillSource); - registerScopedService(LifecycleScope.Session, IExtraFileSkillSource, ExtraFileSkillSource); - registerScopedService(LifecycleScope.Session, IWorkspaceFileSkillSource, WorkspaceFileSkillSource); - registerScopedService(LifecycleScope.Session, IPluginSkillSource, PluginSkillSource); }); - it('merges global and project skills; project wins on name collision', async () => { - const store = new InMemorySkillDiscovery(); - store.setUserSkills([ - stubSkill('global-only'), - stubSkill('shared', { description: 'from user' }), - ]); - store.setProjectSkills([ - stubSkill('project-only'), - stubSkill('shared', { description: 'from project' }), + function makeSession(data: ISessionSkillCatalogData) { + const host = createScopedTestHost([]); + const session = host.child(LifecycleScope.Session, 's1', [ + stubPair(ISessionSkillCatalogData, data), ]); - const { stub: ws } = workspaceStub('/work'); - const { host, session } = makeHost(store, ws); - - const catalog = session.accessor.get(ISessionSkillCatalog); - await catalog.load(); + return { host, catalog: session.accessor.get(ISessionSkillCatalog) }; + } - const names = catalog.catalog.listSkills().map((s) => s.name); - expect(names).toContain('global-only'); - expect(names).toContain('project-only'); - expect(names).toContain('shared'); - expect(catalog.catalog.getSkill('shared')?.description).toBe('from project'); - host.dispose(); - }); + it('exposes the seeded snapshot after ready', async () => { + const seed = dataSeed(catalogOf(stubSkill('from-workspace'))); + const { host, catalog } = makeSession(seed.data); - it('orders project, user and plugin skills as project > user > plugin', async () => { - const store = new InMemorySkillDiscovery(); - store.setUserSkills([ - stubSkill('shared', { description: 'from user' }), - stubSkill('user-plugin', { description: 'from user' }), - ]); - store.setProjectSkills([stubSkill('shared', { description: 'from project' })]); - store.setExtraSkills([ - stubSkill('shared', { description: 'from extra', source: 'extra' }), - stubSkill('user-plugin', { description: 'from extra', source: 'extra' }), - stubSkill('extra-plugin', { description: 'from extra', source: 'extra' }), - ]); - store.setPluginSkills([ - stubSkill('shared', { - description: 'from plugin', - source: 'extra', - plugin: { id: 'demo' }, - }), - stubSkill('user-plugin', { - description: 'from plugin', - source: 'extra', - plugin: { id: 'demo' }, - }), - stubSkill('extra-plugin', { - description: 'from plugin', - source: 'extra', - plugin: { id: 'demo' }, - }), - ]); - const pluginRoot: SkillRoot = { - path: '/plugins/demo/skills', - source: 'extra', - plugin: { id: 'demo' }, - }; - const { stub: ws } = workspaceStub('/work'); - const { host, session, config } = makeHost(store, ws, [pluginRoot]); - config.setExtraSkillDirs(['/']); - - const catalog = session.accessor.get(ISessionSkillCatalog); await catalog.load(); - - expect(catalog.catalog.getSkill('shared')?.description).toBe('from project'); - expect(catalog.catalog.getSkill('user-plugin')?.description).toBe('from user'); - expect(catalog.catalog.getSkill('extra-plugin')?.description).toBe('from extra'); + expect(catalog.catalog.getSkill('from-workspace')).toBeDefined(); host.dispose(); }); - it('replaces default user and project discovery with explicitDirs', async () => { - const store = new InMemorySkillDiscovery(); - store.setUserSkills([stubSkill('from-explicit', { description: 'from explicit' })]); - store.setProjectSkills([stubSkill('project-only', { description: 'from project' })]); - store.setExtraSkills([stubSkill('extra-only', { description: 'from extra', source: 'extra' })]); - store.setPluginSkills([ - stubSkill('plugin-only', { - description: 'from plugin', - source: 'extra', - plugin: { id: 'demo' }, - }), - ]); - const pluginRoot: SkillRoot = { - path: '/plugins/demo/skills', - source: 'extra', - plugin: { id: 'demo' }, - }; - const { stub: ws } = workspaceStub('/work'); - const { host, session, config } = makeHost(store, ws, [pluginRoot], ['/']); - config.setExtraSkillDirs(['/']); - - const catalog = session.accessor.get(ISessionSkillCatalog); + it('re-folds and forwards the source id when the seed fires a change', async () => { + const seed = dataSeed(catalogOf(stubSkill('before'))); + const { host, catalog } = makeSession(seed.data); await catalog.load(); - expect(catalog.catalog.getSkill('from-explicit')?.description).toBe('from explicit'); - expect(catalog.catalog.getSkill('project-only')).toBeUndefined(); - expect(catalog.catalog.getSkill('extra-only')?.description).toBe('from extra'); - expect(catalog.catalog.getSkill('plugin-only')?.description).toBe('from plugin'); - host.dispose(); - }); + const seen: string[] = []; + const subscription = catalog.onDidChange((sourceId) => seen.push(sourceId)); + seed.replace(catalogOf(stubSkill('after'))); + seed.changes.fire('workspace'); - it('waits for config ready before loading extra skill dirs', async () => { - let markReady!: () => void; - let ready = false; - const configReady = new Promise((resolve) => { - markReady = () => { - ready = true; - resolve(); - }; - }); - const config = { - ...configStub(), - ready: configReady, - get: (domain: string) => { - if (domain === EXTRA_SKILL_DIRS_SECTION) return ready ? ['/'] : []; - if (domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) return true; - return undefined; - }, - } as unknown as IConfigService; - const store = new InMemorySkillDiscovery(); - store.setExtraSkills([stubSkill('extra-only', { description: 'from extra', source: 'extra' })]); - const runtimeOptions = { - _serviceBrand: undefined, - } as unknown as ISkillCatalogRuntimeOptions; - const { stub: ws } = workspaceStub('/work'); - const host = createScopedTestHost([ - stubPair(ISkillDiscovery, store), - stubPair(IBootstrapService, bootstrapStub), - stubPair(IConfigService, config), - stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), - stubPair(IPluginService, pluginStub()), - ]); - const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionWorkspaceContext, ws)]); - - const catalog = session.accessor.get(ISessionSkillCatalog); - let settled = false; - const loading = catalog.load().then(() => { - settled = true; - }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(settled).toBe(false); - - markReady(); - await loading; - - expect(catalog.catalog.getSkill('extra-only')?.description).toBe('from extra'); + expect(catalog.catalog.getSkill('before')).toBeUndefined(); + expect(catalog.catalog.getSkill('after')).toBeDefined(); + expect(seen).toEqual(['workspace']); + subscription.dispose(); host.dispose(); }); - it('reloads user and workspace sources when mergeAllAvailableSkills changes', async () => { - class CountingDiscovery implements ISkillDiscovery { - declare readonly _serviceBrand: undefined; - calls = 0; - async discover() { - this.calls++; - return { skills: [], skipped: [], scannedRoots: [] }; - } - } - const store = new CountingDiscovery(); - const config = configStub(); - const runtimeOptions = { - _serviceBrand: undefined, - } as unknown as ISkillCatalogRuntimeOptions; - const { stub: ws } = workspaceStub('/work'); - const host = createScopedTestHost([ - stubPair(ISkillDiscovery, store), - stubPair(IBootstrapService, bootstrapStub), - stubPair(IConfigService, config), - stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), - stubPair(IPluginService, pluginStub()), - ]); - const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionWorkspaceContext, ws)]); - - const catalog = session.accessor.get(ISessionSkillCatalog); + it('merges ad-hoc sink contributions over the seed and drops them on remove', async () => { + const seed = dataSeed(catalogOf(stubSkill('shared', { description: 'from workspace' }))); + const { host, catalog } = makeSession(seed.data); await catalog.load(); - const afterLoad = store.calls; - const reloaded = waitForEvents(catalog.onDidChange, 2); - config.fireSectionChange(MERGE_ALL_AVAILABLE_SKILLS_SECTION); - await reloaded; + const sink = catalog as unknown as ISkillCatalogSink; + sink.set( + 'adhoc', + { skills: [stubSkill('shared', { description: 'from adhoc' }), stubSkill('adhoc-only')] }, + { priority: 40 }, + ); + expect(catalog.catalog.getSkill('shared')?.description).toBe('from adhoc'); + expect(catalog.catalog.getSkill('adhoc-only')).toBeDefined(); - expect(store.calls).toBe(afterLoad + 2); + sink.remove('adhoc'); + expect(catalog.catalog.getSkill('shared')?.description).toBe('from workspace'); + expect(catalog.catalog.getSkill('adhoc-only')).toBeUndefined(); host.dispose(); }); - it('reload replaces project skills when the workDir changes', async () => { - const store = new InMemorySkillDiscovery(); - store.setUserSkills([stubSkill('global-only')]); - store.setProjectSkills([stubSkill('first')]); - const { stub: ws, setWorkDir } = workspaceStub('/work1'); - const { host, session } = makeHost(store, ws); - - const catalog = session.accessor.get(ISessionSkillCatalog); + it('reload re-folds the current seed without rescanning and fires catalog', async () => { + const seed = dataSeed(catalogOf(stubSkill('one'))); + const { host, catalog } = makeSession(seed.data); await catalog.load(); - expect(catalog.catalog.getSkill('first')).toBeDefined(); - setWorkDir('/work2'); - store.setProjectSkills([stubSkill('second')]); - const changes: string[] = []; - const subscription = catalog.onDidChange((sourceId) => changes.push(sourceId)); + // A silent seed swap (no change event) becomes visible through reload. + seed.replace(catalogOf(stubSkill('two'))); + const seen: string[] = []; + const subscription = catalog.onDidChange((sourceId) => seen.push(sourceId)); await catalog.reload(); - expect(catalog.catalog.getSkill('first')).toBeUndefined(); - expect(catalog.catalog.getSkill('second')).toBeDefined(); - expect(catalog.catalog.getSkill('global-only')).toBeDefined(); - expect(changes).toEqual(['catalog']); + expect(catalog.catalog.getSkill('two')).toBeDefined(); + expect(seen).toEqual(['catalog']); subscription.dispose(); host.dispose(); }); - - it('does not reload when the workDir is unchanged', async () => { - const store = new InMemorySkillDiscovery(); - store.setProjectSkills([stubSkill('first')]); - const { stub: ws } = workspaceStub('/work'); - const { host, session } = makeHost(store, ws); - - const catalog = session.accessor.get(ISessionSkillCatalog); - await catalog.load(); - - store.setProjectSkills([stubSkill('second')]); - await catalog.load(); - - expect(catalog.catalog.getSkill('first')).toBeDefined(); - expect(catalog.catalog.getSkill('second')).toBeUndefined(); - host.dispose(); - }); - - it('passes plugin skill roots to the store so plugin skills are discoverable', async () => { - const pluginRoot: SkillRoot = { - path: '/plugins/demo/skills', - source: 'extra', - plugin: { id: 'demo', instructions: 'Use the demo tools.' }, - }; - class ExtraRootStore implements ISkillDiscovery { - declare readonly _serviceBrand: undefined; - receivedRoots: readonly SkillRoot[] | undefined; - async discover(roots: readonly SkillRoot[]) { - if (roots.some((root) => root.plugin !== undefined)) { - this.receivedRoots = roots; - } - const pluginSkills = roots - .filter((root) => root.plugin !== undefined) - .map((root) => stubSkill('demo-skill', { source: 'extra', plugin: root.plugin })); - return { skills: pluginSkills, skipped: [], scannedRoots: [] }; - } - } - const store = new ExtraRootStore(); - const { stub: ws } = workspaceStub('/work'); - const { host, session } = makeHost(store, ws, [pluginRoot]); - - const catalog = session.accessor.get(ISessionSkillCatalog); - await catalog.load(); - - expect(store.receivedRoots).toEqual([pluginRoot]); - expect(catalog.catalog.getSkill('demo-skill')?.plugin?.id).toBe('demo'); - expect(catalog.catalog.getPluginSkill('demo', 'demo-skill')).toBeDefined(); - host.dispose(); - }); - - it('feeds scanned roots from file sources into the merged catalog', async () => { - await withSkillCatalogWorkspace(async ({ workDir, skillRoot }) => { - class RootDiscovery implements ISkillDiscovery { - declare readonly _serviceBrand: undefined; - async discover(roots: readonly SkillRoot[]) { - return { - skills: [], - skipped: [], - scannedRoots: roots - .filter((root) => root.source === 'project') - .map((root) => root.path), - }; - } - } - const { stub: ws } = workspaceStub(workDir); - const { host, session } = makeHost(new RootDiscovery(), ws); - - try { - const catalog = session.accessor.get(ISessionSkillCatalog); - await catalog.load(); - - expect(catalog.catalog.getSkillRoots()).toEqual([skillRoot]); - } finally { - host.dispose(); - } - }); - }); - - it('feeds skipped skills from file sources into the merged catalog', async () => { - await withSkillCatalogWorkspace(async ({ workDir }) => { - const skippedEntry = { - path: join(workDir, '.kimi-code', 'skills', 'bad', 'SKILL.md'), - type: 'nope', - reason: 'unsupported skill type "nope"', - }; - class SkippingDiscovery implements ISkillDiscovery { - declare readonly _serviceBrand: undefined; - async discover(roots: readonly SkillRoot[]) { - const isProject = roots.some((root) => root.source === 'project'); - return { - skills: [], - skipped: isProject ? [skippedEntry] : [], - scannedRoots: [], - }; - } - } - const { stub: ws } = workspaceStub(workDir); - const { host, session } = makeHost(new SkippingDiscovery(), ws); - - try { - const catalog = session.accessor.get(ISessionSkillCatalog); - await catalog.load(); - - expect(catalog.catalog.getSkippedByPolicy()).toEqual([skippedEntry]); - } finally { - host.dispose(); - } - }); - }); - - it('fires onDidChange with the plugin source id after a plugin reload re-pulls plugin skills', async () => { - const store = new InMemorySkillDiscovery(); - store.setPluginSkills([ - stubSkill('demo-skill', { source: 'extra', plugin: { id: 'demo' } }), - ]); - const reloadEmitter = new Emitter(); - const pluginRoot: SkillRoot = { - path: '/plugins/demo/skills', - source: 'extra', - plugin: { id: 'demo' }, - }; - const { stub: ws } = workspaceStub('/work'); - const { host, session } = makeHost(store, ws, [pluginRoot], undefined, reloadEmitter); - - try { - const catalog = session.accessor.get(ISessionSkillCatalog); - await catalog.load(); - expect(catalog.catalog.getPluginSkill('demo', 'demo-skill')).toBeDefined(); - - const refreshed = new Promise((resolve) => { - const d = catalog.onDidChange((sourceId) => { - d.dispose(); - resolve(sourceId); - }); - }); - reloadEmitter.fire({ added: [], removed: [], errors: [] }); - - await expect(refreshed).resolves.toBe('plugin'); - } finally { - host.dispose(); - reloadEmitter.dispose(); - } - }); - - it('queues a plugin refresh during initial load so the refreshed contribution remains active', async () => { - const initialLoad = deferred(); - const refreshedLoad = deferred(); - const initialStarted = deferred(); - const refreshedStarted = deferred(); - const sourceChanges = new Emitter(); - let loadCount = 0; - const pluginSource: IPluginSkillSource = { - _serviceBrand: undefined, - id: 'plugin', - priority: 5, - onDidChange: sourceChanges.event, - load: () => { - loadCount += 1; - if (loadCount === 1) { - initialStarted.resolve(undefined); - return initialLoad.promise; - } - if (loadCount === 2) { - refreshedStarted.resolve(undefined); - return refreshedLoad.promise; - } - throw new Error('unexpected plugin source load'); - }, - }; - const { stub: ws } = workspaceStub('/work'); - const host = createScopedTestHost([ - stubPair(ISkillDiscovery, new InMemorySkillDiscovery()), - stubPair(IBootstrapService, bootstrapStub), - stubPair(IConfigService, configStub()), - stubPair(ISkillCatalogRuntimeOptions, { - _serviceBrand: undefined, - } as unknown as ISkillCatalogRuntimeOptions), - stubPair(IPluginService, pluginStub()), - ]); - const session = host.child(LifecycleScope.Session, 's1', [ - stubPair(ISessionWorkspaceContext, ws), - stubPair(IPluginSkillSource, pluginSource), - ]); - - try { - const catalog = session.accessor.get(ISessionSkillCatalog); - const loading = catalog.load(); - await initialStarted.promise; - const sourceIds: string[] = []; - const refreshed = new Promise((resolve) => { - const subscription = catalog.onDidChange((sourceId) => { - sourceIds.push(sourceId); - if (sourceId === 'plugin') { - subscription.dispose(); - resolve(); - } - }); - }); - - sourceChanges.fire(); - - expect(loadCount).toBe(1); - - initialLoad.resolve({ - skills: [ - stubSkill('stale-skill', { source: 'extra', plugin: { id: 'demo' } }), - ], - }); - await refreshedStarted.promise; - refreshedLoad.resolve({ - skills: [ - stubSkill('fresh-skill', { source: 'extra', plugin: { id: 'demo' } }), - ], - }); - await Promise.all([loading, refreshed]); - - expect(sourceIds).toEqual(['plugin']); - expect(catalog.catalog.getPluginSkill('demo', 'stale-skill')).toBeUndefined(); - expect(catalog.catalog.getPluginSkill('demo', 'fresh-skill')).toBeDefined(); - } finally { - host.dispose(); - sourceChanges.dispose(); - } - }); - - it('binds thisArg when forwarding plugin reloads through the plugin skill source', async () => { - const reloadEmitter = new Emitter(); - const pluginService = pluginStub([], reloadEmitter); - const { stub: ws } = workspaceStub('/work'); - const host = createScopedTestHost([ - stubPair(ISkillDiscovery, new InMemorySkillDiscovery()), - stubPair(IBootstrapService, bootstrapStub), - stubPair(IConfigService, configStub()), - stubPair(ISkillCatalogRuntimeOptions, { - _serviceBrand: undefined, - } as unknown as ISkillCatalogRuntimeOptions), - stubPair(IPluginService, pluginService), - ]); - const session = host.child(LifecycleScope.Session, 's1', [ - stubPair(ISessionWorkspaceContext, ws), - ]); - - try { - const source = session.accessor.get(IPluginSkillSource); - void source.id; - const receiver = { tag: 'receiver' }; - const seen: unknown[] = []; - const subscription = source.onDidChange?.( - function (this: unknown) { - seen.push(this); - }, - receiver, - ); - - reloadEmitter.fire({ added: [], removed: [], errors: [] }); - - expect(seen).toEqual([receiver]); - subscription?.dispose(); - } finally { - host.dispose(); - reloadEmitter.dispose(); - } - }); - - it('keeps non-plugin skills working and recovers plugin skills after a corrupt installed.json is fixed and reloaded', async () => { - const homeDir = await mkdtemp(join(tmpdir(), 'plugin-home-')); - await mkdir(join(homeDir, 'plugins'), { recursive: true }); - await writeFile(join(homeDir, 'plugins', 'installed.json'), '{ not json', 'utf8'); - - const managedRoot = join(homeDir, 'plugins', 'managed', 'demo'); - await mkdir(join(managedRoot, 'skills', 'demo-skill'), { recursive: true }); - await writeFile( - join(managedRoot, 'kimi.plugin.json'), - JSON.stringify({ name: 'demo', skills: './skills/' }), - 'utf8', - ); - await writeFile( - join(managedRoot, 'skills', 'demo-skill', 'SKILL.md'), - '---\nname: demo-skill\ndescription: demo\n---\nbody', - 'utf8', - ); - - const store = new InMemorySkillDiscovery(); - store.setUserSkills([stubSkill('global-only')]); - store.setPluginSkills([ - stubSkill('demo-skill', { source: 'extra', plugin: { id: 'demo' } }), - ]); - const host = createScopedTestHost([ - stubPair(ISkillDiscovery, store), - stubPair(IBootstrapService, stubBootstrap(homeDir)), - stubPair(IConfigService, configStub()), - stubPair(ISkillCatalogRuntimeOptions, { - _serviceBrand: undefined, - } as unknown as ISkillCatalogRuntimeOptions), - stubPair(IProviderService, stubProviderService()), - ]); - const { stub: ws } = workspaceStub('/work'); - const session = host.child(LifecycleScope.Session, 's1', [ - stubPair(ISessionWorkspaceContext, ws), - ]); - - try { - const catalog = session.accessor.get(ISessionSkillCatalog); - await catalog.load(); - expect(catalog.catalog.getSkill('global-only')).toBeDefined(); - expect(catalog.catalog.getPluginSkill('demo', 'demo-skill')).toBeUndefined(); - - await writeFile( - join(homeDir, 'plugins', 'installed.json'), - JSON.stringify({ - version: 1, - plugins: [ - { - id: 'demo', - root: managedRoot, - source: 'local-path', - enabled: true, - installedAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - }, - ], - }), - 'utf8', - ); - const refreshed = new Promise((resolve) => { - const d = catalog.onDidChange((sourceId) => { - if (sourceId === 'plugin') { - d.dispose(); - resolve(); - } - }); - }); - await host.app.accessor.get(IPluginService).reloadPlugins(); - await refreshed; - - expect(catalog.catalog.getPluginSkill('demo', 'demo-skill')).toBeDefined(); - } finally { - host.dispose(); - await rm(homeDir, { recursive: true, force: true }); - } - }); }); diff --git a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts index 85721a0a45..dabeba5326 100644 --- a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts +++ b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts @@ -1013,7 +1013,6 @@ describe('SessionSwarmService metadata compatibility', () => { profile: 'coder', model: 'kimi-test', thinking: 'medium', - cwd: '/repo', }, labels: { parentAgentId: 'main', swarmItem: 'src/a.ts' }, }), @@ -1039,7 +1038,6 @@ describe('SessionSwarmService metadata compatibility', () => { profileName: opts.binding?.profile ?? 'coder', modelAlias: opts.binding?.model ?? 'kimi-test', thinkingLevel: opts.binding?.thinking ?? 'medium', - cwd: opts.binding?.cwd ?? '/repo', }, new Map([[IAgentUserToolService, childUserTools]]), ); @@ -1126,7 +1124,6 @@ describe('SessionSwarmService metadata compatibility', () => { profile: 'coder', model: 'provider/secondary', thinking: 'low', - cwd: '/repo', }, }), ); @@ -1299,7 +1296,6 @@ function lifecycleStub( profileName: opts.binding?.profile ?? 'coder', modelAlias: opts.binding?.model ?? 'kimi-test', thinkingLevel: opts.binding?.thinking ?? 'medium', - cwd: opts.binding?.cwd ?? '/repo', }); handles.set(id, handle); return handle; @@ -1337,7 +1333,6 @@ function agentHandle( services: ReadonlyMap = new Map(), ): IAgentScopeHandle { const profile = profileService({ - cwd: '/repo', modelAlias: 'kimi-test', modelCapabilities: {} as never, profileName: 'agent', diff --git a/packages/agent-core-v2/test/session/terminal/terminalService.test.ts b/packages/agent-core-v2/test/session/terminal/terminalService.test.ts index 39f323acc7..d71d8c3c14 100644 --- a/packages/agent-core-v2/test/session/terminal/terminalService.test.ts +++ b/packages/agent-core-v2/test/session/terminal/terminalService.test.ts @@ -73,13 +73,9 @@ function stubWorkspace(workDir = '/ws'): ISessionWorkspaceContext { _serviceBrand: undefined, workDir, additionalDirs: [], - setWorkDir: () => {}, - setAdditionalDirs: () => {}, resolve: (rel) => resolve(workDir, rel), isWithin: () => true, assertAllowed: (absPath) => resolve(workDir, absPath), - addAdditionalDir: () => {}, - removeAdditionalDir: () => {}, }; } diff --git a/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts b/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts deleted file mode 100644 index dd64ee7c14..0000000000 --- a/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts +++ /dev/null @@ -1,458 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { DisposableStore } from '#/_base/di/lifecycle'; -import type { IAgentScopeHandle } from '#/_base/di/scope'; -import { LifecycleScope } from '#/_base/di/scope'; -import type { ServiceIdentifier } from '#/_base/di/instantiation'; -import { createServices, type TestInstantiationService } from '#/_base/di/test'; -import { Emitter } from '#/_base/event'; -import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; -import { ErrorCodes, Error2 } from '#/errors'; -import { - type HostDirEntry, - type HostFileStat, - IHostFileSystem, -} from '#/os/interface/hostFileSystem'; -import { FileProjectLocalConfigService } from '#/persistence/backends/node-fs/projectLocalConfigService'; -import { - IAgentLifecycleService, - MAIN_AGENT_ID, -} from '#/session/agentLifecycle/agentLifecycle'; -import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; -import { ISessionWorkspaceCommandService } from '#/session/workspaceCommand/workspaceCommand'; -import { SessionWorkspaceCommandService } from '#/session/workspaceCommand/workspaceCommandService'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; - -import { stubContextMemory, type StubContextMemory } from '../../agent/contextMemory/stubs'; -import { registerStateServices } from '../../state/stubs'; - -const WORK_DIR = '/repo/work'; -const EXTRA_DIR = `${WORK_DIR}/extra`; -const DIR_A = `${WORK_DIR}/a`; -const DIR_B = `${WORK_DIR}/b`; - -class MemoryHostFs implements IHostFileSystem { - declare readonly _serviceBrand: undefined; - readonly files = new Map(); - readonly dirs = new Set(); - readonly danglingSymlinks = new Set(); - readonly statErrors = new Map(); - readonly readErrors = new Map(); - readonly readsDuringPausedWrite: string[] = []; - private pausedWrites = 0; - private nextWritePause: - | { - readonly started: () => void; - readonly release: Promise; - } - | undefined; - - constructor(seedDirs: readonly string[] = []) { - for (const d of seedDirs) this.dirs.add(d); - } - - async readText(path: string): Promise { - if (this.pausedWrites > 0) this.readsDuringPausedWrite.push(path); - const error = this.readErrors.get(path); - if (error !== undefined) throw error; - const text = this.files.get(path); - if (text === undefined) throw enoent(path); - return text; - } - - async writeText(path: string, data: string): Promise { - const pause = this.nextWritePause; - if (pause !== undefined) { - this.nextWritePause = undefined; - this.pausedWrites++; - pause.started(); - try { - await pause.release; - } finally { - this.pausedWrites--; - } - } - this.files.set(path, data); - } - - async appendText(path: string, data: string): Promise { - this.files.set(path, (this.files.get(path) ?? '') + data); - } - - pauseNextWrite(): { readonly started: Promise; readonly release: () => void } { - let started!: () => void; - let release!: () => void; - const startedPromise = new Promise((resolve) => { - started = resolve; - }); - const releasePromise = new Promise((resolve) => { - release = resolve; - }); - this.nextWritePause = { started, release: releasePromise }; - return { started: startedPromise, release }; - } - - async readBytes(): Promise { - throw new Error('not implemented'); - } - - async writeBytes(): Promise { - throw new Error('not implemented'); - } - - async *readLines(): AsyncGenerator { - yield* []; - throw new Error('not implemented'); - } - - async createExclusive(): Promise { - throw new Error('not implemented'); - } - - async stat(path: string): Promise { - const error = this.statErrors.get(path); - if (error !== undefined) throw error; - if (this.danglingSymlinks.has(path)) throw enoent(path); - if (this.files.has(path)) { - return { isFile: true, isDirectory: false, size: this.files.get(path)?.length ?? 0 }; - } - if (this.dirs.has(path)) return { isFile: false, isDirectory: true, size: 0 }; - throw enoent(path); - } - - async lstat(path: string): Promise { - if (this.danglingSymlinks.has(path)) { - return { isFile: false, isDirectory: false, isSymbolicLink: true, size: 0 }; - } - return this.stat(path); - } - - async readdir(): Promise { - throw new Error('not implemented'); - } - - async mkdir(path: string): Promise { - this.dirs.add(path); - } - - async remove(path: string): Promise { - this.files.delete(path); - this.dirs.delete(path); - } - - async realpath(path: string): Promise { - if (this.files.has(path) || this.dirs.has(path)) return path; - throw enoent(path); - } -} - -function enoent(path: string): NodeJS.ErrnoException { - const error = new Error(`ENOENT: ${path}`) as NodeJS.ErrnoException; - error.code = 'ENOENT'; - return error; -} - -interface AgentsStub extends IAgentLifecycleService { - readonly mainContext: StubContextMemory; - setMain(present: boolean): void; -} - -function agentsStub(): AgentsStub { - const mainContext = stubContextMemory(); - let mainPresent = false; - const created = new Emitter(); - - const mainHandle: IAgentScopeHandle = { - id: MAIN_AGENT_ID, - kind: LifecycleScope.Agent, - accessor: { - get: (id: ServiceIdentifier): T => { - if (id === IAgentContextMemoryService) return mainContext as unknown as T; - throw new Error(`unexpected service on main handle: ${String(id)}`); - }, - }, - dispose: () => {}, - }; - - return { - _serviceBrand: undefined, - mainContext, - onDidCreate: created.event, - onDidDispose: () => ({ dispose: () => {} }), - create: () => Promise.reject(new Error('not implemented')), - fork: () => Promise.reject(new Error('not implemented')), - get: (id) => (id === MAIN_AGENT_ID && mainPresent ? mainHandle : undefined), - list: () => [], - remove: () => Promise.resolve(), - broadcastPermissionMode: () => {}, - setMain: (present) => { - mainPresent = present; - if (present) created.fire(mainHandle); - }, - }; -} - -function bootstrapStub(): IBootstrapService { - return { - _serviceBrand: undefined, - homeDir: '/home/test', - osHomeDir: '/users/test', - } as IBootstrapService; -} - -function sessionContext(workDir = WORK_DIR): ISessionContext { - return makeSessionContext({ - sessionId: 'ses', - workspaceId: 'ws', - sessionDir: '/tmp/sessions/ws/ses', - sessionScope: 'sessions/ws/ses', - cwd: workDir, - }); -} - -function nextMacrotask(): Promise { - return new Promise((resolve) => setTimeout(resolve, 0)); -} - -interface Harness { - readonly svc: ISessionWorkspaceCommandService; - readonly fs: MemoryHostFs; - readonly agents: AgentsStub; - readonly workspace: ISessionWorkspaceContext; -} - -describe('SessionWorkspaceCommandService', () => { - let disposables: DisposableStore; - let ix: TestInstantiationService; - - beforeEach(() => { - disposables = new DisposableStore(); - }); - - afterEach(() => { - disposables.dispose(); - }); - - function build( - seedDirs: readonly string[], - mainPresent: boolean, - workDir = WORK_DIR, - gitDir = `${workDir}/.git`, - ): Harness { - const fs = new MemoryHostFs([gitDir, workDir, ...seedDirs]); - const agents = agentsStub(); - const ctx = sessionContext(workDir); - - ix = createServices(disposables, { - additionalServices: (reg) => { - registerStateServices(reg); - reg.defineInstance(ISessionContext, ctx); - reg.define(ISessionWorkspaceContext, SessionWorkspaceContextService); - reg.defineInstance(IBootstrapService, bootstrapStub()); - reg.defineInstance(IHostFileSystem, fs); - reg.define(IProjectLocalConfigService, FileProjectLocalConfigService); - reg.defineInstance(IAgentLifecycleService, agents); - reg.define(ISessionWorkspaceCommandService, SessionWorkspaceCommandService); - }, - }); - - const workspace = ix.get(ISessionWorkspaceContext); - const svc = ix.get(ISessionWorkspaceCommandService); - agents.setMain(mainPresent); - return { svc, fs, agents, workspace }; - } - - it('persists the directory and injects a local-command-stdout message when main exists', async () => { - const { svc, fs, agents, workspace } = build([EXTRA_DIR], true); - - const result = await svc.addAdditionalDir({ path: 'extra', persist: true }); - - expect(result.persisted).toBe(true); - expect(result.configPath).toBe(`${WORK_DIR}/.kimi-code/local.toml`); - expect(result.additionalDirs).toContain(EXTRA_DIR); - expect(workspace.additionalDirs).toContain(EXTRA_DIR); - - const written = fs.files.get(`${WORK_DIR}/.kimi-code/local.toml`); - expect(written).toContain('additional_dir'); - expect(written).toContain(EXTRA_DIR); - - expect(agents.mainContext.messages).toHaveLength(1); - expect(agents.mainContext.messages[0]?.content).toEqual([ - { - type: 'text', - text: `\nAdded workspace directory:\n extra\n Saved to:\n ${WORK_DIR}/.kimi-code/local.toml\n`, - }, - ]); - expect(agents.mainContext.messages[0]?.origin).toEqual({ - kind: 'injection', - variant: 'local-command-stdout', - }); - }); - - it('does not persist and injects a session-only message when persist is false', async () => { - const { svc, fs, agents, workspace } = build([EXTRA_DIR], true); - - const result = await svc.addAdditionalDir({ path: 'extra', persist: false }); - - expect(result.persisted).toBe(false); - expect(workspace.additionalDirs).toContain(EXTRA_DIR); - expect(fs.files.has(`${WORK_DIR}/.kimi-code/local.toml`)).toBe(false); - - expect(agents.mainContext.messages).toHaveLength(1); - expect(agents.mainContext.messages[0]?.content).toEqual([ - { - type: 'text', - text: '\nAdded workspace directory:\n extra\n For this session only\n', - }, - ]); - }); - - it('queues the injection until the main agent is created', async () => { - const { svc, agents } = build([EXTRA_DIR], false); - - await svc.addAdditionalDir({ path: 'extra', persist: true }); - expect(agents.mainContext.messages).toHaveLength(0); - - agents.setMain(true); - - expect(agents.mainContext.messages).toHaveLength(1); - expect(agents.mainContext.messages[0]?.content[0]).toMatchObject({ - type: 'text', - text: expect.stringContaining('Added workspace directory:'), - }); - }); - - it('keeps the persisted config idempotent when the same dir is added twice', async () => { - const { svc, fs } = build([EXTRA_DIR], true); - - await svc.addAdditionalDir({ path: 'extra', persist: true }); - await svc.addAdditionalDir({ path: 'extra', persist: true }); - - const written = fs.files.get(`${WORK_DIR}/.kimi-code/local.toml`); - expect(written).toBeDefined(); - const matches = written?.match(new RegExp(EXTRA_DIR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')); - expect(matches).toHaveLength(1); - }); - - it('serializes concurrent persisted additions so local.toml keeps both directories', async () => { - const { svc, fs, workspace } = build([DIR_A, DIR_B], true); - const pause = fs.pauseNextWrite(); - - const first = svc.addAdditionalDir({ path: 'a', persist: true }); - await pause.started; - - const second = svc.addAdditionalDir({ path: 'b', persist: true }); - await nextMacrotask(); - const overlappingReads = [...fs.readsDuringPausedWrite]; - - pause.release(); - const [, secondResult] = await Promise.all([first, second]); - - expect(overlappingReads).toEqual([]); - expect(secondResult.additionalDirs).toEqual([DIR_A, DIR_B]); - expect(workspace.additionalDirs).toEqual([DIR_A, DIR_B]); - - const written = fs.files.get(`${WORK_DIR}/.kimi-code/local.toml`); - expect(written).toContain(DIR_A); - expect(written).toContain(DIR_B); - }); - - it('resolves caller-relative dirs against the session workDir when project root is above it', async () => { - const projectRoot = '/repo/project'; - const workDir = `${projectRoot}/apps/foo`; - const sharedDir = `${workDir}/shared`; - const { svc, fs, workspace } = build([sharedDir], true, workDir, `${projectRoot}/.git`); - - const result = await svc.addAdditionalDir({ path: 'shared', persist: true }); - - expect(result.projectRoot).toBe(projectRoot); - expect(result.configPath).toBe(`${projectRoot}/.kimi-code/local.toml`); - expect(result.additionalDirs).toEqual([sharedDir]); - expect(workspace.additionalDirs).toEqual([sharedDir]); - expect(fs.files.get(`${projectRoot}/.kimi-code/local.toml`)).toContain(sharedDir); - }); - - it('keeps a dangling .git symlink as the local project-root marker', async () => { - const ancestorRoot = '/repo/project'; - const workDir = `${ancestorRoot}/apps/foo`; - const sharedDir = `${workDir}/shared`; - const { svc, fs } = build([sharedDir], true, workDir, `${ancestorRoot}/.git`); - fs.danglingSymlinks.add(`${workDir}/.git`); - - const result = await svc.addAdditionalDir({ path: 'shared', persist: true }); - - expect(result.projectRoot).toBe(workDir); - expect(result.configPath).toBe(`${workDir}/.kimi-code/local.toml`); - expect(fs.files.get(`${workDir}/.kimi-code/local.toml`)).toContain(sharedDir); - }); - - it('resolves session-only relative dirs against the session workDir when project root is above it', async () => { - const projectRoot = '/repo/project'; - const workDir = `${projectRoot}/apps/foo`; - const sharedDir = `${workDir}/shared`; - const { svc, fs, workspace } = build([sharedDir], true, workDir, `${projectRoot}/.git`); - - const result = await svc.addAdditionalDir({ path: 'shared', persist: false }); - - expect(result.projectRoot).toBe(projectRoot); - expect(result.configPath).toBe(`${projectRoot}/.kimi-code/local.toml`); - expect(result.additionalDirs).toEqual([sharedDir]); - expect(workspace.additionalDirs).toEqual([sharedDir]); - expect(fs.files.has(`${projectRoot}/.kimi-code/local.toml`)).toBe(false); - }); - - it('expands home-relative dirs against the OS home like v1', async () => { - const homeDir = '/users/test/shared'; - const { svc, workspace } = build([homeDir], true); - - const result = await svc.addAdditionalDir({ path: '~/shared', persist: false }); - - expect(result.additionalDirs).toEqual([homeDir]); - expect(workspace.additionalDirs).toEqual([homeDir]); - }); - - it('treats project-root stat errors as absent git markers like v1', async () => { - const projectRoot = '/repo'; - const { svc, fs } = build([EXTRA_DIR], true, WORK_DIR, `${projectRoot}/.git`); - const error = new Error('EACCES: cannot stat .git') as NodeJS.ErrnoException; - error.code = 'EACCES'; - fs.statErrors.set(`${WORK_DIR}/.git`, error); - - const result = await svc.addAdditionalDir({ path: 'extra', persist: true }); - - expect(result.projectRoot).toBe(projectRoot); - expect(result.configPath).toBe(`${projectRoot}/.kimi-code/local.toml`); - }); - - it('rejects a relative path that does not resolve to an existing directory', async () => { - const { svc } = build([], true); - - await expect(svc.addAdditionalDir({ path: 'missing', persist: true })).rejects.toSatisfy( - (error) => error instanceof Error2 && error.code === ErrorCodes.CONFIG_INVALID, - ); - }); - - it('surfaces a config read IO failure as storage.io_failed', async () => { - const { svc, fs } = build([EXTRA_DIR], true); - const error = new Error('EACCES: permission denied') as NodeJS.ErrnoException; - error.code = 'EACCES'; - fs.readErrors.set(`${WORK_DIR}/.kimi-code/local.toml`, error); - - await expect(svc.addAdditionalDir({ path: 'extra', persist: true })).rejects.toMatchObject({ - code: 'storage.io_failed', - }); - }); - - it('surfaces invalid TOML in the config as storage.decode_failed', async () => { - const { svc, fs } = build([EXTRA_DIR], true); - fs.files.set(`${WORK_DIR}/.kimi-code/local.toml`, 'not [valid toml'); - - await expect(svc.addAdditionalDir({ path: 'extra', persist: true })).rejects.toMatchObject({ - code: 'storage.decode_failed', - }); - }); -}); diff --git a/packages/agent-core-v2/test/session/workspaceContext/stub-workspace-context.ts b/packages/agent-core-v2/test/session/workspaceContext/stub-workspace-context.ts index 3a4fe4b52d..f563c3b4c8 100644 --- a/packages/agent-core-v2/test/session/workspaceContext/stub-workspace-context.ts +++ b/packages/agent-core-v2/test/session/workspaceContext/stub-workspace-context.ts @@ -8,12 +8,8 @@ export function stubWorkspaceContext( _serviceBrand: undefined, workDir, additionalDirs, - setWorkDir: () => {}, - setAdditionalDirs: () => {}, resolve: (rel) => `${workDir}/${rel}`, isWithin: () => true, assertAllowed: (absPath) => absPath, - addAdditionalDir: () => {}, - removeAdditionalDir: () => {}, }; } diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index bdb9de0940..04d3c5b388 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -186,6 +186,7 @@ function profileCatalogWithPreference( get: (name) => [main, target].find((profile) => profile.name === name), getDefault: () => main, list: () => [target], + inspect: () => undefined, load: async () => {}, reload: async () => {}, }; @@ -238,7 +239,7 @@ function createAgentLifecycleStub(options: AgentLifecycleStubOptions = {}): Agen const servicesByAgentId = new Map(options.handleServices); const handle = (agentId: string): IAgentScopeHandle => ({ id: agentId, - kind: 2, + kind: LifecycleScope.Agent, accessor: { get: (serviceId) => { const service = servicesByAgentId.get(agentId)?.get(serviceId); @@ -586,6 +587,7 @@ describe('Agent tool description', () => { get: (name) => profiles.find((profile) => profile.name === name), getDefault: () => restricted, list: () => profiles, + inspect: () => undefined, load: async () => {}, reload: async () => {}, }; @@ -623,6 +625,7 @@ describe('Agent tool description', () => { get: (name) => profiles.find((profile) => profile.name === name), getDefault: () => caller, list: () => [coder, explore], + inspect: () => undefined, load: async () => {}, reload: async () => {}, }; @@ -658,12 +661,12 @@ describe('Agent tool description', () => { get: (name) => [caller, coder, explore].find((profile) => profile.name === name), getDefault: () => caller, list: () => [coder, explore], + inspect: () => undefined, load: async () => {}, reload: async () => {}, }; ctx = createTestAgent(sessionService(ISessionAgentProfileCatalog, catalog)); ctx.get(IAgentProfileService).applyBindingSnapshot({ - cwd: '', profileName: 'deleted-profile', thinkingLevel: 'off', systemPrompt: 'persisted prompt', @@ -803,6 +806,7 @@ describe('Agent tool execution contract', () => { get: (name) => profiles.find((profile) => profile.name === name), getDefault: () => caller, list: () => [coder, explore], + inspect: () => undefined, load: async () => {}, reload: async () => {}, }; @@ -834,7 +838,6 @@ describe('Agent tool execution contract', () => { sessionService(ISessionAgentProfileCatalog, allowlistCatalog(['coder'])), ); context.get(IAgentProfileService).applyBindingSnapshot({ - cwd: '', profileName: 'deleted-profile', thinkingLevel: 'off', systemPrompt: 'persisted prompt', diff --git a/packages/agent-core-v2/test/app/agentFileCatalog/agentFile.test.ts b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentFile.test.ts similarity index 96% rename from packages/agent-core-v2/test/app/agentFileCatalog/agentFile.test.ts rename to packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentFile.test.ts index f7240672b1..eeb74d7391 100644 --- a/packages/agent-core-v2/test/app/agentFileCatalog/agentFile.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentFile.test.ts @@ -10,9 +10,9 @@ import { describe, expect, it } from 'vitest'; -import { AgentFileParseError, parseAgentFileText } from '#/app/agentFileCatalog/agentFile'; -import { agentProfileFromFile } from '#/app/agentFileCatalog/agentProfileFromFile'; -import type { AgentFileDefinition } from '#/app/agentFileCatalog/types'; +import { AgentFileParseError, parseAgentFileText } from '#/workspace/workspaceAgentProfileLoader/internal/agentFile'; +import { agentProfileFromFile } from '#/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile'; +import type { AgentFileDefinition } from '#/workspace/workspaceAgentProfileLoader/internal/types'; const FULL_FILE = `--- name: code-reviewer diff --git a/packages/agent-core-v2/test/app/agentFileCatalog/agentFileDiscovery.test.ts b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentFileDiscovery.test.ts similarity index 97% rename from packages/agent-core-v2/test/app/agentFileCatalog/agentFileDiscovery.test.ts rename to packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentFileDiscovery.test.ts index 66657f5484..b89173fcf9 100644 --- a/packages/agent-core-v2/test/app/agentFileCatalog/agentFileDiscovery.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentFileDiscovery.test.ts @@ -13,8 +13,8 @@ import { tmpdir } from 'node:os'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { discoverAgentFiles } from '#/app/agentFileCatalog/agentFileDiscovery'; -import type { AgentFileRoot } from '#/app/agentFileCatalog/types'; +import { discoverAgentFiles } from '#/workspace/workspaceAgentProfileLoader/internal/agentFileDiscovery'; +import type { AgentFileRoot } from '#/workspace/workspaceAgentProfileLoader/internal/types'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; diff --git a/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts new file mode 100644 index 0000000000..f6cf5f332d --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts @@ -0,0 +1,828 @@ +/** + * Scenario: agent-profile loaders + session catalog — the Contribution / + * Registry / Catalog extension point end to end. Exercises the real loader + * services (builtin / user / plugin / workspace / extra / explicit), the + * App-scope `AgentProfileRegistryService`, and the Session-scope + * `SessionAgentProfileCatalogService` as directly-constructed instances (no DI + * scope host) against real temp directories: source-priority merge, the + * builtin-override rule, explicit fatal semantics, config / plugin-reload / + * fs-watch driven reloads, and SYSTEM.md interplay. Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts`. + */ + +import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; + +import { join } from 'pathe'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Emitter, Event } from '#/_base/event'; +import type { ILogService } from '#/_base/log/log'; +import type { IAgentCatalogRuntimeOptions } from '#/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions'; +import { EXTRA_AGENT_DIRS_SECTION } from '#/workspace/workspaceAgentProfileLoader/configSection'; +import { UserAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService'; +import type { PluginAgentRoot } from '#/app/plugin/types'; +import { + DEFAULT_AGENT_PROFILE_NAME, + type AgentProfile, +} from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { AgentProfileRegistryService } from '#/app/agentProfileCatalog/agentProfileRegistryService'; +import { BuiltinAgentProfileLoaderService } from '#/app/agentProfileCatalog/builtinAgentProfileLoaderService'; +import { + _clearAgentProfileContributionsForTests, + registerAgentProfile, +} from '#/app/agentProfileCatalog/contribution'; +import type { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import type { IConfigService } from '#/app/config/config'; +import { IPluginService } from '#/app/plugin/plugin'; +import { PluginAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService'; +import type { ReloadSummary } from '#/app/plugin/types'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { HostFsWatchService } from '#/os/backends/node-local/hostFsWatchService'; +import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; +import { + type HostFsChange, + type IHostFsWatchHandle, + type IHostFsWatchService, +} from '#/os/interface/hostFsWatch'; +import { SessionAgentProfileCatalogService } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService'; +import type { ISessionAgentProfileCatalogSeed } from '#/session/sessionAgentProfileCatalog/agentProfileCatalogSeed'; +import { ExplicitAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService'; +import { ExtraAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService'; +import { WorkspaceAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService'; +import type { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +import { stubBootstrap } from '../../app/bootstrap/stubs'; + +function configStub(): IConfigService & { + setExtraAgentDirs(dirs: readonly string[]): void; + fireSectionChange(domain: string): void; +} { + let extraAgentDirs: readonly string[] = []; + const sectionChangeListeners: Array<(event: unknown) => void> = []; + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChangeConfiguration: () => ({ dispose: () => {} }), + onDidSectionChange: (listener: (event: unknown) => void) => { + sectionChangeListeners.push(listener); + return { dispose: () => {} }; + }, + get: (domain: string) => + domain === EXTRA_AGENT_DIRS_SECTION ? [...extraAgentDirs] : undefined, + inspect: () => ({ + value: undefined, + defaultValue: undefined, + userValue: undefined, + memoryValue: undefined, + }), + getAll: () => ({}), + set: async () => {}, + replace: async () => {}, + reload: async () => {}, + diagnostics: () => [], + setExtraAgentDirs: (dirs: readonly string[]) => { + extraAgentDirs = [...dirs]; + }, + fireSectionChange: (domain: string) => { + for (const listener of sectionChangeListeners) { + listener({ domain, source: 'set', value: undefined, previousValue: undefined }); + } + }, + } as unknown as IConfigService & { + setExtraAgentDirs(dirs: readonly string[]): void; + fireSectionChange(domain: string): void; + }; +} + +function workspaceContextStub(workDir: string): IWorkspaceContext { + return { + _serviceBrand: undefined, + workspaceId: 'wd_test', + cwd: workDir, + source: 'local', + meta: { id: 'wd_test', root: workDir, name: 'test', createdAt: 0, lastOpenedAt: 0 }, + persistenceScope: 'sessions/wd_test', + osBackendId: 'local', + persistenceBackendId: 'local', + }; +} + +function fsWatchStub(): IHostFsWatchService { + return { + _serviceBrand: undefined, + watch: (): IHostFsWatchHandle => ({ + onDidChange: Event.None as Event, + dispose: () => {}, + }), + }; +} + +function agentMd(name: string, description: string, override = false): string { + const overrideLine = override ? 'override: true\n' : ''; + return `---\nname: ${name}\ndescription: ${description}\n${overrideLine}---\n\nYou are ${name}.\n`; +} + +interface Fixture { + readonly homeDir: string; + readonly osHomeDir: string; + readonly workDir: string; + readonly extraDir: string; +} + +async function withFixture(run: (fixture: Fixture) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'agent-profile-loader-')); + try { + const make = async (dir: string): Promise => { + const p = join(root, dir); + await mkdir(p, { recursive: true }); + return realpath(p); + }; + const [homeDir, osHomeDir, workDir, extraDir] = await Promise.all([ + make('kimi-home'), + make('os-home'), + make('work'), + make('extra-agents'), + ]); + await run({ homeDir, osHomeDir, workDir, extraDir }); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function writeAgent(dir: string, fileName: string, content: string): Promise { + await mkdir(dir, { recursive: true }); + const filePath = join(dir, fileName); + await writeFile(filePath, content); + return filePath; +} + +function logStub(warnings?: string[]): ILogService { + return { + _serviceBrand: undefined, + warn: (message: unknown) => { + warnings?.push(String(message)); + }, + info: () => {}, + error: () => {}, + debug: () => {}, + trace: () => {}, + setLevel: () => {}, + } as unknown as ILogService; +} + +function pluginStub( + agentRoots: readonly PluginAgentRoot[] = [], + reloadEmitter?: Emitter, +): IPluginService { + return { + _serviceBrand: undefined, + onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }), + listPlugins: async () => [], + installPlugin: async () => ({ id: '' }) as never, + setPluginEnabled: async () => {}, + setPluginMcpServerEnabled: async () => {}, + removePlugin: async () => {}, + reloadPlugins: async () => ({ added: [], removed: [], errors: [] }), + getPluginInfo: async () => { + throw new Error('getPluginInfo is not used by these tests'); + }, + listPluginCommands: async () => [], + checkUpdates: async () => [], + pluginSkillRoots: async () => [], + pluginAgentRoots: async () => agentRoots, + enabledSessionStarts: async () => [], + enabledSystemPrompts: async () => [], + enabledMcpServers: async () => ({}), + enabledHooks: async () => [], + }; +} + +function waitForEvent(event: Event): Promise { + return new Promise((resolve) => { + const disposable = event(() => { + disposable.dispose(); + resolve(); + }); + }); +} + +/** + * Wraps a real `HostFileSystem` so `readdir` throws an injected + * `os.fs.unavailable` whenever `shouldFail` accepts the path — the failure + * class that propagates out of discovery and exercises the loader failure + * policy (non-fatal warn / fatal reject). + */ +function failingReaddirFs( + hostFs: HostFileSystem, + shouldFail: (path: string) => boolean, +): HostFileSystem { + const failing = Object.create(hostFs) as HostFileSystem; + failing.readdir = async (path: string) => { + if (shouldFail(path)) { + throw new HostFsError( + OsFsErrors.codes.OS_FS_UNAVAILABLE, + `injected readdir failure for ${path}`, + ); + } + return hostFs.readdir(path); + }; + return failing; +} + +interface StackOptions { + readonly extraAgentDirs?: readonly string[]; + readonly explicitFiles?: readonly string[]; + readonly pluginAgentRoots?: readonly PluginAgentRoot[]; + readonly pluginReloadEmitter?: Emitter; + readonly hostFs?: HostFileSystem; + readonly fsWatch?: IHostFsWatchService; +} + +/** + * Builds the whole loader stack as directly-constructed instances sharing one + * registry, plus the session catalog projected over it. Loaders start their + * first load in their constructors, so callers `await stack.ready()` (or the + * individual `loader.ready`) before asserting. + */ +function makeStack(fixture: Fixture, opts?: StackOptions) { + const warnings: string[] = []; + const log = logStub(warnings); + const config = configStub(); + if (opts?.extraAgentDirs !== undefined) config.setExtraAgentDirs(opts.extraAgentDirs); + const bootstrap: IBootstrapService = { + ...stubBootstrap(fixture.homeDir), + osHomeDir: fixture.osHomeDir, + }; + const hostFs = opts?.hostFs ?? new HostFileSystem(); + const workspaceContext = workspaceContextStub(fixture.workDir); + const runtimeOptions = { + _serviceBrand: undefined, + explicitFiles: opts?.explicitFiles, + } as unknown as IAgentCatalogRuntimeOptions; + + const registry = new AgentProfileRegistryService(); + const builtinLoader = new BuiltinAgentProfileLoaderService(registry); + const userLoader = new UserAgentProfileLoaderService( + bootstrap, + hostFs, + log, + builtinLoader, + registry, + workspaceContext, + ); + const pluginLoader = new PluginAgentProfileLoaderService( + pluginStub(opts?.pluginAgentRoots ?? [], opts?.pluginReloadEmitter), + hostFs, + log, + userLoader, + registry, + workspaceContext, + ); + const workspaceLoader = new WorkspaceAgentProfileLoaderService( + workspaceContext, + hostFs, + log, + userLoader, + opts?.fsWatch ?? fsWatchStub(), + registry, + ); + const extraLoader = new ExtraAgentProfileLoaderService( + config, + workspaceContext, + bootstrap, + hostFs, + log, + userLoader, + registry, + ); + const explicitLoader = new ExplicitAgentProfileLoaderService( + runtimeOptions, + workspaceContext, + bootstrap, + hostFs, + log, + userLoader, + registry, + ); + const seed: ISessionAgentProfileCatalogSeed = { + _serviceBrand: undefined, + workspaceKey: workspaceContext.workspaceId, + }; + const catalog = new SessionAgentProfileCatalogService(registry, seed, log); + + return { + registry, + builtinLoader, + userLoader, + pluginLoader, + workspaceLoader, + extraLoader, + explicitLoader, + catalog, + config, + warnings, + /** Awaits every loader's latest load pass plus the catalog readiness. */ + async ready(): Promise { + await Promise.all([ + userLoader.ready, + pluginLoader.ready, + workspaceLoader.ready, + extraLoader.ready, + explicitLoader.ready, + ]); + await catalog.ready; + }, + dispose(): void { + for (const disposable of [ + catalog, + explicitLoader, + extraLoader, + workspaceLoader, + pluginLoader, + userLoader, + builtinLoader, + registry, + ]) { + disposable.dispose(); + } + }, + }; +} + +type Stack = ReturnType; + +async function withStack( + fixture: Fixture, + opts: StackOptions | undefined, + run: (stack: Stack) => Promise, +): Promise { + const stack = makeStack(fixture, opts); + try { + await run(stack); + } finally { + stack.dispose(); + } +} + +describe('agent profile loaders + session catalog', () => { + beforeEach(() => { + // The builtin loader snapshots the module-level contributions on + // construction; pin them to one known default profile per test. + _clearAgentProfileContributionsForTests(); + const builtinDefault: AgentProfile = { + name: DEFAULT_AGENT_PROFILE_NAME, + description: 'builtin default', + systemPrompt: () => 'BUILTIN PROMPT', + }; + registerAgentProfile(builtinDefault); + }); + + it('lists builtin profiles when no agent directories exist', async () => { + await withFixture(async (fixture) => { + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + expect(stack.catalog.get(DEFAULT_AGENT_PROFILE_NAME)?.description).toBe('builtin default'); + expect(stack.catalog.getDefault().name).toBe(DEFAULT_AGENT_PROFILE_NAME); + expect(stack.catalog.list().length).toBeGreaterThan(0); + expect(stack.catalog.inspect(DEFAULT_AGENT_PROFILE_NAME)?.sourceId).toBe('builtin'); + }); + }); + }); + + it('merges user and workspace agents; workspace wins on name collision', async () => { + await withFixture(async (fixture) => { + await writeAgent(join(fixture.homeDir, 'agents'), 'shared.md', agentMd('shared', 'from user')); + await writeAgent(join(fixture.homeDir, 'agents'), 'user-only.md', agentMd('user-only', 'user agent')); + await writeAgent( + join(fixture.workDir, '.kimi-code', 'agents'), + 'shared.md', + agentMd('shared', 'from project'), + ); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + expect(stack.catalog.get('shared')?.description).toBe('from project'); + expect(stack.catalog.get('user-only')?.description).toBe('user agent'); + const inspection = stack.catalog.inspect('shared'); + expect(inspection?.sourceId).toBe('workspace'); + expect(inspection?.suppressed).toContainEqual({ + sourceId: 'user', + priority: 10, + reason: 'priority', + }); + }); + }); + }); + + it('orders sources user < extra < workspace < explicit', async () => { + await withFixture(async (fixture) => { + await writeAgent(join(fixture.homeDir, 'agents'), 'shared.md', agentMd('shared', 'from user')); + await writeAgent(join(fixture.homeDir, 'agents'), 'user-extra.md', agentMd('user-extra', 'from user')); + await writeAgent(fixture.extraDir, 'shared.md', agentMd('shared', 'from extra')); + await writeAgent(fixture.extraDir, 'user-extra.md', agentMd('user-extra', 'from extra')); + await writeAgent( + join(fixture.workDir, '.kimi-code', 'agents'), + 'shared.md', + agentMd('shared', 'from project'), + ); + const explicitFile = await writeAgent( + fixture.workDir, + 'explicit.md', + agentMd('shared', 'from explicit'), + ); + await withStack( + fixture, + { extraAgentDirs: [fixture.extraDir], explicitFiles: [explicitFile] }, + async (stack) => { + await stack.ready(); + + expect(stack.catalog.get('shared')?.description).toBe('from explicit'); + expect(stack.catalog.get('user-extra')?.description).toBe('from extra'); + expect(stack.catalog.inspect('shared')?.sourceId).toBe('explicit'); + }, + ); + }); + }); + + it('merges plugin agents below user; user wins on name collision', async () => { + await withFixture(async (fixture) => { + const pluginAgentsDir = join(fixture.extraDir, 'plugin-agents'); + await writeAgent(pluginAgentsDir, 'shared.md', agentMd('shared', 'from plugin')); + await writeAgent(pluginAgentsDir, 'plugin-only.md', agentMd('plugin-only', 'from plugin')); + await writeAgent(join(fixture.homeDir, 'agents'), 'shared.md', agentMd('shared', 'from user')); + await withStack( + fixture, + { pluginAgentRoots: [{ path: pluginAgentsDir, source: 'plugin' }] }, + async (stack) => { + await stack.ready(); + + expect(stack.catalog.get('shared')?.description).toBe('from user'); + expect(stack.catalog.get('plugin-only')?.description).toBe('from plugin'); + }, + ); + }); + }); + + it('reloads the plugin loader when plugins reload', async () => { + await withFixture(async (fixture) => { + const pluginAgentsDir = join(fixture.extraDir, 'plugin-agents'); + await mkdir(pluginAgentsDir, { recursive: true }); + const reloadEmitter = new Emitter(); + await withStack( + fixture, + { + pluginAgentRoots: [{ path: pluginAgentsDir, source: 'plugin' }], + pluginReloadEmitter: reloadEmitter, + }, + async (stack) => { + await stack.ready(); + expect(stack.catalog.get('late')).toBeUndefined(); + + await writeAgent(pluginAgentsDir, 'late.md', agentMd('late', 'late plugin agent')); + const changed = waitForEvent(stack.catalog.onDidChange); + reloadEmitter.fire({ added: [], removed: [], errors: [] }); + await changed; + + expect(stack.catalog.get('late')?.description).toBe('late plugin agent'); + }, + ); + }); + }); + + it('fails ready when an explicit agent file is invalid', async () => { + await withFixture(async (fixture) => { + const bad = await writeAgent( + fixture.workDir, + 'bad.md', + '---\nname: bad\n---\n\nbody\n', + ); + await withStack(fixture, { explicitFiles: [bad] }, async (stack) => { + await expect(stack.explicitLoader.ready).rejects.toThrow(/description/i); + }); + }); + }); + + it('fails ready when an explicit agent file does not exist', async () => { + await withFixture(async (fixture) => { + await withStack( + fixture, + { explicitFiles: [join(fixture.workDir, 'missing.md')] }, + async (stack) => { + await expect(stack.explicitLoader.ready).rejects.toMatchObject({ + code: 'os.fs.not_found', + }); + }, + ); + }); + }); + + it('recovers ready after a reload fixes a previously fatal explicit file', async () => { + await withFixture(async (fixture) => { + const bad = await writeAgent( + fixture.workDir, + 'bad.md', + '---\nname: bad\n---\n\nbody\n', + ); + await withStack(fixture, { explicitFiles: [bad] }, async (stack) => { + await expect(stack.explicitLoader.ready).rejects.toThrow(/description/i); + + await writeFile(bad, agentMd('fixed', 'fixed agent')); + await stack.explicitLoader.reload(); + + await expect(stack.explicitLoader.ready).resolves.toBeUndefined(); + expect(stack.catalog.get('fixed')?.description).toBe('fixed agent'); + }); + }); + }); + + it('resolves relative explicit files against the workspace root', async () => { + await withFixture(async (fixture) => { + await writeAgent( + join(fixture.workDir, 'agents'), + 'solo.md', + agentMd('solo', 'relative explicit'), + ); + await withStack(fixture, { explicitFiles: ['agents/solo.md'] }, async (stack) => { + await stack.ready(); + + expect(stack.catalog.get('solo')?.description).toBe('relative explicit'); + }); + }); + }); + + it('reloads the extra loader when extraAgentDirs changes', async () => { + await withFixture(async (fixture) => { + await writeAgent(fixture.extraDir, 'from-extra.md', agentMd('from-extra', 'extra agent')); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + expect(stack.catalog.get('from-extra')).toBeUndefined(); + + stack.config.setExtraAgentDirs([fixture.extraDir]); + const changed = waitForEvent(stack.catalog.onDidChange); + stack.config.fireSectionChange(EXTRA_AGENT_DIRS_SECTION); + await changed; + + expect(stack.catalog.get('from-extra')?.description).toBe('extra agent'); + }); + }); + }); + + it('skips invalid workspace files and still loads valid ones', async () => { + await withFixture(async (fixture) => { + const badPath = await writeAgent( + join(fixture.workDir, '.kimi-code', 'agents'), + 'bad.md', + '---\nname: bad\n---\n\nbody\n', + ); + await writeAgent(join(fixture.workDir, '.kimi-code', 'agents'), 'good.md', agentMd('good', 'valid')); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + expect(stack.catalog.get('good')?.description).toBe('valid'); + expect(stack.catalog.get('bad')).toBeUndefined(); + const workspaceEntry = stack.registry.entries().find((e) => e.sourceId === 'workspace'); + expect(workspaceEntry?.contribution.skipped).toHaveLength(1); + expect(workspaceEntry?.contribution.skipped?.[0]?.path).toBe(badPath); + }); + }); + }); + + it('keeps the builtin default when a same-name file does not opt in to override', async () => { + await withFixture(async (fixture) => { + await writeAgent( + join(fixture.workDir, '.kimi-code', 'agents'), + 'agent.md', + agentMd('agent', 'project default override'), + ); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + expect(stack.catalog.getDefault().description).toBe('builtin default'); + expect(stack.catalog.getDefault().description).not.toBe('project default override'); + const inspection = stack.catalog.inspect(DEFAULT_AGENT_PROFILE_NAME); + expect(inspection?.sourceId).toBe('builtin'); + expect(inspection?.suppressed).toContainEqual({ + sourceId: 'workspace', + priority: 30, + reason: 'builtin-override-required', + }); + }); + }); + }); + + it('lets a file profile explicitly override the builtin default', async () => { + await withFixture(async (fixture) => { + await writeAgent( + join(fixture.workDir, '.kimi-code', 'agents'), + 'agent.md', + agentMd('agent', 'project default override', true), + ); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + expect(stack.catalog.getDefault().description).toBe('project default override'); + expect(stack.catalog.inspect(DEFAULT_AGENT_PROFILE_NAME)?.sourceId).toBe('workspace'); + }); + }); + }); + + it('falls back to a valid lower-priority override when the higher candidate does not opt in', async () => { + await withFixture(async (fixture) => { + await writeAgent( + join(fixture.homeDir, 'agents'), + 'agent.md', + agentMd('agent', 'user default override', true), + ); + await writeAgent( + join(fixture.workDir, '.kimi-code', 'agents'), + 'agent.md', + agentMd('agent', 'project default without override'), + ); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + expect(stack.catalog.getDefault().description).toBe('user default override'); + expect(stack.catalog.getDefault().description).not.toBe('project default without override'); + expect(stack.catalog.inspect(DEFAULT_AGENT_PROFILE_NAME)?.sourceId).toBe('user'); + }); + }); + }); + + it('keeps builtin profiles and warns when a non-fatal loader fails its first load', async () => { + await withFixture(async (fixture) => { + await mkdir(join(fixture.homeDir, 'agents'), { recursive: true }); + const hostFs = failingReaddirFs(new HostFileSystem(), () => true); + await withStack(fixture, { hostFs }, async (stack) => { + // A non-fatal first-load failure degrades to a warning; `ready` still + // resolves and nothing replaces the builtin contribution. + await stack.ready(); + + expect(stack.catalog.get(DEFAULT_AGENT_PROFILE_NAME)?.description).toBe('builtin default'); + expect(stack.warnings.some((w) => w.includes('"user"') && w.includes('load failed'))).toBe( + true, + ); + expect(stack.registry.entries().some((e) => e.sourceId === 'user')).toBe(false); + }); + }); + }); + + it('keeps the previous contribution when a non-fatal loader reload fails', async () => { + await withFixture(async (fixture) => { + await writeAgent( + join(fixture.homeDir, 'agents'), + 'file-agent.md', + agentMd('file-agent', 'from file'), + ); + let fail = false; + const hostFs = failingReaddirFs(new HostFileSystem(), () => fail); + await withStack(fixture, { hostFs }, async (stack) => { + await stack.ready(); + expect(stack.catalog.get('file-agent')?.description).toBe('from file'); + + fail = true; + await stack.userLoader.reload(); + + expect(stack.warnings.some((w) => w.includes('load failed'))).toBe(true); + expect(stack.catalog.get('file-agent')?.description).toBe('from file'); + const userEntry = stack.registry.entries().find((e) => e.sourceId === 'user'); + expect(userEntry?.contribution.profiles.map((p) => p.name)).toContain('file-agent'); + }); + }); + }); + + it('warns and keeps stale data when a fatal loader reload fails', async () => { + await withFixture(async (fixture) => { + const explicitFile = await writeAgent( + fixture.workDir, + 'explicit.md', + agentMd('exp-agent', 'explicit'), + ); + await withStack(fixture, { explicitFiles: [explicitFile] }, async (stack) => { + await stack.ready(); + expect(stack.catalog.get('exp-agent')?.description).toBe('explicit'); + + await rm(explicitFile, { force: true }); + // Mirror the production event-handler call style + // (`void loader.reload().catch(warn)`): a rejecting fatal reload must + // surface as a warning, never crash, and leave the stale contribution. + void stack.explicitLoader + .reload() + .catch((error) => + stack.warnings.push(`agent profile loader "explicit" reload failed: ${String(error)}`), + ); + await vi.waitFor(() => { + expect(stack.warnings.some((w) => w.includes('reload failed'))).toBe(true); + }); + + expect(stack.catalog.get('exp-agent')?.description).toBe('explicit'); + const explicitEntry = stack.registry.entries().find((e) => e.sourceId === 'explicit'); + expect(explicitEntry?.contribution.profiles.map((p) => p.name)).toContain('exp-agent'); + }); + }); + }); + + it('replaces the builtin default system prompt with user-level SYSTEM.md', async () => { + await withFixture(async (fixture) => { + await writeFile( + join(fixture.homeDir, 'SYSTEM.md'), + 'You are a custom main agent. cwd=${cwd} unknown=${nope}', + ); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + const prompt = stack.catalog.getDefault().systemPrompt({ cwd: '/work/dir' }); + expect(prompt).toContain('You are a custom main agent.'); + expect(prompt).toContain('cwd=/work/dir'); + expect(prompt).toContain('unknown=${nope}'); + }); + }); + }); + + it('lets SYSTEM.md win over a same-name scanned user agent file', async () => { + await withFixture(async (fixture) => { + await writeAgent( + join(fixture.homeDir, 'agents'), + 'agent.md', + agentMd('agent', 'user agents dir default', true), + ); + await writeFile(join(fixture.homeDir, 'SYSTEM.md'), 'system md prompt'); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + const defaultProfile = stack.catalog.getDefault(); + expect(defaultProfile.systemPrompt({})).toContain('system md prompt'); + expect(defaultProfile.systemPrompt({})).not.toContain('You are agent.'); + }); + }); + }); + + it('lets a same-name workspace agent file win over user-level SYSTEM.md', async () => { + await withFixture(async (fixture) => { + await writeFile(join(fixture.homeDir, 'SYSTEM.md'), 'system md prompt'); + await writeAgent( + join(fixture.workDir, '.kimi-code', 'agents'), + 'agent.md', + agentMd('agent', 'project default override', true), + ); + await withStack(fixture, undefined, async (stack) => { + await stack.ready(); + + expect(stack.catalog.getDefault().description).toBe('project default override'); + expect(stack.catalog.getDefault().systemPrompt({})).not.toContain('system md prompt'); + }); + }); + }); + + it('lets an explicit agent file win over user-level SYSTEM.md', async () => { + await withFixture(async (fixture) => { + await writeFile(join(fixture.homeDir, 'SYSTEM.md'), 'system md prompt'); + const explicitFile = await writeAgent( + fixture.workDir, + 'explicit.md', + agentMd('agent', 'explicit default override', true), + ); + await withStack(fixture, { explicitFiles: [explicitFile] }, async (stack) => { + await stack.ready(); + + expect(stack.catalog.getDefault().description).toBe('explicit default override'); + expect(stack.catalog.getDefault().systemPrompt({})).not.toContain('system md prompt'); + }); + }); + }); + + it('rescans the workspace source when a project agent file changes on disk', async () => { + await withFixture(async (fixture) => { + // Pre-create the candidate directory so the chokidar initial scan sees + // it; a file written afterwards is guaranteed a non-initial `add` event. + await mkdir(join(fixture.workDir, '.kimi-code', 'agents'), { recursive: true }); + await withStack(fixture, { fsWatch: new HostFsWatchService() }, async (stack) => { + await stack.ready(); + expect(stack.catalog.get('watched-agent')).toBeUndefined(); + + const refreshed = new Promise((resolvePromise) => { + const d = stack.catalog.onDidChange((sourceId) => { + if (sourceId !== 'workspace') return; + d.dispose(); + resolvePromise(sourceId); + }); + }); + const timedOut = new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('watch-driven refresh timed out')), 10000); + }); + // Let the watcher finish its initial scan before the change lands. + await new Promise((resolve) => setTimeout(resolve, 300)); + await writeAgent( + join(fixture.workDir, '.kimi-code', 'agents'), + 'watched-agent.md', + agentMd('watched-agent', 'from watch'), + ); + + await expect(Promise.race([refreshed, timedOut])).resolves.toBe('workspace'); + expect(stack.catalog.get('watched-agent')?.description).toBe('from watch'); + }); + }); + }, 15000); +}); diff --git a/packages/agent-core-v2/test/app/agentFileCatalog/agentRoots.test.ts b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentRoots.test.ts similarity index 99% rename from packages/agent-core-v2/test/app/agentFileCatalog/agentRoots.test.ts rename to packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentRoots.test.ts index 3530d83ce3..857201f198 100644 --- a/packages/agent-core-v2/test/app/agentFileCatalog/agentRoots.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentRoots.test.ts @@ -16,7 +16,7 @@ import { configuredAgentRoots, projectAgentRoots, userAgentRoots, -} from '#/app/agentFileCatalog/agentRoots'; +} from '#/workspace/workspaceAgentProfileLoader/internal/agentRoots'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; diff --git a/packages/agent-core-v2/test/app/agentFileCatalog/systemFile.test.ts b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/systemFile.test.ts similarity index 98% rename from packages/agent-core-v2/test/app/agentFileCatalog/systemFile.test.ts rename to packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/systemFile.test.ts index 4a23d49d70..4a3c91c011 100644 --- a/packages/agent-core-v2/test/app/agentFileCatalog/systemFile.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/systemFile.test.ts @@ -23,7 +23,7 @@ import { import { SYSTEM_MD_FILENAME, loadSystemMdProfile, -} from '#/app/agentFileCatalog/systemFile'; +} from '#/workspace/workspaceAgentProfileLoader/internal/systemFile'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; diff --git a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts new file mode 100644 index 0000000000..89df2cadbb --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts @@ -0,0 +1,486 @@ +/** + * Scenario: workspace-level add-dir (the phase-3.5 behavior contract). + * + * Drives the REAL handler chain (WorkspaceLifecycleService → + * WorkspaceHandlerService) with the real `WorkspaceDirsService`, the real + * node-fs `FileProjectLocalConfigService`, the real fs watch service, and + * the real Session-scope `workspaceContext` view, and proves: + * - a persisted `addDir` writes `.kimi-code/local.toml` and refreshes every + * live session's view of the workspace; + * - a second session of the same workspace sees the dir immediately; + * - a handler rebuilt from scratch (simulated restart) keeps the persisted + * dir; + * - `persist: false` stays in handler memory and never touches the disk; + * - an external `local.toml` edit (another process) refreshes live session + * views through the fs watch. + * Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceDirs/workspaceDirs.test.ts`. + */ + +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + LifecycleScope, + ScopeActivation, + _clearScopedRegistryForTests, + registerScopedService, + type ISessionScopeHandle, +} from '#/_base/di/scope'; +import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test'; +import { Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; +import { IEventService } from '#/app/event/event'; +import { + IProjectLocalConfigService, +} from '#/app/projectLocalConfig/projectLocalConfig'; +import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; +import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { WorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycleService'; +import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; +import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import { HostFsWatchService } from '#/os/backends/node-local/hostFsWatchService'; +import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { FileProjectLocalConfigService } from '#/persistence/backends/node-fs/projectLocalConfigService'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { ISessionProcessRunner } from '#/session/process/processRunner'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; +import { IWorkspaceAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; +import { IExtraAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader'; +import { IExplicitAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader'; +import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService'; +import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; +import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; +import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; +import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; + +import { stubLog } from '../../_base/log/stubs'; + +function workspaceCatalogStub(): IWorkspaceService { + const workspaces = new Map(); + return { + _serviceBrand: undefined, + list: () => Promise.resolve([...workspaces.values()]), + get: (id) => Promise.resolve(workspaces.get(id)), + createOrTouch: (root, name) => { + const id = encodeWorkDirKey(root); + const workspace: Workspace = workspaces.get(id) ?? { + id, + root, + name: name ?? 'proj', + createdAt: 1, + lastOpenedAt: 1, + }; + workspaces.set(id, workspace); + return Promise.resolve(workspace); + }, + update: () => Promise.resolve(undefined), + delete: () => Promise.resolve(), + }; +} + +function workspaceSkillCatalogStub(): IWorkspaceSkillCatalog { + const catalog = { + getSkill: () => undefined, + getPluginSkill: () => undefined, + renderSkillPrompt: () => '', + listSkills: () => [], + listInvocableSkills: () => [], + getSkillRoots: () => [], + getSkippedByPolicy: () => [], + getModelSkillListing: () => '', + }; + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + catalog, + onDidChange: Event.None, + load: () => Promise.resolve(), + reload: () => Promise.resolve(), + sessionData: () => ({ + _serviceBrand: undefined, + ready: Promise.resolve(), + catalog, + onDidChange: Event.None, + }), + } as unknown as IWorkspaceSkillCatalog; +} + +function agentProfileLoaderStub(): IWorkspaceAgentProfileLoader { + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + reload: () => Promise.resolve(), + }; +} + +function userAgentProfileLoaderStub(): IUserAgentProfileLoader { + return { + ...agentProfileLoaderStub(), + getDefaultProfile: () => { + throw new Error('not implemented'); + }, + }; +} + +function workspaceInstructionsStub(): IWorkspaceInstructionsService { + const provider = { + _serviceBrand: undefined, + ready: Promise.resolve(), + agentsMd: undefined, + agentsMdWarning: undefined, + onDidChange: Event.None, + }; + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + snapshot: { agentsMd: undefined, agentsMdWarning: undefined }, + onDidChange: Event.None, + reload: () => Promise.resolve(), + sessionProvider: () => provider, + } as unknown as IWorkspaceInstructionsService; +} + +function workspaceMcpStub(): IWorkspaceMcpService { + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + sessionHandle: () => ({ + _serviceBrand: undefined, + ready: Promise.resolve(), + get connectionManager(): never { + throw new Error('not implemented'); + }, + }), + } as unknown as IWorkspaceMcpService; +} + +describe('workspace add-dir (handler chain)', () => { + let hosts: ScopedTestHost[]; + let tmpRoots: string[]; + + beforeEach(() => { + _clearScopedRegistryForTests(); + hosts = []; + tmpRoots = []; + registerScopedService( + LifecycleScope.App, + IWorkspaceLifecycleService, + WorkspaceLifecycleService, + ScopeActivation.OnScopeCreated, + 'workspaceLifecycle', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceHandlerService, + WorkspaceHandlerService, + ScopeActivation.OnScopeCreated, + 'workspaceHandler', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceToolPolicy, + WorkspaceToolPolicyService, + ScopeActivation.OnScopeCreated, + 'workspaceToolPolicy', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceDirs, + WorkspaceDirsService, + ScopeActivation.OnScopeCreated, + 'workspaceDirs', + ); + registerScopedService( + LifecycleScope.Session, + ISessionStateService, + SessionStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); + registerScopedService( + LifecycleScope.Session, + ISessionWorkspaceContext, + SessionWorkspaceContextService, + ScopeActivation.OnScopeCreated, + 'workspaceContext', + ); + }); + + afterEach(async () => { + for (const host of hosts.splice(0)) { + host.dispose(); + } + await Promise.all(tmpRoots.map((root) => rm(root, { recursive: true, force: true }))); + }); + + async function makeRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)); + tmpRoots.push(root); + return root; + } + + /** A project root with a `.git` marker so local.toml lands at the root. */ + async function makeProjectRoot(): Promise { + const root = await makeRoot('kimi-add-dir-proj-'); + await mkdir(join(root, '.git')); + return root; + } + + function buildHost(homeDir: string): ScopedTestHost { + const bootstrap = { + _serviceBrand: undefined, + homeDir, + osHomeDir: homeDir, + scope: (name: string) => name, + } as unknown as IBootstrapService; + const hostFs = new HostFileSystem(); + const host = createScopedTestHost([ + stubPair(IBootstrapService, bootstrap), + stubPair(IHostEnvironment, { + _serviceBrand: undefined, + osKind: 'Linux', + homeDir, + ready: Promise.resolve(), + } as unknown as IHostEnvironment), + stubPair(IHostFileSystem, hostFs), + stubPair(IHostFsWatchService, new HostFsWatchService()), + stubPair(ILogService, stubLog()), + stubPair(IConfigService, { + _serviceBrand: undefined, + ready: Promise.resolve(), + get: () => undefined, + onDidSectionChange: () => ({ dispose: () => {} }), + } as unknown as IConfigService), + stubPair(ITelemetryService, noopTelemetryService), + stubPair(IWorkspaceService, workspaceCatalogStub()), + stubPair(ISessionIndex, { + _serviceBrand: undefined, + list: () => Promise.resolve({ items: [], total: 0, hasMore: false }), + get: () => Promise.resolve(undefined), + countActive: () => Promise.resolve(0), + } as unknown as ISessionIndex), + stubPair(IAppendLogStore, { + _serviceBrand: undefined, + append: () => {}, + read: async function* () {}, + rewrite: () => Promise.resolve(), + flush: () => Promise.resolve(), + close: () => Promise.resolve(), + acquire: () => ({ dispose: () => {} }), + } as unknown as IAppendLogStore), + stubPair(IAtomicDocumentStore, { + _serviceBrand: undefined, + get: () => Promise.resolve(undefined), + set: () => Promise.resolve(), + delete: () => Promise.resolve(), + list: () => Promise.resolve([]), + watch: () => Event.None, + acquire: () => ({ dispose: () => {} }), + } as unknown as IAtomicDocumentStore), + stubPair(IEventService, { + _serviceBrand: undefined, + publish: () => {}, + subscribe: () => ({ dispose: () => {} }), + } as unknown as IEventService), + stubPair(ICronTaskPersistence, { + _serviceBrand: undefined, + list: () => Promise.resolve([]), + } as unknown as ICronTaskPersistence), + stubPair( + IProjectLocalConfigService, + new FileProjectLocalConfigService(bootstrap, hostFs), + ), + stubPair(ISessionMetadata, { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChangeMetadata: () => ({ dispose: () => {} }), + read: () => Promise.resolve({} as never), + update: () => Promise.resolve(), + setTitle: () => Promise.resolve(), + setArchived: () => Promise.resolve(), + registerAgent: () => Promise.resolve(), + } as unknown as ISessionMetadata), + stubPair(ISessionToolPolicy, { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChange: () => ({ dispose: () => {} }), + disabledTools: () => [], + setDisabledTools: () => Promise.resolve(), + } as unknown as ISessionToolPolicy), + stubPair(ISessionProcessRunner, { + _serviceBrand: undefined, + exec: () => Promise.reject(new Error('process exec is not supported in this test')), + } satisfies ISessionProcessRunner), + stubPair(IAgentLifecycleService, { + _serviceBrand: undefined, + onDidCreate: () => ({ dispose: () => {} }), + onDidDispose: () => ({ dispose: () => {} }), + create: () => Promise.reject(new Error('not implemented')), + fork: () => Promise.reject(new Error('not implemented')), + get: () => undefined, + list: () => [], + remove: () => Promise.resolve(), + broadcastPermissionMode: () => {}, + } as unknown as IAgentLifecycleService), + stubPair(IWorkspaceSkillCatalog, workspaceSkillCatalogStub()), + stubPair(IWorkspaceAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IExtraAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IExplicitAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IUserAgentProfileLoader, userAgentProfileLoaderStub()), + stubPair(IPluginAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IWorkspaceInstructionsService, workspaceInstructionsStub()), + stubPair(IWorkspaceMcpService, workspaceMcpStub()), + ]); + hosts.push(host); + return host; + } + + async function handlerFor( + host: ScopedTestHost, + root: string, + ): Promise<{ service: IWorkspaceHandlerService; dirs: IWorkspaceDirs }> { + const handler = await host.app.accessor.get(IWorkspaceLifecycleService).handlerFor({ root }); + return { + service: handler.accessor.get(IWorkspaceHandlerService), + dirs: handler.accessor.get(IWorkspaceDirs), + }; + } + + function dirsOf(handle: ISessionScopeHandle): readonly string[] { + return handle.accessor.get(ISessionWorkspaceContext).additionalDirs; + } + + it('persists addDir to local.toml and every session of the workspace sees the dir', async () => { + const homeDir = await makeRoot('kimi-add-dir-home-'); + const root = await makeProjectRoot(); + const extra = await makeRoot('kimi-add-dir-extra-'); + const host = buildHost(homeDir); + const { service, dirs } = await handlerFor(host, root); + + const s1 = await service.create({ sessionId: 's1', workDir: root }); + expect(dirsOf(s1)).toEqual([]); + + const result = await dirs.addDir({ path: extra, persist: true }); + + expect(result.persisted).toBe(true); + expect(result.projectRoot).toBe(root); + expect(result.configPath).toBe(join(root, '.kimi-code', 'local.toml')); + expect(result.additionalDirs).toEqual([extra]); + // local.toml written on disk. + const toml = await readFile(join(root, '.kimi-code', 'local.toml'), 'utf8'); + expect(toml).toContain('additional_dir'); + expect(toml).toContain(extra); + // The live session's view refreshed through the change event. + expect(dirsOf(s1)).toEqual([extra]); + // A second session of the same workspace sees it immediately. + const s2 = await service.create({ sessionId: 's2', workDir: root }); + expect(dirsOf(s2)).toEqual([extra]); + }); + + it('keeps the persisted dir across a handler rebuild (simulated restart)', async () => { + const homeDir = await makeRoot('kimi-add-dir-home-'); + const root = await makeProjectRoot(); + const extra = await makeRoot('kimi-add-dir-extra-'); + const first = buildHost(homeDir); + const firstHandler = await handlerFor(first, root); + await firstHandler.service.create({ sessionId: 's1', workDir: root }); + await firstHandler.dirs.addDir({ path: extra, persist: true }); + first.dispose(); + hosts = hosts.filter((h) => h !== first); + + const second = buildHost(homeDir); + const secondHandler = await handlerFor(second, root); + const s2 = await secondHandler.service.create({ sessionId: 's2', workDir: root }); + expect(dirsOf(s2)).toEqual([extra]); + }); + + it('persist: false stays in handler memory, never touches the disk, and is shared', async () => { + const homeDir = await makeRoot('kimi-add-dir-home-'); + const root = await makeProjectRoot(); + const extra = await makeRoot('kimi-add-dir-extra-'); + const host = buildHost(homeDir); + const { service, dirs } = await handlerFor(host, root); + const s1 = await service.create({ sessionId: 's1', workDir: root }); + + const result = await dirs.addDir({ path: extra, persist: false }); + + expect(result.persisted).toBe(false); + expect(result.additionalDirs).toEqual([extra]); + expect(dirsOf(s1)).toEqual([extra]); + // Nothing written: local.toml does not exist. + await expect(readFile(join(root, '.kimi-code', 'local.toml'), 'utf8')).rejects.toThrow(); + // The in-memory dir is shared with a second session of the workspace. + const s2 = await service.create({ sessionId: 's2', workDir: root }); + expect(dirsOf(s2)).toEqual([extra]); + }); + + it('refreshes live session views when another process edits local.toml', async () => { + const homeDir = await makeRoot('kimi-add-dir-home-'); + const root = await makeProjectRoot(); + const extra = await makeRoot('kimi-add-dir-extra-'); + const host = buildHost(homeDir); + const { service } = await handlerFor(host, root); + const s1 = await service.create({ sessionId: 's1', workDir: root }); + expect(dirsOf(s1)).toEqual([]); + + // External write (another process, an editor, `kimi` in a second CLI). + await mkdir(join(root, '.kimi-code'), { recursive: true }); + const writeLocalToml = () => + writeFile(join(root, '.kimi-code', 'local.toml'), `[workspace]\nadditional_dir = ["${extra}"]\n`); + await writeLocalToml(); + + // The chokidar watcher ignores files it finds during its initial scan + // (`ignoreInitial`), so a write landing inside that window is swallowed; + // rewrite while polling (slower than the 200ms reload debounce, so the + // debounce always gets a quiet window) until a `modify` event lands. + const deadline = Date.now() + 10_000; + while (!dirsOf(s1).includes(extra)) { + if (Date.now() > deadline) { + throw new Error('timed out waiting for watch-driven local.toml reload'); + } + await writeLocalToml(); + await new Promise((resolve) => setTimeout(resolve, 400)); + } + }, 15_000); + + it('unions caller additionalDirs from create options into the shared set', async () => { + const homeDir = await makeRoot('kimi-add-dir-home-'); + const root = await makeProjectRoot(); + const extra = await makeRoot('kimi-add-dir-extra-'); + const host = buildHost(homeDir); + const { service } = await handlerFor(host, root); + + const s1 = await service.create({ sessionId: 's1', workDir: root, additionalDirs: [extra] }); + expect(dirsOf(s1)).toEqual([extra]); + // Caller dirs join the handler-shared set: a session created WITHOUT the + // option sees them too, and nothing was persisted. + const s2 = await service.create({ sessionId: 's2', workDir: root }); + expect(dirsOf(s2)).toEqual([extra]); + await expect(readFile(join(root, '.kimi-code', 'local.toml'), 'utf8')).rejects.toThrow(); + }); +}); diff --git a/packages/agent-core-v2/test/session/sessionFs/fsProcess.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsProcess.test.ts similarity index 96% rename from packages/agent-core-v2/test/session/sessionFs/fsProcess.test.ts rename to packages/agent-core-v2/test/workspace/workspaceFs/fsProcess.test.ts index 7d86ceaaff..0eb5760950 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsProcess.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsProcess.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import { type IProcess, type ISessionProcessRunner } from '#/session/process/processRunner'; -import { runCommand, type RunCommandOptions } from '#/session/sessionFs/fsProcess'; +import { runCommand, type RunCommandOptions } from '#/workspace/workspaceFs/fsProcess'; interface FakeProcessOptions { readonly stdout?: string; diff --git a/packages/agent-core-v2/test/session/sessionFs/fsSearch.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsSearch.test.ts similarity index 98% rename from packages/agent-core-v2/test/session/sessionFs/fsSearch.test.ts rename to packages/agent-core-v2/test/workspace/workspaceFs/fsSearch.test.ts index 3f7da0b21e..6c171e2404 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsSearch.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsSearch.test.ts @@ -7,7 +7,7 @@ import { matchesAnyGlob, rgPath, stripTrailingNewline, -} from '#/session/sessionFs/fsSearch'; +} from '#/workspace/workspaceFs/fsSearch'; describe('computeFuzzyScore', () => { it('returns 0 for an empty query', () => { diff --git a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts similarity index 86% rename from packages/agent-core-v2/test/session/sessionFs/fsService.test.ts rename to packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts index d53a3bc47f..a9b73c976c 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts @@ -13,31 +13,48 @@ import { createScopedTestHost, stubPair } from '#/_base/di/test'; import { IGitService } from '#/app/git/git'; import { ErrorCodes, Error2 } from '#/errors'; import { type HostDirEntry, IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { ISessionFsService } from '#/session/sessionFs/fs'; -import { SessionFsService } from '#/session/sessionFs/fsService'; +import { IWorkspaceFsService } from '#/workspace/workspaceFs/fs'; +import { WorkspaceFsService } from '#/workspace/workspaceFs/fsService'; import { ISessionProcessRunner, type IProcess } from '#/session/process/processRunner'; -import { ISessionStateService } from '#/session/state/sessionState'; -import { SessionStateService } from '#/session/state/sessionStateService'; import { ITelemetryService, type TelemetryProperties } from '#/app/telemetry/telemetry'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { IWorkspaceGitService } from '#/workspace/workspaceGit/workspaceGit'; const WORK_DIR = '/repo'; -function stubWorkspace(): ISessionWorkspaceContext { +function stubWorkspaceContext(): IWorkspaceContext { return { _serviceBrand: undefined, - workDir: WORK_DIR, + workspaceId: 'w', + cwd: WORK_DIR, + source: 'local', + meta: { id: 'w', root: WORK_DIR, name: 'proj', createdAt: 1, lastOpenedAt: 1 }, + persistenceScope: 'sessions/w', + osBackendId: 'local', + persistenceBackendId: 'local', + }; +} + +function stubWorkspaceDirs(): IWorkspaceDirs { + return { + _serviceBrand: undefined, + ready: Promise.resolve(), additionalDirs: [], - setWorkDir: () => {}, - setAdditionalDirs: () => {}, - resolve: (rel) => (isAbsolute(rel) ? rel : resolve(WORK_DIR, rel)), - isWithin: (abs) => { - const r = relative(WORK_DIR, abs); - return r === '' || (!r.startsWith('..') && !isAbsolute(r)); + onDidChange: () => ({ dispose: () => {} }), + addDir: () => Promise.reject(new Error('not supported in tests')), + mergeAdditionalDirs: () => Promise.resolve(), + sessionInfo: () => { + throw new Error('not supported in tests'); }, - assertAllowed: (abs) => abs, - addAdditionalDir: () => {}, - removeAdditionalDir: () => {}, + }; +} + +function workspaceGitStub(git: IGitService): IWorkspaceGitService { + return { + _serviceBrand: undefined, + status: (filter) => git.status(WORK_DIR, filter), + diff: (rel, abs) => git.diff(WORK_DIR, rel, abs), }; } @@ -299,18 +316,11 @@ function telemetryStub(events: Array<{ event: string; properties: Record { _clearScopedRegistryForTests(); registerScopedService( - LifecycleScope.Session, - ISessionStateService, - SessionStateService, - ScopeActivation.OnScopeCreated, - 'state', - ); - registerScopedService( - LifecycleScope.Session, - ISessionFsService, - SessionFsService, + LifecycleScope.Workspace, + IWorkspaceFsService, + WorkspaceFsService, ScopeActivation.OnDemand, - 'sessionFs', + 'workspaceFs', ); }); @@ -334,6 +344,7 @@ function defaultGitStub(): IGitService { pullRequest: null, }), diff: async () => ({ path: '', diff: '', truncated: false }), + findWorkTree: async () => null, }; } @@ -345,22 +356,23 @@ function makeSession( symlinks: readonly string[] = [], runner?: ISessionProcessRunner, symlinkTargets: Record = {}, -): ISessionFsService { +): IWorkspaceFsService { host = createScopedTestHost(); - const session = host.child(LifecycleScope.Session, 's1', [ - stubPair(ISessionWorkspaceContext, stubWorkspace()), + const workspace = host.child(LifecycleScope.Workspace, 'w1', [ + stubPair(IWorkspaceContext, stubWorkspaceContext()), + stubPair(IWorkspaceDirs, stubWorkspaceDirs()), stubPair(IHostFileSystem, fakeFs(files, symlinks, symlinkTargets)), stubPair(ISessionProcessRunner, runner ?? fakeRunner(handler)), stubPair(ITelemetryService, telemetryStub(events)), - stubPair(IGitService, git), + stubPair(IWorkspaceGitService, workspaceGitStub(git)), ]); - return session.accessor.get(ISessionFsService); + return workspace.accessor.get(IWorkspaceFsService); } const emptyHandler: RunHandler = () => ({ stdout: '', exitCode: 0 }); -describe('SessionFsService.gitStatus', () => { - it('delegates to IGitService with the session cwd and a confined filter', async () => { +describe('WorkspaceFsService.gitStatus', () => { + it('delegates to IWorkspaceGitService with the handler root and a confined filter', async () => { const calls: Array<{ cwd: string; filter: ReadonlySet | undefined }> = []; const git: IGitService = { _serviceBrand: undefined, @@ -377,6 +389,7 @@ describe('SessionFsService.gitStatus', () => { }; }, diff: async () => ({ path: '', diff: '', truncated: false }), + findWorkTree: async () => null, }; const fs = makeSession({}, emptyHandler, [], git); const result = await fs.gitStatus({ paths: ['src/a.ts'] }); @@ -395,14 +408,15 @@ describe('SessionFsService.gitStatus', () => { throw new Error2(ErrorCodes.FS_GIT_UNAVAILABLE, 'git unavailable at /repo: not a repo'); }, diff: async () => ({ path: '', diff: '', truncated: false }), + findWorkTree: async () => null, }; const fs = makeSession({}, emptyHandler, [], git); await expect(fs.gitStatus({})).rejects.toMatchObject({ code: 'fs.git_unavailable' }); }); }); -describe('SessionFsService.diff', () => { - it('delegates to IGitService with confined rel and abs paths', async () => { +describe('WorkspaceFsService.diff', () => { + it('delegates to IWorkspaceGitService with confined rel and abs paths', async () => { const calls: Array<{ cwd: string; rel: string; abs: string }> = []; const git: IGitService = { _serviceBrand: undefined, @@ -419,6 +433,7 @@ describe('SessionFsService.diff', () => { calls.push({ cwd, rel, abs }); return { path: rel, diff: '-old\n+new\n', truncated: false }; }, + findWorkTree: async () => null, }; const fs = makeSession({ 'src/a.ts': 'content' }, emptyHandler, [], git); const result = await fs.diff({ path: 'src/a.ts' }); @@ -438,7 +453,7 @@ describe('SessionFsService.diff', () => { }); }); -describe('SessionFsService.search', () => { +describe('WorkspaceFsService.search', () => { it('finds files by fuzzy query and respects the result cap', async () => { const fs = makeSession( { 'src/foo.ts': '', 'src/bar.ts': '', 'README.md': '' }, @@ -464,9 +479,43 @@ describe('SessionFsService.search', () => { expect(result.items.find((i) => i.path === 'src/link')?.kind).toBe('symlink'); expect(paths.some((p) => p.startsWith('src/link/'))).toBe(false); }); + + it('lists the workspace root top-level entries when the query is empty', async () => { + const fs = makeSession( + { 'src/foo.ts': '', 'src/nested/deep.ts': '', 'README.md': '', '.hidden.ts': '' }, + emptyHandler, + ); + const result = await fs.search({ query: '', limit: 50, follow_gitignore: false }); + // Dirs first, then files, alphabetical inside each group; hidden entries + // and nested paths are not listed. + expect(result.items.map((i) => i.path)).toEqual(['src', 'README.md']); + expect(result.items[0]).toMatchObject({ + name: 'src', + kind: 'directory', + score: 1, + match_positions: [], + }); + expect(result.truncated).toBe(false); + }); + + it('truncates the empty-query listing at the limit', async () => { + const fs = makeSession({ 'a.ts': '', 'b.ts': '', 'c.ts': '' }, emptyHandler); + const result = await fs.search({ query: '', limit: 2, follow_gitignore: false }); + expect(result.items.map((i) => i.path)).toEqual(['a.ts', 'b.ts']); + expect(result.truncated).toBe(true); + }); + + it('respects .gitignore in the empty-query listing', async () => { + const fs = makeSession( + { '.gitignore': 'ignored.ts\n', 'ignored.ts': '', 'kept.ts': '' }, + emptyHandler, + ); + const result = await fs.search({ query: '', limit: 50, follow_gitignore: true }); + expect(result.items.map((i) => i.path)).toEqual(['kept.ts']); + }); }); -describe('SessionFsService.grep', () => { +describe('WorkspaceFsService.grep', () => { it('falls back to the node implementation when rg is unavailable', async () => { const events: Array<{ event: string; properties: Record }> = []; const fs = makeSession( @@ -581,7 +630,7 @@ describe('SessionFsService.grep', () => { }); }); -describe('SessionFsService.list', () => { +describe('WorkspaceFsService.list', () => { it('lists files and directories with kinds', async () => { const fs = makeSession( { 'src/a.ts': '', 'src/sub/b.ts': '', 'README.md': '' }, @@ -634,7 +683,7 @@ describe('SessionFsService.list', () => { }); }); -describe('SessionFsService.read', () => { +describe('WorkspaceFsService.read', () => { it('reads utf-8 content with metadata', async () => { const fs = makeSession({ 'src/a.ts': 'hello\nworld\n' }, emptyHandler); const result = await fs.read({ @@ -682,7 +731,7 @@ describe('SessionFsService.read', () => { }); }); -describe('SessionFsService.stat', () => { +describe('WorkspaceFsService.stat', () => { it('returns a file entry with mime', async () => { const fs = makeSession({ 'src/a.ts': 'content' }, emptyHandler); const entry = await fs.stat({ path: 'src/a.ts' }); @@ -698,7 +747,7 @@ describe('SessionFsService.stat', () => { }); }); -describe('SessionFsService.statMany', () => { +describe('WorkspaceFsService.statMany', () => { it('returns null per missing path and entries for present ones', async () => { const fs = makeSession({ 'a.txt': 'hi' }, emptyHandler); const result = await fs.statMany({ paths: ['a.txt', 'missing.txt'] }); @@ -707,7 +756,7 @@ describe('SessionFsService.statMany', () => { }); }); -describe('SessionFsService.listMany', () => { +describe('WorkspaceFsService.listMany', () => { it('returns results per path and partial_errors for failures', async () => { const fs = makeSession({ 'a.txt': '' }, emptyHandler); const result = await fs.listMany({ @@ -724,7 +773,7 @@ describe('SessionFsService.listMany', () => { }); }); -describe('SessionFsService.mkdir', () => { +describe('WorkspaceFsService.mkdir', () => { it('creates a directory and returns its entry', async () => { const fs = makeSession({}, emptyHandler); const entry = await fs.mkdir({ path: 'newdir', recursive: false }); @@ -740,7 +789,7 @@ describe('SessionFsService.mkdir', () => { }); }); -describe('SessionFsService.resolvePath', () => { +describe('WorkspaceFsService.resolvePath', () => { it('returns absolute, relative, and isDirectory', async () => { const fs = makeSession({ 'src/a.ts': '' }, emptyHandler); const res = await fs.resolvePath('src/a.ts'); @@ -750,7 +799,7 @@ describe('SessionFsService.resolvePath', () => { }); }); -describe('SessionFsService.resolveDownload', () => { +describe('WorkspaceFsService.resolveDownload', () => { it('returns size, etag, mime, modifiedAt', async () => { const fs = makeSession({ 'a.txt': 'hello' }, emptyHandler); const res = await fs.resolveDownload('a.txt'); @@ -766,10 +815,10 @@ describe('SessionFsService.resolveDownload', () => { }); }); -describe('SessionFsService symlink confinement', () => { +describe('WorkspaceFsService symlink confinement', () => { const escapeTargets = { docs: '/outside' }; - function escapeSession(): ISessionFsService { + function escapeSession(): IWorkspaceFsService { return makeSession( { 'src/a.ts': '' }, emptyHandler, diff --git a/packages/agent-core-v2/test/workspace/workspaceFs/fsWatchService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsWatchService.test.ts new file mode 100644 index 0000000000..ef5a39498a --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsWatchService.test.ts @@ -0,0 +1,298 @@ +/** + * `workspaceFs` fs-watch (L3) — verifies the shared os watcher fan-out: + * confinement to each subscription's declared subtree, workspace-relative + * path mapping, per-subscription debounce coalescing and window truncation, + * `.gitignore` filtering, and the handle lifecycle (one os watch per handler + * no matter how many subscriptions), using a fake os watcher. + */ + +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LifecycleScope } from '#/_base/di/scope'; +import { createScopedTestHost, stubPair } from '#/_base/di/test'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { + type HostFsChange, + type IHostFsWatchHandle, + IHostFsWatchService, +} from '#/os/interface/hostFsWatch'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import type { FsChangeEvent } from '#/workspace/workspaceFs/fsWatch'; + +import { IWorkspaceFsWatchService } from '#/workspace/workspaceFs/fsWatch'; +import { WorkspaceFsWatchService } from '#/workspace/workspaceFs/fsWatchService'; + +const WORK_DIR = '/repo'; + +void WorkspaceFsWatchService; + +function stubWorkspaceContext(): IWorkspaceContext { + return { + _serviceBrand: undefined, + workspaceId: 'w', + cwd: WORK_DIR, + source: 'local', + meta: { id: 'w', root: WORK_DIR, name: 'proj', createdAt: 1, lastOpenedAt: 1 }, + persistenceScope: 'sessions/w', + osBackendId: 'local', + persistenceBackendId: 'local', + }; +} + +function stubWorkspaceDirs(): IWorkspaceDirs { + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + additionalDirs: [], + onDidChange: () => ({ dispose: () => {} }), + addDir: () => Promise.reject(new Error('not supported in tests')), + mergeAdditionalDirs: () => Promise.resolve(), + sessionInfo: () => { + throw new Error('not supported in tests'); + }, + }; +} + +interface FakeWatch { + readonly service: IHostFsWatchService; + readonly watchCalls: string[]; + fire: (rel: string, action: HostFsChange['action'], kind?: HostFsChange['kind']) => void; + readonly disposedCount: () => number; +} + +function fakeHostFsWatch(): FakeWatch { + const watchCalls: string[] = []; + let listener: ((e: HostFsChange) => void) | undefined; + let disposedCount = 0; + const handle: IHostFsWatchHandle = { + onDidChange: (l) => { + listener = l; + return { dispose: () => (listener = undefined) }; + }, + dispose: () => { + disposedCount += 1; + listener = undefined; + }, + }; + const service: IHostFsWatchService = { + _serviceBrand: undefined, + watch: (path) => { + watchCalls.push(path); + return handle; + }, + }; + return { + service, + watchCalls, + fire: (rel, action, kind = 'file') => + listener?.({ path: join(WORK_DIR, rel), action, kind }), + disposedCount: () => disposedCount, + }; +} + +function fakeHostFs(gitignore?: string): IHostFileSystem { + return { + _serviceBrand: undefined, + readText: async (p: string) => { + if (gitignore !== undefined && p === join(WORK_DIR, '.gitignore')) return gitignore; + const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }, + } as unknown as IHostFileSystem; +} + +interface Harness { + readonly svc: IWorkspaceFsWatchService; + readonly watch: FakeWatch; +} + +function makeWorkspace(gitignore?: string): Harness { + const watch = fakeHostFsWatch(); + const host = createScopedTestHost(); + const workspace = host.child(LifecycleScope.Workspace, 'w1', [ + stubPair(IWorkspaceContext, stubWorkspaceContext()), + stubPair(IWorkspaceDirs, stubWorkspaceDirs()), + stubPair(IHostFsWatchService, watch.service), + stubPair(IHostFileSystem, fakeHostFs(gitignore)), + ]); + const svc = workspace.accessor.get(IWorkspaceFsWatchService); + disposers.push(() => host.dispose()); + return { svc, watch }; +} + +function collect(sub: { onDidChangeFiles: (l: (e: FsChangeEvent) => void) => unknown }): FsChangeEvent[] { + const events: FsChangeEvent[] = []; + sub.onDidChangeFiles((e) => events.push(e)); + return events; +} + +const disposers: Array<() => void> = []; + +describe('WorkspaceFsWatchService', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + for (const d of disposers.splice(0)) d(); + vi.useRealTimers(); + }); + + it('starts the os watcher on the workspace root for a non-empty subscription', () => { + const { svc, watch } = makeWorkspace(); + const sub = svc.subscribe(); + sub.setWatchedPaths(['src']); + expect(watch.watchCalls).toEqual([WORK_DIR]); + expect(sub.watchedPaths).toEqual(['src']); + }); + + it('drops events outside the subscribed subtree', () => { + const { svc, watch } = makeWorkspace(); + const sub = svc.subscribe(); + sub.setWatchedPaths(['src']); + const events = collect(sub); + + watch.fire('src/a.ts', 'created'); + watch.fire('lib/b.ts', 'created'); + vi.advanceTimersByTime(200); + + expect(events).toHaveLength(1); + expect(events[0]?.changes).toEqual([{ path: 'src/a.ts', change: 'created', kind: 'file' }]); + }); + + it('coalesces changes within a window into one event', () => { + const { svc, watch } = makeWorkspace(); + const sub = svc.subscribe(); + sub.setWatchedPaths(['.']); + const events = collect(sub); + + watch.fire('a.ts', 'created'); + watch.fire('b.ts', 'modified'); + watch.fire('c.ts', 'deleted'); + vi.advanceTimersByTime(200); + + expect(events).toHaveLength(1); + expect(events[0]?.coalesced_window_ms).toBe(200); + expect(events[0]?.changes).toHaveLength(3); + }); + + it('marks the event truncated when the window overflows', () => { + const { svc, watch } = makeWorkspace(); + const sub = svc.subscribe(); + sub.setWatchedPaths(['.']); + const events = collect(sub); + + for (let i = 0; i < 501; i++) watch.fire(`f${i}.ts`, 'created'); + vi.advanceTimersByTime(200); + + expect(events).toHaveLength(1); + expect(events[0]?.truncated).toBe(true); + expect(events[0]?.changes).toEqual([]); + expect(events[0]?.count).toBe(501); + }); + + it('filters out `.gitignore`d paths once loaded', async () => { + const { svc, watch } = makeWorkspace('dist/\n'); + const sub = svc.subscribe(); + sub.setWatchedPaths(['.']); + const events = collect(sub); + await Promise.resolve(); + await Promise.resolve(); + + watch.fire('dist/x.js', 'created'); + watch.fire('src/keep.ts', 'created'); + vi.advanceTimersByTime(200); + + expect(events).toHaveLength(1); + expect(events[0]?.changes.map((c) => c.path)).toEqual(['src/keep.ts']); + }); + + it('rejects paths that escape the workspace', () => { + const { svc } = makeWorkspace(); + const sub = svc.subscribe(); + expect(() => sub.setWatchedPaths(['../x'])).toThrowError(/escapes workspace|rejected/); + expect(() => sub.setWatchedPaths(['/abs'])).toThrowError(/rejected/); + }); + + it('disposes the os handle when the last watched path set becomes empty', () => { + const { svc, watch } = makeWorkspace(); + const sub = svc.subscribe(); + sub.setWatchedPaths(['src']); + expect(watch.disposedCount()).toBe(0); + sub.setWatchedPaths([]); + expect(watch.disposedCount()).toBe(1); + }); + + it('does not fire after the service is disposed', () => { + const { svc, watch } = makeWorkspace(); + const sub = svc.subscribe(); + sub.setWatchedPaths(['.']); + const events = collect(sub); + watch.fire('a.ts', 'created'); + (svc as unknown as { dispose: () => void }).dispose(); + vi.advanceTimersByTime(200); + expect(events).toHaveLength(0); + }); + + // Phase-4 behavior contract: two sessions of one workspace share the + // handler's single os watch — subscriptions fan out, they never hang a + // second watcher. + it('shares one os watch across subscriptions and fans events out per subscription', () => { + const { svc, watch } = makeWorkspace(); + const subA = svc.subscribe(); + const subB = svc.subscribe(); + subA.setWatchedPaths(['src']); + subB.setWatchedPaths(['lib']); + subB.setWatchedPaths(['lib', 'src']); + const eventsA = collect(subA); + const eventsB = collect(subB); + + expect(watch.watchCalls).toEqual([WORK_DIR]); + + watch.fire('src/a.ts', 'created'); + watch.fire('lib/b.ts', 'modified'); + watch.fire('other/c.ts', 'deleted'); + vi.advanceTimersByTime(200); + + expect(eventsA).toHaveLength(1); + expect(eventsA[0]?.changes).toEqual([{ path: 'src/a.ts', change: 'created', kind: 'file' }]); + expect(eventsB).toHaveLength(1); + expect(eventsB[0]?.changes).toHaveLength(2); + }); + + it('keeps the shared os watch alive while any subscription still watches', () => { + const { svc, watch } = makeWorkspace(); + const subA = svc.subscribe(); + const subB = svc.subscribe(); + subA.setWatchedPaths(['src']); + subB.setWatchedPaths(['lib']); + expect(watch.watchCalls).toEqual([WORK_DIR]); + + subA.setWatchedPaths([]); + expect(watch.disposedCount()).toBe(0); + + subB.dispose(); + expect(watch.disposedCount()).toBe(1); + }); + + it('gives each subscription its own truncation counters', () => { + const { svc, watch } = makeWorkspace(); + const subA = svc.subscribe(); + const subB = svc.subscribe(); + subA.setWatchedPaths(['.']); + subB.setWatchedPaths(['.']); + const eventsA = collect(subA); + const eventsB = collect(subB); + + for (let i = 0; i < 501; i++) watch.fire(`f${i}.ts`, 'created'); + vi.advanceTimersByTime(200); + + expect(eventsA).toHaveLength(1); + expect(eventsB).toHaveLength(1); + expect(eventsA[0]?.truncated).toBe(true); + expect(eventsB[0]?.truncated).toBe(true); + }); +}); diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts similarity index 81% rename from packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts rename to packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts index 40766015e6..9beaa25c89 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts @@ -14,30 +14,51 @@ import { } from '#/_base/di/scope'; import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test'; import { Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import type { Hooks } from '#/hooks'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { IEventService } from '#/app/event/event'; import { IAgentLifecycleService, MAIN_AGENT_ID, } from '#/session/agentLifecycle/agentLifecycle'; -import { ISessionMcpService } from '#/session/mcp/sessionMcp'; +import type { McpConnectionManager } from '#/mcpCore/connection-manager'; +import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; +import { IWorkspaceAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; +import { IExtraAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader'; +import { IExplicitAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader'; +import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; +import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; +import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; import { IAgentPlanService } from '#/agent/plan/plan'; import { ISessionCronService } from '#/session/cron/sessionCronService'; import { ISessionSecondaryModelWarningService } from '#/session/subagent/secondaryModelWarning'; import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; import { CRON_SESSION_TAG, type CronTask } from '#/app/cron/cronTask'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; -import { SessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycleService'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { WorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycleService'; +import { resumeSessionById } from '#/app/workspaceLifecycle/sessionLookup'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService'; +import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; +import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; import { IAgentActivityView } from '#/agent/activityView/activityView'; import { ISessionExternalHooksService } from '#/session/externalHooks/externalHooks'; +import { + ISessionLifecycleHooks, + type SessionLifecycleHookSlots, +} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; -import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; -import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; +import { ISessionProcessRunner } from '#/session/process/processRunner'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; @@ -52,31 +73,20 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { Error2, ErrorCodes } from '#/errors'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; +import { stubLog } from '../../_base/log/stubs'; function bootstrapStub(): IBootstrapService { return { - sessionsDir: '/tmp/sessions', homeDir: '/tmp', - sessionScope: (workspaceId: string, sessionId: string) => - `sessions/${workspaceId}/${sessionId}`, - agentScope: (workspaceId: string, sessionId: string, agentId: string) => - `sessions/${workspaceId}/${sessionId}/agents/${agentId}`, - sessionDir: (workspaceId: string, sessionId: string) => - `/tmp/sessions/${workspaceId}/${sessionId}`, - } as IBootstrapService; + scope: (name: string) => name, + } as unknown as IBootstrapService; } function tmpBootstrapStub(root: string): IBootstrapService { return { - sessionsDir: join(root, 'sessions'), homeDir: root, - sessionScope: (workspaceId: string, sessionId: string) => - `sessions/${workspaceId}/${sessionId}`, - agentScope: (workspaceId: string, sessionId: string, agentId: string) => - `sessions/${workspaceId}/${sessionId}/agents/${agentId}`, - sessionDir: (workspaceId: string, sessionId: string) => - join(root, 'sessions', workspaceId, sessionId), - } as IBootstrapService; + scope: (name: string) => name, + } as unknown as IBootstrapService; } function cronStoreStub( @@ -135,41 +145,77 @@ function hostEnvironmentStub(): IHostEnvironment { }; } -function skillCatalogStub(): ISessionSkillCatalog { +function workspaceSkillCatalogStub(): IWorkspaceSkillCatalog { + const catalog = { + getSkill: () => undefined, + getPluginSkill: () => undefined, + renderSkillPrompt: () => '', + listSkills: () => [], + listInvocableSkills: () => [], + getSkillRoots: () => [], + getSkippedByPolicy: () => [], + getModelSkillListing: () => '', + }; return { _serviceBrand: undefined, - catalog: { - getSkill: () => undefined, - getPluginSkill: () => undefined, - renderSkillPrompt: () => '', - listSkills: () => [], - listInvocableSkills: () => [], - getSkillRoots: () => [], - getSkippedByPolicy: () => [], - getModelSkillListing: () => '', - }, ready: Promise.resolve(), + catalog, onDidChange: () => ({ dispose: () => {} }), - load: () => Promise.resolve(), reload: () => Promise.resolve(), - }; + sessionData: () => ({ + _serviceBrand: undefined, + ready: Promise.resolve(), + catalog, + onDidChange: () => ({ dispose: () => {} }), + }), + } as unknown as IWorkspaceSkillCatalog; } -function agentProfileCatalogStub(): ISessionAgentProfileCatalog { +function agentProfileLoaderStub(): IWorkspaceAgentProfileLoader { return { _serviceBrand: undefined, ready: Promise.resolve(), - onDidChange: () => ({ dispose: () => {} }), - get: () => undefined, - getDefault: () => { + reload: () => Promise.resolve(), + }; +} + +function userAgentProfileLoaderStub(): IUserAgentProfileLoader { + return { + ...agentProfileLoaderStub(), + getDefaultProfile: () => { throw new Error('not implemented'); }, - list: () => [], - load: () => Promise.resolve(), - reload: () => Promise.resolve(), }; } +function agentProfileLoaderStubs(): ReturnType[] { + return [ + stubPair(IWorkspaceAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IExtraAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IExplicitAgentProfileLoader, agentProfileLoaderStub()), + stubPair(IUserAgentProfileLoader, userAgentProfileLoaderStub()), + stubPair(IPluginAgentProfileLoader, agentProfileLoaderStub()), + ]; +} + +function workspaceInstructionsStub(): IWorkspaceInstructionsService { + const provider = { + _serviceBrand: undefined, + ready: Promise.resolve(), + agentsMd: undefined, + agentsMdWarning: undefined, + onDidChange: () => ({ dispose: () => {} }), + }; + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + snapshot: { agentsMd: undefined, agentsMdWarning: undefined }, + onDidChange: () => ({ dispose: () => {} }), + reload: () => Promise.resolve(), + sessionProvider: () => provider, + } as unknown as IWorkspaceInstructionsService; +} + function workspaceStub(): IWorkspaceService { return { _serviceBrand: undefined, @@ -311,15 +357,20 @@ function agentLifecycleStub(): IAgentLifecycleService { }; } -function sessionMcpServiceStub( - ensureMcpReady: () => Promise = () => Promise.resolve(), -): ISessionMcpService { +function workspaceMcpServiceStub(ready: Promise = Promise.resolve()): IWorkspaceMcpService { return { _serviceBrand: undefined, - ensureMcpReady, + ready, connectionManager: () => { throw new Error('not implemented'); }, + sessionHandle: () => ({ + _serviceBrand: undefined, + ready, + get connectionManager(): McpConnectionManager { + throw new Error('not implemented'); + }, + }), }; } @@ -402,24 +453,27 @@ class RecordingSessionExternalHooksService { declare readonly _serviceBrand: undefined; - constructor(@ISessionLifecycleService lifecycle: ISessionLifecycleService) { + constructor( + @ISessionContext private readonly context: ISessionContext, + @ISessionLifecycleHooks hooks: Hooks, + ) { super(); this._register( - lifecycle.hooks.onDidCreateSession.register('test', async (event, next) => { - recordedSessionHookEvents.push(`create:${event.source}:${event.sessionId}`); + hooks.onDidCreateSession.register('test', async (event, next) => { + recordedSessionHookEvents.push(`create:${event.source}:${this.context.sessionId}`); await next(); }), ); this._register( - lifecycle.hooks.onWillCloseSession.register('test', async (event, next) => { - recordedSessionHookEvents.push(`close:${event.reason}:${event.sessionId}`); + hooks.onWillCloseSession.register('test', async (event, next) => { + recordedSessionHookEvents.push(`close:${event.reason}:${this.context.sessionId}`); await next(); }), ); } } -describe('SessionLifecycleService', () => { +describe('WorkspaceHandlerService', () => { let host: ScopedTestHost | undefined; let telemetryRecords: TelemetryRecord[]; let tmpRoots: string[]; @@ -431,10 +485,31 @@ describe('SessionLifecycleService', () => { _clearScopedRegistryForTests(); registerScopedService( LifecycleScope.App, - ISessionLifecycleService, - SessionLifecycleService, + IWorkspaceLifecycleService, + WorkspaceLifecycleService, ScopeActivation.OnDemand, - 'sessionLifecycle', + 'workspaceLifecycle', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceHandlerService, + WorkspaceHandlerService, + ScopeActivation.OnScopeCreated, + 'workspaceHandler', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceToolPolicy, + WorkspaceToolPolicyService, + ScopeActivation.OnScopeCreated, + 'workspaceToolPolicy', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceDirs, + WorkspaceDirsService, + ScopeActivation.OnScopeCreated, + 'workspaceDirs', ); registerScopedService( LifecycleScope.Session, @@ -458,21 +533,33 @@ describe('SessionLifecycleService', () => { await Promise.all(tmpRoots.map((root) => rm(root, { recursive: true, force: true }))); }); - function build(extra: ReturnType[] = []): ISessionLifecycleService { + /** + * Build the App host and materialize the default handler (`/tmp/proj`, + * `wd_stub` with the default workspace stub), returning its session + * lifecycle service. + */ + async function build( + extra: ReturnType[] = [], + ): Promise { host = createScopedTestHost([ stubPair(IBootstrapService, bootstrapStub()), stubPair(ISessionMetadata, metadataStub()), stubPair(IHostEnvironment, hostEnvironmentStub()), - stubPair(ISessionSkillCatalog, skillCatalogStub()), + stubPair(IWorkspaceSkillCatalog, workspaceSkillCatalogStub()), stubPair(ISessionToolPolicy, sessionToolPolicyStub()), - stubPair(ISessionAgentProfileCatalog, agentProfileCatalogStub()), + stubPair(ISessionProcessRunner, { + _serviceBrand: undefined, + exec: () => Promise.reject(new Error('process exec is not supported in this test')), + } satisfies ISessionProcessRunner), + ...agentProfileLoaderStubs(), + stubPair(IWorkspaceInstructionsService, workspaceInstructionsStub()), stubPair(IWorkspaceService, workspaceStub()), stubPair(ISessionIndex, sessionIndexStub()), stubPair(IAppendLogStore, appendLogStoreStub()), stubPair(IAtomicDocumentStore, atomicDocumentStoreStub()), stubPair(IEventService, eventStub()), stubPair(IAgentLifecycleService, agentLifecycleStub()), - stubPair(ISessionMcpService, sessionMcpServiceStub()), + stubPair(IWorkspaceMcpService, workspaceMcpServiceStub()), stubPair(IConfigService, configStub()), stubPair(ISessionCronService, { _serviceBrand: undefined } as unknown as ISessionCronService), stubPair(ISessionSecondaryModelWarningService, { @@ -480,11 +567,18 @@ describe('SessionLifecycleService', () => { getSecondaryModelWarning: () => undefined, } as ISessionSecondaryModelWarningService), stubPair(IProjectLocalConfigService, projectLocalConfigStub()), + stubPair(IHostFsWatchService, { + _serviceBrand: undefined, + watch: () => ({ onDidChange: Event.None, dispose: () => {} }), + } as unknown as IHostFsWatchService), + stubPair(ILogService, stubLog()), stubPair(ITelemetryService, recordingTelemetry(telemetryRecords)), stubPair(ICronTaskPersistence, cronStoreStub()), ...extra, ]); - return host.app.accessor.get(ISessionLifecycleService); + const lifecycle = host.app.accessor.get(IWorkspaceLifecycleService); + const handler = await lifecycle.handlerFor({ root: '/tmp/proj' }); + return handler.accessor.get(IWorkspaceHandlerService); } async function makeTmpRoot(): Promise { @@ -494,7 +588,7 @@ describe('SessionLifecycleService', () => { } it('create / get / list / close', async () => { - const svc = build(); + const svc = await build(); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); expect(h.id).toBe('s1'); expect(svc.get('s1')).toBe(h); @@ -505,24 +599,39 @@ describe('SessionLifecycleService', () => { }); it('create seeds identity and materializes metadata', async () => { - const svc = build(); + const svc = await build(); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); expect(h.kind).toBe(LifecycleScope.Session); }); - it('create forwards caller-supplied MCP servers to the session MCP initial load', async () => { - const ensureMcpReady = vi.fn(() => Promise.resolve()); - const svc = build([ - stubPair(ISessionMcpService, sessionMcpServiceStub(ensureMcpReady)), - ]); - const mcpServers = { docs: { transport: 'http', url: 'https://mcp.example.com' } } as const; - await svc.create({ sessionId: 's1', workDir: '/tmp/proj', mcpServers }); - expect(ensureMcpReady).toHaveBeenCalledWith(mcpServers); + it('re-arms the explicit agent-profile loader after a fatal ready rejection so a later create succeeds', async () => { + let readyAttempts = 0; + const reload = vi.fn(() => Promise.resolve()); + const loaderStub = { + ...agentProfileLoaderStub(), + get ready(): Promise { + readyAttempts += 1; + return readyAttempts === 1 + ? Promise.reject(new Error('fatal explicit file')) + : Promise.resolve(); + }, + reload, + } satisfies IExplicitAgentProfileLoader; + const svc = await build([stubPair(IExplicitAgentProfileLoader, loaderStub)]); + + await expect(svc.create({ sessionId: 's1', workDir: '/tmp/proj' })).rejects.toThrow( + 'fatal explicit file', + ); + expect(reload).toHaveBeenCalledTimes(1); + expect(svc.get('s1')).toBeUndefined(); + + await svc.create({ sessionId: 's2', workDir: '/tmp/proj' }); + expect(svc.get('s2')).toBeDefined(); }); it('create appends the session to the shared session_index.jsonl', async () => { const appended: unknown[] = []; - const svc = build([ + const svc = await build([ stubPair(IAppendLogStore, { ...appendLogStoreStub(), append: (scope: string, key: string, record: unknown) => { @@ -533,10 +642,10 @@ describe('SessionLifecycleService', () => { const handle = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - // The index entry addresses the session under the registry-resolved - // workspace id — the same id seeding the session's storage scope — not a - // recomputed encodeWorkDirKey, so the v1 reader finds it in the bucket it - // was materialized into. + // The index entry addresses the session under the handler's workspace id + // — the same id seeding the session's storage scope — not a recomputed + // encodeWorkDirKey, so the v1 reader finds it in the bucket it was + // materialized into. const workspaceId = handle.accessor.get(ISessionContext).workspaceId; expect(appended).toEqual([ { @@ -555,7 +664,7 @@ describe('SessionLifecycleService', () => { const appended: unknown[] = []; const remove = vi.fn(() => Promise.resolve()); const create = vi.fn(() => Promise.reject(new Error('Unknown agent profile'))); - const svc = build([ + const svc = await build([ stubPair(IAppendLogStore, { ...appendLogStoreStub(), append: (_scope: string, _key: string, record: unknown) => appended.push(record), @@ -582,7 +691,7 @@ describe('SessionLifecycleService', () => { it('indexes the session under the registry-resolved id when the workDir is an alias spelling', async () => { const appended: unknown[] = []; - const svc = build([ + const svc = await build([ stubPair(IAppendLogStore, { ...appendLogStoreStub(), append: (scope: string, key: string, record: unknown) => { @@ -624,7 +733,7 @@ describe('SessionLifecycleService', () => { const workDir = '/tmp/proj'; const workspaces = persistentWorkspaceStub(); const sessionIndex = sessionIndexWithSummary('s1', workDir); - const first = build([ + const first = await build([ stubPair(IWorkspaceService, workspaces), stubPair(ISessionIndex, sessionIndex), ]); @@ -636,7 +745,7 @@ describe('SessionLifecycleService', () => { host?.dispose(); host = undefined; - const second = build([ + const second = await build([ stubPair(IWorkspaceService, workspaces), stubPair(ISessionIndex, sessionIndex), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), @@ -649,7 +758,7 @@ describe('SessionLifecycleService', () => { it('resumes from the persisted cwd when the workspace registry entry is missing', async () => { const workDir = '/tmp/proj'; - const svc = build([ + const svc = await build([ stubPair(IWorkspaceService, persistentWorkspaceStub()), stubPair(ISessionIndex, sessionIndexWithSummary('s1', workDir)), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), @@ -662,8 +771,8 @@ describe('SessionLifecycleService', () => { }); it('does not cache a session whose tool policy fails to initialize', async () => { - const svc = build([ - stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj')), + const svc = await build([ + stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj', 'wd_stub')), stubPair(ISessionToolPolicy, { ...sessionToolPolicyStub(), ready: Promise.reject(new Error('invalid tool policy')), @@ -705,13 +814,13 @@ describe('SessionLifecycleService', () => { update: () => Promise.resolve(undefined), delete: () => Promise.resolve(), }; - const svc = build([ + await build([ stubPair(IWorkspaceService, workspaces), stubPair(ISessionIndex, sessionIndexWithSummary('s1', workDir, indexedWorkspaceId)), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), ]); - const resumed = await svc.resume('s1'); + const resumed = await resumeSessionById(host!.app.accessor, 's1'); const ctx = resumed?.accessor.get(ISessionContext); expect(ctx?.cwd).toBe(workDir); @@ -729,7 +838,7 @@ describe('SessionLifecycleService', () => { accessor: { get: () => ({}) }, dispose: () => {}, } as unknown as IAgentScopeHandle; - const svc = build([ + const svc = await build([ stubPair(ISessionMetadata, { ...metadataStub(), setArchived: (value: boolean) => { @@ -765,8 +874,8 @@ describe('SessionLifecycleService', () => { it('restore clears the archived flag when the session exists on disk', async () => { let archived: boolean | undefined; - const svc = build([ - stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj')), + const svc = await build([ + stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj', 'wd_stub')), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), stubPair(ISessionMetadata, { ...metadataStub(), @@ -803,18 +912,7 @@ describe('SessionLifecycleService', () => { }, dispose: () => {}, } as unknown as IAgentScopeHandle; - const svc = build([ - stubPair(IWorkspaceService, { - ...workspaceStub(), - get: () => - Promise.resolve({ - id: 'wd_stub', - root: '/tmp/proj', - name: 'stub', - createdAt: 0, - lastOpenedAt: 0, - }), - }), + const svc = await build([ stubPair(IAgentLifecycleService, { ...agentLifecycleStub(), list: () => [busyAgent], @@ -830,7 +928,7 @@ describe('SessionLifecycleService', () => { }); it('fires onDidCreateSession with the new handle', async () => { - const svc = build(); + const svc = await build(); let captured: { readonly sessionId: string } | undefined; svc.onDidCreateSession((e) => { captured = e; @@ -840,7 +938,7 @@ describe('SessionLifecycleService', () => { }); it('emits session_started with resumed: false and the bound session id on create', async () => { - const svc = build(); + const svc = await build(); await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); expect(telemetryRecords).toContainEqual({ event: 'session_started', @@ -849,7 +947,7 @@ describe('SessionLifecycleService', () => { }); it('keeps telemetry session context isolated when multiple sessions emit interleaved events', async () => { - const svc = build(); + const svc = await build(); const first = await svc.create({ sessionId: 'first', workDir: '/tmp/proj' }); const second = await svc.create({ sessionId: 'second', workDir: '/tmp/proj' }); telemetryRecords.length = 0; @@ -876,7 +974,7 @@ describe('SessionLifecycleService', () => { it('emits session_started with resumed: true and the bound session id on resume', async () => { const workDir = '/tmp/proj'; - const svc = build([ + const svc = await build([ stubPair(IWorkspaceService, persistentWorkspaceStub()), stubPair(ISessionIndex, sessionIndexWithSummary('s1', workDir)), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), @@ -891,7 +989,7 @@ describe('SessionLifecycleService', () => { }); it('emits session_load_failed with the bound session id and the error code when resume fails, then rethrows', async () => { - const svc = build([ + const svc = await build([ stubPair(ISessionIndex, { ...sessionIndexStub(), get: () => Promise.reject(new Error2(ErrorCodes.SESSION_NOT_FOUND, 'index read failed')), @@ -906,7 +1004,7 @@ describe('SessionLifecycleService', () => { }); it('emits session_load_failed with the bound session id and the error name for plain errors', async () => { - const svc = build([ + const svc = await build([ stubPair(ISessionIndex, { ...sessionIndexStub(), get: () => Promise.reject(new TypeError('bad index')), @@ -928,7 +1026,7 @@ describe('SessionLifecycleService', () => { ScopeActivation.OnScopeCreated, 'externalHooks', ); - const svc = build(); + const svc = await build(); await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); await svc.close('s1'); @@ -941,8 +1039,8 @@ describe('SessionLifecycleService', () => { const mcpReady = new Promise((resolve) => { resolveMcpReady = resolve; }); - const svc = build([ - stubPair(ISessionMcpService, sessionMcpServiceStub(() => mcpReady)), + const svc = await build([ + stubPair(IWorkspaceMcpService, workspaceMcpServiceStub(mcpReady)), ]); let settled = false; @@ -963,10 +1061,10 @@ describe('SessionLifecycleService', () => { const mcpReady = new Promise((resolve) => { resolveMcpReady = resolve; }); - const svc = build([ - stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj')), + const svc = await build([ + stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj', 'wd_stub')), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), - stubPair(ISessionMcpService, sessionMcpServiceStub(() => mcpReady)), + stubPair(IWorkspaceMcpService, workspaceMcpServiceStub(mcpReady)), ]); const resumed = svc.resume('s1'); @@ -984,7 +1082,7 @@ describe('SessionLifecycleService', () => { }); it('fires onDidCloseSession when a session is closed', async () => { - const svc = build(); + const svc = await build(); const closed: string[] = []; svc.onDidCloseSession((e) => closed.push(e.sessionId)); await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); @@ -993,7 +1091,7 @@ describe('SessionLifecycleService', () => { }); it('fires onDidArchiveSession when a session is archived', async () => { - const svc = build([ + const svc = await build([ stubPair(IAgentLifecycleService, { ...agentLifecycleStub(), _serviceBrand: undefined, @@ -1032,7 +1130,7 @@ describe('SessionLifecycleService', () => { } it('loads project-local additional dirs into the session workspace on create', async () => { - const svc = build([ + const svc = await build([ stubPair(IProjectLocalConfigService, projectLocalConfigStub(['/tmp/extra'])), ]); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); @@ -1040,7 +1138,7 @@ describe('SessionLifecycleService', () => { }); it('merges caller additionalDirs and resolves relative paths against workDir', async () => { - const svc = build(); + const svc = await build(); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj', @@ -1050,7 +1148,7 @@ describe('SessionLifecycleService', () => { }); it('deduplicates project-local and caller dirs after resolving', async () => { - const svc = build([ + const svc = await build([ stubPair(IProjectLocalConfigService, projectLocalConfigStub(['/tmp/shared'])), ]); const h = await svc.create({ @@ -1062,7 +1160,7 @@ describe('SessionLifecycleService', () => { }); it('supports multiple project-local and caller additionalDirs', async () => { - const svc = build([ + const svc = await build([ stubPair(IProjectLocalConfigService, projectLocalConfigStub(['/tmp/a', '/tmp/b'])), ]); const h = await svc.create({ @@ -1081,23 +1179,12 @@ describe('SessionLifecycleService', () => { dispose: () => {}, } as unknown as IAgentScopeHandle; const summary = { id: 's1', workspaceId: 'wd_stub' } as SessionSummary; - const svc = build([ + const svc = await build([ stubPair(IProjectLocalConfigService, projectLocalConfigStub(['/tmp/extra'])), stubPair(ISessionIndex, { ...sessionIndexStub(), get: () => Promise.resolve(summary), }), - stubPair(IWorkspaceService, { - ...workspaceStub(), - get: () => - Promise.resolve({ - id: 'wd_stub', - root: '/tmp/proj', - name: 'stub', - createdAt: 0, - lastOpenedAt: 0, - }), - }), stubPair(IAgentLifecycleService, { ...agentLifecycleStub(), get: () => mainHandle, @@ -1111,19 +1198,8 @@ describe('SessionLifecycleService', () => { }); it('fork inherits project-local dirs', async () => { - const svc = build([ + const svc = await build([ stubPair(IProjectLocalConfigService, projectLocalConfigStub(['/tmp/extra'])), - stubPair(IWorkspaceService, { - ...workspaceStub(), - get: () => - Promise.resolve({ - id: 'wd_stub', - root: '/tmp/proj', - name: 'stub', - createdAt: 0, - lastOpenedAt: 0, - }), - }), ]); await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); @@ -1133,7 +1209,7 @@ describe('SessionLifecycleService', () => { }); it('create mints a session_-prefixed lowercase id when none is supplied', async () => { - const svc = build(); + const svc = await build(); const h = await svc.create({ workDir: '/tmp/proj' }); expect(h.id).toMatch(/^session_[0-9a-f-]{36}$/); @@ -1142,19 +1218,7 @@ describe('SessionLifecycleService', () => { }); it('fork mints a session_-prefixed lowercase id when newSessionId is omitted', async () => { - const svc = build([ - stubPair(IWorkspaceService, { - ...workspaceStub(), - get: () => - Promise.resolve({ - id: 'wd_stub', - root: '/tmp/proj', - name: 'stub', - createdAt: 0, - lastOpenedAt: 0, - }), - }), - ]); + const svc = await build(); await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); const target = await svc.fork({ sourceSessionId: 'src' }); @@ -1166,25 +1230,10 @@ describe('SessionLifecycleService', () => { }); describe('fork session state', () => { - function workspaceGetStub(): ReturnType { - return stubPair(IWorkspaceService, { - ...workspaceStub(), - get: () => - Promise.resolve({ - id: 'wd_stub', - root: '/tmp/proj', - name: 'stub', - createdAt: 0, - lastOpenedAt: 0, - }), - }); - } - it('copies blobs, plans, background tasks, and media originals into the fork', async () => { const root = await makeTmpRoot(); - const svc = build([ + const svc = await build([ stubPair(IBootstrapService, tmpBootstrapStub(root)), - workspaceGetStub(), ]); await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); @@ -1244,9 +1293,8 @@ describe('SessionLifecycleService', () => { }, disabledTools: () => disabledTools, } satisfies ISessionToolPolicy; - const svc = build([ + const svc = await build([ stubPair(IBootstrapService, bootstrap), - workspaceGetStub(), stubPair(ISessionToolPolicy, policy), ]); await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); @@ -1261,9 +1309,8 @@ describe('SessionLifecycleService', () => { it('rolls back the target session when fork fails after materializing', async () => { const root = await makeTmpRoot(); const srcDir = join(root, 'sessions', 'wd_stub', 'src'); - const svc = build([ + const svc = await build([ stubPair(IBootstrapService, tmpBootstrapStub(root)), - workspaceGetStub(), stubPair(ISessionMetadata, { ...metadataStub(), read: () => @@ -1307,9 +1354,8 @@ describe('SessionLifecycleService', () => { }, { id: 'task-untagged', cron: '* * * * *', prompt: 'x', createdAt: 1 }, ]); - const svc = build([ + const svc = await build([ stubPair(IBootstrapService, tmpBootstrapStub(root)), - workspaceGetStub(), stubPair(ICronTaskPersistence, cron), ]); await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); @@ -1328,7 +1374,7 @@ describe('SessionLifecycleService', () => { describe('defaultPlanMode bootstrap', () => { it('enters plan mode on a fresh session when config.defaultPlanMode is true', async () => { const { lifecycle, enter, create } = agentLifecycleCapturingPlanSpy(); - const svc = build([ + const svc = await build([ stubPair(IConfigService, configStub({ defaultPlanMode: true })), stubPair(IAgentLifecycleService, lifecycle), ]); @@ -1341,7 +1387,7 @@ describe('SessionLifecycleService', () => { it('leaves plan mode inactive when config.defaultPlanMode is absent', async () => { const { lifecycle, enter, create } = agentLifecycleCapturingPlanSpy(); - const svc = build([ + const svc = await build([ stubPair(IConfigService, configStub({})), stubPair(IAgentLifecycleService, lifecycle), ]); @@ -1358,14 +1404,13 @@ describe('SessionLifecycleService', () => { const { lifecycle, enter, create } = agentLifecycleCapturingPlanSpy({ mainPreexists: true, }); - const svc = build([ + const svc = await build([ stubPair(IConfigService, configStub({ defaultPlanMode: true })), stubPair(IAgentLifecycleService, lifecycle), stubPair(ISessionIndex, { ...sessionIndexStub(), get: () => Promise.resolve(summary), }), - stubPair(IWorkspaceService, persistentWorkspaceStub()), ]); await svc.resume('s1'); diff --git a/packages/agent-core-v2/test/workspace/workspaceInstructions/instructions.test.ts b/packages/agent-core-v2/test/workspace/workspaceInstructions/instructions.test.ts new file mode 100644 index 0000000000..9ef2fbe289 --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceInstructions/instructions.test.ts @@ -0,0 +1,170 @@ +/** + * Scenario: workspace AGENTS.md instructions — build-time snapshot and + * watch-driven refresh. + * + * Exercises the real `WorkspaceInstructionsService` against real temp + * instruction files with a manually-fired fs-watch stub. Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceInstructions/instructions.test.ts`. + */ + +import { mkdtempSync } from 'node:fs'; +import { rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices } from '#/_base/di/test'; +import { Emitter } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { + IHostFsWatchService, + type HostFsChange, + type IHostFsWatchHandle, +} from '#/os/interface/hostFsWatch'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; +import { WorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructionsService'; + +import { stubLog } from '../../_base/log/stubs'; + +describe('WorkspaceInstructionsService', () => { + let workDir: string; + let osHomeDir: string; + let brandHomeDir: string; + let disposables: DisposableStore; + let watchFires: Map>; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'kimi-instructions-work-')); + osHomeDir = mkdtempSync(join(tmpdir(), 'kimi-instructions-os-')); + brandHomeDir = mkdtempSync(join(tmpdir(), 'kimi-instructions-brand-')); + disposables = new DisposableStore(); + watchFires = new Map(); + }); + + afterEach(async () => { + disposables.dispose(); + await Promise.all([ + rm(workDir, { recursive: true, force: true }), + rm(osHomeDir, { recursive: true, force: true }), + rm(brandHomeDir, { recursive: true, force: true }), + ]); + }); + + function fsWatchStub(): IHostFsWatchService { + return { + _serviceBrand: undefined, + watch: (path: string): IHostFsWatchHandle => { + let emitter = watchFires.get(path); + if (emitter === undefined) { + emitter = new Emitter(); + watchFires.set(path, emitter); + } + return { onDidChange: emitter.event, dispose: () => {} }; + }, + }; + } + + function fireWatch(path: string): void { + // The service watches each plan ROOT (brand home, real home, project + // root) recursively — fire on the root that contains the changed file. + for (const [root, emitter] of watchFires) { + if (path === root || path.startsWith(`${root}/`)) { + emitter.fire({ path, action: 'modified', kind: 'file' }); + } + } + } + + function createService(): IWorkspaceInstructionsService { + const ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + reg.definePartialInstance(IWorkspaceContext, { cwd: workDir }); + reg.defineInstance(IHostFileSystem, new HostFileSystem()); + reg.definePartialInstance(IHostEnvironment, { homeDir: osHomeDir }); + reg.definePartialInstance(IBootstrapService, { homeDir: brandHomeDir }); + reg.defineInstance(IHostFsWatchService, fsWatchStub()); + reg.defineInstance(ILogService, stubLog()); + reg.define(IWorkspaceInstructionsService, WorkspaceInstructionsService); + }, + }); + return ix.get(IWorkspaceInstructionsService); + } + + it('loads the AGENTS.md snapshot at build and projects it through the session provider', async () => { + await writeFile(join(workDir, 'AGENTS.md'), 'project instructions', 'utf8'); + await writeFile(join(brandHomeDir, 'AGENTS.md'), 'brand instructions', 'utf8'); + + const service = createService(); + await service.ready; + + expect(service.snapshot.agentsMd).toContain('brand instructions'); + expect(service.snapshot.agentsMd).toContain('project instructions'); + const provider = service.sessionProvider(); + expect(provider.agentsMd).toBe(service.snapshot.agentsMd); + expect(provider.agentsMdWarning).toBeUndefined(); + }); + + it('refreshes the snapshot and fires onDidChange when a watched file changes', async () => { + const file = join(workDir, 'AGENTS.md'); + await writeFile(file, 'old instructions', 'utf8'); + const service = createService(); + await service.ready; + expect(service.snapshot.agentsMd).toContain('old instructions'); + + const changed = new Promise((resolvePromise) => { + const d = service.onDidChange(() => { + d.dispose(); + resolvePromise(); + }); + }); + await writeFile(file, 'new instructions', 'utf8'); + fireWatch(file); + await changed; + + expect(service.snapshot.agentsMd).toContain('new instructions'); + expect(service.snapshot.agentsMd).not.toContain('old instructions'); + }); + + it('picks up a newly created AGENTS.md through the watch', async () => { + const service = createService(); + await service.ready; + expect(service.snapshot.agentsMd).toBe(''); + + const file = join(workDir, 'AGENTS.md'); + const changed = new Promise((resolvePromise) => { + const d = service.onDidChange(() => { + d.dispose(); + resolvePromise(); + }); + }); + await writeFile(file, 'created later', 'utf8'); + fireWatch(file); + await changed; + + expect(service.snapshot.agentsMd).toContain('created later'); + }); + + it('does not fire when a reload produces identical content', async () => { + const file = join(workDir, 'AGENTS.md'); + await writeFile(file, 'stable', 'utf8'); + const service = createService(); + await service.ready; + + let fired = 0; + service.onDidChange(() => { + fired += 1; + }); + fireWatch(file); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 500)); + + expect(fired).toBe(0); + }); +}); diff --git a/packages/agent-core-v2/test/workspace/workspaceMcp/initialization.test.ts b/packages/agent-core-v2/test/workspace/workspaceMcp/initialization.test.ts new file mode 100644 index 0000000000..f5b405e934 --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceMcp/initialization.test.ts @@ -0,0 +1,165 @@ +/** + * Scenario: workspace MCP initialization — config-readiness gating and the + * global `[mcp]` timeout preferences, end to end. + * + * Exercises the real `WorkspaceMcpService` + `WorkspaceMcpConfigService` + * through DI against real temp config files and stdio fixture servers. Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceMcp/initialization.test.ts`. + */ + +import { mkdtempSync } from 'node:fs'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices } from '#/_base/di/test'; +import { Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { McpConnectionManager } from '#/mcpCore/connection-manager'; +import { MCP_SECTION, type McpSection } from '#/app/mcpConfig/configSection'; +import { IMcpOAuthStore } from '#/app/mcpConfig/oauthStore'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { IPluginService } from '#/app/plugin/plugin'; +import type { ReloadSummary } from '#/app/plugin/types'; +import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { + IHostFsWatchService, + type HostFsChange, + type IHostFsWatchHandle, +} from '#/os/interface/hostFsWatch'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceTrust } from '#/workspace/workspaceTrust/workspaceTrust'; +import { IWorkspaceMcpConfigService } from '#/workspace/workspaceMcpConfig/workspaceMcpConfig'; +import { WorkspaceMcpConfigService } from '#/workspace/workspaceMcpConfig/workspaceMcpConfigService'; +import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import { WorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcpService'; + +import { stubLog } from '../../_base/log/stubs'; +import { + createMemoryMcpOAuthStore, + slowToolStdioFixture, + stdioFixture, +} from '../../mcpCore/stubs'; + +describe('Workspace MCP initialization', () => { + let cwd: string; + let homeDir: string; + let disposables: DisposableStore; + let manager: McpConnectionManager | undefined; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'kimi-session-mcp-cwd-')); + homeDir = mkdtempSync(join(tmpdir(), 'kimi-session-mcp-home-')); + disposables = new DisposableStore(); + manager = undefined; + }); + + afterEach(async () => { + await manager?.shutdown(); + disposables.dispose(); + await Promise.all([ + rm(cwd, { recursive: true, force: true }), + rm(homeDir, { recursive: true, force: true }), + ]); + }); + + function createWorkspaceMcpService(ready: Promise, mcpSection?: McpSection) { + const ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + reg.definePartialInstance(IBootstrapService, { homeDir }); + reg.definePartialInstance(IWorkspaceContext, { cwd }); + reg.definePartialInstance(IPluginService, { + enabledMcpServers: async () => ({}), + onDidReload: Event.None as Event, + }); + reg.definePartialInstance(IMcpOAuthStore, createMemoryMcpOAuthStore()); + reg.defineInstance(ILogService, stubLog()); + reg.defineInstance(ITelemetryService, noopTelemetryService); + reg.definePartialInstance(IConfigService, { + ready, + get: ((domain: string): T => + (domain === MCP_SECTION ? mcpSection : undefined) as T), + }); + reg.definePartialInstance(IHostFsWatchService, { + watch: (): IHostFsWatchHandle => ({ + onDidChange: Event.None as Event, + dispose: () => {}, + }), + }); + reg.defineInstance(IHostFileSystem, new HostFileSystem()); + reg.definePartialInstance(IWorkspaceTrust, { + ready: Promise.resolve(), + isTrusted: () => true, + onDidChange: Event.None as IWorkspaceTrust['onDidChange'], + }); + reg.define(IWorkspaceMcpConfigService, WorkspaceMcpConfigService); + reg.define(IWorkspaceMcpService, WorkspaceMcpService); + }, + }); + return ix.get(IWorkspaceMcpService); + } + + it('exposes the connection manager before config is ready and starts connecting once ready', async () => { + let resolveConfigReady!: () => void; + const ready = new Promise((resolve) => { + resolveConfigReady = resolve; + }); + await writeProjectMcpJson(cwd, { + alpha: { + transport: 'stdio', + command: process.execPath, + args: [stdioFixture], + }, + }); + const service = createWorkspaceMcpService(ready); + // The manager is available synchronously, independent of config readiness. + manager = service.connectionManager(); + expect(manager.list()).toEqual([]); + + // The initial connect is gated on config.ready: no entry exists yet. + await sleep(50); + expect(manager.list()).toEqual([]); + + resolveConfigReady(); + await service.ready; + expect(manager.get('alpha')?.status).toBe('connected'); + }, 15000); + + it('times out tool calls using the session MCP timeout preference', async () => { + await writeProjectMcpJson(cwd, { + slowTool: { + transport: 'stdio', + command: process.execPath, + args: [slowToolStdioFixture], + env: { KIMI_TEST_MCP_TOOL_DELAY_MS: '300' }, + }, + }); + const service = createWorkspaceMcpService(Promise.resolve(), { toolTimeoutMs: 1 }); + await service.ready; + manager = service.connectionManager(); + const client = manager.resolved('slowTool')?.client; + if (client === undefined) throw new Error('expected a connected client'); + await expect(client.callTool('slow_echo', { text: 'hi' })).rejects.toThrow(/timed out/i); + }, 15000); +}); + +async function writeProjectMcpJson( + cwd: string, + servers: Record, +): Promise { + await mkdir(join(cwd, '.kimi-code'), { recursive: true }); + await writeFile( + join(cwd, '.kimi-code', 'mcp.json'), + JSON.stringify({ mcpServers: servers }), + 'utf8', + ); +} diff --git a/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts b/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts new file mode 100644 index 0000000000..37a5eeb3f9 --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts @@ -0,0 +1,172 @@ +/** + * Scenario: workspace MCP — the shared connection manager is driven by the + * config domain: the initial connect consumes its snapshot, and its diffed + * change events are applied incrementally after the initial connect settles. + * + * Exercises the real `WorkspaceMcpService` against a stubbed + * `IWorkspaceMcpConfigService` and real stdio fixture servers. Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceMcp/workspaceMcp.test.ts`. + */ + +import { mkdtempSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices } from '#/_base/di/test'; +import { Emitter } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { McpConnectionManager } from '#/mcpCore/connection-manager'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; +import { IMcpOAuthStore } from '#/app/mcpConfig/oauthStore'; +import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { + IWorkspaceMcpConfigService, + type McpServersChange, + type McpTunables, +} from '#/workspace/workspaceMcpConfig/workspaceMcpConfig'; +import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import { WorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcpService'; + +import { stubLog } from '../../_base/log/stubs'; +import { createMemoryMcpOAuthStore, stdioFixture } from '../../mcpCore/stubs'; + +function stdioServer(): McpServerConfig { + return { transport: 'stdio', command: process.execPath, args: [stdioFixture] }; +} + +describe('WorkspaceMcpService', () => { + let cwd: string; + let disposables: DisposableStore; + let current: Record; + let tunablesValue: McpTunables; + let tunablesFn: Mock<() => McpTunables>; + let configChanges: Emitter; + let manager: InstanceType | undefined; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'kimi-workspace-mcp-cwd-')); + disposables = new DisposableStore(); + current = {}; + tunablesValue = {}; + tunablesFn = vi.fn(() => tunablesValue); + configChanges = new Emitter(); + manager = undefined; + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await manager?.shutdown(); + disposables.dispose(); + await rm(cwd, { recursive: true, force: true }); + }); + + function mcpConfigStub(): IWorkspaceMcpConfigService { + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + servers: () => current, + tunables: () => tunablesFn() as McpTunables, + onDidChange: configChanges.event, + }; + } + + function createService(): IWorkspaceMcpService { + const ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + reg.definePartialInstance(IWorkspaceContext, { cwd }); + reg.defineInstance(IWorkspaceMcpConfigService, mcpConfigStub()); + reg.definePartialInstance(IMcpOAuthStore, createMemoryMcpOAuthStore()); + reg.defineInstance(ILogService, stubLog()); + reg.defineInstance(ITelemetryService, noopTelemetryService); + reg.define(IWorkspaceMcpService, WorkspaceMcpService); + }, + }); + return ix.get(IWorkspaceMcpService); + } + + it('connects the config snapshot in the initial load', async () => { + current = { alpha: stdioServer(), beta: stdioServer() }; + const connectAll = vi + .spyOn(McpConnectionManager.prototype, 'connectAll') + .mockResolvedValue(undefined); + + const service = createService(); + manager = service.connectionManager(); + await service.ready; + + expect(connectAll).toHaveBeenCalledTimes(1); + expect(Object.keys(connectAll.mock.calls[0]?.[0] ?? {}).toSorted()).toEqual(['alpha', 'beta']); + }); + + it('reads timeout tunables from the config domain at connect', async () => { + tunablesValue = { startupTimeoutMs: 4321, toolTimeoutMs: 9876 }; + current = { alpha: stdioServer() }; + + const service = createService(); + manager = service.connectionManager(); + await service.ready; + + expect(manager.get('alpha')?.status).toBe('connected'); + expect(tunablesFn).toHaveBeenCalled(); + }, 20000); + + it('applies upserts and removals from config change events', async () => { + current = { alpha: stdioServer() }; + const service = createService(); + manager = service.connectionManager(); + await service.ready; + expect(manager.get('alpha')?.status).toBe('connected'); + + configChanges.fire({ upsert: { beta: stdioServer() }, remove: ['alpha'] }); + + await vi.waitFor( + () => { + expect(manager?.get('alpha')).toBeUndefined(); + expect(manager?.get('beta')?.status).toBe('connected'); + }, + { timeout: 10000, interval: 50 }, + ); + }, 20000); + + it('queues change events until the initial connect settles', async () => { + current = { alpha: stdioServer() }; + let settleConnectAll: () => void = () => undefined; + vi.spyOn(McpConnectionManager.prototype, 'connectAll').mockImplementation( + () => + new Promise((resolve) => { + settleConnectAll = resolve; + }), + ); + const connect = vi + .spyOn(McpConnectionManager.prototype, 'connect') + .mockResolvedValue(undefined as never); + const remove = vi + .spyOn(McpConnectionManager.prototype, 'remove') + .mockResolvedValue(undefined as never); + + const service = createService(); + manager = service.connectionManager(); + + configChanges.fire({ upsert: { beta: stdioServer() }, remove: ['alpha'] }); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 300)); + expect(connect).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + + settleConnectAll(); + await service.ready; + await vi.waitFor( + () => { + expect(remove).toHaveBeenCalledWith('alpha'); + expect(connect).toHaveBeenCalledWith('beta', stdioServer()); + }, + { timeout: 10000, interval: 50 }, + ); + }, 20000); +}); diff --git a/packages/agent-core-v2/test/agent/mcp/config-loader.test.ts b/packages/agent-core-v2/test/workspace/workspaceMcpConfig/config-loader.test.ts similarity index 78% rename from packages/agent-core-v2/test/agent/mcp/config-loader.test.ts rename to packages/agent-core-v2/test/workspace/workspaceMcpConfig/config-loader.test.ts index 29759bc100..c7426ce353 100644 --- a/packages/agent-core-v2/test/agent/mcp/config-loader.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceMcpConfig/config-loader.test.ts @@ -3,7 +3,7 @@ * * Exercises the real loader against temporary JSON files. Run with `pnpm * --filter @moonshot-ai/agent-core-v2 exec vitest run - * test/agent/mcp/config-loader.test.ts`. + * test/workspace/workspaceMcpConfig/config-loader.test.ts`. */ import { mkdtempSync } from 'node:fs'; @@ -13,7 +13,10 @@ import { join } from 'pathe'; import { afterEach, describe, expect, it } from 'vitest'; import { ErrorCodes, Error2 } from '#/errors'; -import { loadMcpServers, resolveMcpJsonPaths } from '#/agent/mcp/config-loader'; +import { loadMcpServers, resolveMcpJsonPaths } from '#/workspace/workspaceMcpConfig/config-loader'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; + +const fs = new HostFileSystem(); const tempDirs: string[] = []; @@ -41,7 +44,7 @@ describe('resolveMcpJsonPaths', () => { await mkdir(join(repoRoot, '.git'), { recursive: true }); await mkdir(cwd, { recursive: true }); - const paths = await resolveMcpJsonPaths({ cwd, homeDir: '/home/user/.kimi-code' }); + const paths = await resolveMcpJsonPaths({ fs, cwd, homeDir: '/home/user/.kimi-code' }); expect(paths.user).toBe('/home/user/.kimi-code/mcp.json'); expect(paths.projectRoot).toBe(join(repoRoot, '.mcp.json')); @@ -53,7 +56,7 @@ describe('loadMcpServers', () => { it('returns an empty map when no files exist', async () => { const home = makeTempDir(); const cwd = makeTempDir(); - const servers = await loadMcpServers({ cwd, homeDir: home }); + const servers = await loadMcpServers({ fs, cwd, homeDir: home }); expect(servers).toEqual({}); }); @@ -61,7 +64,7 @@ describe('loadMcpServers', () => { const home = makeTempDir(); const cwd = makeTempDir(); await writeFile(join(home, 'mcp.json'), ' \n'); - const servers = await loadMcpServers({ cwd, homeDir: home }); + const servers = await loadMcpServers({ fs, cwd, homeDir: home }); expect(servers).toEqual({}); }); @@ -82,7 +85,7 @@ describe('loadMcpServers', () => { }, }); - const servers = await loadMcpServers({ cwd, homeDir: home }); + const servers = await loadMcpServers({ fs, cwd, homeDir: home }); expect(Object.keys(servers).toSorted()).toEqual(['local', 'shared', 'userOnly']); expect(servers['shared']).toEqual({ @@ -99,6 +102,41 @@ describe('loadMcpServers', () => { }); }); + it('loads only the user file when includeProject is false (untrusted workspace)', async () => { + const home = makeTempDir(); + const repoRoot = makeTempDir(); + const cwd = join(repoRoot, 'packages', 'agent-core'); + await mkdir(join(repoRoot, '.git'), { recursive: true }); + await mkdir(cwd, { recursive: true }); + + await writeJson(join(home, 'mcp.json'), { + mcpServers: { + shared: { transport: 'stdio', command: 'shared-user' }, + userOnly: { transport: 'stdio', command: 'user-only' }, + }, + }); + await writeJson(join(repoRoot, '.mcp.json'), { + mcpServers: { + shared: { transport: 'stdio', command: 'shared-root' }, + rootOnly: { command: 'root-only' }, + }, + }); + await writeJson(join(cwd, '.kimi-code', 'mcp.json'), { + mcpServers: { + shared: { transport: 'stdio', command: 'shared-project' }, + projectOnly: { transport: 'http', url: 'https://mcp.example.com' }, + }, + }); + + const servers = await loadMcpServers({ fs, cwd, homeDir: home, includeProject: false }); + + expect(Object.keys(servers).toSorted()).toEqual(['shared', 'userOnly']); + expect(servers['shared']).toEqual({ + transport: 'stdio', + command: 'shared-user', + }); + }); + it('loads root .mcp.json from the repo root and lets project-local override it', async () => { const home = makeTempDir(); const repoRoot = makeTempDir(); @@ -125,7 +163,7 @@ describe('loadMcpServers', () => { }, }); - const servers = await loadMcpServers({ cwd, homeDir: home }); + const servers = await loadMcpServers({ fs, cwd, homeDir: home }); expect(Object.keys(servers).toSorted()).toEqual([ 'projectOnly', @@ -159,7 +197,7 @@ describe('loadMcpServers', () => { }, }); - const servers = await loadMcpServers({ cwd, homeDir: home }); + const servers = await loadMcpServers({ fs, cwd, homeDir: home }); expect(servers['implicitRoot']).toEqual({ transport: 'stdio', @@ -191,8 +229,8 @@ describe('loadMcpServers', () => { const home = makeTempDir(); const cwd = makeTempDir(); await writeFile(join(home, 'mcp.json'), '{not json}', 'utf-8'); - await expect(loadMcpServers({ cwd, homeDir: home })).rejects.toBeInstanceOf(Error2); - await expect(loadMcpServers({ cwd, homeDir: home })).rejects.toMatchObject({ + await expect(loadMcpServers({ fs, cwd, homeDir: home })).rejects.toBeInstanceOf(Error2); + await expect(loadMcpServers({ fs, cwd, homeDir: home })).rejects.toMatchObject({ code: ErrorCodes.CONFIG_INVALID, }); }); @@ -203,7 +241,7 @@ describe('loadMcpServers', () => { await writeJson(join(home, 'mcp.json'), { mcpServers: { bad: { transport: 'websocket', url: 'https://x.example.com' } }, }); - await expect(loadMcpServers({ cwd, homeDir: home })).rejects.toMatchObject({ + await expect(loadMcpServers({ fs, cwd, homeDir: home })).rejects.toMatchObject({ code: ErrorCodes.CONFIG_INVALID, }); }); @@ -214,7 +252,7 @@ describe('loadMcpServers', () => { await writeJson(join(home, 'mcp.json'), { mcpServers: { bad: { transport: 'stdio' } }, }); - await expect(loadMcpServers({ cwd, homeDir: home })).rejects.toMatchObject({ + await expect(loadMcpServers({ fs, cwd, homeDir: home })).rejects.toMatchObject({ code: ErrorCodes.CONFIG_INVALID, }); }); @@ -232,7 +270,7 @@ describe('loadMcpServers', () => { }, }, }); - await expect(loadMcpServers({ cwd, homeDir: home })).rejects.toMatchObject({ + await expect(loadMcpServers({ fs, cwd, homeDir: home })).rejects.toMatchObject({ code: ErrorCodes.CONFIG_INVALID, }); }); @@ -250,7 +288,7 @@ describe('loadMcpServers', () => { }, }, }); - await expect(loadMcpServers({ cwd, homeDir: home })).resolves.toEqual({ + await expect(loadMcpServers({ fs, cwd, homeDir: home })).resolves.toEqual({ boundary: { transport: 'stdio', command: 'node', @@ -268,7 +306,7 @@ describe('loadMcpServers', () => { gh: { command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] }, }, }); - const servers = await loadMcpServers({ cwd, homeDir: home }); + const servers = await loadMcpServers({ fs, cwd, homeDir: home }); expect(servers['gh']).toEqual({ transport: 'stdio', command: 'npx', @@ -284,7 +322,7 @@ describe('loadMcpServers', () => { remote: { url: 'https://mcp.example.com/sse' }, }, }); - const servers = await loadMcpServers({ cwd, homeDir: home }); + const servers = await loadMcpServers({ fs, cwd, homeDir: home }); expect(servers['remote']).toEqual({ transport: 'http', url: 'https://mcp.example.com/sse', @@ -304,7 +342,7 @@ describe('loadMcpServers', () => { }, }, }); - const servers = await loadMcpServers({ cwd, homeDir: home }); + const servers = await loadMcpServers({ fs, cwd, homeDir: home }); expect(servers['legacy']).toEqual({ transport: 'sse', url: 'https://mcp.example.com/sse', @@ -322,7 +360,7 @@ describe('loadMcpServers', () => { const saved = process.env['KIMI_CODE_HOME']; process.env['KIMI_CODE_HOME'] = home; try { - const servers = await loadMcpServers({ cwd }); + const servers = await loadMcpServers({ fs, cwd }); expect(servers['from_env']).toEqual({ transport: 'stdio', command: 'env-cmd' }); } finally { if (saved === undefined) delete process.env['KIMI_CODE_HOME']; diff --git a/packages/agent-core-v2/test/workspace/workspaceMcpConfig/workspaceMcpConfig.test.ts b/packages/agent-core-v2/test/workspace/workspaceMcpConfig/workspaceMcpConfig.test.ts new file mode 100644 index 0000000000..7ede8521c8 --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceMcpConfig/workspaceMcpConfig.test.ts @@ -0,0 +1,300 @@ +/** + * Scenario: workspace MCP config — the initial file+plugin merge (file wins + * name collisions) and watch/plugin-reload-driven reconciliation published + * as already-diffed change events. + * + * Exercises the real `WorkspaceMcpConfigService` against real temp config + * files with a manually-fired fs-watch stub. Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceMcpConfig/workspaceMcpConfig.test.ts`. + */ + +import { mkdtempSync } from 'node:fs'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices } from '#/_base/di/test'; +import { Emitter } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; +import { MCP_SECTION, type McpSection } from '#/app/mcpConfig/configSection'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { IPluginService } from '#/app/plugin/plugin'; +import type { ReloadSummary } from '#/app/plugin/types'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { + IHostFsWatchService, + type HostFsChange, + type IHostFsWatchHandle, +} from '#/os/interface/hostFsWatch'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { + IWorkspaceTrust, + type WorkspaceTrustChange, +} from '#/workspace/workspaceTrust/workspaceTrust'; +import { + IWorkspaceMcpConfigService, + type McpServersChange, +} from '#/workspace/workspaceMcpConfig/workspaceMcpConfig'; +import { WorkspaceMcpConfigService } from '#/workspace/workspaceMcpConfig/workspaceMcpConfigService'; + +import { stubLog } from '../../_base/log/stubs'; + +function stdioConfig(command: string): McpServerConfig { + return { transport: 'stdio', command, args: [] }; +} + +describe('WorkspaceMcpConfigService', () => { + let cwd: string; + let homeDir: string; + let disposables: DisposableStore; + let watchFires: Map>; + let pluginServers: Record; + let pluginReloads: Emitter; + let trusted: boolean; + let trustFlips: Emitter; + let changes: McpServersChange[]; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'kimi-workspace-mcp-config-cwd-')); + homeDir = mkdtempSync(join(tmpdir(), 'kimi-workspace-mcp-config-home-')); + disposables = new DisposableStore(); + watchFires = new Map(); + pluginServers = {}; + pluginReloads = new Emitter(); + trusted = true; + trustFlips = new Emitter(); + changes = []; + }); + + afterEach(async () => { + vi.restoreAllMocks(); + disposables.dispose(); + await Promise.all([ + rm(cwd, { recursive: true, force: true }), + rm(homeDir, { recursive: true, force: true }), + ]); + }); + + function fsWatchStub(): IHostFsWatchService { + return { + _serviceBrand: undefined, + watch: (path: string): IHostFsWatchHandle => { + let emitter = watchFires.get(path); + if (emitter === undefined) { + emitter = new Emitter(); + watchFires.set(path, emitter); + } + return { onDidChange: emitter.event, dispose: () => {} }; + }, + }; + } + + function createService(mcpSection?: McpSection): IWorkspaceMcpConfigService { + const ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + reg.definePartialInstance(IBootstrapService, { homeDir }); + reg.definePartialInstance(IWorkspaceContext, { cwd }); + reg.definePartialInstance(IPluginService, { + enabledMcpServers: async () => pluginServers, + onDidReload: pluginReloads.event, + }); + reg.defineInstance(ILogService, stubLog()); + reg.definePartialInstance(IConfigService, { + ready: Promise.resolve(), + get: ((domain: string): T => + (domain === MCP_SECTION ? mcpSection : undefined) as T), + }); + reg.defineInstance(IHostFsWatchService, fsWatchStub()); + reg.defineInstance(IHostFileSystem, new HostFileSystem()); + reg.definePartialInstance(IWorkspaceTrust, { + ready: Promise.resolve(), + isTrusted: () => trusted, + onDidChange: trustFlips.event, + }); + reg.define(IWorkspaceMcpConfigService, WorkspaceMcpConfigService); + }, + }); + const service = ix.get(IWorkspaceMcpConfigService); + service.onDidChange((change) => changes.push(change)); + return service; + } + + async function writeProjectConfig(servers: Record): Promise { + const dir = join(cwd, '.kimi-code'); + await mkdir(dir, { recursive: true }); + const file = join(dir, 'mcp.json'); + await writeFile(file, JSON.stringify({ mcpServers: servers }), 'utf8'); + return file; + } + + it('merges file and plugin servers in the initial resolve (file wins name collisions)', async () => { + await writeProjectConfig({ shared: stdioConfig('file-version'), fileOnly: stdioConfig('file') }); + pluginServers = { shared: stdioConfig('plugin-version'), pluginOnly: stdioConfig('plugin') }; + + const service = createService(); + await service.ready; + + expect(service.servers()).toEqual({ + shared: stdioConfig('file-version'), + fileOnly: stdioConfig('file'), + pluginOnly: stdioConfig('plugin'), + }); + expect(changes).toEqual([]); + }); + + it('skips the project-level config files while the workspace is untrusted', async () => { + await writeProjectConfig({ fileOnly: stdioConfig('file') }); + pluginServers = { pluginOnly: stdioConfig('plugin') }; + trusted = false; + + const service = createService(); + await service.ready; + + expect(service.servers()).toEqual({ pluginOnly: stdioConfig('plugin') }); + }); + + it('picks up the project servers when the workspace becomes trusted', async () => { + await writeProjectConfig({ fileOnly: stdioConfig('file') }); + trusted = false; + const service = createService(); + await service.ready; + expect(service.servers()).toEqual({}); + + trusted = true; + trustFlips.fire({ trusted: true }); + + await vi.waitFor( + () => { + expect(changes).toEqual([{ upsert: { fileOnly: stdioConfig('file') }, remove: [] }]); + }, + { timeout: 10000, interval: 50 }, + ); + expect(service.servers()).toEqual({ fileOnly: stdioConfig('file') }); + }, 20000); + + it('drops the project servers when the workspace loses trust', async () => { + await writeProjectConfig({ fileOnly: stdioConfig('file') }); + pluginServers = { pluginOnly: stdioConfig('plugin') }; + const service = createService(); + await service.ready; + expect(service.servers()).toEqual({ + fileOnly: stdioConfig('file'), + pluginOnly: stdioConfig('plugin'), + }); + + trusted = false; + trustFlips.fire({ trusted: false }); + + await vi.waitFor( + () => { + expect(changes).toEqual([{ upsert: {}, remove: ['fileOnly'] }]); + }, + { timeout: 10000, interval: 50 }, + ); + expect(service.servers()).toEqual({ pluginOnly: stdioConfig('plugin') }); + }, 20000); + + it('exposes the [mcp] section as tunables, resolved live', async () => { + const service = createService({ startupTimeoutMs: 1234, toolTimeoutMs: 5678 }); + await service.ready; + + expect(service.tunables()).toEqual({ startupTimeoutMs: 1234, toolTimeoutMs: 5678 }); + }); + + it('publishes the diff when a watched config file changes', async () => { + const file = await writeProjectConfig({ alpha: stdioConfig('alpha') }); + const service = createService(); + await service.ready; + expect(service.servers()).toEqual({ alpha: stdioConfig('alpha') }); + + await writeProjectConfig({ beta: stdioConfig('beta') }); + watchFires.get(cwd)?.fire({ path: file, action: 'modified', kind: 'file' }); + + await vi.waitFor( + () => { + expect(changes).toEqual([ + { upsert: { beta: stdioConfig('beta') }, remove: ['alpha'] }, + ]); + }, + { timeout: 10000, interval: 50 }, + ); + expect(service.servers()).toEqual({ beta: stdioConfig('beta') }); + }, 20000); + + it('falls back to the same-named plugin server when a file server vanishes', async () => { + const file = await writeProjectConfig({ shared: stdioConfig('file-version') }); + pluginServers = { shared: stdioConfig('plugin-version') }; + const service = createService(); + await service.ready; + expect(service.servers()).toEqual({ shared: stdioConfig('file-version') }); + + await writeProjectConfig({}); + watchFires.get(cwd)?.fire({ path: file, action: 'modified', kind: 'file' }); + + await vi.waitFor( + () => { + expect(changes).toEqual([ + { upsert: { shared: stdioConfig('plugin-version') }, remove: [] }, + ]); + }, + { timeout: 10000, interval: 50 }, + ); + }, 20000); + + it('publishes a plugin server that appears on plugin reload', async () => { + const service = createService(); + await service.ready; + + pluginServers = { gamma: stdioConfig('gamma') }; + pluginReloads.fire({ added: [], removed: [], errors: [] }); + + await vi.waitFor( + () => { + expect(changes).toEqual([{ upsert: { gamma: stdioConfig('gamma') }, remove: [] }]); + }, + { timeout: 10000, interval: 50 }, + ); + expect(service.servers()).toEqual({ gamma: stdioConfig('gamma') }); + }, 20000); + + it('removes a plugin server that vanishes on plugin reload', async () => { + pluginServers = { alpha: stdioConfig('alpha') }; + const service = createService(); + await service.ready; + + pluginServers = {}; + pluginReloads.fire({ added: [], removed: [], errors: [] }); + + await vi.waitFor( + () => { + expect(changes).toEqual([{ upsert: {}, remove: ['alpha'] }]); + }, + { timeout: 10000, interval: 50 }, + ); + }, 20000); + + it('stays silent when a vanished plugin server leaves the same-named file entry in place', async () => { + await writeProjectConfig({ shared: stdioConfig('file-version') }); + pluginServers = { shared: stdioConfig('plugin-version') }; + const service = createService(); + await service.ready; + expect(service.servers()).toEqual({ shared: stdioConfig('file-version') }); + + pluginServers = {}; + pluginReloads.fire({ added: [], removed: [], errors: [] }); + + // The merged view is unchanged (the file entry still wins), so no event + // fires and the snapshot stays put. + await new Promise((resolvePromise) => setTimeout(resolvePromise, 500)); + expect(changes).toEqual([]); + expect(service.servers()).toEqual({ shared: stdioConfig('file-version') }); + }, 20000); +}); diff --git a/packages/agent-core-v2/test/workspace/workspaceProcess/workspaceProcessRunnerService.test.ts b/packages/agent-core-v2/test/workspace/workspaceProcess/workspaceProcessRunnerService.test.ts new file mode 100644 index 0000000000..8f26c497c4 --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceProcess/workspaceProcessRunnerService.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; + +import { + LifecycleScope, + ScopeActivation, + _clearScopedRegistryForTests, + registerScopedService, +} from '#/_base/di/scope'; +import { createScopedTestHost, stubPair } from '#/_base/di/test'; +import { IHostProcessService } from '#/os/interface/hostProcess'; +import { HostProcessService } from '#/os/backends/node-local/hostProcessService'; +import { ISessionProcessRunner } from '#/session/process/processRunner'; +import { WorkspaceProcessRunnerService } from '#/workspace/workspaceProcess/workspaceProcessRunnerService'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; + +async function collect(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString('utf8'); +} + +describe('WorkspaceProcessRunnerService', () => { + let dir: string; + + beforeEach(async () => { + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.App, + IHostProcessService, + HostProcessService, + ScopeActivation.OnDemand, + 'hostProcess', + ); + registerScopedService( + LifecycleScope.Workspace, + ISessionProcessRunner, + WorkspaceProcessRunnerService, + ScopeActivation.OnDemand, + 'workspaceProcess', + ); + dir = await mkdtemp(join(tmpdir(), 'procrunner-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + async function makeRunner(): Promise { + const host = createScopedTestHost(); + const workspace = host.child(LifecycleScope.Workspace, 'w', [ + stubPair(IWorkspaceContext, { + _serviceBrand: undefined, + workspaceId: 'w', + cwd: dir, + source: 'local', + meta: { id: 'w', root: dir, name: 'proj', createdAt: 1, lastOpenedAt: 1 }, + persistenceScope: 'sessions/w', + osBackendId: 'local', + persistenceBackendId: 'local', + } satisfies IWorkspaceContext), + ]); + return workspace.accessor.get(ISessionProcessRunner); + } + + it('exec runs a command and captures stdout + exit code', async () => { + const runner = await makeRunner(); + const proc = await runner.exec(['node', '-e', 'process.stdout.write("ok")']); + const out = await collect(proc.stdout); + expect(out).toBe('ok'); + expect(await proc.wait()).toBe(0); + expect(proc.exitCode).toBe(0); + }); + + it('exec overlays per-call env', async () => { + const runner = await makeRunner(); + const proc = await runner.exec( + ['node', '-e', 'process.stdout.write(process.env.FOO ?? "")'], + { env: { FOO: 'bar' } }, + ); + const out = await collect(proc.stdout); + expect(out).toBe('bar'); + expect(await proc.wait()).toBe(0); + }); +}); diff --git a/packages/agent-core-v2/test/workspace/workspaceResources.test.ts b/packages/agent-core-v2/test/workspace/workspaceResources.test.ts new file mode 100644 index 0000000000..db2e24d1aa --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceResources.test.ts @@ -0,0 +1,475 @@ +/** + * Scenario: workspace resource sharing across concurrent sessions (the + * phase-3 behavior contract). + * + * Drives the REAL handler chain (WorkspaceLifecycleService → + * WorkspaceHandlerService) with the real Workspace-scope resource services + * and proves: one shared MCP connection manager (and one initial connect) + * for two sessions of the same workspace, no skill rescan when the second + * session lists skills, and the fs-watch fan-out refreshing a live session's + * skill list after `.agents/skills` changes on disk. Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceResources.test.ts`. + */ + +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + LifecycleScope, + ScopeActivation, + _clearScopedRegistryForTests, + registerScopedService, +} from '#/_base/di/scope'; +import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test'; +import { Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { McpConnectionManager } from '#/mcpCore/connection-manager'; +import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; +import { AgentProfileRegistryService } from '#/app/agentProfileCatalog/agentProfileRegistryService'; +import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; +import { BuiltinAgentProfileLoaderService } from '#/app/agentProfileCatalog/builtinAgentProfileLoaderService'; +import { IAgentCatalogRuntimeOptions } from '#/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions'; +import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import { UserAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService'; +import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; +import { PluginAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; +import { IEventService } from '#/app/event/event'; +import { IPluginService } from '#/app/plugin/plugin'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; +import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; +import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; +import { FileSkillDiscovery } from '#/app/skillCatalog/fileSkillDiscovery'; +import { InMemorySkillDiscovery } from '#/app/skillCatalog/inMemorySkillDiscovery'; +import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; +import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; +import { BuiltinSkillSource, IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; +import { IUserFileSkillSource, UserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; +import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; +import { WorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycleService'; +import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; +import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import { HostFsWatchService } from '#/os/backends/node-local/hostFsWatchService'; +import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import '#/session/agentLifecycle/profile/profiles'; +import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { SessionSkillCatalogService } from '#/session/sessionSkillCatalog/skillCatalogService'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; +import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { ISessionProcessRunner } from '#/session/process/processRunner'; +import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService'; +import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; +import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; +import { IWorkspaceAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; +import { WorkspaceAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService'; +import { IExtraAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader'; +import { ExtraAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService'; +import { IExplicitAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader'; +import { ExplicitAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService'; +import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; +import { WorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructionsService'; +import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import { WorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcpService'; +import { IMcpOAuthStore, McpOAuthStoreAdapter } from '#/app/mcpConfig/oauthStore'; +import { IWorkspaceMcpConfigService } from '#/workspace/workspaceMcpConfig/workspaceMcpConfig'; +import { WorkspaceMcpConfigService } from '#/workspace/workspaceMcpConfig/workspaceMcpConfigService'; +import { IWorkspaceTrust } from '#/workspace/workspaceTrust/workspaceTrust'; +import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; +import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; +import { WorkspaceSkillCatalogService } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalogService'; +import { ExplicitFileSkillSource, IExplicitFileSkillSource } from '#/workspace/workspaceSkillCatalog/explicitFileSkillSource'; +import { ExtraFileSkillSource, IExtraFileSkillSource } from '#/workspace/workspaceSkillCatalog/extraFileSkillSource'; +import { IPluginSkillSource, PluginSkillSource } from '#/workspace/workspaceSkillCatalog/pluginSkillSource'; +import { IWorkspaceRootSkillSource, WorkspaceRootSkillSource } from '#/workspace/workspaceSkillCatalog/rootFileSkillSource'; + +import { stubLog } from '../_base/log/stubs'; +import { stubSkill } from '../app/skillCatalog/stubs'; +import { stdioFixture } from '../mcpCore/stubs'; + +function workspaceCatalogStub(): IWorkspaceService { + const workspaces = new Map(); + return { + _serviceBrand: undefined, + list: () => Promise.resolve([...workspaces.values()]), + get: (id) => Promise.resolve(workspaces.get(id)), + createOrTouch: (root, name) => { + const id = encodeWorkDirKey(root); + const workspace: Workspace = workspaces.get(id) ?? { + id, + root, + name: name ?? 'proj', + createdAt: 1, + lastOpenedAt: 1, + }; + workspaces.set(id, workspace); + return Promise.resolve(workspace); + }, + update: () => Promise.resolve(undefined), + delete: () => Promise.resolve(), + }; +} + +function pluginStub(): IPluginService { + return { + _serviceBrand: undefined, + onDidReload: Event.None, + enabledMcpServers: async () => ({}), + pluginSkillRoots: async () => [], + pluginAgentRoots: async () => [], + } as unknown as IPluginService; +} + +class TrustedWorkspaceTrustStub implements IWorkspaceTrust { + declare readonly _serviceBrand: undefined; + readonly ready = Promise.resolve(); + readonly onDidChange = Event.None as IWorkspaceTrust['onDidChange']; + isTrusted(): boolean { + return true; + } + get(): Promise { + return Promise.resolve(true); + } + trust(): Promise { + return Promise.resolve(); + } + untrust(): Promise { + return Promise.resolve(); + } +} + +describe('workspace resource sharing (handler chain)', () => { + let host: ScopedTestHost | undefined; + let tmpRoots: string[]; + + beforeEach(() => { + _clearScopedRegistryForTests(); + tmpRoots = []; + registerScopedService( + LifecycleScope.App, + IWorkspaceLifecycleService, + WorkspaceLifecycleService, + ScopeActivation.OnScopeCreated, + 'workspaceLifecycle', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceHandlerService, + WorkspaceHandlerService, + ScopeActivation.OnScopeCreated, + 'workspaceHandler', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceToolPolicy, + WorkspaceToolPolicyService, + ScopeActivation.OnScopeCreated, + 'workspaceToolPolicy', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceSkillCatalog, + WorkspaceSkillCatalogService, + ScopeActivation.OnScopeCreated, + 'workspaceSkillCatalog', + ); + registerScopedService(LifecycleScope.Workspace, IExplicitFileSkillSource, ExplicitFileSkillSource, ScopeActivation.OnScopeCreated, 'workspaceSkillCatalog'); + registerScopedService(LifecycleScope.Workspace, IExtraFileSkillSource, ExtraFileSkillSource, ScopeActivation.OnScopeCreated, 'workspaceSkillCatalog'); + registerScopedService(LifecycleScope.Workspace, IWorkspaceRootSkillSource, WorkspaceRootSkillSource, ScopeActivation.OnScopeCreated, 'workspaceSkillCatalog'); + registerScopedService(LifecycleScope.Workspace, IPluginSkillSource, PluginSkillSource, ScopeActivation.OnScopeCreated, 'workspaceSkillCatalog'); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceAgentProfileLoader, + WorkspaceAgentProfileLoaderService, + ScopeActivation.OnScopeCreated, + 'workspaceAgentProfileLoader', + ); + registerScopedService(LifecycleScope.Workspace, IExtraAgentProfileLoader, ExtraAgentProfileLoaderService, ScopeActivation.OnScopeCreated, 'workspaceAgentProfileLoader'); + registerScopedService(LifecycleScope.Workspace, IExplicitAgentProfileLoader, ExplicitAgentProfileLoaderService, ScopeActivation.OnScopeCreated, 'workspaceAgentProfileLoader'); + registerScopedService(LifecycleScope.Workspace, IUserAgentProfileLoader, UserAgentProfileLoaderService, ScopeActivation.OnScopeCreated, 'workspaceAgentProfileLoader'); + registerScopedService(LifecycleScope.Workspace, IPluginAgentProfileLoader, PluginAgentProfileLoaderService, ScopeActivation.OnScopeCreated, 'workspaceAgentProfileLoader'); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceInstructionsService, + WorkspaceInstructionsService, + ScopeActivation.OnScopeCreated, + 'workspaceInstructions', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceMcpService, + WorkspaceMcpService, + ScopeActivation.OnScopeCreated, + 'workspaceMcp', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceMcpConfigService, + WorkspaceMcpConfigService, + ScopeActivation.OnScopeCreated, + 'workspaceMcpConfig', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceTrust, + TrustedWorkspaceTrustStub, + ScopeActivation.OnDemand, + 'workspaceTrust', + ); + registerScopedService( + LifecycleScope.App, + IMcpOAuthStore, + McpOAuthStoreAdapter, + ScopeActivation.OnDemand, + 'mcpConfig', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceDirs, + WorkspaceDirsService, + ScopeActivation.OnScopeCreated, + 'workspaceDirs', + ); + registerScopedService(LifecycleScope.Session, ISessionSkillCatalog, SessionSkillCatalogService, ScopeActivation.OnScopeCreated, 'sessionSkillCatalog'); + registerScopedService(LifecycleScope.Session, ISessionStateService, SessionStateService, ScopeActivation.OnScopeCreated, 'state'); + registerScopedService(LifecycleScope.App, IBuiltinSkillSource, BuiltinSkillSource, ScopeActivation.OnDemand, 'skillCatalog'); + registerScopedService(LifecycleScope.App, IUserFileSkillSource, UserFileSkillSource, ScopeActivation.OnDemand, 'skillCatalog'); + registerScopedService(LifecycleScope.App, IAgentProfileRegistry, AgentProfileRegistryService, ScopeActivation.OnDemand, 'agentProfileCatalog'); + registerScopedService(LifecycleScope.App, IBuiltinAgentProfileLoader, BuiltinAgentProfileLoaderService, ScopeActivation.OnDemand, 'agentProfileCatalog'); + }); + + afterEach(async () => { + host?.dispose(); + host = undefined; + await Promise.all(tmpRoots.map((root) => rm(root, { recursive: true, force: true }))); + }); + + async function makeRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)); + tmpRoots.push(root); + return root; + } + + function buildHost(discovery: ISkillDiscovery, homeDir: string): void { + host = createScopedTestHost([ + stubPair(IBootstrapService, { + _serviceBrand: undefined, + homeDir, + osHomeDir: homeDir, + scope: (name: string) => name, + } as unknown as IBootstrapService), + stubPair(IHostEnvironment, { + _serviceBrand: undefined, + osKind: 'Linux', + homeDir, + ready: Promise.resolve(), + } as unknown as IHostEnvironment), + stubPair(IHostFileSystem, new HostFileSystem()), + stubPair(IHostFsWatchService, new HostFsWatchService()), + stubPair(ILogService, stubLog()), + stubPair(IConfigService, { + _serviceBrand: undefined, + ready: Promise.resolve(), + get: () => undefined, + onDidSectionChange: () => ({ dispose: () => {} }), + } as unknown as IConfigService), + stubPair(ITelemetryService, noopTelemetryService), + stubPair(ISkillDiscovery, discovery), + stubPair(ISkillCatalogRuntimeOptions, { + _serviceBrand: undefined, + } as unknown as ISkillCatalogRuntimeOptions), + stubPair(IAgentCatalogRuntimeOptions, { + _serviceBrand: undefined, + } as unknown as IAgentCatalogRuntimeOptions), + stubPair(IPluginService, pluginStub()), + stubPair(IWorkspaceService, workspaceCatalogStub()), + stubPair(ISessionIndex, { + _serviceBrand: undefined, + list: () => Promise.resolve({ items: [], total: 0, hasMore: false }), + get: () => Promise.resolve(undefined), + countActive: () => Promise.resolve(0), + } as unknown as ISessionIndex), + stubPair(IAppendLogStore, { + _serviceBrand: undefined, + append: () => {}, + read: async function* () {}, + rewrite: () => Promise.resolve(), + flush: () => Promise.resolve(), + close: () => Promise.resolve(), + acquire: () => ({ dispose: () => {} }), + } as unknown as IAppendLogStore), + stubPair(IAtomicDocumentStore, { + _serviceBrand: undefined, + get: () => Promise.resolve(undefined), + set: () => Promise.resolve(), + delete: () => Promise.resolve(), + list: () => Promise.resolve([]), + watch: () => Event.None, + acquire: () => ({ dispose: () => {} }), + } as unknown as IAtomicDocumentStore), + stubPair(IEventService, { + _serviceBrand: undefined, + publish: () => {}, + subscribe: () => ({ dispose: () => {} }), + } as unknown as IEventService), + stubPair(ICronTaskPersistence, { + _serviceBrand: undefined, + list: () => Promise.resolve([]), + } as unknown as ICronTaskPersistence), + stubPair(IProjectLocalConfigService, { + _serviceBrand: undefined, + readAdditionalDirs: (workDir: string) => + Promise.resolve({ + projectRoot: workDir, + configPath: `${workDir}/.kimi-code/local.toml`, + additionalDirs: [], + }), + resolveAdditionalDirs: (_base: string, dirs: readonly string[]) => + Promise.resolve([...dirs]), + } as unknown as IProjectLocalConfigService), + stubPair(ISessionMetadata, { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChangeMetadata: () => ({ dispose: () => {} }), + read: () => Promise.resolve({} as never), + update: () => Promise.resolve(), + setTitle: () => Promise.resolve(), + setArchived: () => Promise.resolve(), + registerAgent: () => Promise.resolve(), + } as unknown as ISessionMetadata), + stubPair(ISessionToolPolicy, { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChange: () => ({ dispose: () => {} }), + disabledTools: () => [], + setDisabledTools: () => Promise.resolve(), + } as unknown as ISessionToolPolicy), + stubPair(ISessionProcessRunner, { + _serviceBrand: undefined, + exec: () => Promise.reject(new Error('process exec is not supported in this test')), + } satisfies ISessionProcessRunner), + stubPair(IAgentLifecycleService, { + _serviceBrand: undefined, + onDidCreate: () => ({ dispose: () => {} }), + onDidDispose: () => ({ dispose: () => {} }), + create: () => Promise.reject(new Error('not implemented')), + fork: () => Promise.reject(new Error('not implemented')), + get: () => undefined, + list: () => [], + remove: () => Promise.resolve(), + broadcastPermissionMode: () => {}, + } as unknown as IAgentLifecycleService), + ]); + } + + async function handlerFor(root: string): Promise { + const handler = await (host as ScopedTestHost).app.accessor + .get(IWorkspaceLifecycleService) + .handlerFor({ root }); + return handler.accessor.get(IWorkspaceHandlerService); + } + + it('runs one shared MCP manager and one initial connect for two concurrent sessions', async () => { + const root = await makeRoot('kimi-ws-mcp-'); + await mkdir(join(root, '.kimi-code'), { recursive: true }); + await writeFile( + join(root, '.kimi-code', 'mcp.json'), + JSON.stringify({ + mcpServers: { + alpha: { transport: 'stdio', command: process.execPath, args: [stdioFixture] }, + }, + }), + 'utf8', + ); + const home = await makeRoot('kimi-ws-mcp-home-'); + buildHost(new InMemorySkillDiscovery(), home); + const connectAll = vi.spyOn(McpConnectionManager.prototype, 'connectAll'); + + const svc = await handlerFor(root); + const [s1, s2] = await Promise.all([ + svc.create({ sessionId: 's1', workDir: root }), + svc.create({ sessionId: 's2', workDir: root }), + ]); + + const m1 = s1.accessor.get(ISessionMcpHandle); + const m2 = s2.accessor.get(ISessionMcpHandle); + expect(m1.connectionManager).toBe(m2.connectionManager); + expect(connectAll).toHaveBeenCalledTimes(1); + expect(m1.connectionManager.get('alpha')?.status).toBe('connected'); + }, 20000); + + it('does not rescan skills when a second session of the workspace lists them', async () => { + const root = await makeRoot('kimi-ws-skill-'); + await mkdir(join(root, '.kimi-code', 'skills'), { recursive: true }); + const home = await makeRoot('kimi-ws-skill-home-'); + const counting = new InMemorySkillDiscovery(); + counting.setProjectSkills([stubSkill('project-skill')]); + let discoverCalls = 0; + const discovery: ISkillDiscovery = { + _serviceBrand: undefined, + discover: async (roots) => { + discoverCalls += 1; + return counting.discover(roots); + }, + }; + buildHost(discovery, home); + + const svc = await handlerFor(root); + const s1 = await svc.create({ sessionId: 's1', workDir: root }); + const c1 = s1.accessor.get(ISessionSkillCatalog); + await c1.ready; + expect(c1.catalog.getSkill('project-skill')).toBeDefined(); + const callsAfterFirst = discoverCalls; + expect(callsAfterFirst).toBeGreaterThan(0); + + const s2 = await svc.create({ sessionId: 's2', workDir: root }); + const c2 = s2.accessor.get(ISessionSkillCatalog); + await c2.ready; + expect(c2.catalog.getSkill('project-skill')).toBeDefined(); + expect(discoverCalls).toBe(callsAfterFirst); + }, 20000); + + it('refreshes a live session skill list when .agents/skills changes on disk', async () => { + const root = await makeRoot('kimi-ws-watch-'); + const home = await makeRoot('kimi-ws-watch-home-'); + buildHost(new FileSkillDiscovery(stubLog()), home); + + const svc = await handlerFor(root); + const s1 = await svc.create({ sessionId: 's1', workDir: root }); + const catalog = s1.accessor.get(ISessionSkillCatalog); + await catalog.ready; + expect(catalog.catalog.getSkill('watched-skill')).toBeUndefined(); + + await mkdir(join(root, '.agents', 'skills', 'watched-skill'), { recursive: true }); + await writeFile( + join(root, '.agents', 'skills', 'watched-skill', 'SKILL.md'), + '---\nname: watched-skill\ndescription: from watch\n---\nbody', + 'utf8', + ); + + await vi.waitFor( + () => { + expect(catalog.catalog.getSkill('watched-skill')?.description).toBe('from watch'); + }, + // Real FSEvents delivery + the 200 ms source debounce + a real disk + // rescan: under high parallel load the 10 s budget flakes, so allow 30 s. + { timeout: 30000, interval: 100 }, + ); + }, 60000); +}); diff --git a/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts new file mode 100644 index 0000000000..6931f4c3ce --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts @@ -0,0 +1,836 @@ +/** + * Scenario: workspace skill-source discovery, merge, and plugin refresh. + * + * Exercises the real Workspace-scoped catalog and source services with + * filesystem or in-memory discovery boundaries, including controlled + * concurrent refreshes and the fs-watch-driven single-source rescan. + * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceSkillCatalog/skillCatalog.test.ts`. + */ + +import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; + +import { join } from 'pathe'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { createScopedTestHost, stubPair } from '#/_base/di/test'; +import { + _clearScopedRegistryForTests, + LifecycleScope, + registerScopedService, +} from '#/_base/di/scope'; +import { Emitter, Event } from '#/_base/event'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IPluginService } from '#/app/plugin/plugin'; +import { PluginService } from '#/app/plugin/pluginService'; +import type { ReloadSummary } from '#/app/plugin/types'; +import { IProviderService } from '#/kosong/provider/provider'; +import { IHostFsWatchService, type HostFsChange, type IHostFsWatchHandle } from '#/os/interface/hostFsWatch'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IConfigService } from '#/app/config/config'; +import { + EXTRA_SKILL_DIRS_SECTION, + MERGE_ALL_AVAILABLE_SKILLS_SECTION, +} from '#/app/skillCatalog/configSection'; +import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; +import { BuiltinSkillSource, IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; +import { IUserFileSkillSource, UserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; +import { InMemorySkillDiscovery } from '#/app/skillCatalog/inMemorySkillDiscovery'; +import type { SkillContribution } from '#/app/skillCatalog/skillSource'; +import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; +import { WorkspaceSkillCatalogService } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalogService'; +import { ExplicitFileSkillSource, IExplicitFileSkillSource } from '#/workspace/workspaceSkillCatalog/explicitFileSkillSource'; +import { ExtraFileSkillSource, IExtraFileSkillSource } from '#/workspace/workspaceSkillCatalog/extraFileSkillSource'; +import { IWorkspaceRootSkillSource, WorkspaceRootSkillSource } from '#/workspace/workspaceSkillCatalog/rootFileSkillSource'; +import { IPluginSkillSource, PluginSkillSource } from '#/workspace/workspaceSkillCatalog/pluginSkillSource'; +import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; +import { FileSkillDiscovery } from '#/app/skillCatalog/fileSkillDiscovery'; +import type { SkillRoot } from '#/app/skillCatalog/types'; +import { ILogService } from '#/_base/log/log'; +import { HostFsWatchService } from '#/os/backends/node-local/hostFsWatchService'; + +import { stubBootstrap } from '../../app/bootstrap/stubs'; +import { stubSkill } from '../../app/skillCatalog/stubs'; +import { stubProviderService } from '../../app/provider/stubs'; +import { stubLog } from '../../_base/log/stubs'; + +const bootstrapStub = stubBootstrap('/home'); + +function configStub(): IConfigService & { + setExtraSkillDirs(dirs: readonly string[]): void; + setMergeAllAvailableSkills(value: boolean): void; + fireSectionChange(domain: string): void; +} { + let extraSkillDirs: readonly string[] = []; + let mergeAllAvailableSkills = true; + const sectionChangeListeners: Array<(event: unknown) => void> = []; + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChangeConfiguration: () => ({ dispose: () => {} }), + onDidSectionChange: (listener: (event: unknown) => void) => { + sectionChangeListeners.push(listener); + return { dispose: () => {} }; + }, + get: (domain: string) => { + if (domain === EXTRA_SKILL_DIRS_SECTION) return [...extraSkillDirs]; + if (domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) return mergeAllAvailableSkills; + return undefined; + }, + inspect: () => ({ value: undefined, defaultValue: undefined, userValue: undefined, memoryValue: undefined }), + getAll: () => ({}), + set: async () => {}, + replace: async () => {}, + reload: async () => {}, + diagnostics: () => [], + setExtraSkillDirs: (dirs: readonly string[]) => { + extraSkillDirs = [...dirs]; + }, + setMergeAllAvailableSkills: (value: boolean) => { + mergeAllAvailableSkills = value; + }, + fireSectionChange: (domain: string) => { + for (const listener of sectionChangeListeners) { + listener({ domain, source: 'set', value: undefined, previousValue: undefined }); + } + }, + } as unknown as IConfigService & { + setExtraSkillDirs(dirs: readonly string[]): void; + setMergeAllAvailableSkills(value: boolean): void; + fireSectionChange(domain: string): void; + }; +} + +function pluginStub( + skillRoots: readonly SkillRoot[] = [], + reloadEmitter?: Emitter, +): IPluginService { + return { + _serviceBrand: undefined, + onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }), + listPlugins: async () => [], + installPlugin: async () => ({ id: '' }) as never, + setPluginEnabled: async () => {}, + setPluginMcpServerEnabled: async () => {}, + removePlugin: async () => {}, + reloadPlugins: async () => ({ added: [], removed: [], errors: [] }), + getPluginInfo: async () => { + throw new Error('getPluginInfo is not used by these tests'); + }, + listPluginCommands: async () => [], + checkUpdates: async () => [], + pluginSkillRoots: async () => skillRoots, + pluginAgentRoots: async () => [], + enabledSessionStarts: async () => [], + enabledSystemPrompts: async () => [], + enabledMcpServers: async () => ({}), + enabledHooks: async () => [], + }; +} + +function workspaceContextStub(workDir: string): IWorkspaceContext { + return { + _serviceBrand: undefined, + workspaceId: 'wd_test', + cwd: workDir, + source: 'local', + meta: { id: 'wd_test', root: workDir, name: 'test', createdAt: 0, lastOpenedAt: 0 }, + persistenceScope: `sessions/wd_test`, + osBackendId: 'local', + persistenceBackendId: 'local', + }; +} + +function fsWatchStub(): IHostFsWatchService { + return { + _serviceBrand: undefined, + watch: (): IHostFsWatchHandle => ({ + onDidChange: Event.None as Event, + dispose: () => {}, + }), + }; +} + +function makeHost( + store: ISkillDiscovery, + ws: IWorkspaceContext, + pluginRoots: readonly SkillRoot[] = [], + explicitDirs?: readonly string[], + pluginReloadEmitter?: Emitter, +) { + const config = configStub(); + const runtimeOptions = { + _serviceBrand: undefined, + explicitDirs, + } as unknown as ISkillCatalogRuntimeOptions; + const host = createScopedTestHost([ + stubPair(ISkillDiscovery, store), + stubPair(IBootstrapService, bootstrapStub), + stubPair(IConfigService, config), + stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), + stubPair(IPluginService, pluginStub(pluginRoots, pluginReloadEmitter)), + stubPair(IHostFsWatchService, fsWatchStub()), + ]); + const workspace = host.child(LifecycleScope.Workspace, 'w1', [stubPair(IWorkspaceContext, ws)]); + return { host, workspace, config }; +} + +function waitForEvents(event: Event, count: number): Promise { + return new Promise((resolve) => { + let received = 0; + const disposable = event(() => { + received += 1; + if (received === count) { + disposable.dispose(); + resolve(); + } + }); + }); +} + +function deferred(): { + readonly promise: Promise; + readonly resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function withSkillCatalogWorkspace( + run: (fixture: { readonly workDir: string; readonly skillRoot: string }) => Promise, +): Promise { + const workDir = await mkdtemp(join(tmpdir(), 'skill-catalog-')); + const skillRoot = join(workDir, '.kimi-code', 'skills'); + await mkdir(skillRoot, { recursive: true }); + try { + await run({ workDir, skillRoot: await realpath(skillRoot) }); + } finally { + await rm(workDir, { recursive: true, force: true }); + } +} + +describe('WorkspaceSkillCatalogService', () => { + beforeEach(() => { + // Keep the scoped registry limited to the catalog chain these tests + // exercise so unrelated OnScopeCreated registrations do not run; every + // other dependency arrives as a seeded stub via `createScopedTestHost`. + _clearScopedRegistryForTests(); + registerScopedService(LifecycleScope.App, IBuiltinSkillSource, BuiltinSkillSource); + registerScopedService(LifecycleScope.App, IUserFileSkillSource, UserFileSkillSource); + registerScopedService(LifecycleScope.App, IPluginService, PluginService); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceSkillCatalog, + WorkspaceSkillCatalogService, + ); + registerScopedService(LifecycleScope.Workspace, IExplicitFileSkillSource, ExplicitFileSkillSource); + registerScopedService(LifecycleScope.Workspace, IExtraFileSkillSource, ExtraFileSkillSource); + registerScopedService(LifecycleScope.Workspace, IWorkspaceRootSkillSource, WorkspaceRootSkillSource); + registerScopedService(LifecycleScope.Workspace, IPluginSkillSource, PluginSkillSource); + }); + + it('merges global and project skills; project wins on name collision', async () => { + const store = new InMemorySkillDiscovery(); + store.setUserSkills([ + stubSkill('global-only'), + stubSkill('shared', { description: 'from user' }), + ]); + store.setProjectSkills([ + stubSkill('project-only'), + stubSkill('shared', { description: 'from project' }), + ]); + const ws = workspaceContextStub('/work'); + const { host, workspace } = makeHost(store, ws); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + + const names = catalog.catalog.listSkills().map((s) => s.name); + expect(names).toContain('global-only'); + expect(names).toContain('project-only'); + expect(names).toContain('shared'); + expect(catalog.catalog.getSkill('shared')?.description).toBe('from project'); + host.dispose(); + }); + + it('orders project, user and plugin skills as project > user > plugin', async () => { + const store = new InMemorySkillDiscovery(); + store.setUserSkills([ + stubSkill('shared', { description: 'from user' }), + stubSkill('user-plugin', { description: 'from user' }), + ]); + store.setProjectSkills([stubSkill('shared', { description: 'from project' })]); + store.setExtraSkills([ + stubSkill('shared', { description: 'from extra', source: 'extra' }), + stubSkill('user-plugin', { description: 'from extra', source: 'extra' }), + stubSkill('extra-plugin', { description: 'from extra', source: 'extra' }), + ]); + store.setPluginSkills([ + stubSkill('shared', { + description: 'from plugin', + source: 'extra', + plugin: { id: 'demo' }, + }), + stubSkill('user-plugin', { + description: 'from plugin', + source: 'extra', + plugin: { id: 'demo' }, + }), + stubSkill('extra-plugin', { + description: 'from plugin', + source: 'extra', + plugin: { id: 'demo' }, + }), + ]); + const pluginRoot: SkillRoot = { + path: '/plugins/demo/skills', + source: 'extra', + plugin: { id: 'demo' }, + }; + const ws = workspaceContextStub('/work'); + const { host, workspace, config } = makeHost(store, ws, [pluginRoot]); + config.setExtraSkillDirs(['/']); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + + expect(catalog.catalog.getSkill('shared')?.description).toBe('from project'); + expect(catalog.catalog.getSkill('user-plugin')?.description).toBe('from user'); + expect(catalog.catalog.getSkill('extra-plugin')?.description).toBe('from extra'); + host.dispose(); + }); + + it('replaces default user and project discovery with explicitDirs', async () => { + const store = new InMemorySkillDiscovery(); + store.setUserSkills([stubSkill('from-explicit', { description: 'from explicit' })]); + store.setProjectSkills([stubSkill('project-only', { description: 'from project' })]); + store.setExtraSkills([stubSkill('extra-only', { description: 'from extra', source: 'extra' })]); + store.setPluginSkills([ + stubSkill('plugin-only', { + description: 'from plugin', + source: 'extra', + plugin: { id: 'demo' }, + }), + ]); + const pluginRoot: SkillRoot = { + path: '/plugins/demo/skills', + source: 'extra', + plugin: { id: 'demo' }, + }; + const ws = workspaceContextStub('/work'); + const { host, workspace, config } = makeHost(store, ws, [pluginRoot], ['/']); + config.setExtraSkillDirs(['/']); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + + expect(catalog.catalog.getSkill('from-explicit')?.description).toBe('from explicit'); + expect(catalog.catalog.getSkill('project-only')).toBeUndefined(); + expect(catalog.catalog.getSkill('extra-only')?.description).toBe('from extra'); + expect(catalog.catalog.getSkill('plugin-only')?.description).toBe('from plugin'); + host.dispose(); + }); + + it('waits for config ready before loading extra skill dirs', async () => { + let markReady!: () => void; + let ready = false; + const configReady = new Promise((resolve) => { + markReady = () => { + ready = true; + resolve(); + }; + }); + const config = { + ...configStub(), + ready: configReady, + get: (domain: string) => { + if (domain === EXTRA_SKILL_DIRS_SECTION) return ready ? ['/'] : []; + if (domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) return true; + return undefined; + }, + } as unknown as IConfigService; + const store = new InMemorySkillDiscovery(); + store.setExtraSkills([stubSkill('extra-only', { description: 'from extra', source: 'extra' })]); + const runtimeOptions = { + _serviceBrand: undefined, + } as unknown as ISkillCatalogRuntimeOptions; + const ws = workspaceContextStub('/work'); + const host = createScopedTestHost([ + stubPair(ISkillDiscovery, store), + stubPair(IBootstrapService, bootstrapStub), + stubPair(IConfigService, config), + stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), + stubPair(IPluginService, pluginStub()), + stubPair(IHostFsWatchService, fsWatchStub()), + ]); + const workspace = host.child(LifecycleScope.Workspace, 'w1', [stubPair(IWorkspaceContext, ws)]); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + let settled = false; + const loading = catalog.load().then(() => { + settled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(settled).toBe(false); + + markReady(); + await loading; + + expect(catalog.catalog.getSkill('extra-only')?.description).toBe('from extra'); + host.dispose(); + }); + + it('reloads user and workspace sources when mergeAllAvailableSkills changes', async () => { + class CountingDiscovery implements ISkillDiscovery { + declare readonly _serviceBrand: undefined; + calls = 0; + async discover() { + this.calls++; + return { skills: [], skipped: [], scannedRoots: [] }; + } + } + const store = new CountingDiscovery(); + const config = configStub(); + const runtimeOptions = { + _serviceBrand: undefined, + } as unknown as ISkillCatalogRuntimeOptions; + const ws = workspaceContextStub('/work'); + const host = createScopedTestHost([ + stubPair(ISkillDiscovery, store), + stubPair(IBootstrapService, bootstrapStub), + stubPair(IConfigService, config), + stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), + stubPair(IPluginService, pluginStub()), + stubPair(IHostFsWatchService, fsWatchStub()), + ]); + const workspace = host.child(LifecycleScope.Workspace, 'w1', [stubPair(IWorkspaceContext, ws)]); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + const afterLoad = store.calls; + + const reloaded = waitForEvents(catalog.onDidChange, 2); + config.fireSectionChange(MERGE_ALL_AVAILABLE_SKILLS_SECTION); + await reloaded; + + expect(store.calls).toBe(afterLoad + 2); + host.dispose(); + }); + + it('reload re-scans every source and replaces the merged view', async () => { + const store = new InMemorySkillDiscovery(); + store.setUserSkills([stubSkill('global-only')]); + store.setProjectSkills([stubSkill('first')]); + const ws = workspaceContextStub('/work1'); + const { host, workspace } = makeHost(store, ws); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + expect(catalog.catalog.getSkill('first')).toBeDefined(); + + store.setProjectSkills([stubSkill('second')]); + const changes: string[] = []; + const subscription = catalog.onDidChange((sourceId) => changes.push(sourceId)); + await catalog.reload(); + + expect(catalog.catalog.getSkill('first')).toBeUndefined(); + expect(catalog.catalog.getSkill('second')).toBeDefined(); + expect(catalog.catalog.getSkill('global-only')).toBeDefined(); + expect(changes).toEqual(['catalog']); + subscription.dispose(); + host.dispose(); + }); + + it('does not rescan on subsequent load calls without change events', async () => { + const store = new InMemorySkillDiscovery(); + store.setProjectSkills([stubSkill('first')]); + const ws = workspaceContextStub('/work'); + const { host, workspace } = makeHost(store, ws); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + + store.setProjectSkills([stubSkill('second')]); + await catalog.load(); + + expect(catalog.catalog.getSkill('first')).toBeDefined(); + expect(catalog.catalog.getSkill('second')).toBeUndefined(); + host.dispose(); + }); + + it('passes plugin skill roots to the store so plugin skills are discoverable', async () => { + const pluginRoot: SkillRoot = { + path: '/plugins/demo/skills', + source: 'extra', + plugin: { id: 'demo', instructions: 'Use the demo tools.' }, + }; + class ExtraRootStore implements ISkillDiscovery { + declare readonly _serviceBrand: undefined; + receivedRoots: readonly SkillRoot[] | undefined; + async discover(roots: readonly SkillRoot[]) { + if (roots.some((root) => root.plugin !== undefined)) { + this.receivedRoots = roots; + } + const pluginSkills = roots + .filter((root) => root.plugin !== undefined) + .map((root) => stubSkill('demo-skill', { source: 'extra', plugin: root.plugin })); + return { skills: pluginSkills, skipped: [], scannedRoots: [] }; + } + } + const store = new ExtraRootStore(); + const ws = workspaceContextStub('/work'); + const { host, workspace } = makeHost(store, ws, [pluginRoot]); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + + expect(store.receivedRoots).toEqual([pluginRoot]); + expect(catalog.catalog.getSkill('demo-skill')?.plugin?.id).toBe('demo'); + expect(catalog.catalog.getPluginSkill('demo', 'demo-skill')).toBeDefined(); + host.dispose(); + }); + + it('feeds scanned roots from file sources into the merged catalog', async () => { + await withSkillCatalogWorkspace(async ({ workDir, skillRoot }) => { + class RootDiscovery implements ISkillDiscovery { + declare readonly _serviceBrand: undefined; + async discover(roots: readonly SkillRoot[]) { + return { + skills: [], + skipped: [], + scannedRoots: roots + .filter((root) => root.source === 'project') + .map((root) => root.path), + }; + } + } + const ws = workspaceContextStub(workDir); + const { host, workspace } = makeHost(new RootDiscovery(), ws); + + try { + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + + expect(catalog.catalog.getSkillRoots()).toEqual([skillRoot]); + } finally { + host.dispose(); + } + }); + }); + + it('feeds skipped skills from file sources into the merged catalog', async () => { + await withSkillCatalogWorkspace(async ({ workDir }) => { + const skippedEntry = { + path: join(workDir, '.kimi-code', 'skills', 'bad', 'SKILL.md'), + type: 'nope', + reason: 'unsupported skill type "nope"', + }; + class SkippingDiscovery implements ISkillDiscovery { + declare readonly _serviceBrand: undefined; + async discover(roots: readonly SkillRoot[]) { + const isProject = roots.some((root) => root.source === 'project'); + return { + skills: [], + skipped: isProject ? [skippedEntry] : [], + scannedRoots: [], + }; + } + } + const ws = workspaceContextStub(workDir); + const { host, workspace } = makeHost(new SkippingDiscovery(), ws); + + try { + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + + expect(catalog.catalog.getSkippedByPolicy()).toEqual([skippedEntry]); + } finally { + host.dispose(); + } + }); + }); + + it('fires onDidChange with the plugin source id after a plugin reload re-pulls plugin skills', async () => { + const store = new InMemorySkillDiscovery(); + store.setPluginSkills([ + stubSkill('demo-skill', { source: 'extra', plugin: { id: 'demo' } }), + ]); + const reloadEmitter = new Emitter(); + const pluginRoot: SkillRoot = { + path: '/plugins/demo/skills', + source: 'extra', + plugin: { id: 'demo' }, + }; + const ws = workspaceContextStub('/work'); + const { host, workspace } = makeHost(store, ws, [pluginRoot], undefined, reloadEmitter); + + try { + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + expect(catalog.catalog.getPluginSkill('demo', 'demo-skill')).toBeDefined(); + + const refreshed = new Promise((resolve) => { + const d = catalog.onDidChange((sourceId) => { + d.dispose(); + resolve(sourceId); + }); + }); + reloadEmitter.fire({ added: [], removed: [], errors: [] }); + + await expect(refreshed).resolves.toBe('plugin'); + } finally { + host.dispose(); + reloadEmitter.dispose(); + } + }); + + it('queues a plugin refresh during initial load so the refreshed contribution remains active', async () => { + const initialLoad = deferred(); + const refreshedLoad = deferred(); + const initialStarted = deferred(); + const refreshedStarted = deferred(); + const sourceChanges = new Emitter(); + let loadCount = 0; + const pluginSource: IPluginSkillSource = { + _serviceBrand: undefined, + id: 'plugin', + priority: 5, + onDidChange: sourceChanges.event, + load: () => { + loadCount += 1; + if (loadCount === 1) { + initialStarted.resolve(undefined); + return initialLoad.promise; + } + if (loadCount === 2) { + refreshedStarted.resolve(undefined); + return refreshedLoad.promise; + } + throw new Error('unexpected plugin source load'); + }, + }; + const ws = workspaceContextStub('/work'); + const host = createScopedTestHost([ + stubPair(ISkillDiscovery, new InMemorySkillDiscovery()), + stubPair(IBootstrapService, bootstrapStub), + stubPair(IConfigService, configStub()), + stubPair(ISkillCatalogRuntimeOptions, { + _serviceBrand: undefined, + } as unknown as ISkillCatalogRuntimeOptions), + stubPair(IPluginService, pluginStub()), + stubPair(IHostFsWatchService, fsWatchStub()), + ]); + const workspace = host.child(LifecycleScope.Workspace, 'w1', [ + stubPair(IWorkspaceContext, ws), + stubPair(IPluginSkillSource, pluginSource), + ]); + + try { + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + const loading = catalog.load(); + await initialStarted.promise; + const sourceIds: string[] = []; + const refreshed = new Promise((resolve) => { + const subscription = catalog.onDidChange((sourceId) => { + sourceIds.push(sourceId); + if (sourceId === 'plugin') { + subscription.dispose(); + resolve(); + } + }); + }); + + sourceChanges.fire(); + + expect(loadCount).toBe(1); + + initialLoad.resolve({ + skills: [ + stubSkill('stale-skill', { source: 'extra', plugin: { id: 'demo' } }), + ], + }); + await refreshedStarted.promise; + refreshedLoad.resolve({ + skills: [ + stubSkill('fresh-skill', { source: 'extra', plugin: { id: 'demo' } }), + ], + }); + await Promise.all([loading, refreshed]); + + expect(sourceIds).toEqual(['plugin']); + expect(catalog.catalog.getPluginSkill('demo', 'stale-skill')).toBeUndefined(); + expect(catalog.catalog.getPluginSkill('demo', 'fresh-skill')).toBeDefined(); + } finally { + host.dispose(); + sourceChanges.dispose(); + } + }); + + it('binds thisArg when forwarding plugin reloads through the plugin skill source', async () => { + const reloadEmitter = new Emitter(); + const pluginService = pluginStub([], reloadEmitter); + const ws = workspaceContextStub('/work'); + const host = createScopedTestHost([ + stubPair(ISkillDiscovery, new InMemorySkillDiscovery()), + stubPair(IBootstrapService, bootstrapStub), + stubPair(IConfigService, configStub()), + stubPair(ISkillCatalogRuntimeOptions, { + _serviceBrand: undefined, + } as unknown as ISkillCatalogRuntimeOptions), + stubPair(IPluginService, pluginService), + stubPair(IHostFsWatchService, fsWatchStub()), + ]); + const workspace = host.child(LifecycleScope.Workspace, 'w1', [ + stubPair(IWorkspaceContext, ws), + ]); + + try { + const source = workspace.accessor.get(IPluginSkillSource); + void source.id; + const receiver = { tag: 'receiver' }; + const seen: unknown[] = []; + const subscription = source.onDidChange?.( + function (this: unknown) { + seen.push(this); + }, + receiver, + ); + + reloadEmitter.fire({ added: [], removed: [], errors: [] }); + + expect(seen).toEqual([receiver]); + subscription?.dispose(); + } finally { + host.dispose(); + reloadEmitter.dispose(); + } + }); + + it('keeps non-plugin skills working and recovers plugin skills after a corrupt installed.json is fixed and reloaded', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'plugin-home-')); + await mkdir(join(homeDir, 'plugins'), { recursive: true }); + await writeFile(join(homeDir, 'plugins', 'installed.json'), '{ not json', 'utf8'); + + const managedRoot = join(homeDir, 'plugins', 'managed', 'demo'); + await mkdir(join(managedRoot, 'skills', 'demo-skill'), { recursive: true }); + await writeFile( + join(managedRoot, 'kimi.plugin.json'), + JSON.stringify({ name: 'demo', skills: './skills/' }), + 'utf8', + ); + await writeFile( + join(managedRoot, 'skills', 'demo-skill', 'SKILL.md'), + '---\nname: demo-skill\ndescription: demo\n---\nbody', + 'utf8', + ); + + const store = new InMemorySkillDiscovery(); + store.setUserSkills([stubSkill('global-only')]); + store.setPluginSkills([ + stubSkill('demo-skill', { source: 'extra', plugin: { id: 'demo' } }), + ]); + const host = createScopedTestHost([ + stubPair(ISkillDiscovery, store), + stubPair(IBootstrapService, stubBootstrap(homeDir)), + stubPair(IConfigService, configStub()), + stubPair(ISkillCatalogRuntimeOptions, { + _serviceBrand: undefined, + } as unknown as ISkillCatalogRuntimeOptions), + stubPair(IProviderService, stubProviderService()), + stubPair(IHostFsWatchService, fsWatchStub()), + ]); + const ws = workspaceContextStub('/work'); + const workspace = host.child(LifecycleScope.Workspace, 'w1', [ + stubPair(IWorkspaceContext, ws), + ]); + + try { + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + expect(catalog.catalog.getSkill('global-only')).toBeDefined(); + expect(catalog.catalog.getPluginSkill('demo', 'demo-skill')).toBeUndefined(); + + await writeFile( + join(homeDir, 'plugins', 'installed.json'), + JSON.stringify({ + version: 1, + plugins: [ + { + id: 'demo', + root: managedRoot, + source: 'local-path', + enabled: true, + installedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + }), + 'utf8', + ); + const refreshed = new Promise((resolve) => { + const d = catalog.onDidChange((sourceId) => { + if (sourceId === 'plugin') { + d.dispose(); + resolve(); + } + }); + }); + await host.app.accessor.get(IPluginService).reloadPlugins(); + await refreshed; + + expect(catalog.catalog.getPluginSkill('demo', 'demo-skill')).toBeDefined(); + } finally { + host.dispose(); + await rm(homeDir, { recursive: true, force: true }); + } + }); + it('rescans the workspace-root source when a project skill file changes on disk', async () => { + const workDir = await mkdtemp(join(tmpdir(), 'skill-watch-')); + const host = createScopedTestHost([ + stubPair(IBootstrapService, bootstrapStub), + stubPair(IConfigService, configStub()), + stubPair(ISkillCatalogRuntimeOptions, { + _serviceBrand: undefined, + } as unknown as ISkillCatalogRuntimeOptions), + stubPair(IPluginService, pluginStub()), + stubPair(ILogService, stubLog()), + stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())), + stubPair(IHostFsWatchService, new HostFsWatchService()), + ]); + const workspace = host.child(LifecycleScope.Workspace, 'w1', [ + stubPair(IWorkspaceContext, workspaceContextStub(workDir)), + ]); + + try { + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + expect(catalog.catalog.getSkill('watched-skill')).toBeUndefined(); + + const refreshed = new Promise((resolvePromise) => { + const d = catalog.onDidChange((sourceId) => { + d.dispose(); + resolvePromise(sourceId); + }); + }); + const timedOut = new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('watch-driven refresh timed out')), 10000); + }); + await mkdir(join(workDir, '.agents', 'skills', 'watched-skill'), { recursive: true }); + await writeFile( + join(workDir, '.agents', 'skills', 'watched-skill', 'SKILL.md'), + '---\nname: watched-skill\ndescription: from watch\n---\nbody', + 'utf8', + ); + + await expect(Promise.race([refreshed, timedOut])).resolves.toBe('workspace'); + expect(catalog.catalog.getSkill('watched-skill')?.description).toBe('from watch'); + } finally { + host.dispose(); + await rm(workDir, { recursive: true, force: true }); + } + }, 15000); +}); diff --git a/packages/agent-core-v2/test/workspace/workspaceToolPolicy/workspaceToolPolicy.test.ts b/packages/agent-core-v2/test/workspace/workspaceToolPolicy/workspaceToolPolicy.test.ts new file mode 100644 index 0000000000..6fe3dcf923 --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceToolPolicy/workspaceToolPolicy.test.ts @@ -0,0 +1,66 @@ +/** + * `workspaceToolPolicy` domain (L2) — verifies the capability-derived veto + * set and the `ISessionToolPolicyGate` live read view the handler seeds into + * every session. + */ + +import { afterEach, describe, expect, it } from 'vitest'; + +import { LifecycleScope } from '#/_base/di/scope'; +import { createScopedTestHost, stubPair, type ScopedTestHost } from '#/_base/di/test'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; +import { + WorkspaceToolPolicyService, + computeCapabilityDisabledTools, +} from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; + +void WorkspaceToolPolicyService; + +const WORK_DIR = '/repo'; + +function stubWorkspaceContext(osBackendId = 'local'): IWorkspaceContext { + return { + _serviceBrand: undefined, + workspaceId: 'w', + cwd: WORK_DIR, + source: 'local', + meta: { id: 'w', root: WORK_DIR, name: 'proj', createdAt: 1, lastOpenedAt: 1 }, + persistenceScope: 'sessions/w', + osBackendId, + persistenceBackendId: 'local', + }; +} + +let host: ScopedTestHost | undefined; + +afterEach(() => { + host?.dispose(); + host = undefined; +}); + +function makePolicy(osBackendId = 'local'): IWorkspaceToolPolicy { + host = createScopedTestHost(); + const workspace = host.child(LifecycleScope.Workspace, 'w1', [ + stubPair(IWorkspaceContext, stubWorkspaceContext(osBackendId)), + ]); + return workspace.accessor.get(IWorkspaceToolPolicy); +} + +describe('computeCapabilityDisabledTools', () => { + it('disables nothing for the local os backend', () => { + expect(computeCapabilityDisabledTools('local')).toEqual([]); + }); +}); + +describe('WorkspaceToolPolicyService', () => { + it('exposes an empty veto set on the local runtime', () => { + expect(makePolicy().disabledTools()).toEqual([]); + }); + + it('hands sessions a live gate view over the same veto set', () => { + const gate = makePolicy().sessionGate(); + expect(gate.disabledTools).toEqual([]); + expect(gate.onDidChange).toBeDefined(); + }); +}); diff --git a/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts b/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts new file mode 100644 index 0000000000..d21b6d734a --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts @@ -0,0 +1,130 @@ +/** + * Scenario: workspace trust — explicit trust/untrust flips persisted outside + * the workspace, idempotency and the change event, per-root independence, + * and marker survival across a restart. + * + * Exercises the real `WorkspaceTrustService` against the real node-fs + * `JsonAtomicDocumentStore` over a temp home. Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/workspace/workspaceTrust/workspaceTrust.test.ts`. + */ + +import { mkdtempSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices } from '#/_base/di/test'; +import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; +import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { + IWorkspaceTrust, + type WorkspaceTrustChange, +} from '#/workspace/workspaceTrust/workspaceTrust'; +import { WorkspaceTrustService } from '#/workspace/workspaceTrust/workspaceTrustService'; + +describe('WorkspaceTrustService', () => { + let homeDir: string; + let cwd: string; + let disposables: DisposableStore; + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'kimi-workspace-trust-home-')); + cwd = mkdtempSync(join(tmpdir(), 'kimi-workspace-trust-cwd-')); + disposables = new DisposableStore(); + }); + + afterEach(async () => { + disposables.dispose(); + await Promise.all([ + rm(homeDir, { recursive: true, force: true }), + rm(cwd, { recursive: true, force: true }), + ]); + }); + + function createService(root: string, events?: WorkspaceTrustChange[]): IWorkspaceTrust { + const ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + reg.definePartialInstance(IWorkspaceContext, { cwd: root }); + reg.defineInstance( + IAtomicDocumentStore, + new JsonAtomicDocumentStore(new FileStorageService(homeDir)), + ); + reg.define(IWorkspaceTrust, WorkspaceTrustService); + }, + }); + const service = ix.get(IWorkspaceTrust); + if (events !== undefined) { + service.onDidChange((change) => events.push(change)); + } + return service; + } + + it('defaults to untrusted when no marker exists', async () => { + const service = createService(cwd); + await service.ready; + + expect(service.isTrusted()).toBe(false); + expect(await service.get()).toBe(false); + }); + + it('trust() flips the state, fires once, and stays idempotent', async () => { + const events: WorkspaceTrustChange[] = []; + const service = createService(cwd, events); + await service.ready; + + await service.trust(); + await service.trust(); + + expect(service.isTrusted()).toBe(true); + expect(await service.get()).toBe(true); + expect(events).toEqual([{ trusted: true }]); + }); + + it('untrust() revokes the state and both directions stay idempotent', async () => { + const events: WorkspaceTrustChange[] = []; + const service = createService(cwd, events); + await service.ready; + + await service.untrust(); + await service.trust(); + await service.untrust(); + await service.untrust(); + + expect(service.isTrusted()).toBe(false); + expect(events).toEqual([{ trusted: true }, { trusted: false }]); + }); + + it('keeps the marker across a restart', async () => { + const first = createService(cwd); + await first.ready; + await first.trust(); + + const second = createService(cwd); + await second.ready; + + expect(second.isTrusted()).toBe(true); + }); + + it('tracks different roots independently', async () => { + const other = mkdtempSync(join(tmpdir(), 'kimi-workspace-trust-other-')); + try { + const first = createService(cwd); + await first.ready; + await first.trust(); + + const second = createService(other); + await second.ready; + + expect(second.isTrusted()).toBe(false); + } finally { + await rm(other, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/kap-server/src/openapi/transforms.ts b/packages/kap-server/src/openapi/transforms.ts index ffc27d4974..fdd2a4b6ee 100644 --- a/packages/kap-server/src/openapi/transforms.ts +++ b/packages/kap-server/src/openapi/transforms.ts @@ -33,7 +33,7 @@ import { fsStatManyResponseSchema, fsStatRequestSchema, fsStatResponseSchema, -} from '@moonshot-ai/agent-core-v2/session/sessionFs/fs'; +} from '@moonshot-ai/agent-core-v2/workspace/workspaceFs/fs'; import { z } from 'zod'; import { diff --git a/packages/kap-server/src/protocol/rest-fs.ts b/packages/kap-server/src/protocol/rest-fs.ts index 113c4cbb34..adc47d5455 100644 --- a/packages/kap-server/src/protocol/rest-fs.ts +++ b/packages/kap-server/src/protocol/rest-fs.ts @@ -1,7 +1,8 @@ /** * The `fs:open` / `fs:open_in` / `fs:reveal` request schemas — the only fs - * wire shapes the engine does not own (the `sessionFs` domain in agent-core-v2 - * holds the rest). Also home of `fsOpenInAppIdSchema`, referenced by the + * wire shapes the engine does not own (the `workspaceFs` domain in + * agent-core-v2 holds the rest). Also home of `fsOpenInAppIdSchema`, + * referenced by the * `/v1/meta` capabilities document. */ diff --git a/packages/kap-server/src/protocol/rest-workspace.ts b/packages/kap-server/src/protocol/rest-workspace.ts index 73d9a9dfc9..0fefd7f47e 100644 --- a/packages/kap-server/src/protocol/rest-workspace.ts +++ b/packages/kap-server/src/protocol/rest-workspace.ts @@ -3,6 +3,9 @@ * POST /v1/workspaces * PATCH /v1/workspaces/{workspace_id} * DELETE /v1/workspaces/{workspace_id} + * GET /v1/workspaces/{workspace_id}/trust + * POST /v1/workspaces/{workspace_id}/trust + * POST /v1/workspaces/{workspace_id}/untrust */ import { z } from 'zod'; @@ -40,3 +43,8 @@ export const deleteWorkspaceResponseSchema = z.object({ deleted: z.literal(true), }); export type DeleteWorkspaceResponse = z.infer; + +export const workspaceTrustResponseSchema = z.object({ + trusted: z.boolean(), +}); +export type WorkspaceTrustResponse = z.infer; diff --git a/packages/kap-server/src/routes/approvals.ts b/packages/kap-server/src/routes/approvals.ts index 9e59669485..b60fc29702 100644 --- a/packages/kap-server/src/routes/approvals.ts +++ b/packages/kap-server/src/routes/approvals.ts @@ -31,7 +31,7 @@ import { ISessionApprovalService, ISessionInteractionService, - ISessionLifecycleService, + resumeSessionById, type ApprovalRequest, type ApprovalResponse, type Interaction, @@ -101,7 +101,7 @@ export function registerApprovalsRoutes(app: ApprovalRouteHost, core: Scope): vo }, async (req, reply) => { const { session_id } = req.params; - const handle = await core.accessor.get(ISessionLifecycleService).resume(session_id); + const handle = await resumeSessionById(core.accessor, session_id); if (handle === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), @@ -135,7 +135,7 @@ export function registerApprovalsRoutes(app: ApprovalRouteHost, core: Scope): vo }, async (req, reply) => { const { session_id, approval_id } = req.params; - const handle = await core.accessor.get(ISessionLifecycleService).resume(session_id); + const handle = await resumeSessionById(core.accessor, session_id); if (handle === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), diff --git a/packages/kap-server/src/routes/fs.ts b/packages/kap-server/src/routes/fs.ts index 4a55cd33d4..d01a9cb546 100644 --- a/packages/kap-server/src/routes/fs.ts +++ b/packages/kap-server/src/routes/fs.ts @@ -3,18 +3,22 @@ * * Mirrors `packages/server/src/routes/fs.ts` path-for-path and schema-for-schema * so existing v1 clients keep working against server-v2. Backed by the v2 - * Session-scoped `ISessionFsService` (`agent-core-v2/src/sessionFs`): the route resolves - * the session from the URL, then dispatches `fs:` to the matching - * `ISessionFsService` method. The wire schema comes from the engine's own - * `sessionFs` domain contract (`agent-core-v2`). + * Workspace-scoped `IWorkspaceFsService` (`agent-core-v2/src/workspace/workspaceFs`): + * the route resolves the session from the URL, then dispatches `fs:` + * to the matching `IWorkspaceFsService` method — the session's accessor + * resolves it from its parent Workspace scope (the handler), which is the + * "session → handler → workspace fs" chain (chdir is gone, so the handler + * root is the one fixed fs root). The wire schema comes from the engine's own + * `workspaceFs` domain contract (`agent-core-v2`). */ import { createReadStream } from 'node:fs'; import { ErrorCodes, - ISessionFsService, - ISessionLifecycleService, + IWorkspaceFsService, + getLiveSessionById, + resumeSessionById, isError2, Error2, type Scope, @@ -30,7 +34,7 @@ import { fsSearchRequestSchema, fsStatManyRequestSchema, fsStatRequestSchema, -} from '@moonshot-ai/agent-core-v2/session/sessionFs/fs'; +} from '@moonshot-ai/agent-core-v2/workspace/workspaceFs/fs'; import { z } from 'zod'; import { errEnvelope, okEnvelope } from '../envelope'; @@ -99,12 +103,14 @@ const FS_ACTIONS = [ type FsAction = (typeof FS_ACTIONS)[number]; const FS_TAIL_PREFIX = 'fs:'; -function resolveFs(core: Scope, sessionId: string): ISessionFsService { - const session = core.accessor.get(ISessionLifecycleService).get(sessionId); +function resolveFs(core: Scope, sessionId: string): IWorkspaceFsService { + const session = getLiveSessionById(core.accessor, sessionId); if (session === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); } - return session.accessor.get(ISessionFsService); + // The fs service lives on the session's parent Workspace scope (the + // handler): one instance per workspace, pinned to the handler root. + return session.accessor.get(IWorkspaceFsService); } export function registerFsRoutes(app: FsRouteHost, core: Scope): void { @@ -154,7 +160,7 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { // need the work dir) do not 404 on a freshly-opened session. Matches v1, // which reads the persisted cwd. `resume` returns undefined only when the // session is unknown or its workspace is gone. - const session = await core.accessor.get(ISessionLifecycleService).resume(session_id); + const session = await resumeSessionById(core.accessor, session_id); if (session === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), @@ -251,7 +257,7 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { // Cold-load so a freshly-opened (persisted but not live) session can still // serve downloads; `resume` only returns undefined for unknown / workspace-gone. - const session = await core.accessor.get(ISessionLifecycleService).resume(session_id); + const session = await resumeSessionById(core.accessor, session_id); if (session === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), @@ -259,7 +265,7 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { return; } - let resolved: Awaited>; + let resolved: Awaited>; try { resolved = await resolveFs(core, session_id).resolveDownload(relPath); } catch (err) { @@ -332,7 +338,7 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { } // --------------------------------------------------------------------------- -// Action handlers — thin adapters: parse body, call ISessionFsService, wrap result. +// Action handlers — thin adapters: parse body, call IWorkspaceFsService, wrap result. // --------------------------------------------------------------------------- type Req = { id: string; body: unknown }; @@ -531,7 +537,7 @@ function sendMappedError(reply: Reply, req: { id: string }, err: unknown): void case ErrorCodes.SESSION_NOT_FOUND: reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack)); return; - // hostFs errors that escaped the sessionFs layer keep their `os.fs.*` + // hostFs errors that escaped the workspaceFs layer keep their `os.fs.*` // code; map them onto the closest v1 wire code (ENOTDIR collapses into // path-not-found, matching `mapFsError`). case ErrorCodes.OS_FS_NOT_FOUND: diff --git a/packages/kap-server/src/routes/prompts.ts b/packages/kap-server/src/routes/prompts.ts index 2520ad63ac..d5a0cca394 100644 --- a/packages/kap-server/src/routes/prompts.ts +++ b/packages/kap-server/src/routes/prompts.ts @@ -30,7 +30,7 @@ import { type PromptHandle, type PromptQueueSnapshot, ISessionContext, - ISessionLifecycleService, + resumeSessionById, ITelemetryService, applyPromptMetadataUpdate, buildImageCompressionCaption, @@ -110,7 +110,7 @@ async function resolveSession(core: Scope, sessionId: string): Promise { - const session = await core.accessor.get(ISessionLifecycleService).resume(session_id); + const session = await resumeSessionById(core.accessor, session_id); if (session === undefined) return undefined; return sessionMediaOriginalsDir(session.accessor.get(ISessionContext).sessionDir); }, resolveAttachmentsDir: async () => { - const session = await core.accessor.get(ISessionLifecycleService).resume(session_id); + const session = await resumeSessionById(core.accessor, session_id); if (session === undefined) return undefined; return join(session.accessor.get(ISessionContext).sessionDir, 'attachments'); }, diff --git a/packages/kap-server/src/routes/questions.ts b/packages/kap-server/src/routes/questions.ts index 1ba3ef4135..c642e53d49 100644 --- a/packages/kap-server/src/routes/questions.ts +++ b/packages/kap-server/src/routes/questions.ts @@ -45,7 +45,7 @@ import { type Interaction, ISessionInteractionService, ISessionQuestionService, - ISessionLifecycleService, + resumeSessionById, type QuestionAnswers, type QuestionItem, type QuestionOption, @@ -122,7 +122,7 @@ export function registerQuestionsRoutes(app: QuestionRouteHost, core: Scope): vo }, async (req, reply) => { const { session_id } = req.params; - const handle = await core.accessor.get(ISessionLifecycleService).resume(session_id); + const handle = await resumeSessionById(core.accessor, session_id); if (handle === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), @@ -171,7 +171,7 @@ export function registerQuestionsRoutes(app: QuestionRouteHost, core: Scope): vo const questionId = parsed.id; const action: 'resolve' | 'dismiss' = parsed.kind === 'bare' ? 'resolve' : parsed.action; - const handle = await core.accessor.get(ISessionLifecycleService).resume(session_id); + const handle = await resumeSessionById(core.accessor, session_id); if (handle === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 1623004e09..8bf59b8c07 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -18,13 +18,15 @@ * * The `POST /sessions/{tail}` actions split into two groups. The thin * pass-throughs — `fork` / `compact` / `abort` / `archive` / `restore` — call - * the native v2 services directly (`ISessionLifecycleService.fork` / `archive` / `restore`, + * the native v2 services directly (the workspace handler's + * `IWorkspaceHandlerService.fork` / `archive` / `restore`, reached through the + * `sessionIndex` → `IWorkspaceLifecycleService.handlerFor` composition, * `IAgentFullCompactionService.begin`, `IAgentRPCService.cancel`); there is no * v1-only projection to centralize, so no adapter is involved. `undo` likewise * calls `IAgentConversationUndoService.undo` directly (it throws * `session.undo_unavailable` with a structured reason) and only borrows * `ISessionLegacyService.status` for the cross-domain status rollup. The - * `/sessions/{id}/children` endpoints call `ISessionLifecycleService.createChild` + * `/sessions/{id}/children` endpoints call `IWorkspaceHandlerService.createChild` * and `ISessionIndex.list({ childOf })` directly — the child markers and * parent-title default live in the lifecycle, and the child filter lives in the * index. Only `POST /sessions/{id}/profile` (`updateProfile`), @@ -85,13 +87,17 @@ import { ISessionBtwService, ISessionContext, ISessionIndex, - ISessionLifecycleService, ISessionMetadata, ISessionLegacyService, ISessionSecondaryModelWarningService, IEventService, IWorkspaceAliases, + IWorkspaceHandlerService, + IWorkspaceLifecycleService, IWorkspaceService, + getLiveSessionById, + handlerForSession, + resumeSessionById, isError2, Error2, toProtocolMessage, @@ -310,11 +316,17 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void } // Ensure the workspace is registered so `metadata.cwd` is resolvable on - // read (gap G3 — v2 does not store workDir on the session). + // read (gap G3 — v2 does not store workDir on the session). The session + // is created through the workspace's handler (`handlerFor` → the + // handler's `IWorkspaceHandlerService`) — there is no App-scope session + // lifecycle entry point. try { const touched = await registry.createOrTouch(workDir); - const handle = await core.accessor.get(ISessionLifecycleService).create({ + const handler = await core.accessor.get(IWorkspaceLifecycleService).handlerFor({ + root: workDir, + }); + const handle = await handler.accessor.get(IWorkspaceHandlerService).create({ workDir, }); if (typeof body.title === 'string') { @@ -648,9 +660,17 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void if (parsed.action === 'fork') { const body = forkSessionRequestSchema.parse(req.body); - // `lifecycle.fork` throws `session.not_found` for an unknown source, - // so no explicit existence check is needed here. - const handle = await core.accessor.get(ISessionLifecycleService).fork({ + // Fork lives on the source session's handler; the index routes us + // there (`session.not_found` for an unknown source, same as the + // lifecycle's own guard). + const forkHandler = await handlerForSession(core.accessor, parsed.id); + if (forkHandler === undefined) { + throw new Error2( + ErrorCodes.SESSION_NOT_FOUND, + `session ${parsed.id} does not exist`, + ); + } + const handle = await forkHandler.accessor.get(IWorkspaceHandlerService).fork({ sourceSessionId: parsed.id, title: body.title, metadata: body.metadata, @@ -732,7 +752,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void if (parsed.action === 'btw') { // `resume` (not `get`) so a freshly-opened cold session can start a // side-channel agent; matches v1's `startBtw` which resumes first. - const session = await core.accessor.get(ISessionLifecycleService).resume(parsed.id); + const session = await resumeSessionById(core.accessor, parsed.id); if (session === undefined) { throw new Error2( ErrorCodes.SESSION_NOT_FOUND, @@ -746,7 +766,11 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void } if (parsed.action === 'restore') { - const restored = await core.accessor.get(ISessionLifecycleService).restore(parsed.id); + const restoreHandler = await handlerForSession(core.accessor, parsed.id); + const restored = + restoreHandler === undefined + ? undefined + : await restoreHandler.accessor.get(IWorkspaceHandlerService).restore(parsed.id); if (restored === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`); } @@ -765,11 +789,15 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void // archive — `resume` (not `get`) so archiving a freshly-opened cold // session still works; `resume` returns undefined only when the session // is unknown or its workspace is gone, reported as `session.not_found`. - const archived = await core.accessor.get(ISessionLifecycleService).resume(parsed.id); - if (archived === undefined) { + const archiveHandler = await handlerForSession(core.accessor, parsed.id); + const archived = + archiveHandler === undefined + ? undefined + : await archiveHandler.accessor.get(IWorkspaceHandlerService).resume(parsed.id); + if (archived === undefined || archiveHandler === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`); } - await core.accessor.get(ISessionLifecycleService).archive(parsed.id); + await archiveHandler.accessor.get(IWorkspaceHandlerService).archive(parsed.id); requestLog(req)?.info({ session_id: parsed.id, action: 'archive' }, 'session action completed'); reply.send(okEnvelope({ archived: true }, req.id)); } catch (error) { @@ -803,7 +831,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void // 404 when the parent is unknown — the live handle wins, otherwise the // persisted index (a closed parent can still list children, like v1). const exists = - core.accessor.get(ISessionLifecycleService).get(session_id) !== undefined || + getLiveSessionById(core.accessor, session_id) !== undefined || (await core.accessor.get(ISessionIndex).get(session_id)) !== undefined; if (!exists) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${session_id} does not exist`); @@ -887,8 +915,12 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void // `createChild` throws `session.not_found` for an unknown source (via // `fork`), so no explicit existence check is needed here. The child // markers (`parent_session_id` / `child_session_kind`) and the default - // `Child: ` title are applied by the lifecycle. - const handle = await core.accessor.get(ISessionLifecycleService).createChild({ + // `Child: ` title are applied by the handler's lifecycle. + const childHandler = await handlerForSession(core.accessor, session_id); + if (childHandler === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${session_id} does not exist`); + } + const handle = await childHandler.accessor.get(IWorkspaceHandlerService).createChild({ sourceSessionId: session_id, title: req.body.title, metadata: req.body.metadata, @@ -991,7 +1023,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void const { session_id } = req.params; // `resume` (not `get`) so a freshly-opened cold session still computes its // warnings; matches v1's best-effort `resumeSession` before reading them. - const session = await core.accessor.get(ISessionLifecycleService).resume(session_id); + const session = await resumeSessionById(core.accessor, session_id); if (session === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), @@ -1103,7 +1135,7 @@ export interface SessionFacts { * outcome. */ export function resolveSessionFacts(core: Scope, sessionId: string): SessionFacts { - const handle = core.accessor.get(ISessionLifecycleService).get(sessionId); + const handle = getLiveSessionById(core.accessor, sessionId); if (handle === undefined) { return { busy: false, @@ -1122,7 +1154,7 @@ export function resolveSessionFacts(core: Scope, sessionId: string): SessionFact * `ISessionLegacyService`. */ async function resolveMainAgent(core: Scope, sessionId: string): Promise { - const session = await core.accessor.get(ISessionLifecycleService).resume(sessionId); + const session = await resumeSessionById(core.accessor, sessionId); if (session === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); } diff --git a/packages/kap-server/src/routes/skills.ts b/packages/kap-server/src/routes/skills.ts index 7c0a1045b2..982125e0b5 100644 --- a/packages/kap-server/src/routes/skills.ts +++ b/packages/kap-server/src/routes/skills.ts @@ -25,7 +25,8 @@ * are exported for exactly this purpose. * * **Activation gate**: by convention the session endpoints are only valid for - * an *activated* session — one that is live in `ISessionLifecycleService`. When + * an *activated* session — one that is live in a workspace handler's session + * registry. When * the session is not in the live map we still answer `40401 session.not_found` * (the only session error code on the v1 wire contract), but we enrich the * message: @@ -78,7 +79,6 @@ import { IEventService, IPluginService, ISessionIndex, - ISessionLifecycleService, ISessionMetadata, ISessionSkillCatalog, ISkillCatalogRuntimeOptions, @@ -86,6 +86,7 @@ import { IWorkspaceService, InMemorySkillCatalog, isError2, + resumeSessionById, MERGE_ALL_AVAILABLE_SKILLS_SECTION, SKILL_SOURCE_PRIORITY, applyPromptMetadataUpdate, @@ -161,7 +162,7 @@ async function resolveActivatedSession( // session cold-loads it instead of reporting "not activated"; matches v1's // `resumeSession` in SkillService. `resume` returns undefined only when the // session is unknown or its workspace is gone. - const handle = await core.accessor.get(ISessionLifecycleService).resume(sessionId); + const handle = await resumeSessionById(core.accessor, sessionId); if (handle !== undefined) return { handle }; const summary = await core.accessor.get(ISessionIndex).get(sessionId); diff --git a/packages/kap-server/src/routes/snapshot.ts b/packages/kap-server/src/routes/snapshot.ts index ee75e8bf64..929b06caa0 100644 --- a/packages/kap-server/src/routes/snapshot.ts +++ b/packages/kap-server/src/routes/snapshot.ts @@ -5,8 +5,8 @@ * * - `auto` (default) — delegate to `ISnapshotReader`, which reads * `state.json` + `agents/main/wire.jsonl` directly from disk and bypasses - * the heavy `ISessionLifecycleService.resume` chain (DI scope, MCP connect, - * full wire replay). Sub-200ms warm / sub-1s cold. + * the heavy session-resume chain (handler + DI scope materialization, MCP + * connect, full wire replay). Sub-200ms warm / sub-1s cold. * - `legacy` — fall back to `resume` + live service assembly. Pure operator * escape hatch; no silent per-request fallback. * @@ -25,9 +25,9 @@ import { ILogService, ISessionInteractionService, ISessionContext, - ISessionLifecycleService, ISessionMetadata, IWorkspaceService, + resumeSessionById, toProtocolMessage, type IAgentScopeHandle, type Scope, @@ -148,7 +148,7 @@ async function readViaLegacyAssembly( // Resolve the live handle, loading the session from disk when it is cold // (created by a previous process or by v1). `resume` returns `undefined` // only when the session is unknown or its workspace is gone → 404. - const handle = await core.accessor.get(ISessionLifecycleService).resume(sessionId); + const handle = await resumeSessionById(core.accessor, sessionId); if (handle === undefined) { throw new SnapshotNotFoundError(sessionId); } diff --git a/packages/kap-server/src/routes/tasks.ts b/packages/kap-server/src/routes/tasks.ts index 3a664a4713..e2f6e68049 100644 --- a/packages/kap-server/src/routes/tasks.ts +++ b/packages/kap-server/src/routes/tasks.ts @@ -18,7 +18,7 @@ * `packages/agent-core/src/services/task/task.ts`. * * **Resolution**: `core` → `ISessionIndex` (existence, → 40401) → - * `ISessionLifecycleService` (live session handle) → `IAgentLifecycleService` + * the live handler registry (live session handle) → `IAgentLifecycleService` * (the `main` agent) → `IAgentTaskService`. When the session is not live or * has no main agent yet (server-v2 gap G10 — the main agent is not created on * session creation), there is no task service: `list` returns an empty page @@ -41,7 +41,7 @@ import { IAgentTaskService, ISessionIndex, - ISessionLifecycleService, + getLiveSessionById, type AgentTaskInfo, type Scope, } from '@moonshot-ai/agent-core-v2'; @@ -279,7 +279,7 @@ async function resolveSessionTasks(core: Scope, sid: string): Promise { - const session = await core.accessor.get(ISessionLifecycleService).resume(sessionId); + const session = await resumeSessionById(core.accessor, sessionId); if (session === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); } diff --git a/packages/kap-server/src/routes/tools.ts b/packages/kap-server/src/routes/tools.ts index f729dcf356..a5d151a54e 100644 --- a/packages/kap-server/src/routes/tools.ts +++ b/packages/kap-server/src/routes/tools.ts @@ -19,7 +19,7 @@ * the most-recent session. v2 has no global tool/MCP state — both services are * Agent-scoped — so we reproduce the fallback: `core` → `ISessionIndex` (pick * the newest session by `createdAt`, or the explicit `session_id`) → - * `ISessionLifecycleService` → `IAgentLifecycleService` (the `main` agent) → + * the live handler registry → `IAgentLifecycleService` (the `main` agent) → * the service. When no session is live, or the main agent does not exist yet * (server-v2 gap G10), the GET endpoints answer an empty list and `:restart` * answers `40408`, exactly like v1. @@ -51,9 +51,9 @@ import { ErrorCodes, IAgentMcpService, ISessionIndex, - ISessionLifecycleService, IAgentToolRegistryService, IAgentToolPolicyService, + getLiveSessionById, Error2, type Scope, type ToolInfo, @@ -222,7 +222,7 @@ export function registerToolsRoutes(app: ToolsRouteHost, core: Scope): void { async function resolveEffectiveAgent(core: Scope, sessionId: string | undefined) { const sid = sessionId ?? (await mostRecentSessionId(core)); if (sid === undefined) return undefined; - const session = core.accessor.get(ISessionLifecycleService).get(sid); + const session = getLiveSessionById(core.accessor, sid); if (session === undefined) return undefined; return ensureMainAgent(session); } diff --git a/packages/kap-server/src/routes/workspaceFs.ts b/packages/kap-server/src/routes/workspaceFs.ts index f10725c54a..ff59022607 100644 --- a/packages/kap-server/src/routes/workspaceFs.ts +++ b/packages/kap-server/src/routes/workspaceFs.ts @@ -26,7 +26,7 @@ * All file handling lives here in the transport layer on top of the os * `IHostFileSystem` primitives — the engine deliberately has no "unconfined * read" domain Service. The mime / etag helpers are shared with the engine's - * `sessionFs` via `agent-core-v2/_base/utils/fileMeta` so both surfaces label + * `workspaceFs` via `agent-core-v2/_base/utils/fileMeta` so both surfaces label * content the same way. `IHostFileSystem` failures arrive as coded `os.fs.*` * errors and are mapped here: * diff --git a/packages/kap-server/src/routes/workspaces.ts b/packages/kap-server/src/routes/workspaces.ts index f6d9aca891..c326305039 100644 --- a/packages/kap-server/src/routes/workspaces.ts +++ b/packages/kap-server/src/routes/workspaces.ts @@ -10,6 +10,14 @@ * POST /workspaces register (idempotent on root) * PATCH /workspaces/{workspace_id} rename (display name only) * DELETE /workspaces/{workspace_id} unregister + * GET /workspaces/{workspace_id}/trust read the trust state + * POST /workspaces/{workspace_id}/trust mark the workspace trusted + * POST /workspaces/{workspace_id}/untrust revoke trust + * + * The trust routes resolve the workspace's live handler + * (`IWorkspaceLifecycleService.handlerFor`, materializing it on demand) and + * read/flip the Workspace-scope `IWorkspaceTrust`; while untrusted, the + * handler's project-level MCP config files are not loaded. * * **Wire fidelity**: the v1 `workspaceSchema` carries more fields than v2's * `Workspace` (`{ id, root, name, createdAt, lastOpenedAt }`). The handler @@ -24,8 +32,10 @@ import { IHostFileSystem, + IWorkspaceLifecycleService, IWorkspaceService, IWorkspaceSessions, + IWorkspaceTrust, type Scope, type Workspace, } from '@moonshot-ai/agent-core-v2'; @@ -45,6 +55,7 @@ import { updateWorkspaceRequestSchema, updateWorkspaceResponseSchema, workspaceIdParamSchema, + workspaceTrustResponseSchema, } from '../protocol/rest-workspace'; import type { Workspace as WorkspaceWire } from '../protocol/workspace'; @@ -216,6 +227,101 @@ export function registerWorkspacesRoutes(app: WorkspaceRouteHost, core: Scope): deleteRoute.options, deleteRoute.handler as Parameters[2], ); + + const getTrustRoute = defineRoute( + { + method: 'GET', + path: '/workspaces/{workspace_id}/trust', + params: workspaceIdParamSchema, + success: { data: workspaceTrustResponseSchema }, + errors: { + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: 'Read the workspace trust state', + tags: ['workspaces'], + }, + async (req, reply) => { + const trust = await resolveTrust(core, req.params.workspace_id, req.id, reply); + if (trust === undefined) return; + reply.send(okEnvelope({ trusted: await trust.get() }, req.id)); + }, + ); + app.get( + getTrustRoute.path, + getTrustRoute.options, + getTrustRoute.handler as Parameters[2], + ); + + const trustRoute = defineRoute( + { + method: 'POST', + path: '/workspaces/{workspace_id}/trust', + params: workspaceIdParamSchema, + success: { data: workspaceTrustResponseSchema }, + errors: { + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: 'Mark the workspace trusted (project-level MCP config loads)', + tags: ['workspaces'], + }, + async (req, reply) => { + const trust = await resolveTrust(core, req.params.workspace_id, req.id, reply); + if (trust === undefined) return; + await trust.trust(); + reply.send(okEnvelope({ trusted: true }, req.id)); + }, + ); + app.post( + trustRoute.path, + trustRoute.options, + trustRoute.handler as Parameters[2], + ); + + const untrustRoute = defineRoute( + { + method: 'POST', + path: '/workspaces/{workspace_id}/untrust', + params: workspaceIdParamSchema, + success: { data: workspaceTrustResponseSchema }, + errors: { + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: 'Revoke workspace trust (project-level MCP config unloads)', + tags: ['workspaces'], + }, + async (req, reply) => { + const trust = await resolveTrust(core, req.params.workspace_id, req.id, reply); + if (trust === undefined) return; + await trust.untrust(); + reply.send(okEnvelope({ trusted: false }, req.id)); + }, + ); + app.post( + untrustRoute.path, + untrustRoute.options, + untrustRoute.handler as Parameters[2], + ); +} + +type TrustReply = { send(payload: unknown): unknown }; + +async function resolveTrust( + core: Scope, + workspaceId: string, + requestId: string, + reply: TrustReply, +): Promise { + const ws = await core.accessor.get(IWorkspaceService).get(workspaceId); + if (ws === undefined) { + reply.send( + errEnvelope(ErrorCode.WORKSPACE_NOT_FOUND, `workspace ${workspaceId} does not exist`, requestId), + ); + return undefined; + } + const handle = await core + .accessor.get(IWorkspaceLifecycleService) + .handlerFor({ workspaceId, root: ws.root }); + return handle.accessor.get(IWorkspaceTrust); } // --------------------------------------------------------------------------- diff --git a/packages/kap-server/src/search/searchService.ts b/packages/kap-server/src/search/searchService.ts index 64f5c91781..b1f8a78427 100644 --- a/packages/kap-server/src/search/searchService.ts +++ b/packages/kap-server/src/search/searchService.ts @@ -43,6 +43,8 @@ import { LifecycleScope, ScopeActivation, registerScopedService, + sessionDirOf, + workspacePersistenceScope, type SessionSummary, } from '@moonshot-ai/agent-core-v2'; import { LockError, MiniDb, normalizeLiteral, tokenize, type BatchInputOp } from '@moonshot-ai/minidb'; @@ -780,7 +782,11 @@ export class GlobalSearchService implements IGlobalSearchService { } private async syncSession(db: MiniDb, summary: SessionSummary): Promise { - const sessionDir = this.bootstrap.sessionDir(summary.workspaceId, summary.id); + const sessionDir = sessionDirOf( + this.bootstrap.homeDir, + workspacePersistenceScope(this.bootstrap.scope('sessions'), summary.workspaceId), + summary.id, + ); const wireFiles = await collectWireFiles(sessionDir); const seenPaths = new Set(wireFiles.map((file) => file.path)); diff --git a/packages/kap-server/src/services/snapshot/snapshot.ts b/packages/kap-server/src/services/snapshot/snapshot.ts index 7f6bdb5833..9e68c18cdd 100644 --- a/packages/kap-server/src/services/snapshot/snapshot.ts +++ b/packages/kap-server/src/services/snapshot/snapshot.ts @@ -3,7 +3,7 @@ * `GET /sessions/{sid}/snapshot` in `auto` mode. * * Reads `state.json` + `agents/main/wire.jsonl` directly from disk, bypassing - * the `ISessionLifecycleService.resume` chain (DI-scope materialization, MCP + * the session-resume chain (handler + DI-scope materialization, MCP * connect, full wire replay). Mirrors v1's `ISnapshotService` * (`packages/server/src/services/snapshot/snapshot.ts`). */ diff --git a/packages/kap-server/src/services/snapshot/snapshotReader.ts b/packages/kap-server/src/services/snapshot/snapshotReader.ts index 0723f10f9f..f5ff963e76 100644 --- a/packages/kap-server/src/services/snapshot/snapshotReader.ts +++ b/packages/kap-server/src/services/snapshot/snapshotReader.ts @@ -3,8 +3,8 @@ * (`KIMI_SNAPSHOT_READER=auto`, the default). * * Reads `/sessions///state.json` and - * `…/agents/main/wire.jsonl` directly, bypassing - * `ISessionLifecycleService.resume` (DI-scope materialization, MCP connect, + * `…/agents/main/wire.jsonl` directly, bypassing the session-resume chain + * (handler materialization, DI-scope materialization, MCP connect, * full wire replay). The transcript is reduced from the `context.*` records * with `reduceContextTranscript`, which mirrors the live reducers EXCEPT that * `context.apply_compaction` keeps the full history and appends a summary @@ -26,12 +26,13 @@ import { IAgentPromptService, ISessionIndex, ISessionInteractionService, - ISessionLifecycleService, IWorkspaceService, + getLiveSessionById, normalizeSessionMeta, reduceContextTranscript, toProtocolMessage, type ContextMessage, + type ISessionScopeHandle, type Scope, type SessionMeta, } from '@moonshot-ai/agent-core-v2'; @@ -115,7 +116,7 @@ export class SnapshotReader implements ISnapshotReader { return toProtocolMessage(sid, index, msg, located.meta.createdAt, createdAtMs); }); - const live = core.accessor.get(ISessionLifecycleService).get(sid); + const live = getLiveSessionById(core.accessor, sid); const session = toWireSession( { ...located.meta, workspaceId: located.workspaceId }, located.cwd, @@ -227,7 +228,7 @@ export class SnapshotReader implements ISnapshotReader { private attachCurrentPromptId( sid: string, - live: ReturnType, + live: ISessionScopeHandle | undefined, inFlightTurn: InFlightTurn | null, ): InFlightTurn | null { if (inFlightTurn === null || live === undefined) return inFlightTurn; @@ -245,7 +246,7 @@ export class SnapshotReader implements ISnapshotReader { private readPending( sid: string, - live: ReturnType, + live: ISessionScopeHandle | undefined, ): { approvals: ReturnType[]; questions: ReturnType[] } { if (live === undefined) return { approvals: [], questions: [] }; const interaction = live.accessor.get(ISessionInteractionService); diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index c911990286..a7165294a2 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -47,9 +47,10 @@ import { readFile } from 'node:fs/promises'; import { IAgentLifecycleService, ISessionIndex, - ISessionLifecycleService, ISessionMetadata, IAgentLoopService, + followWorkspaceHandlers, + getLiveSessionById, reduceContextTranscript, type IDisposable, type Scope, @@ -134,10 +135,19 @@ export class TranscriptService { constructor(private readonly deps: TranscriptServiceDeps) { // Live entries must not outlive their session: once it closes or archives, - // reads should fall through to the cold rebuild from disk. - const lifecycle = deps.core.accessor.get(ISessionLifecycleService); - lifecycle.onDidCloseSession(({ sessionId }) => this.dropSession(sessionId)); - lifecycle.onDidArchiveSession(({ sessionId }) => this.dropSession(sessionId)); + // reads should fall through to the cold rebuild from disk. Close/archive + // events are per-handler (Workspace scope), so follow every handler — + // present and future — through the App-scope registry. + followWorkspaceHandlers(deps.core.accessor, (service) => { + const d1 = service.onDidCloseSession(({ sessionId }) => this.dropSession(sessionId)); + const d2 = service.onDidArchiveSession(({ sessionId }) => this.dropSession(sessionId)); + return { + dispose: () => { + d1.dispose(); + d2.dispose(); + }, + }; + }); } /** @@ -147,7 +157,7 @@ export class TranscriptService { forSessionLive(sessionId: string): TranscriptStore | undefined { const existing = this.live.get(sessionId); if (existing !== undefined) { - if (this.deps.core.accessor.get(ISessionLifecycleService).get(sessionId) !== undefined) { + if (getLiveSessionById(this.deps.core.accessor, sessionId) !== undefined) { return existing.store; } // Stale entry for a session already closed/archived (the drop event may @@ -155,7 +165,7 @@ export class TranscriptService { this.dropSession(sessionId); return undefined; } - const session = this.deps.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(this.deps.core.accessor, sessionId); if (session === undefined) return undefined; const store = new TranscriptStore(sessionId); let binding: TranscriptBinding; @@ -234,7 +244,7 @@ export class TranscriptService { // reads (and agent pickers) see the complete historical roster — // including subagents not materialized in this process. try { - const session = this.deps.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(this.deps.core.accessor, sessionId); const meta = await session?.accessor.get(ISessionMetadata).read(); for (const [agentId, agentMeta] of Object.entries(meta?.agents ?? {})) { store.describeAgent(descriptorFromMeta(agentId, agentMeta)); @@ -444,7 +454,7 @@ export class TranscriptService { transcript: AgentTranscript, snapshot: AgentTranscriptSnapshot, ): TranscriptOperation | undefined { - const session = this.deps.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(this.deps.core.accessor, sessionId); const agent = session?.accessor.get(IAgentLifecycleService).get(agentId); const status = agent?.accessor.get(IAgentLoopService).status(); if (status?.state !== 'running' || status.activeTurnId === undefined) return undefined; diff --git a/packages/kap-server/src/transport/channel.ts b/packages/kap-server/src/transport/channel.ts index 5d98ed3f75..1c1e2c632e 100644 --- a/packages/kap-server/src/transport/channel.ts +++ b/packages/kap-server/src/transport/channel.ts @@ -8,7 +8,7 @@ */ /** Which scope a route resolves before dispatching. */ -export type ScopeKind = 'core' | 'session' | 'agent'; +export type ScopeKind = 'core' | 'workspace' | 'session' | 'agent'; /** The client-facing channel contract (request/response + future events). */ export interface IChannel { diff --git a/packages/kap-server/src/transport/channelRegistry.ts b/packages/kap-server/src/transport/channelRegistry.ts index 0b874c8bda..8bda0e997b 100644 --- a/packages/kap-server/src/transport/channelRegistry.ts +++ b/packages/kap-server/src/transport/channelRegistry.ts @@ -39,7 +39,7 @@ export interface ChannelDescriptor { * Registration scope — the minimal scope at which the channel resolves. * Derived from the scoped DI registry. */ - readonly scope: 'app' | 'session' | 'agent'; + readonly scope: 'app' | 'workspace' | 'session' | 'agent'; /** Domain tag recorded at `registerScopedService`. */ readonly domain: string; /** Public prototype members, sorted — events are instance properties and never appear. */ @@ -48,6 +48,7 @@ export interface ChannelDescriptor { const SCOPE_NAME: Record = { [LifecycleScope.App]: 'app', + [LifecycleScope.Workspace]: 'workspace', [LifecycleScope.Session]: 'session', [LifecycleScope.Agent]: 'agent', }; @@ -64,7 +65,12 @@ let serviceNameIndex: Map> | undefined; function scopedServiceNameIndex(): Map> { serviceNameIndex ??= (() => { const map = new Map>(); - for (const scope of [LifecycleScope.App, LifecycleScope.Session, LifecycleScope.Agent]) { + for (const scope of [ + LifecycleScope.App, + LifecycleScope.Workspace, + LifecycleScope.Session, + LifecycleScope.Agent, + ]) { for (const entry of getScopedServiceDescriptors(scope)) { const name = entry.id.toString(); if (!map.has(name)) map.set(name, entry.id); @@ -138,7 +144,12 @@ function describeMethods( */ export function describeAllChannels(): readonly ChannelDescriptor[] { const byName = new Map(); - for (const scope of [LifecycleScope.App, LifecycleScope.Session, LifecycleScope.Agent]) { + for (const scope of [ + LifecycleScope.App, + LifecycleScope.Workspace, + LifecycleScope.Session, + LifecycleScope.Agent, + ]) { for (const entry of getScopedServiceDescriptors(scope)) { const name = entry.id.toString(); if (!byName.has(name)) byName.set(name, entry); diff --git a/packages/kap-server/src/transport/dispatcher.ts b/packages/kap-server/src/transport/dispatcher.ts index 17a8cf4e22..e3b75dba49 100644 --- a/packages/kap-server/src/transport/dispatcher.ts +++ b/packages/kap-server/src/transport/dispatcher.ts @@ -9,8 +9,9 @@ import { ErrorCodes, IAgentGoalService, IAgentLifecycleService, - ISessionLifecycleService, + IWorkspaceLifecycleService, Error2, + getLiveSessionById, type IScopeHandle, type Scope, type ServiceIdentifier, @@ -44,9 +45,13 @@ export async function resolveScope( switch (scopeKind) { case 'core': return core; + case 'workspace': { + const workspaceId = params['workspace_id'] ?? ''; + return core.accessor.get(IWorkspaceLifecycleService).handlerFor({ workspaceId }); + } case 'session': { const sessionId = params['session_id'] ?? ''; - const session = core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(core.accessor, sessionId); if (session === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} not found`); } @@ -55,7 +60,7 @@ export async function resolveScope( case 'agent': { const sessionId = params['session_id'] ?? ''; const agentId = params['agent_id'] ?? ''; - const session = core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(core.accessor, sessionId); if (session === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} not found`); } diff --git a/packages/kap-server/src/transport/serviceDispatcherRoutes.ts b/packages/kap-server/src/transport/serviceDispatcherRoutes.ts index 5ff70a8be0..81163cb7a6 100644 --- a/packages/kap-server/src/transport/serviceDispatcherRoutes.ts +++ b/packages/kap-server/src/transport/serviceDispatcherRoutes.ts @@ -1,12 +1,13 @@ /** * Shared Service-dispatcher route registration. * - * Mounts the reflection dispatcher under `basePath`: three routes mirror the + * Mounts the reflection dispatcher under `basePath`: the routes mirror the * scope tree; all share one handler. `:service` is a decorator id (channel * name) resolved against the scoped DI registry; `:method` is invoked by * reflection. Reads use `GET`, writes use `POST`. * * GET|POST {basePath}/:service/:method + * GET|POST {basePath}/workspace/:workspace_id/:service/:method * GET|POST {basePath}/session/:session_id/:service/:method * GET|POST {basePath}/session/:session_id/agent/:agent_id/:service/:method * @@ -75,6 +76,7 @@ export function registerServiceDispatcherRoutes( const lookup = opts.lookup ?? resolveAnyScopedServiceId; const scopeRoutes: { path: string; scopeKind: ScopeKind }[] = [ { path: `${basePath}/:service/:method`, scopeKind: 'core' }, + { path: `${basePath}/workspace/:workspace_id/:service/:method`, scopeKind: 'workspace' }, { path: `${basePath}/session/:session_id/:service/:method`, scopeKind: 'session' }, { path: `${basePath}/session/:session_id/agent/:agent_id/:service/:method`, diff --git a/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts b/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts index 7a0d100903..6e2fc258da 100644 --- a/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts +++ b/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts @@ -1,9 +1,9 @@ /** * `FsWatchBridge` — volatile `/api/v1/ws` delivery for filesystem changes. * - * Turns the core `ISessionFsWatchService.onDidChangeFiles` feed into - * `event.fs.changed` frames on the v1 WebSocket, byte-compatible with the v1 - * server (`packages/server/.../fsWatcherService.ts`): + * Turns the core `IWorkspaceFsWatchService` feed into `event.fs.changed` + * frames on the v1 WebSocket, byte-compatible with the v1 server + * (`packages/server/.../fsWatcherService.ts`): * * client → `{type:'watch_fs_add', id, payload:{session_id, paths}}` * client → `{type:'watch_fs_remove', id, payload:{session_id, paths}}` @@ -18,9 +18,12 @@ * to the socket — they never enter the broadcaster / journal (fs changes are * volatile: on overflow the client sees `truncated` and re-syncs). * - * The core `ISessionFsWatchService` keeps a single subscription set per - * session; the bridge drives it with the **union** of every connection's - * paths for that session, then re-filters per connection on the way out. + * The core watch service is Workspace-scoped: one os watcher per handler, + * shared by every session of the workspace. The bridge holds ONE + * `IWorkspaceFsWatchSubscription` per session (driven with the union of every + * connection's paths for that session) and re-filters per connection on the + * way out — two sessions of one workspace fan out from the same handler + * watch instead of hanging a second os watcher. */ import { isAbsolute, relative, sep } from 'node:path'; @@ -28,12 +31,16 @@ import { isAbsolute, relative, sep } from 'node:path'; import { type IDisposable, type ISessionScopeHandle, - ISessionFsWatchService, - ISessionLifecycleService, + IWorkspaceFsWatchService, ISessionWorkspaceContext, + getLiveSessionById, type Scope, } from '@moonshot-ai/agent-core-v2'; -import type { FsChangeEntry, FsChangeEvent } from '@moonshot-ai/agent-core-v2/session/sessionFs/fsWatch'; +import type { + FsChangeEntry, + FsChangeEvent, + IWorkspaceFsWatchSubscription, +} from '@moonshot-ai/agent-core-v2/workspace/workspaceFs/fsWatch'; import type { EventEnvelope, JournalLogger } from './sessionEventJournal'; @@ -75,7 +82,7 @@ interface ConnEntry { interface SessionWatch { readonly id: string; readonly session: ISessionScopeHandle; - readonly fsWatch: ISessionFsWatchService; + readonly fsWatch: IWorkspaceFsWatchSubscription; readonly workspace: ISessionWorkspaceContext; readonly conns: Map; union: Set; @@ -176,12 +183,14 @@ export class FsWatchBridge { private resolveSession(sessionId: string): SessionWatch | undefined { const existing = this.bySession.get(sessionId); if (existing !== undefined) return existing; - const session = this.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(this.core.accessor, sessionId); if (session === undefined) return undefined; const sw: SessionWatch = { id: sessionId, session, - fsWatch: session.accessor.get(ISessionFsWatchService), + // One subscription per session, held on the handler-shared Workspace + // watch service (resolved through the session's parent scope). + fsWatch: session.accessor.get(IWorkspaceFsWatchService).subscribe(), workspace: session.accessor.get(ISessionWorkspaceContext), conns: new Map(), union: new Set(), @@ -208,6 +217,7 @@ export class FsWatchBridge { sw.sub?.dispose(); sw.sub = undefined; sw.fsWatch.setWatchedPaths([]); + sw.fsWatch.dispose(); this.bySession.delete(sw.id); } diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index c1d6ebe8c7..e49fca40fd 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -70,8 +70,8 @@ import { ISessionActivityView, ISessionInteractionService, ISessionIndex, - ISessionLifecycleService, MAIN_AGENT_ID, + getLiveSessionById, } from '@moonshot-ai/agent-core-v2'; import type { SessionCreatedEvent, SessionMetaUpdatedEvent, Event } from './events'; import { isVolatileEventType } from './events'; @@ -754,7 +754,7 @@ export class SessionEventBroadcaster { private async createSessionState(sessionId: string): Promise { if (this.closed) return undefined; - const session = this.opts.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(this.opts.core.accessor, sessionId); if (session === undefined) return undefined; const journal = await SessionEventJournal.open( diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index df90320865..0457902050 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -72,6 +72,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "GET", "/api/v1/debug/session/{session_id}/agent/{agent_id}/{service}/{method}", ], + [ + "GET", + "/api/v1/debug/workspace/{workspace_id}/{service}/{method}", + ], [ "GET", "/api/v1/files/{file_id}", @@ -236,6 +240,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "GET", "/api/v1/workspaces/{workspace_id}/skills", ], + [ + "GET", + "/api/v1/workspaces/{workspace_id}/trust", + ], [ "GET", "/asyncapi.json", @@ -264,6 +272,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "POST", "/api/v1/debug/session/{session_id}/agent/{agent_id}/{service}/{method}", ], + [ + "POST", + "/api/v1/debug/workspace/{workspace_id}/{service}/{method}", + ], [ "POST", "/api/v1/files", @@ -384,6 +396,14 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "POST", "/api/v1/workspaces", ], + [ + "POST", + "/api/v1/workspaces/{workspace_id}/trust", + ], + [ + "POST", + "/api/v1/workspaces/{workspace_id}/untrust", + ], [ "PUT", "/api/v1/providers/{provider_id}", diff --git a/packages/kap-server/test/approvals.test.ts b/packages/kap-server/test/approvals.test.ts index 8622c9bb95..2385af446e 100644 --- a/packages/kap-server/test/approvals.test.ts +++ b/packages/kap-server/test/approvals.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { ISessionApprovalService, ISessionLifecycleService } from '@moonshot-ai/agent-core-v2'; +import { ISessionApprovalService, getLiveSessionById } from '@moonshot-ai/agent-core-v2'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; @@ -99,7 +99,7 @@ describe('server-v2 /api/v1/sessions/{sid}/approvals', () => { /** Park an approval in-process so the REST route has something to list/resolve. */ function enqueueApproval(sessionId: string, toolCallId: string): string { - const handle = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const handle = getLiveSessionById(server!.core.accessor, sessionId); expect(handle).toBeDefined(); const parked = handle!.accessor.get(ISessionApprovalService).enqueue({ toolCallId, diff --git a/packages/kap-server/test/messages.test.ts b/packages/kap-server/test/messages.test.ts index 0e7102e96e..0a0a2bb2f4 100644 --- a/packages/kap-server/test/messages.test.ts +++ b/packages/kap-server/test/messages.test.ts @@ -6,7 +6,7 @@ import { IAgentContextMemoryService, IAgentLifecycleService, IWireService, - ISessionLifecycleService, + getLiveSessionById, IModelCatalog, type ContextMessage, type ScopeSeed, @@ -127,7 +127,7 @@ describe('server-v2 /api/v1/sessions/{sid}/messages', () => { sessionId: string, messages: readonly ContextMessage[], ): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); if (session === undefined) throw new Error(`session ${sessionId} not found`); let agent = session.accessor.get(IAgentLifecycleService).get('main'); if (agent === undefined) { @@ -302,7 +302,7 @@ describe('server-v2 /api/v1/sessions/{sid}/messages', () => { // on the same home so the session is genuinely cold on the read path. it('reads the persisted full transcript for a cold session', async () => { const id = await createSession(); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); if (session === undefined) throw new Error(`session ${id} not found`); const agent = await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); const ctx = agent.accessor.get(IAgentContextMemoryService); diff --git a/packages/kap-server/test/prompts.test.ts b/packages/kap-server/test/prompts.test.ts index 06cae83d48..859c66148c 100644 --- a/packages/kap-server/test/prompts.test.ts +++ b/packages/kap-server/test/prompts.test.ts @@ -8,7 +8,8 @@ import { IAgentLifecycleService, IAgentProfileService, IAgentToolPolicyService, - ISessionLifecycleService, + closeSessionById, + getLiveSessionById, } from '@moonshot-ai/agent-core-v2'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -185,7 +186,7 @@ describe('server-v2 /api/v1 prompts', () => { // The main agent scope is not created automatically on session creation // (server-v2 gap G10); create it here so the prompt route resolves. async function createMainAgent(sessionId: string): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); if (session === undefined) throw new Error(`session ${sessionId} not found`); await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); } @@ -216,7 +217,7 @@ describe('server-v2 /api/v1 prompts', () => { it('rejects a stale file reference without creating the agent or mutating the model', async () => { const id = await createSession(home as string); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const { body } = await call('POST', `/api/v1/sessions/${id}/prompts`, { model: 'stub', @@ -233,7 +234,7 @@ describe('server-v2 /api/v1 prompts', () => { it('rejects a mis-kinded file reference without creating the agent', async () => { const id = await createSession(home as string); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); // A real upload, but referenced with the wrong media kind. const form = new FormData(); @@ -640,8 +641,8 @@ describe('server-v2 /api/v1 prompts', () => { // Drop the in-memory handle so the session only exists on disk / in the // index — the state a session is in after a server restart. The route must // cold-resume it rather than report 40401. - await server!.core.accessor.get(ISessionLifecycleService).close(id); - expect(server!.core.accessor.get(ISessionLifecycleService).get(id)).toBeUndefined(); + await closeSessionById(server!.core.accessor, id); + expect(getLiveSessionById(server!.core.accessor, id)).toBeUndefined(); const list = await call<{ active: PromptItemWire | null; queued: PromptItemWire[] }>( 'GET', @@ -657,7 +658,7 @@ describe('server-v2 /api/v1 prompts', () => { await createMainAgent(id); // Fork the main agent into a side-channel child the way `/btw` does. - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); if (session === undefined) throw new Error(`session ${id} not found`); const lifecycle = session.accessor.get(IAgentLifecycleService); const child = await lifecycle.fork('main'); @@ -745,7 +746,7 @@ describe('server-v2 /api/v1 prompts', () => { }); expect(submitted.body.code).toBe(0); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); if (session === undefined) throw new Error(`session ${id} not found`); const main = session.accessor.get(IAgentLifecycleService).get('main'); expect(main?.accessor.get(IAgentProfileService).data().profileName).toBe('route-reviewer'); @@ -791,7 +792,7 @@ describe('server-v2 /api/v1 prompts', () => { }); expect(submitted.body.code).toBe(0); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); if (session === undefined) throw new Error(`session ${id} not found`); const main = session.accessor.get(IAgentLifecycleService).get('main'); const profile = main?.accessor.get(IAgentProfileService); @@ -812,7 +813,7 @@ describe('server-v2 /api/v1 prompts', () => { }); expect(submitted.body.code).toBe(0); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); if (session === undefined) throw new Error(`session ${id} not found`); const toolPolicy = session.accessor.get(IAgentLifecycleService).get('main')?.accessor .get(IAgentToolPolicyService); @@ -848,7 +849,7 @@ describe('server-v2 /api/v1 prompts', () => { }); expect(submitted.body.code).toBe(0); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); if (session === undefined) throw new Error(`session ${id} not found`); const child = await session.accessor.get(IAgentLifecycleService).create({ binding: { @@ -887,15 +888,15 @@ describe('server-v2 /api/v1 prompts', () => { expect(submitted.body.code).toBe(0); // Drop the live handle; the next submit cold-resumes the session from disk. - await server!.core.accessor.get(ISessionLifecycleService).close(id); - expect(server!.core.accessor.get(ISessionLifecycleService).get(id)).toBeUndefined(); + await closeSessionById(server!.core.accessor, id); + expect(getLiveSessionById(server!.core.accessor, id)).toBeUndefined(); const again = await call('POST', `/api/v1/sessions/${id}/prompts`, { content: [{ type: 'text', text: 'again' }], }); expect(again.body.code).toBe(0); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); if (session === undefined) throw new Error(`session ${id} not found`); const toolPolicy = session.accessor.get(IAgentLifecycleService).get('main')?.accessor .get(IAgentToolPolicyService); diff --git a/packages/kap-server/test/questions.test.ts b/packages/kap-server/test/questions.test.ts index 3c28e79cd4..a69d6af466 100644 --- a/packages/kap-server/test/questions.test.ts +++ b/packages/kap-server/test/questions.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { ISessionQuestionService, - ISessionLifecycleService, + getLiveSessionById, type QuestionRequest, type QuestionResult, } from '@moonshot-ai/agent-core-v2'; @@ -123,7 +123,7 @@ describe('server-v2 /api/v1/sessions/{sid}/questions', () => { } function questionService(sessionId: string): ISessionQuestionService { - const handle = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const handle = getLiveSessionById(server!.core.accessor, sessionId); expect(handle).toBeDefined(); return handle!.accessor.get(ISessionQuestionService); } diff --git a/packages/kap-server/test/rpc.test.ts b/packages/kap-server/test/rpc.test.ts index aa6a2f8a31..be903e643d 100644 --- a/packages/kap-server/test/rpc.test.ts +++ b/packages/kap-server/test/rpc.test.ts @@ -12,9 +12,10 @@ import { IEventService, IPluginService, ISessionIndex, - ISessionLifecycleService, ISessionMetadata, + IWorkspaceHandlerService, IWorkspaceService, + getLiveSessionById, } from '@moonshot-ai/agent-core-v2'; import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -60,12 +61,14 @@ interface GoalToolResultWire { // name — exactly how the typed client composes URLs, so the test never hardcodes // a channel name that could drift from the token. function rpc( - scope: 'core' | 'session' | 'agent', + scope: 'core' | 'workspace' | 'session' | 'agent', service: ServiceIdentifier, method: string, - ids: { sid?: string; aid?: string } = {}, + ids: { wid?: string; sid?: string; aid?: string } = {}, ): string { if (scope === 'core') return `/api/v1/debug/${String(service)}/${method}`; + if (scope === 'workspace') + return `/api/v1/debug/workspace/${ids.wid}/${String(service)}/${method}`; if (scope === 'session') return `/api/v1/debug/session/${ids.sid}/${String(service)}/${method}`; return `/api/v1/debug/session/${ids.sid}/agent/${ids.aid}/${String(service)}/${method}`; } @@ -132,13 +135,13 @@ describe('server-v2 /api/v1/debug RPC', () => { // The main agent scope is not created automatically on session creation // (server-v2 gap G10); create it here so the agent-scope dispatch resolves. async function createMainAgent(sessionId: string): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); if (session === undefined) throw new Error(`session ${sessionId} not found`); await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); } async function createSubagent(sessionId: string, agentId: string): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); if (session === undefined) throw new Error(`session ${sessionId} not found`); await session.accessor.get(IAgentLifecycleService).create({ agentId }); } @@ -290,8 +293,16 @@ describe('server-v2 /api/v1/debug RPC', () => { it('archives a session', async () => { const id = await createSession(home as string); - const { body } = await call('POST', rpc('session', ISessionLifecycleService, 'archive', { sid: id }), id); + const workspaceId = (await server!.core.accessor.get(ISessionIndex).get(id))!.workspaceId; + const { body } = await call( + 'POST', + rpc('workspace', IWorkspaceHandlerService, 'archive', { wid: workspaceId }), + id, + ); expect(body.code).toBe(0); + // archive is a method on the handler's session lifecycle service; its + // single argument is the session id. + expect(body.data).toBeNull(); }); // --- Agent scope ---------------------------------------------------------- diff --git a/packages/kap-server/test/search/searchService.test.ts b/packages/kap-server/test/search/searchService.test.ts index bc587b27eb..6640da340b 100644 --- a/packages/kap-server/test/search/searchService.test.ts +++ b/packages/kap-server/test/search/searchService.test.ts @@ -35,7 +35,7 @@ function summary(id: string, title: string, updatedAt = T1): SessionSummary { function makeBootstrap(home: string): IBootstrapService { return { homeDir: home, - sessionDir: (ws: string, sid: string) => join(home, 'sessions', ws, sid), + scope: (name: string) => name, } as unknown as IBootstrapService; } diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 28c25fc766..83f8fa5d44 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -16,8 +16,10 @@ import { IEventBus, ISessionIndex, ISessionInteractionService, - ISessionLifecycleService, ISessionMetadata, + IWorkspaceHandlerService, + IWorkspaceLifecycleService, + LifecycleScope, SessionInteractionService, StateRegistry, type DomainEvent, @@ -1471,10 +1473,11 @@ describe('AgentTranscriptProjector', () => { core: { accessor: { get: (token: unknown) => { - if (token === ISessionLifecycleService) { + if (token === IWorkspaceLifecycleService) { return { - onDidCloseSession: () => ({ dispose: () => undefined }), - onDidArchiveSession: () => ({ dispose: () => undefined }), + handlers: { list: () => [] }, + sessions: { list: () => [] }, + onDidMaterializeHandler: () => ({ dispose: () => undefined }), }; } if (token === ISessionIndex) return { get: async () => ({ workspaceId: 'ws' }) }; @@ -1569,10 +1572,11 @@ describe('AgentTranscriptProjector', () => { core: { accessor: { get: (token: unknown) => { - if (token === ISessionLifecycleService) { + if (token === IWorkspaceLifecycleService) { return { - onDidCloseSession: () => ({ dispose: () => undefined }), - onDidArchiveSession: () => ({ dispose: () => undefined }), + handlers: { list: () => [] }, + sessions: { list: () => [] }, + onDidMaterializeHandler: () => ({ dispose: () => undefined }), }; } if (token === ISessionIndex) return { get: async () => ({ workspaceId: 'ws' }) }; @@ -1881,14 +1885,27 @@ describe('bindSessionTranscript', () => { } function fakeCoreWithAgents(interactions: SessionInteractionService, agents: FakeAgents): Scope { + const handlerService = { + onDidCloseSession: () => ({ dispose: () => undefined }), + onDidArchiveSession: () => ({ dispose: () => undefined }), + get: (sid: string) => (sid === 's1' ? fakeSession(interactions, agents) : undefined), + }; + const handler = { + id: 'ws', + kind: LifecycleScope.Workspace, + accessor: { + get: (t: unknown) => (t === IWorkspaceHandlerService ? handlerService : undefined), + }, + dispose: () => undefined, + }; return { accessor: { get: (token: unknown) => { - if (token === ISessionLifecycleService) { + if (token === IWorkspaceLifecycleService) { return { - onDidCloseSession: () => ({ dispose: () => undefined }), - onDidArchiveSession: () => ({ dispose: () => undefined }), - get: (sid: string) => (sid === 's1' ? fakeSession(interactions, agents) : undefined), + handlers: { list: () => [handler] }, + sessions: { list: () => [] }, + onDidMaterializeHandler: () => ({ dispose: () => undefined }), }; } if (token === ISessionIndex) return { get: async () => ({ workspaceId: 'ws' }) }; diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 8c8c76466a..92b76b589b 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -18,6 +18,7 @@ import type { import { ContextSizeModel, IAgentActivityView, + LifecycleScope, IAgentContextSizeService, IAgentLifecycleService, IAgentProfileService, @@ -27,9 +28,10 @@ import { IModelCatalog, ISessionActivityView, ISessionInteractionService, - ISessionLifecycleService, IWireService, ISessionMetadata, + IWorkspaceHandlerService, + IWorkspaceLifecycleService, MAIN_AGENT_ID, SECONDARY_DERIVED_MODEL_ID, SessionInteractionService, @@ -112,7 +114,7 @@ class FakeEventBus { } class FakeAgentHandle { - readonly kind = 2; + readonly kind = LifecycleScope.Agent; readonly bus = new FakeAgentBus(); readonly accessor; private readonly services = new Map(); @@ -350,29 +352,43 @@ function makeCore( eventBus = new FakeEventBus(), metaAgents: Record = {}, ): Scope { + const sessionFor = (sid: string) => { + const lifecycle = sessions.get(sid); + if (lifecycle === undefined) return undefined; + const sessionAccessor = { + get: (t: unknown) => { + if (t === IAgentLifecycleService) return lifecycle; + if (t === ISessionInteractionService) return lifecycle.interactions; + if (t === ISessionActivityView) return lifecycle.workView; + // Minimal metadata read for the transcript binding's descriptor pass. + if (t === ISessionMetadata) return { read: async () => ({ agents: metaAgents }) }; + return undefined; + }, + }; + return { id: sid, kind: LifecycleScope.Session, accessor: sessionAccessor, dispose: () => {} }; + }; + const handlerService = { + // Inert lifecycle events (TranscriptService subscribes on construction). + onDidCloseSession: () => ({ dispose: () => {} }), + onDidArchiveSession: () => ({ dispose: () => {} }), + get: sessionFor, + }; + const handler = { + id: 'wd', + kind: LifecycleScope.Workspace, + accessor: { + get: (t: unknown) => (t === IWorkspaceHandlerService ? handlerService : undefined), + }, + dispose: () => {}, + }; const accessor = { get(token: unknown): unknown { if (token === IEventService) return eventBus; - if (token === ISessionLifecycleService) { + if (token === IWorkspaceLifecycleService) { return { - // Inert lifecycle events (TranscriptService subscribes on construction). - onDidCloseSession: () => ({ dispose: () => {} }), - onDidArchiveSession: () => ({ dispose: () => {} }), - get: (sid: string) => { - const lifecycle = sessions.get(sid); - if (lifecycle === undefined) return undefined; - const sessionAccessor = { - get: (t: unknown) => { - if (t === IAgentLifecycleService) return lifecycle; - if (t === ISessionInteractionService) return lifecycle.interactions; - if (t === ISessionActivityView) return lifecycle.workView; - // Minimal metadata read for the transcript binding's descriptor pass. - if (t === ISessionMetadata) return { read: async () => ({ agents: metaAgents }) }; - return undefined; - }, - }; - return { id: sid, kind: 1, accessor: sessionAccessor, dispose: () => {} }; - }, + handlers: { list: () => [handler] }, + sessions: { list: () => [] }, + onDidMaterializeHandler: () => ({ dispose: () => {} }), }; } return undefined; diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index e7c5a1f7a2..c8049bcd41 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -22,8 +22,10 @@ import { IAgentLifecycleService, IEventBus, IEventService, - ISessionLifecycleService, MAIN_AGENT_ID, + closeSessionById, + getLiveSessionById, + sessionDirOf, type ServiceIdentifier, } from '@moonshot-ai/agent-core-v2'; import { sessionWarningsResponseSchema } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; @@ -209,9 +211,11 @@ describe('server-v2 /api/v1/sessions', () => { metadata: { cwd: home as string }, }); const id = created.body.data.id; - const sessionDir = (server as RunningServer).core.accessor - .get(IBootstrapService) - .sessionDir(created.body.data.workspace_id, id); + const sessionDir = sessionDirOf( + (server as RunningServer).core.accessor.get(IBootstrapService).homeDir, + `sessions/${created.body.data.workspace_id}`, + id, + ); await writeFile(join(sessionDir, 'cancel-test.bin'), randomBytes(8 * 1024 * 1024)); const res = await fetch(`${base}/api/v1/sessions/${id}/export`, { @@ -289,9 +293,7 @@ describe('server-v2 /api/v1/sessions', () => { await postJson(`/api/v1/sessions/${id}/profile`, { agent_config: { goal_objective: 'finish the migration' }, }); - const session = (server as RunningServer).core.accessor - .get(ISessionLifecycleService) - .get(id); + const session = getLiveSessionById((server as RunningServer).core.accessor, id); if (session === undefined) throw new Error('expected a live session'); const agent = session.accessor.get(IAgentLifecycleService).get(MAIN_AGENT_ID); if (agent === undefined) throw new Error('expected a live main agent'); @@ -664,7 +666,7 @@ describe('server-v2 /api/v1/sessions', () => { // only) — the state right after opening a session in the web UI before any // prompt has been sent. Before the fix, `:undo` resolved the main agent via // `lifecycle.get` (memory only) and reported 40401 "session does not exist". - await (server as RunningServer).core.accessor.get(ISessionLifecycleService).close(id); + await closeSessionById((server as RunningServer).core.accessor, id); const res = await postJson<{ messages: unknown }>(`/api/v1/sessions/${id}:undo`, { count: 1 }); // Cold-loaded successfully: the empty history yields "nothing to undo" @@ -680,9 +682,7 @@ describe('server-v2 /api/v1/sessions', () => { const created = await postJson('/api/v1/sessions', { metadata: { cwd: home as string }, }); - const session = (server as RunningServer).core.accessor - .get(ISessionLifecycleService) - .get(created.body.data.id); + const session = getLiveSessionById((server as RunningServer).core.accessor, created.body.data.id); if (session === undefined) throw new Error('expected live session'); const agent = await session.accessor .get(IAgentLifecycleService) diff --git a/packages/kap-server/test/skills.test.ts b/packages/kap-server/test/skills.test.ts index 7e7d7e2276..01b40e47c3 100644 --- a/packages/kap-server/test/skills.test.ts +++ b/packages/kap-server/test/skills.test.ts @@ -27,7 +27,7 @@ import { join } from 'node:path'; import { IAgentLifecycleService, - ISessionLifecycleService, + getLiveSessionById, ISkillCatalogRuntimeOptions, } from '@moonshot-ai/agent-core-v2'; import { @@ -108,7 +108,7 @@ describe('server-v2 /api/v1 skills', () => { // The main agent scope is not created automatically on session creation // (server-v2 gap G10); create it here so skill activation can start a turn. async function createMainAgent(sessionId: string): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); if (session === undefined) throw new Error(`session ${sessionId} not found`); const agents = session.accessor.get(IAgentLifecycleService); if (agents.get('main') === undefined) await agents.create({ agentId: 'main' }); diff --git a/packages/kap-server/test/snapshot.test.ts b/packages/kap-server/test/snapshot.test.ts index 9ed27f2dac..5a268fda60 100644 --- a/packages/kap-server/test/snapshot.test.ts +++ b/packages/kap-server/test/snapshot.test.ts @@ -16,9 +16,13 @@ import { ILogService, ISessionInteractionService, ISessionContext, - ISessionLifecycleService, + ISessionIndex, ISessionMetadata, + IWorkspaceHandlerService, + IWorkspaceLifecycleService, IWorkspaceService, + getLiveSessionById, + resumeSessionById, } from '@moonshot-ai/agent-core-v2'; import { sessionSnapshotResponseSchema } from '../src/protocol/rest-snapshot'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -75,12 +79,33 @@ describe('server-v2 snapshot route enrichment', () => { [ISessionInteractionService, { listPending: () => [] }], ]), }; - const core = { + const handler = { accessor: fakeAccessor([ [ - ISessionLifecycleService, + IWorkspaceHandlerService, { resume: async () => session, get: () => undefined }, ], + ]), + }; + const core = { + accessor: fakeAccessor([ + [ + ISessionIndex, + { + get: async () => ({ + id: sessionId, + workspaceId, + cwd: '/workspace', + createdAt: now, + updatedAt: now, + archived: false, + }), + }, + ], + [ + IWorkspaceLifecycleService, + { handlerFor: async () => handler, handlers: { list: () => [] } }, + ], [IWorkspaceService, { get: async () => ({ root: '/workspace' }) }], ]), }; @@ -282,13 +307,13 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { } async function ensureMainAgent(sessionId: string): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); const agents = session!.accessor.get(IAgentLifecycleService); if (agents.get('main') === undefined) await agents.create({ agentId: 'main' }); } function emit(sessionId: string, event: DomainEvent): void { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); const main = session!.accessor.get(IAgentLifecycleService).get('main'); main!.accessor.get(IEventBus).publish(event); } @@ -355,7 +380,7 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { base = `http://127.0.0.1:${server.port}`; // Guard: nothing is live in the new process — the session is cold. - expect(server!.core.accessor.get(ISessionLifecycleService).get(sid)).toBeUndefined(); + expect(getLiveSessionById(server!.core.accessor, sid)).toBeUndefined(); const snap = await snapshot(sid); expect(snap.session.id).toBe(sid); @@ -367,7 +392,7 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { // transcript while the scope stays un-materialized. it('auto reader returns messages read directly from wire.jsonl for a cold session', async () => { const sid = await createSession(); - const live = server!.core.accessor.get(ISessionLifecycleService).get(sid); + const live = getLiveSessionById(server!.core.accessor, sid); if (live === undefined) throw new Error(`session ${sid} not found`); const metaScope = live.accessor.get(ISessionContext).metaScope; @@ -400,7 +425,7 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { base = `http://127.0.0.1:${server.port}`; // Guard: still cold — the auto reader must serve from disk, not resume. - expect(server!.core.accessor.get(ISessionLifecycleService).get(sid)).toBeUndefined(); + expect(getLiveSessionById(server!.core.accessor, sid)).toBeUndefined(); const snap = await snapshot(sid); expect(snap.session.id).toBe(sid); @@ -420,7 +445,7 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { // (no reliance on wire-restore timing). it('serves a v1-layout session (ISO timestamps, no id field) without crashing', async () => { const sid = await createSession(); - const session = server!.core.accessor.get(ISessionLifecycleService).get(sid); + const session = getLiveSessionById(server!.core.accessor, sid); if (session === undefined) throw new Error(`session ${sid} not found`); const metaScope = session.accessor.get(ISessionContext).metaScope; @@ -445,7 +470,7 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { // Resume the cold session, then seed messages into the live context so the // snapshot projects message timestamps from the normalized numeric base. - const resumed = await server!.core.accessor.get(ISessionLifecycleService).resume(sid); + const resumed = await resumeSessionById(server!.core.accessor, sid); if (resumed === undefined) throw new Error(`session ${sid} failed to resume`); const main = await resumed.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); const context = main.accessor.get(IAgentContextMemoryService); diff --git a/packages/kap-server/test/snapshotReader.unit.test.ts b/packages/kap-server/test/snapshotReader.unit.test.ts index cf2b542a15..7ec1f0c813 100644 --- a/packages/kap-server/test/snapshotReader.unit.test.ts +++ b/packages/kap-server/test/snapshotReader.unit.test.ts @@ -15,7 +15,7 @@ import { join } from 'node:path'; import { ISessionIndex, - ISessionLifecycleService, + IWorkspaceLifecycleService, IWorkspaceService, type ContextMessage, type SessionSummary, @@ -67,7 +67,7 @@ async function makeFixtureAsync(opts?: { cacheLimit?: number }): Promise index.get(sid) }], [IWorkspaceService, { get: async (ws: string) => workspaces.get(ws) }], // Cold by default — no live handle. - [ISessionLifecycleService, { get: () => undefined }], + [IWorkspaceLifecycleService, { handlers: { list: () => [] } }], ]), }; const broadcaster = { seq: 0, epoch: 'ep_unit', inFlightTurn: null }; diff --git a/packages/kap-server/test/tasks.test.ts b/packages/kap-server/test/tasks.test.ts index 82fa8e283d..8a56fc007c 100644 --- a/packages/kap-server/test/tasks.test.ts +++ b/packages/kap-server/test/tasks.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path'; import { IAgentLifecycleService, IAgentTaskService, - ISessionLifecycleService, + getLiveSessionById, IModelCatalog, type AgentTask, } from '@moonshot-ai/agent-core-v2'; @@ -126,7 +126,7 @@ describe('server-v2 /api/v1/sessions/{sid}/tasks', () => { // (server-v2 gap G10); create it here, then register fake tasks // directly into its IAgentTaskService to bypass the tool loop. async function mainAgentTasks(sessionId: string): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); if (session === undefined) throw new Error(`session ${sessionId} not found`); const agent = session.accessor.get(IAgentLifecycleService).get('main') ?? diff --git a/packages/kap-server/test/tools.test.ts b/packages/kap-server/test/tools.test.ts index 26526eae18..35889f640e 100644 --- a/packages/kap-server/test/tools.test.ts +++ b/packages/kap-server/test/tools.test.ts @@ -20,7 +20,7 @@ import { join } from 'node:path'; import { IAgentLifecycleService, IAgentToolRegistryService, - ISessionLifecycleService, + getLiveSessionById, ISessionToolPolicy, IModelCatalog, type ExecutableTool, @@ -134,7 +134,7 @@ describe('server-v2 /api/v1 tools + mcp', () => { // The main agent scope is not created automatically on session creation // (server-v2 gap G10); create it here so IToolRegistry / IMcpService resolve. async function ensureMainAgent(sessionId: string) { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); if (session === undefined) throw new Error(`session ${sessionId} not found`); let agent = session.accessor.get(IAgentLifecycleService).get('main'); agent ??= await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); @@ -214,7 +214,7 @@ describe('server-v2 /api/v1 tools + mcp', () => { // Set the session-scope denylist directly: this harness's model catalog is // a throwing stub, so the REST prompt path (which requires a bound profile) // is unavailable here. The composed gate read by the route is the same. - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); if (session === undefined) throw new Error(`session ${id} not found`); await session.accessor.get(ISessionToolPolicy).setDisabledTools(['Bash']); diff --git a/packages/kap-server/test/transcript.test.ts b/packages/kap-server/test/transcript.test.ts index cbeecca312..2cec22368d 100644 --- a/packages/kap-server/test/transcript.test.ts +++ b/packages/kap-server/test/transcript.test.ts @@ -18,8 +18,10 @@ import { IWireService, IEventBus, ISessionInteractionService, - ISessionLifecycleService, ISessionQuestionService, + closeSessionById, + getLiveSessionById, + resumeSessionById, IModelCatalog, type ContextMessage, type DomainEvent, @@ -198,7 +200,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { /** Ensure the main agent exists (server-v2 does not create it with the session). */ async function ensureMainAgent(sessionId: string): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); if (session === undefined) throw new Error(`session ${sessionId} not found`); if (session.accessor.get(IAgentLifecycleService).get('main') === undefined) { await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); @@ -206,7 +208,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { } function mainAgentBus(sessionId: string): IEventBus { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); const agent = session!.accessor.get(IAgentLifecycleService).get('main'); return agent!.accessor.get(IEventBus); } @@ -215,7 +217,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { sessionId: string, messages: readonly ContextMessage[], ): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); const agent = session!.accessor.get(IAgentLifecycleService).get('main'); agent!.accessor.get(IAgentContextMemoryService).append(...messages); await agent!.accessor.get(IWireService).flush(); @@ -299,7 +301,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { }), ); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const interactions = session!.accessor.get(ISessionInteractionService); interactions.enqueue({ id: 'apr-1', @@ -436,7 +438,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { await server!.close(); server = undefined; await boot(); - await server!.core.accessor.get(ISessionLifecycleService).resume(id); + await resumeSessionById(server!.core.accessor, id); const { body } = await getJson( `/api/v1/sessions/${id}/transcript?agent_id=main`, @@ -462,7 +464,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { it('rebuilds a subagent for a cold session from its own wire records', async () => { const id = await createSession(); await ensureMainAgent(id); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const sub = await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' }); sub.accessor .get(IAgentContextMemoryService) @@ -495,7 +497,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { it('backfills an unmaterialized subagent for a resumed live session', async () => { const id = await createSession(); await ensureMainAgent(id); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const sub = await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' }); sub.accessor .get(IAgentContextMemoryService) @@ -511,11 +513,9 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { await server!.close(); server = undefined; await boot(); - await server!.core.accessor.get(ISessionLifecycleService).resume(id); + await resumeSessionById(server!.core.accessor, id); expect( - server!.core.accessor - .get(ISessionLifecycleService) - .get(id)! + getLiveSessionById(server!.core.accessor, id)! .accessor.get(IAgentLifecycleService) .get('sub-1'), ).toBeUndefined(); @@ -535,7 +535,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { it('keeps the metadata-seeded subagent descriptor after an on-demand backfill', async () => { const id = await createSession(); await ensureMainAgent(id); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const sub = await session!.accessor .get(IAgentLifecycleService) .create({ agentId: 'sub-1', labels: { parentAgentId: 'main' } }); @@ -572,7 +572,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { ]); // The approval is already pending when the transcript binds. - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); session!.accessor.get(ISessionInteractionService).enqueue({ id: 'apr-1', kind: 'approval', @@ -620,7 +620,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { it('seeds a subagent pending question only after its own backfill', async () => { const id = await createSession(); await ensureMainAgent(id); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const sub = await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' }); sub.accessor .get(IAgentContextMemoryService) @@ -710,7 +710,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); bus.publish(serverEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); - await server!.core.accessor.get(ISessionLifecycleService).close(id); + await closeSessionById(server!.core.accessor, id); const { body } = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); expect(body.code).toBe(0); @@ -782,7 +782,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { it('routes a subagent question to the subagent transcript, not main', async () => { const id = await createSession(); await ensureMainAgent(id); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const sub = await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' }); // Bind the transcript (main + any agent appearing later). @@ -980,7 +980,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { it('serves per-agent user messages for every rostered agent when agent_id is omitted (live)', async () => { const id = await createSession(); await ensureMainAgent(id); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const sub = await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' }); sub.accessor .get(IAgentContextMemoryService) @@ -1040,7 +1040,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { toolCalls: [], } as ContextMessage, ]); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const sub = await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' }); sub.accessor .get(IAgentContextMemoryService) @@ -1120,7 +1120,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { path: '/tmp/plans/foo.md', options: [{ label: 'Approach A', description: 'fast' }], }; - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const interactions = session!.accessor.get(ISessionInteractionService); interactions.enqueue({ id: 'apr-plan', @@ -1253,7 +1253,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { // A Revise outcome: the tool result carries no plan content — only the // persisted interaction does. - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const interactions = session!.accessor.get(ISessionInteractionService); interactions.enqueue({ id: 'apr-plan', @@ -1366,7 +1366,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { }), ); - const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const session = getLiveSessionById(server!.core.accessor, id); const interactions = session!.accessor.get(ISessionInteractionService); interactions.enqueue({ id: 'apr-final', diff --git a/packages/kap-server/test/workspaceLayout.test.ts b/packages/kap-server/test/workspaceLayout.test.ts new file mode 100644 index 0000000000..6041e0991c --- /dev/null +++ b/packages/kap-server/test/workspaceLayout.test.ts @@ -0,0 +1,156 @@ +/** + * Compatibility专项 — the local/local on-disk layout after the Workspace-domain + * refactor is byte-identical to the pre-refactor one. + * + * Creates a session through the full server stack (workspace handler → + * session scope → main agent) and asserts the persisted artifacts a v1 + * reader depends on: `workspaces.json` (original schema), + * `session_index.jsonl` (`{sessionId, sessionDir, workDir}` with + * `sessionDir = /sessions/{wd_id}/{session_id}`), + * `sessions/{wd_id}/{sid}/state.json`, and + * `sessions/{wd_id}/{sid}/agents/main/wire.jsonl` (metadata envelope first, + * `agents.main.homedir` written with the original value). Also proves the + * disk-reading `ISnapshotReader` serves the layout end-to-end. + * Wiring: real kap-server on a temp home. + * Run: `pnpm --filter @moonshot-ai/kap-server exec vitest run test/workspaceLayout.test.ts`. + */ +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + IAgentLifecycleService, + getLiveSessionById, +} from '@moonshot-ai/agent-core-v2'; + +import { type RunningServer, startServer } from '../src/start'; +import { authHeaders } from './helpers/auth'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +describe('local/local on-disk layout (byte compatibility)', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let workDir: string | undefined; + let base: string; + const homes: string[] = []; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-layout-home-')); + workDir = await mkdtemp(join(tmpdir(), 'kimi-layout-work-')); + homes.push(home, workDir); + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + debugEndpoints: true, + hostIdentity: TEST_HOST_IDENTITY, + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + await Promise.all(homes.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + async function postJson(path: string, body: unknown): Promise> { + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...authHeaders(server!) }, + body: JSON.stringify(body), + }); + return (await res.json()) as Envelope; + } + + it('persists the pre-refactor layout byte-for-byte and serves it through the snapshot reader', async () => { + const created = await postJson<{ id: string; workspace_id: string }>('/api/v1/sessions', { + metadata: { cwd: workDir }, + }); + expect(created.code).toBe(0); + const sessionId = created.data.id; + const workspaceId = created.data.workspace_id; + const sessionDir = join(home!, 'sessions', workspaceId, sessionId); + + // workspaces.json — original v1 schema, one record keyed by workspace id. + const workspacesFile = JSON.parse(await readFile(join(home!, 'workspaces.json'), 'utf8')) as { + version: number; + workspaces: Record< + string, + { root: string; name: string; created_at: string; last_opened_at: string } + >; + deleted_workspace_ids: string[]; + }; + expect(workspacesFile.version).toBe(1); + expect(Object.keys(workspacesFile.workspaces)).toEqual([workspaceId]); + expect(workspacesFile.workspaces[workspaceId]).toMatchObject({ root: workDir }); + expect(workspacesFile.deleted_workspace_ids).toEqual([]); + + // session_index.jsonl — one line per session, `{sessionId, sessionDir, + // workDir}` with the v1 bucket address. + const indexLines = (await readFile(join(home!, 'session_index.jsonl'), 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { sessionId: string; sessionDir: string; workDir: string }); + expect(indexLines).toEqual([{ sessionId, sessionDir, workDir }]); + + // state.json — the session metadata document at the v1 path. + const metaRaw = JSON.parse(await readFile(join(sessionDir, 'state.json'), 'utf8')) as { + id: string; + }; + expect(metaRaw.id).toBe(sessionId); + + // Materialize the main agent in-process; its wire log lands at the v1 + // path with the metadata envelope first and the original homedir value. + const session = getLiveSessionById(server!.core.accessor, sessionId); + expect(session).toBeDefined(); + await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); + const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl'); + await expect(stat(wirePath)).resolves.toBeDefined(); + const firstWireLine = (await readFile(wirePath, 'utf8')).split('\n')[0]!; + expect((JSON.parse(firstWireLine) as { type: string }).type).toBe('metadata'); + const metaWithAgent = JSON.parse(await readFile(join(sessionDir, 'state.json'), 'utf8')) as { + agents: Record; + }; + expect(metaWithAgent.agents['main']?.homedir).toBe(join(sessionDir, 'agents', 'main')); + + // The snapshot reader serves the layout straight from disk (auto mode). + const snapshot = await fetch(`${base}/api/v1/sessions/${sessionId}/snapshot`, { + headers: authHeaders(server!), + }); + const snapshotBody = (await snapshot.json()) as Envelope<{ session: { id: string } }>; + expect(snapshotBody.code).toBe(0); + expect(snapshotBody.data.session.id).toBe(sessionId); + + // A second session in the same workspace lands in the SAME bucket and the + // catalog still holds exactly one record (handler-level touch, no schema + // or duplication drift). + const second = await postJson<{ id: string; workspace_id: string }>('/api/v1/sessions', { + metadata: { cwd: workDir }, + }); + expect(second.code).toBe(0); + expect(second.data.workspace_id).toBe(workspaceId); + const workspacesAfter = JSON.parse( + await readFile(join(home!, 'workspaces.json'), 'utf8'), + ) as { workspaces: Record }; + expect(Object.keys(workspacesAfter.workspaces)).toEqual([workspaceId]); + const indexAfter = (await readFile(join(home!, 'session_index.jsonl'), 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { sessionId: string }); + expect(indexAfter.map((entry) => entry.sessionId)).toEqual([sessionId, second.data.id]); + }); +}); diff --git a/packages/kap-server/test/wsV1Resync.test.ts b/packages/kap-server/test/wsV1Resync.test.ts index f2116d42eb..20f3aea392 100644 --- a/packages/kap-server/test/wsV1Resync.test.ts +++ b/packages/kap-server/test/wsV1Resync.test.ts @@ -12,7 +12,7 @@ import { type DomainEvent, IEventBus, IAgentLifecycleService, - ISessionLifecycleService, + getLiveSessionById, } from '@moonshot-ai/agent-core-v2'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket } from 'ws'; @@ -145,7 +145,7 @@ describe('server-v2 /api/v1/ws resync', () => { } async function ensureMainAgent(sessionId: string): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); expect(session).toBeDefined(); const agents = session!.accessor.get(IAgentLifecycleService); if (agents.get('main') === undefined) { @@ -158,7 +158,7 @@ describe('server-v2 /api/v1/ws resync', () => { } function emitAgentEvent(sessionId: string, event: DomainEvent): void { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + const session = getLiveSessionById(server!.core.accessor, sessionId); expect(session).toBeDefined(); const agents = session!.accessor.get(IAgentLifecycleService); const main = agents.get('main'); @@ -261,7 +261,7 @@ describe('server-v2 /api/v1/ws resync', () => { await ensureMainAgent(sid); // Add a second agent to the same session so we can distinguish sources. - const session = server!.core.accessor.get(ISessionLifecycleService).get(sid); + const session = getLiveSessionById(server!.core.accessor, sid); expect(session).toBeDefined(); const agents = session!.accessor.get(IAgentLifecycleService); const sub = await agents.create({ agentId: 'agent-0' }); diff --git a/packages/klient/src/contract/global/plugins.ts b/packages/klient/src/contract/global/plugins.ts index d3b6207318..3f288d21c8 100644 --- a/packages/klient/src/contract/global/plugins.ts +++ b/packages/klient/src/contract/global/plugins.ts @@ -1,7 +1,7 @@ /** * `pluginService` — plugin management and consumption. Mirrors * `agent-core-v2/app/plugin/plugin.ts` and `agent-core-v2/app/plugin/types.ts`; - * nested `McpServerConfig` mirrors `agent-core-v2/agent/mcp/config-schema.ts`, + * nested `McpServerConfig` mirrors `agent-core-v2/mcpCore/config-schema.ts`, * `HookDefConfig` mirrors `agent-core-v2/agent/externalHooks/configSection.ts`. * `pluginSkillRoots`, `enabledSessionStarts`, `enabledSystemPrompts`, * `enabledMcpServers`, and `enabledHooks` are excluded (not part of the diff --git a/packages/klient/src/contract/index.ts b/packages/klient/src/contract/index.ts index 39065bf553..b98836ec96 100644 --- a/packages/klient/src/contract/index.ts +++ b/packages/klient/src/contract/index.ts @@ -30,7 +30,10 @@ import { sessionsContract } from './global/sessions.js'; import { workspacesContract } from './global/workspaces.js'; import { sessionApprovalContract } from './session/approval.js'; import { sessionInteractionContract } from './session/interaction.js'; -import { sessionLifecycleContract } from './session/lifecycle.js'; +import { + workspaceHandlerContract, + workspaceLifecycleContract, +} from './session/lifecycle.js'; import { sessionMetadataContract } from './session/metadata.js'; import { sessionQuestionContract } from './session/question.js'; @@ -49,8 +52,10 @@ export const globalContract: KlientContract = { pluginService: pluginsContract, hostFolderBrowser: hostFsContract, bootstrapService: envContract, - // session scope (+ the app-registered lifecycle service) - sessionLifecycleService: sessionLifecycleContract, + // workspace scope (+ the app-registered handler registry) + workspaceLifecycleService: workspaceLifecycleContract, + workspaceHandlerService: workspaceHandlerContract, + // session scope sessionMetadata: sessionMetadataContract, sessionInteractionService: sessionInteractionContract, sessionApprovalService: sessionApprovalContract, diff --git a/packages/klient/src/contract/mcp.ts b/packages/klient/src/contract/mcp.ts index d9e673433f..2ecb9bdda2 100644 --- a/packages/klient/src/contract/mcp.ts +++ b/packages/klient/src/contract/mcp.ts @@ -1,6 +1,6 @@ /** * Shared MCP server wire schema for session creation and plugin manifests. - * Mirrors `agent-core-v2/agent/mcp/config-schema.ts`. + * Mirrors `agent-core-v2/mcpCore/config-schema.ts`. */ import { z } from 'zod'; diff --git a/packages/klient/src/contract/session/lifecycle.ts b/packages/klient/src/contract/session/lifecycle.ts index 4fd9c3c503..0941330312 100644 --- a/packages/klient/src/contract/session/lifecycle.ts +++ b/packages/klient/src/contract/session/lifecycle.ts @@ -1,22 +1,24 @@ /** - * `sessionLifecycleService` — creates and tracks sessions at the process - * root. Mirrors `agent-core-v2/app/sessionLifecycle/sessionLifecycle.ts`. - * The engine returns `ISessionScopeHandle`s; over JSON only the plain data - * fields survive, so the wire keeps `{ id, kind }` (loose — extra fields may - * appear in-process). + * `workspaceHandlerService` / `workspaceLifecycleService` — session + * lifecycle after the Workspace-domain split. The App-scope + * `workspaceLifecycleService` materializes one handler per workspace + * (`handlerFor`); the Workspace-scope `workspaceHandlerService` owns that + * workspace's sessions (create/resume/close/archive/restore/fork/ + * createChild). Mirrors `agent-core-v2/app/workspaceLifecycle/*` and + * `agent-core-v2/workspace/workspaceHandler/*`. The engine returns scope + * handles; over JSON only the plain data fields survive, so the wire keeps + * `{ id, kind }` (loose — extra fields may appear in-process). */ import { z } from 'zod'; import { maybe, noResult } from '../helpers.js'; -import { mcpServerConfigSchema } from '../mcp.js'; import type { ServiceContract } from '../types.js'; export const createSessionOptionsSchema = z.object({ sessionId: z.string().optional(), workDir: z.string(), additionalDirs: z.array(z.string()).optional(), - mcpServers: z.record(z.string(), mcpServerConfigSchema).optional(), }); export const forkSessionOptionsSchema = z.object({ @@ -29,13 +31,23 @@ export const forkSessionOptionsSchema = z.object({ /** Same fields as `ForkSessionOptions` in the engine — keep in sync. */ export const createChildSessionOptionsSchema = forkSessionOptionsSchema; -/** `ISessionScopeHandle` as it survives JSON — `{ id, kind }` plus extras. */ +/** `IScopeHandle` as it survives JSON — `{ id, kind }` plus extras. */ export const handleWireSchema = z.looseObject({ id: z.string(), kind: z.number(), }); -export const sessionLifecycleContract = { +/** `WorkspaceRef` — a `workspaceId` (optional `root` hint) or a bare `root`. */ +export const workspaceRefSchema = z.union([ + z.object({ workspaceId: z.string(), root: z.string().optional() }), + z.object({ root: z.string() }), +]); + +export const workspaceLifecycleContract = { + handlerFor: { input: z.tuple([workspaceRefSchema]), output: handleWireSchema }, +} satisfies ServiceContract; + +export const workspaceHandlerContract = { create: { input: z.tuple([createSessionOptionsSchema]), output: handleWireSchema }, resume: { input: z.tuple([z.string()]), output: maybe(handleWireSchema) }, close: { input: z.tuple([z.string()]), output: noResult }, diff --git a/packages/klient/src/core/channel.ts b/packages/klient/src/core/channel.ts index b8bec6ac0e..181290167b 100644 --- a/packages/klient/src/core/channel.ts +++ b/packages/klient/src/core/channel.ts @@ -14,6 +14,7 @@ export interface IDisposable { /** Scope coordinates of a call/subscription. Empty object = core (app) scope. */ export interface ScopeRef { + readonly workspaceId?: string; readonly sessionId?: string; readonly agentId?: string; } diff --git a/packages/klient/src/core/facade/global.ts b/packages/klient/src/core/facade/global.ts index bef77c30d4..d21a5dbbe4 100644 --- a/packages/klient/src/core/facade/global.ts +++ b/packages/klient/src/core/facade/global.ts @@ -49,7 +49,7 @@ export type Caller = (service: string, method: string, args: unknown[]) => Promi /** Scoped variant — the factory's real signature; global methods bind the core scope. */ export type ScopedCaller = ( - scope: { readonly sessionId?: string; readonly agentId?: string }, + scope: { readonly workspaceId?: string; readonly sessionId?: string; readonly agentId?: string }, service: string, method: string, args: unknown[], @@ -57,7 +57,7 @@ export type ScopedCaller = ( /** Streaming variant of `ScopedCaller` — returns a validated `AsyncIterable`. */ export type ScopedStreamCaller = ( - scope: { readonly sessionId?: string; readonly agentId?: string }, + scope: { readonly workspaceId?: string; readonly sessionId?: string; readonly agentId?: string }, service: string, method: string, args: unknown[], @@ -272,7 +272,12 @@ export function createGlobalFacade(scoped: ScopedCaller, scopedStream: ScopedStr countActive: (workspaceIds) => call('sessionIndex', 'countActive', [workspaceIds]) as Promise, create: async ({ workDir, additionalDirs, title }) => { - const handle = (await scoped({}, 'sessionLifecycleService', 'create', [ + // The workspace handler owns session creation: materialize (or reuse) + // the handler for the root, then create under it. + const handler = (await scoped({}, 'workspaceLifecycleService', 'handlerFor', [ + { root: workDir }, + ])) as { id: string }; + const handle = (await scoped({ workspaceId: handler.id }, 'workspaceHandlerService', 'create', [ { workDir, additionalDirs }, ])) as { id: string }; const scope = { sessionId: handle.id }; diff --git a/packages/klient/src/core/facade/session.ts b/packages/klient/src/core/facade/session.ts index cce957aa60..9df21846b2 100644 --- a/packages/klient/src/core/facade/session.ts +++ b/packages/klient/src/core/facade/session.ts @@ -27,11 +27,14 @@ import type { } from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; import type { ScopeRef } from '../channel.js'; +import { RPCError } from '../errors.js'; import type { ScopedCaller } from './global.js'; +const NOT_FOUND = 40404; + export type { ScopedCaller } from './global.js'; -/** What `sessionLifecycleService.create/fork/createChild` leaves on the wire. */ +/** What `workspaceHandlerService.create/fork/createChild` leaves on the wire. */ interface HandleWire { readonly id: string; } @@ -86,11 +89,23 @@ export function createSessionFacade(call: ScopedCaller, sessionId: string): Sess const scope: ScopeRef = { sessionId }; const read = (): Promise => call(scope, 'sessionMetadata', 'read', []) as Promise; + // Session lifecycle methods live on the session's workspace handler + // (Workspace scope) — the index supplies the handler's workspaceId. + const resolveWorkspaceId = async (): Promise => { + const summary = (await call({}, 'sessionIndex', 'get', [sessionId])) as + | { workspaceId: string } + | undefined; + return summary?.workspaceId; + }; const spawn = async ( method: 'fork' | 'createChild', input: { title?: string; metadata?: Record } = {}, ): Promise => { - const handle = (await call({}, 'sessionLifecycleService', method, [ + const workspaceId = await resolveWorkspaceId(); + if (workspaceId === undefined) { + throw new RPCError(NOT_FOUND, `session not found: ${sessionId}`); + } + const handle = (await call({ workspaceId }, 'workspaceHandlerService', method, [ { sourceSessionId: sessionId, title: input.title, metadata: input.metadata }, ])) as HandleWire; return call({ sessionId: handle.id }, 'sessionMetadata', 'read', []) as Promise; @@ -127,10 +142,20 @@ export function createSessionFacade(call: ScopedCaller, sessionId: string): Sess } return 'idle'; }, - close: () => call({}, 'sessionLifecycleService', 'close', [sessionId]) as Promise, - archive: () => call({}, 'sessionLifecycleService', 'archive', [sessionId]) as Promise, + close: async () => { + const workspaceId = await resolveWorkspaceId(); + if (workspaceId === undefined) return; + await call({ workspaceId }, 'workspaceHandlerService', 'close', [sessionId]); + }, + archive: async () => { + const workspaceId = await resolveWorkspaceId(); + if (workspaceId === undefined) return; + await call({ workspaceId }, 'workspaceHandlerService', 'archive', [sessionId]); + }, restore: async () => { - const handle = (await call({}, 'sessionLifecycleService', 'restore', [ + const workspaceId = await resolveWorkspaceId(); + if (workspaceId === undefined) return false; + const handle = (await call({ workspaceId }, 'workspaceHandlerService', 'restore', [ sessionId, ])) as HandleWire | null; return handle !== null; diff --git a/packages/klient/src/transports/ipc/channel.ts b/packages/klient/src/transports/ipc/channel.ts index 912c2d486e..8c19937676 100644 --- a/packages/klient/src/transports/ipc/channel.ts +++ b/packages/klient/src/transports/ipc/channel.ts @@ -45,9 +45,10 @@ interface PendingStream { error(err: Error): void; } -function scopeKindOf(scope: ScopeRef): 'core' | 'session' | 'agent' { +function scopeKindOf(scope: ScopeRef): 'core' | 'workspace' | 'session' | 'agent' { if (scope.agentId !== undefined) return 'agent'; if (scope.sessionId !== undefined) return 'session'; + if (scope.workspaceId !== undefined) return 'workspace'; return 'core'; } @@ -122,6 +123,7 @@ export class IpcChannel implements KlientChannel { // NDJSON is JSON: trailing optional args would cross as `null` and // defeat the host's default parameters — trim them. arg: trimTrailingUndefined(args), + workspaceId: scope.workspaceId, sessionId: scope.sessionId, agentId: scope.agentId, }); @@ -207,6 +209,7 @@ export class IpcChannel implements KlientChannel { service, method, arg: trimTrailingUndefined(args), + workspaceId: scope.workspaceId, sessionId: scope.sessionId, agentId: scope.agentId, }); @@ -261,6 +264,7 @@ export class IpcChannel implements KlientChannel { type: 'listen', id, scope: scopeKindOf(scope), + workspaceId: scope.workspaceId, sessionId: scope.sessionId, agentId: scope.agentId, }; diff --git a/packages/klient/src/transports/ipc/codec.ts b/packages/klient/src/transports/ipc/codec.ts index ccf7be7aa6..21f3262240 100644 --- a/packages/klient/src/transports/ipc/codec.ts +++ b/packages/klient/src/transports/ipc/codec.ts @@ -13,6 +13,7 @@ export interface IpcFrame { readonly service?: string; readonly method?: string; readonly arg?: unknown; + readonly workspaceId?: string; readonly sessionId?: string; readonly agentId?: string; readonly event?: string; diff --git a/packages/klient/src/transports/ipc/host.ts b/packages/klient/src/transports/ipc/host.ts index 26898984b5..f734af46cd 100644 --- a/packages/klient/src/transports/ipc/host.ts +++ b/packages/klient/src/transports/ipc/host.ts @@ -31,7 +31,8 @@ export interface KlientIpcHost { } function scopeRefFromFrame(frame: IpcFrame): ScopeRef { - const scope: { sessionId?: string; agentId?: string } = {}; + const scope: { workspaceId?: string; sessionId?: string; agentId?: string } = {}; + if (typeof frame.workspaceId === 'string') scope.workspaceId = frame.workspaceId; if (typeof frame.sessionId === 'string') scope.sessionId = frame.sessionId; if (typeof frame.agentId === 'string') scope.agentId = frame.agentId; return scope; diff --git a/packages/klient/src/transports/memory/dispatcher.ts b/packages/klient/src/transports/memory/dispatcher.ts index c3553ee4d2..11c2c3fb59 100644 --- a/packages/klient/src/transports/memory/dispatcher.ts +++ b/packages/klient/src/transports/memory/dispatcher.ts @@ -3,7 +3,7 @@ * against a live engine scope and mirrors kap-server's dispatcher semantics * (reflection call, non-function members are property reads, `main` agent * auto-materialized via `ensureMainAgent`). Scope routing walks - * `ISessionLifecycleService` / `IAgentLifecycleService` exactly like the + * `IWorkspaceLifecycleService` / `IAgentLifecycleService` exactly like the * server's `resolveScope`. Every argument, result, and event payload passes * through `wireClone` (a JSON round-trip), so consumers observe * byte-identical data no matter whether the call crossed a socket or stayed @@ -14,7 +14,8 @@ */ import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2/_base/di/instantiation'; -import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle'; +import { IWorkspaceLifecycleService } from '@moonshot-ai/agent-core-v2/app/workspaceLifecycle/workspaceLifecycle'; +import { getLiveSessionById } from '@moonshot-ai/agent-core-v2/app/workspaceLifecycle/sessionLookup'; import { IAgentLifecycleService } from '@moonshot-ai/agent-core-v2/session/agentLifecycle/agentLifecycle'; import { ensureMainAgent } from '@moonshot-ai/agent-core-v2/session/agentLifecycle/mainAgent'; import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; @@ -51,7 +52,7 @@ export interface MemoryDispatcher { const REQUEST_INVALID = 40001; const NOT_FOUND = 40404; -type ScopeKind = 'core' | 'session' | 'agent'; +type ScopeKind = 'core' | 'workspace' | 'session' | 'agent'; interface ResolvedScope { readonly kind: ScopeKind; @@ -61,8 +62,14 @@ interface ResolvedScope { export function createMemoryDispatcher(root: ScopeLike): MemoryDispatcher { /** Mirrors kap-server's `resolveScope`, incl. main-agent materialization. */ async function resolveScope(scope: ScopeRef): Promise { + if (scope.workspaceId !== undefined) { + const handler = await root.accessor + .get(IWorkspaceLifecycleService) + .handlerFor({ workspaceId: scope.workspaceId }); + return { kind: 'workspace', like: handler }; + } if (scope.sessionId === undefined) return { kind: 'core', like: root }; - const session = root.accessor.get(ISessionLifecycleService).get(scope.sessionId); + const session = getLiveSessionById(root.accessor, scope.sessionId); if (session === undefined) { throw new RPCError(NOT_FOUND, `session not found: ${scope.sessionId}`); } diff --git a/packages/klient/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts index 354089a171..b06d1c571a 100644 --- a/packages/klient/src/transports/memory/serviceRegistry.ts +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -22,7 +22,8 @@ import { IPluginService } from '@moonshot-ai/agent-core-v2/app/plugin/plugin'; import { IBootstrapService } from '@moonshot-ai/agent-core-v2/app/bootstrap/bootstrap'; import { IEventService } from '@moonshot-ai/agent-core-v2/app/event/event'; import { IHostFolderBrowser } from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; -import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle'; +import { IWorkspaceLifecycleService } from '@moonshot-ai/agent-core-v2/app/workspaceLifecycle/workspaceLifecycle'; +import { IWorkspaceHandlerService } from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler'; import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval'; @@ -50,7 +51,8 @@ export const serviceTokens: Readonly>> pluginService: IPluginService, hostFolderBrowser: IHostFolderBrowser, bootstrapService: IBootstrapService, - sessionLifecycleService: ISessionLifecycleService, + workspaceLifecycleService: IWorkspaceLifecycleService, + workspaceHandlerService: IWorkspaceHandlerService, sessionMetadata: ISessionMetadata, sessionInteractionService: ISessionInteractionService, sessionApprovalService: ISessionApprovalService, diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index 70a6ba3bc2..2185a86c51 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -44,7 +44,7 @@ import type { CreateChildSessionOptions, CreateSessionOptions, ForkSessionOptions, -} from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle'; +} from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler'; import type { ApprovalRequest, ApprovalResponse, diff --git a/packages/klient/test/contract.test.ts b/packages/klient/test/contract.test.ts index 256a1b70fb..caac024a6b 100644 --- a/packages/klient/test/contract.test.ts +++ b/packages/klient/test/contract.test.ts @@ -14,16 +14,6 @@ import { createSessionOptionsSchema } from '../src/contract/session/lifecycle.js type McpTimeoutField = 'startupTimeoutMs' | 'toolTimeoutMs'; const timeoutCases = [ - { - surface: 'session creation', - parse: (field: McpTimeoutField, value: number) => - createSessionOptionsSchema.safeParse({ - workDir: '/tmp/example', - mcpServers: { - example: { transport: 'stdio', command: 'node', [field]: value }, - }, - }), - }, { surface: 'plugin manifests', parse: (field: McpTimeoutField, value: number) => @@ -47,4 +37,15 @@ describe('MCP timeout contract validation', () => { it.each(timeoutCases)('rejects an above-maximum $field for $surface', ({ field, parse }) => { expect(parse(field, 2_147_483_648).success).toBe(false); }); + + it('session creation options carry no caller mcpServers channel', () => { + const parsed = createSessionOptionsSchema.safeParse({ + workDir: '/tmp/example', + mcpServers: { + example: { transport: 'stdio', command: 'node' }, + }, + }); + expect(parsed.success).toBe(true); + expect(parsed.data).toEqual({ workDir: '/tmp/example' }); + }); }); diff --git a/packages/klient/test/helpers/conformance.ts b/packages/klient/test/helpers/conformance.ts index eeaa2efb3b..8b69c92464 100644 --- a/packages/klient/test/helpers/conformance.ts +++ b/packages/klient/test/helpers/conformance.ts @@ -72,6 +72,28 @@ export function defineKlientConformance( expect(typeof count).toBe('number'); }); + it('creates a titled session through implicit workspace materialization', async () => { + const created = await target.klient.global.sessions.create({ + workDir: process.cwd(), + title: 'conformance session', + }); + + try { + expect(created).toMatchObject({ + title: 'conformance session', + cwd: process.cwd(), + archived: false, + }); + expect(created.id.length).toBeGreaterThan(0); + expect(await target.klient.global.sessions.get(created.id)).toMatchObject({ + id: created.id, + title: 'conformance session', + }); + } finally { + await target.klient.session(created.id).close(); + } + }); + it('providers.set/get/delete works and emits kosong.providers.changed', async () => { const events: Array<{ added: readonly string[]; diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 31f5c64d10..d4b93dea4f 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -30,9 +30,9 @@ * `updateSessionMetadata` / `addAdditionalDir` → the session lifecycle * batch: `klient.global.sessions.list` plus the `klient.session(id)` * metadata mutations where the facade reaches, and the - * `ISessionLifecycleService` / session-scope services through + * `IWorkspaceLifecycleService` / handler chain / session-scope services through * {@link engineAccessor} where it does not (explicit session ids, resume, - * fork ids, workspace commands). The v1 `SessionSummary` / `SessionMeta` + * fork ids, the workspace-level add-dir surface). The v1 `SessionSummary` / `SessionMeta` * shapes are restored by the pure mapping layer in * `src/v2/session-mapper.ts`. `deleteSession` stays `not_implemented` — * the v2 engine has no session-deletion capability anywhere (tracked in @@ -87,9 +87,10 @@ * `IAtomicDocumentStore`, whose on-disk layout * (`/credentials/mcp/-*.json`) matches v1's. * - `listMcpServers` / `getMcpStartupMetrics` / `reconnectMcpServer` → - * `ISessionMcpService.connectionManager()` through the session scope (no - * klient facade exists); the v2 `McpServerEntry` is field-identical with - * v1's `McpServerInfo`. + * the seeded `ISessionMcpHandle.connectionManager` through the session + * scope (no klient facade exists) — one shared manager per workspace + * handler since the workspace-domain resource consolidation; the v2 + * `McpServerEntry` is field-identical with v1's `McpServerInfo`. * - `onEvent` / `receiveEvent` → the base class registries, fed by a * per-live-session wiring (`src/v2/session-wiring.ts`) that subscribes * every live agent's `IEventBus` and translates each `DomainEvent` back @@ -138,14 +139,14 @@ import { type ExperimentalFeatureState, } from '@moonshot-ai/agent-core'; import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; -import { MCP_SECTION, type McpSection } from '@moonshot-ai/agent-core-v2/agent/mcp/configSection'; -import { McpConnectionManager } from '@moonshot-ai/agent-core-v2/agent/mcp/connection-manager'; +import { MCP_SECTION, type McpSection } from '@moonshot-ai/agent-core-v2/app/mcpConfig/configSection'; +import { McpConnectionManager } from '@moonshot-ai/agent-core-v2/mcpCore/connection-manager'; import { AlreadyAuthorizedError, McpOAuthService, type BeginAuthorizationResult, -} from '@moonshot-ai/agent-core-v2/agent/mcp/oauth/service'; -import { createMcpOAuthStore } from '@moonshot-ai/agent-core-v2/agent/mcp/oauth/store'; +} from '@moonshot-ai/agent-core-v2/mcpCore/oauth/service'; +import { createMcpOAuthStore } from '@moonshot-ai/agent-core-v2/app/mcpConfig/oauthStore'; import { SECONDARY_MODEL_SECTION } from '@moonshot-ai/agent-core-v2/app/kosongConfig/configSection'; import { IAtomicDocumentStore } from '@moonshot-ai/agent-core-v2/persistence/interface/atomicDocumentStore'; import { wrapSubagentModelError } from '@moonshot-ai/agent-core-v2/session/subagent/configSection'; @@ -178,7 +179,6 @@ import { IHostFileSystem, IModelCatalog, IModelService, - IProjectLocalConfigService, IProviderService, ISessionBtwService, ISessionContext, @@ -186,18 +186,26 @@ import { ISessionExportService, ISessionIndex, ISessionInitService, - ISessionLifecycleService, - ISessionMcpService, + ISessionMcpHandle, ISessionMetadata, ISessionSecondaryModelWarningService, ISessionSkillCatalog, - ISessionWorkspaceCommandService, ISessionWorkspaceContext, ISkillCatalogRuntimeOptions, ISkillDiscovery, ITelemetryService, IWorkspaceAliases, hostRequestHeadersSeed, + IWorkspaceDirs, + IWorkspaceHandlerService, + IWorkspaceLifecycleService, + closeSessionById, + followWorkspaceHandlers, + getLiveSessionById, + handlerForSession, + resumeSessionById, + sessionDirOf, + workspacePersistenceScope, logSeed, MAIN_AGENT_ID, prepareSystemPromptContext, @@ -454,10 +462,14 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { if (translated !== undefined) this.receiveEvent(translated); }), // A session closed without going through this client (archive, an - // engine-initiated close) drops its wiring with the scope. - this.app.accessor.get(ISessionLifecycleService).onDidCloseSession((closed) => { - this.unwireSession(closed.sessionId); - }), + // engine-initiated close) drops its wiring with the scope. Close events + // fire per workspace handler, so follow every handler — present and + // future — through the App-scope registry. + followWorkspaceHandlers(this.app.accessor, (service) => + service.onDidCloseSession((closed) => { + this.unwireSession(closed.sessionId); + }), + ), ); } @@ -682,7 +694,8 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { // // The v2 engine splits what v1's SessionStore + in-memory session map did // across the app-scope `ISessionIndex` (persisted read model), - // `ISessionLifecycleService` (live session scopes), and the session-scope + // `IWorkspaceLifecycleService` (live workspace handlers and, under them, the + // live session scopes), and the session-scope // metadata/workspace services. The klient facade covers listing and the // metadata mutations of a LIVE session; everything that needs an explicit // session id, a resume, or a workspace command goes through the @@ -698,8 +711,8 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { // the host supplies one). // ----------------------------------------------------------------------- - private get sessionLifecycle(): ISessionLifecycleService { - return this.engineAccessor.get(ISessionLifecycleService); + private liveSession(sessionId: string): ISessionScopeHandle | undefined { + return getLiveSessionById(this.engineAccessor, sessionId); } /** v1's `requireSession` / store lookup failure shape. */ @@ -711,7 +724,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { /** The live session handle, or the error v1 raises for a non-active session. */ private requireLiveSession(sessionId: string): ISessionScopeHandle { - const handle = this.sessionLifecycle.get(sessionId); + const handle = this.liveSession(sessionId); if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(sessionId); return handle; } @@ -906,7 +919,11 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { summaries.push( v2SummaryToSessionSummary(item, { workDir, - sessionDir: bootstrapService.sessionDir(item.workspaceId, item.id), + sessionDir: sessionDirOf( + bootstrapService.homeDir, + workspacePersistenceScope(bootstrapService.scope('sessions'), item.workspaceId), + item.id, + ), }), ); } @@ -915,7 +932,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { /** * v1 semantics: register the workDir as a workspace and create the session - * (the v2 `sessionLifecycleService.create` does both; the klient facade + * (the handler's `IWorkspaceHandlerService.create` does both; the klient facade * wrapper is bypassed because it takes neither an explicit session id nor * caller metadata). The `model` / `thinking` / `permission` options are the * main-agent configuration v1 applies eagerly at creation: supplying any of @@ -931,7 +948,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { const workDir = normalizeRequiredWorkDir('createSession', input.workDir); if (input.id !== undefined) { const existing = - this.sessionLifecycle.get(input.id) ?? + this.liveSession(input.id) ?? (await this.engineAccessor.get(ISessionIndex).get(input.id)); if (existing !== undefined) { throw new KimiError( @@ -940,7 +957,10 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { ); } } - const handle = await this.sessionLifecycle.create({ + const handler = await this.engineAccessor + .get(IWorkspaceLifecycleService) + .handlerFor({ root: workDir }); + const handle = await handler.accessor.get(IWorkspaceHandlerService).create({ sessionId: input.id, workDir, additionalDirs: input.additionalDirs, @@ -982,21 +1002,21 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { if (title.length === 0) { throw new KimiError(ErrorCodes.SESSION_TITLE_EMPTY, 'Session title cannot be empty'); } - if (this.sessionLifecycle.get(input.id) !== undefined) { + if (this.liveSession(input.id) !== undefined) { await this.klient.session(input.id).setTitle(title); return; } - const handle = await this.sessionLifecycle.resume(input.id); + const handle = await resumeSessionById(this.engineAccessor, input.id); if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); try { await this.klient.session(input.id).setTitle(title); } finally { - await this.sessionLifecycle.close(input.id); + await closeSessionById(this.engineAccessor, input.id); } } /** - * Through `engineAccessor` (`ISessionLifecycleService.fork`) because the + * Through `engineAccessor` (the handler chain's `IWorkspaceHandlerService.fork`) because the * klient facade fork takes no explicit target id. Known gaps vs v1: the * engine's fork is unconditional — it never rejects an in-flight source * turn (v1's SESSION_FORK_ACTIVE_TURN) — and `turnIndex` truncation has no @@ -1011,7 +1031,9 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { 'forkSession turnIndex truncation is not wired to agent-core-v2 yet.', ); } - const handle = await this.sessionLifecycle.fork({ + const forkHandler = await handlerForSession(this.engineAccessor, input.id); + if (forkHandler === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); + const handle = await forkHandler.accessor.get(IWorkspaceHandlerService).fork({ sourceSessionId: input.id, newSessionId: input.forkId, title: input.title, @@ -1029,7 +1051,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { /** * Materializes the session through `engineAccessor` - * (`ISessionLifecycleService.resume`; the facade only offers `restore`, + * (`resumeSessionById` through the handler chain; the facade only offers `restore`, * which would also clear the archived flag — v1's resume does not). * `includeSubagents` / `replayTurnLimit` shape the returned per-agent * snapshot exactly like v1: subagent states are folded best-effort from @@ -1037,19 +1059,17 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * most recent N user turns via the shared `limitAgentReplayByTurns`. */ override async resumeSession(input: ResumeSessionInput): Promise { - const handle = await this.sessionLifecycle.resume(input.id); + // v1 re-resolves caller-provided additional dirs on every resume and + // merges them over the workspace-local set; the engine's resume options + // union them into the handler's shared in-memory set while the session + // scope is materialized. Unlike v1, the v2 + // engine has no caller `mcpServers` channel on create/resume (caller + // servers are an ACP-side concern to be designed separately). + const handle = await resumeSessionById(this.engineAccessor, input.id, { + additionalDirs: input.additionalDirs, + }); if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); this.wireSession(handle); - // v1 re-resolves caller-provided additional dirs on every resume and - // merges them over the workspace-local set; the engine's own resume only - // restores the workspace-local ones. - if (input.additionalDirs !== undefined && input.additionalDirs.length > 0) { - const workspace = handle.accessor.get(ISessionWorkspaceContext); - const resolved = await this.engineAccessor - .get(IProjectLocalConfigService) - .resolveAdditionalDirs(workspace.workDir, input.additionalDirs); - workspace.setAdditionalDirs([...workspace.additionalDirs, ...resolved]); - } return this.resumedSessionSummary(handle, { includeSubagents: input.includeSubagents, replayTurnLimit: input.replayTurnLimit, @@ -1066,7 +1086,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { */ override async reloadSession(input: ReloadSessionRpcInput): Promise { const sessionId = input.sessionId; - const live = this.sessionLifecycle.get(sessionId); + const live = this.liveSession(sessionId); if (live !== undefined) { for (const agent of live.accessor.get(IAgentLifecycleService).list()) { if (agent.accessor.get(IAgentActivityView).state().turn !== undefined) { @@ -1084,12 +1104,12 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { await this.klient.global.config.reload(); await this.klient.global.plugins.reload(); if (live !== undefined) { - await this.sessionLifecycle.close(sessionId); + await closeSessionById(this.engineAccessor, sessionId); } // Same print-steer reset as closeSession: v1's reload rebuilds the // Session, and with it the counters. this.printSteerStates.delete(sessionId); - const handle = await this.sessionLifecycle.resume(sessionId); + const handle = await resumeSessionById(this.engineAccessor, sessionId); if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(sessionId); this.wireSession(handle); return this.resumedSessionSummary(handle); @@ -1108,18 +1128,20 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } /** - * Through `engineAccessor` (`ISessionWorkspaceCommandService`, session - * scope) — no klient facade exists for workspace commands. The v2 service - * returns the same `{additionalDirs, projectRoot, configPath, persisted}` - * shape as v1. Gap: with `persist: false` v1 also writes the dir into the - * session metadata so it survives a resume; v2 keeps it in memory only, so - * a non-persisted dir is lost on resume. + * Through the session's handler (`IWorkspaceDirs`, workspace scope) — the + * workspace-level add-dir surface: `persist: true` (default) appends to the + * project-local `.kimi-code/local.toml`, `persist: false` joins the + * handler's shared in-memory set. The set is shared by every session of + * the workspace (a v1 `persist: false` dir was session-scoped and written + * into session metadata to survive a resume; the v2 handler keeps it for + * every session of the workspace until the process exits). Returns the + * same `{additionalDirs, projectRoot, configPath, persisted}` shape as v1. */ override async addAdditionalDir(input: AddAdditionalDirInput): Promise { const handle = this.requireLiveSession(input.id); return handle.accessor - .get(ISessionWorkspaceCommandService) - .addAdditionalDir({ path: input.path, persist: input.persist }); + .get(IWorkspaceDirs) + .addDir({ path: input.path, persist: input.persist }); } /** @@ -1931,9 +1953,9 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { // MCP: the user-global surface is rebuilt over the SDK-side store port in // `src/v2/global-mcp.ts` plus the v2 engine's own OAuth service and // connection manager (agent-core-v2 has no app-scope MCP config service — - // it only reads `mcp.json` — and binds its OAuth orchestrator inside the - // session scope); the session-level reads go through the session scope's - // `ISessionMcpService` (no klient facade exists for either group). + // it only reads `mcp.json`); the session-level reads go through the + // session scope's seeded `ISessionMcpHandle` (no klient facade exists for + // either group). // ----------------------------------------------------------------------- /** v1's per-core `globalMcpOAuth`, built over the app-scope document store. */ @@ -2057,21 +2079,21 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } /** - * Through the session scope (`ISessionMcpService.connectionManager()`). - * Both engines settle the initial connect before create/resume returns, so - * the entry list is final here; the v2 `McpServerEntry` is field-identical - * with v1's `McpServerInfo` (the cast bridges the two packages' type - * declarations). + * Through the session scope (the seeded `ISessionMcpHandle.connectionManager` + * — the workspace handler's one shared manager). Both engines settle the + * initial connect before create/resume returns, so the entry list is final + * here; the v2 `McpServerEntry` is field-identical with v1's + * `McpServerInfo` (the cast bridges the two packages' type declarations). */ override async listMcpServers(input: SessionIdRpcInput): Promise { - const mcp = this.requireLiveSession(input.sessionId).accessor.get(ISessionMcpService); - return mcp.connectionManager().list() as readonly McpServerInfo[]; + const mcp = this.requireLiveSession(input.sessionId).accessor.get(ISessionMcpHandle); + return mcp.connectionManager.list() as readonly McpServerInfo[]; } override async getMcpStartupMetrics(input: SessionIdRpcInput): Promise { - const mcp = this.requireLiveSession(input.sessionId).accessor.get(ISessionMcpService); - await mcp.connectionManager().waitForInitialLoad(); - return { durationMs: mcp.connectionManager().initialLoadDurationMs() }; + const mcp = this.requireLiveSession(input.sessionId).accessor.get(ISessionMcpHandle); + await mcp.connectionManager.waitForInitialLoad(); + return { durationMs: mcp.connectionManager.initialLoadDurationMs() }; } /** @@ -2080,8 +2102,8 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * re-registration rides on the status listeners in both engines. */ override async reconnectMcpServer(input: ReconnectMcpServerRpcInput): Promise { - const mcp = this.requireLiveSession(input.sessionId).accessor.get(ISessionMcpService); - await mcp.connectionManager().reconnect(input.name); + const mcp = this.requireLiveSession(input.sessionId).accessor.get(ISessionMcpHandle); + await mcp.connectionManager.reconnect(input.name); } } diff --git a/packages/node-sdk/src/v2/global-mcp.ts b/packages/node-sdk/src/v2/global-mcp.ts index 8ed70c939a..905776ca56 100644 --- a/packages/node-sdk/src/v2/global-mcp.ts +++ b/packages/node-sdk/src/v2/global-mcp.ts @@ -27,7 +27,7 @@ import { type McpRemoteServerConfig, type McpServerConfig, } from '@moonshot-ai/agent-core'; -import type { McpConnectionManager } from '@moonshot-ai/agent-core-v2/agent/mcp/connection-manager'; +import type { McpConnectionManager } from '@moonshot-ai/agent-core-v2/mcpCore/connection-manager'; import { atomicWrite } from '@moonshot-ai/agent-core-v2/_base/utils/fs'; import type { McpTestResult } from '#/types'; diff --git a/packages/node-sdk/test/session-context.test.ts b/packages/node-sdk/test/session-context.test.ts index 503c416de0..36f9564d09 100644 --- a/packages/node-sdk/test/session-context.test.ts +++ b/packages/node-sdk/test/session-context.test.ts @@ -48,12 +48,12 @@ describe('Session context', () => { it('clears context without replacing the session', async () => { const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-context-home-'); const workDir = await makeTempDir(tempDirs, 'kimi-sdk-context-work-'); - const additionalDir = await makeTempDir(tempDirs, 'kimi-sdk-context-additional-'); + await writeTestConfig(homeDir, 200_000); const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); try { const session = await harness.createSession({ id: 'ses_context_clear', workDir }); - await session.addAdditionalDir(additionalDir, { persist: false }); + await session.importContext('Earlier context to clear.', "file 'notes.md'"); await expect(session.getContext()).resolves.toMatchObject({ history: [{ role: 'user' }], }); diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index b391344b03..2cc2499a75 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -17,8 +17,8 @@ import { afterEach, describe, expect, it } from 'vitest'; import { ISessionApprovalService, - ISessionLifecycleService, ISessionQuestionService, + getLiveSessionById, } from '@moonshot-ai/agent-core-v2'; import { @@ -169,9 +169,9 @@ function scrubHomePrefixes(value: unknown, home: HomePair): unknown { */ const KNOWN_DIFFS = { // v2's flag registry is per-domain and already carries flags v1 does not - // have (minidb backend, subagent); v1-only flags would be - // the symmetric case. Parity is enforced on the intersection of ids until - // the registries are unified. + // have (minidb backend, subagent); v1-only flags would be the symmetric + // case. Parity is enforced on the intersection of ids until the registries + // are unified. getExperimentalFeatures: ( features: readonly { id: string }[], other: readonly { id: string }[], @@ -3710,6 +3710,33 @@ describe('v1↔v2 global MCP parity', () => { }, 20_000); }); +type McpServerList = Awaited>; + +/** + * List MCP servers on both engines once the initial connect has settled. + * v1 connects in the background after create resolves while v2 awaits the + * initial connect inside create, so an immediate list can catch either side + * still pending under load; poll until no server reports a transient status. + */ +async function listMcpServersWhenSettled( + pair: SessionParityPair, + input: { readonly sessionId: string }, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const lists = await Promise.all([ + pair.v1.listMcpServers(input), + pair.v2.listMcpServers(input), + ] as const); + const settled = lists.every((servers) => + servers.every((server) => server.status !== 'pending'), + ); + if (settled || Date.now() >= deadline) return lists; + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + describe('v1↔v2 session MCP parity', () => { /** working (connects, 3 tools) + broken (fails fast) + off (disabled). */ const SESSION_MCP_FIXTURE = { @@ -3735,10 +3762,7 @@ describe('v1↔v2 session MCP parity', () => { try { await createOnBoth(pair, { id: 'session_parity_mcp_list' }); const input = { sessionId: 'session_parity_mcp_list' } as const; - const [v1Servers, v2Servers] = await Promise.all([ - pair.v1.listMcpServers(input), - pair.v2.listMcpServers(input), - ]); + const [v1Servers, v2Servers] = await listMcpServersWhenSettled(pair, input); expect(normalize(v2Servers, 'name')).toEqual(normalize(v1Servers, 'name')); const byName = new Map(v1Servers.map((server) => [server.name, server])); expect(byName.get('working')).toMatchObject({ @@ -4051,7 +4075,7 @@ describe('v1↔v2 event & interaction parity', () => { try { await createOnBoth(pair, { id: 'session_parity_events_approval' }); const sessionId = 'session_parity_events_approval'; - const v2Session = pair.v2.engineAccessor.get(ISessionLifecycleService).get(sessionId); + const v2Session = getLiveSessionById(pair.v2.engineAccessor, sessionId); expect(v2Session).toBeDefined(); const v2Approvals = v2Session!.accessor.get(ISessionApprovalService); const requestInput = { @@ -4130,7 +4154,7 @@ describe('v1↔v2 event & interaction parity', () => { try { await createOnBoth(pair, { id: 'session_parity_events_question' }); const sessionId = 'session_parity_events_question'; - const v2Session = pair.v2.engineAccessor.get(ISessionLifecycleService).get(sessionId); + const v2Session = getLiveSessionById(pair.v2.engineAccessor, sessionId); expect(v2Session).toBeDefined(); const v2Questions = v2Session!.accessor.get(ISessionQuestionService); const requestInput = { diff --git a/packages/protocol/src/__tests__/rest-fs.test.ts b/packages/protocol/src/__tests__/rest-fs.test.ts index cf9a5d376c..7868691435 100644 --- a/packages/protocol/src/__tests__/rest-fs.test.ts +++ b/packages/protocol/src/__tests__/rest-fs.test.ts @@ -242,8 +242,8 @@ describe('fsSearchRequestSchema (W11.1)', () => { }); }); - it('rejects empty query', () => { - expect(fsSearchRequestSchema.safeParse({ query: '' }).success).toBe(false); + it('accepts an empty query (workspace-root listing)', () => { + expect(fsSearchRequestSchema.safeParse({ query: '' }).success).toBe(true); }); it('caps limit at 200', () => { diff --git a/packages/protocol/src/rest/fs.ts b/packages/protocol/src/rest/fs.ts index 157ee0915a..2eef1f0514 100644 --- a/packages/protocol/src/rest/fs.ts +++ b/packages/protocol/src/rest/fs.ts @@ -27,6 +27,8 @@ * Body: FsSearchRequest { query, limit?, include_globs?, exclude_globs?, * follow_gitignore? } * Response data: FsSearchResponse { items, truncated } + * An empty query lists the workspace root's top-level entries (dirs + * first) instead of fuzzy-matching. * Errors: 40401, 41303, 41304 * * POST /v1/sessions/{sid}/fs:grep @@ -221,7 +223,10 @@ export const fsStatManyResponseSchema = z.object({ export type FsStatManyResponse = z.infer; export const fsSearchRequestSchema = z.object({ - query: z.string().min(1), + // An empty query is allowed: the server then lists the workspace root's + // top-level entries (dirs first) instead of fuzzy-matching — the starting + // set for @-mention style file pickers. + query: z.string(), limit: z.number().int().min(1).max(200).default(50), include_globs: z.array(z.string()).optional(), exclude_globs: z.array(z.string()).optional(), From bb2919eb818a6cb51c71cbabf1bac9020131bce7 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 31 Jul 2026 11:35:39 +0800 Subject: [PATCH 03/20] fix(tui): reduce frequent full-screen redraws (#2442) --- .changeset/tui-drop-forced-full-redraws.md | 5 + .../tui/components/editor/custom-editor.ts | 33 ----- apps/kimi-code/src/tui/kimi-tui.ts | 35 ++--- .../components/editor/custom-editor.test.ts | 126 +----------------- 4 files changed, 18 insertions(+), 181 deletions(-) create mode 100644 .changeset/tui-drop-forced-full-redraws.md diff --git a/.changeset/tui-drop-forced-full-redraws.md b/.changeset/tui-drop-forced-full-redraws.md new file mode 100644 index 0000000000..90403031fb --- /dev/null +++ b/.changeset/tui-drop-forced-full-redraws.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Reduce frequent full-screen redraws in the TUI. diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index b1677e87b2..51958dbe96 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -16,7 +16,6 @@ import { import { currentTheme } from '#/tui/theme'; import { createEditorTheme } from '#/tui/theme/pi-tui-theme'; import { printableChar } from '#/tui/utils/printable-key'; -import { isInsideTmux } from '#/tui/utils/terminal-notification'; import { extractAtPrefix } from './file-mention-provider'; import { WrappingSelectList } from './wrapping-select-list'; @@ -163,7 +162,6 @@ export class CustomEditor extends Editor { private consumingPaste = false; private consumeBuffer = ''; private argumentHints: ReadonlyMap = new Map(); - private autocompleteWasShowing = false; setArgumentHints(hints: ReadonlyMap): void { this.argumentHints = hints; @@ -258,38 +256,7 @@ export class CustomEditor extends Editor { (this as unknown as AutocompleteInternals).cancelAutocomplete(); } - // Force a full re-render when the autocomplete dropdown closes, so the editor - // snaps back to the bottom instead of sitting where the taller dropdown left it. - // Only worthwhile when the session content already overflows one screen; below - // that a full clear + home would pull the editor to the top and leave a blank - // tail. Always skipped inside tmux, whose own reflow handles the shrink. - private requestFullRenderOnAutocompleteClose(): void { - if (isInsideTmux()) return; - const { columns, rows } = this.tui.terminal; - // Redraw when content fills or overflows the viewport. An exact fill (== - // rows) is safe to clear (no blank tail) and still needs the redraw: the - // differential renderer keeps the old viewport offset after a shrink. - if (this.tui.render(columns).length < rows) return; - this.tui.requestRender(true); - } - - // Detect an autocomplete open→close edge from a render frame and force a full - // re-render. Running from render() (not handleInput) also catches asynchronous - // closes — e.g. Backspace deleting the leading `/`, where pi-tui only cancels - // the menu once the provider re-query resolves. The render request is deferred - // to a microtask so the overflow probe inside the helper does not re-enter - // render() synchronously. - private trackAutocompleteCloseForFullRender(): void { - const showing = this.isShowingAutocomplete(); - const closed = this.autocompleteWasShowing && !showing; - this.autocompleteWasShowing = showing; - if (closed) { - queueMicrotask(() => this.requestFullRenderOnAutocompleteClose()); - } - } - override render(width: number): string[] { - this.trackAutocompleteCloseForFullRender(); const lines = super.render(width); if (lines.length < 3) return lines; const firstContentIdx = 1; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 79c9f1d7d7..59807b835e 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -2052,11 +2052,10 @@ export class KimiTUI { this.state.todoPanelContainer.clear(); this.imageStore.clear(); this.renderWelcome(); - // Session resets (/new, /clear, session switch) want a pristine screen. - // Force a destructive full render: the renderer's collapse repaint - // intentionally preserves scrollback, which would leave the previous - // session's text above the welcome banner. - this.state.ui.requestRender(true); + // No forced full render on session reset: let the differential renderer + // converge on its own (a mass change above the viewport still makes the + // engine repaint everything, but nothing is forced destructively here). + this.state.ui.requestRender(); } private isTurnBoundaryComponent(child: Component): boolean { @@ -2550,12 +2549,10 @@ export class KimiTUI { if (!isExpandable(child)) continue; child.setExpanded(this.state.toolOutputExpanded && i >= expandCutoff); } - // Expanding/collapsing shifts content above the viewport; the clamped - // differential render would paint a second copy below the stale one in - // scrollback. This is a deliberate user action (like /clear), so do a - // destructive full render: scrollback holds exactly one copy and the - // expanded output can be read by scrolling up. - this.state.ui.requestRender(true); + // Differential render only — no destructive full redraw on expand/collapse. + // (When the expanded region reaches above the viewport, the engine's own + // fallback may still do a full render; that path is not forced from here.) + this.state.ui.requestRender(); } toggleTodoPanelExpansion(): void { @@ -2793,18 +2790,10 @@ export class KimiTUI { this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); this.state.ui.setFocus(this.state.editor); - // Measure overflow against the restored tree (editor mounted), not the tall - // panel just removed — otherwise a short session with a tall panel looks like - // it overflows and we take a full clear/home that yanks the editor to the top. - // Treat an exact one-screen fill as overflowing too: a full redraw is safe - // there (no blank tail) and clears a stale viewport offset after a shrink. - const { columns, rows } = this.state.terminal; - const overflowsViewport = this.state.ui.render(columns).length >= rows; - // Force a full re-render after replacing a tall panel with the shorter editor: - // differential rendering leaves the editor shifted up when the bottom-anchored - // region shrinks in place. Skip under tmux (its own reflow handles the shrink) - // and when content fits on one screen (a full clear would pull the editor up). - this.state.ui.requestRender(!this.state.terminalState.insideTmux && overflowsViewport); + // Differential render only: closing a tall panel leaves the editor a few + // rows above the bottom (blank tail) until the next append, but avoids a + // destructive full redraw on every dialog close. + this.state.ui.requestRender(); } restoreInputText(text: string): void { diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index cf7184b3bd..f66c92e7d6 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -4,7 +4,7 @@ import type { AutocompleteSuggestions, TUI, } from '@moonshot-ai/pi-tui'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { CustomEditor } from '#/tui/components/editor/custom-editor'; import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; @@ -788,127 +788,3 @@ describe('CustomEditor bash mode file completion', () => { expect(calls.every((call) => call.force === true)).toBe(true); }); }); - -describe('CustomEditor full re-render on autocomplete close', () => { - function makeEditorWithRenderSpy(contentLines: number): { - editor: CustomEditor; - requestRender: ReturnType; - } { - const requestRender = vi.fn(); - const tui = { - requestRender, - terminal: { rows: 40, cols: 120 }, - render: vi.fn(() => Array.from({ length: contentLines }, () => '')), - } as unknown as TUI; - return { editor: new CustomEditor(tui), requestRender }; - } - - // Drive one render frame so the render-edge detector observes the menu state. - function renderFrame(editor: CustomEditor): void { - editor.render(120); - } - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it('forces a full re-render on the render frame after Escape closes the menu (content overflows)', async () => { - vi.stubEnv('TMUX', ''); - const { editor, requestRender } = makeEditorWithRenderSpy(50); - editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }])); - - editor.handleInput('/'); - await flushAutocomplete(); - expect(editor.isShowingAutocomplete()).toBe(true); - renderFrame(editor); // record wasShowing = true - - editor.handleInput(''); - expect(editor.isShowingAutocomplete()).toBe(false); - - renderFrame(editor); // close edge -> schedule helper - await flushAutocomplete(); - expect(requestRender).toHaveBeenCalledWith(true); - }); - - it('keeps differential rendering when the content fits on one screen', async () => { - vi.stubEnv('TMUX', ''); - const { editor, requestRender } = makeEditorWithRenderSpy(10); - editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }])); - - editor.handleInput('/'); - await flushAutocomplete(); - expect(editor.isShowingAutocomplete()).toBe(true); - renderFrame(editor); - - editor.handleInput(''); - expect(editor.isShowingAutocomplete()).toBe(false); - - renderFrame(editor); - await flushAutocomplete(); - expect(requestRender).not.toHaveBeenCalledWith(true); - }); - - it('forces a full re-render when the content exactly fills one screen', async () => { - vi.stubEnv('TMUX', ''); - const { editor, requestRender } = makeEditorWithRenderSpy(40); - editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }])); - - editor.handleInput('/'); - await flushAutocomplete(); - expect(editor.isShowingAutocomplete()).toBe(true); - renderFrame(editor); - - editor.handleInput(''); - expect(editor.isShowingAutocomplete()).toBe(false); - - renderFrame(editor); - await flushAutocomplete(); - expect(requestRender).toHaveBeenCalledWith(true); - }); - - it('does not force a full re-render inside tmux', async () => { - vi.stubEnv('TMUX', '/tmp/tmux-501/default,1234,0'); - const { editor, requestRender } = makeEditorWithRenderSpy(50); - editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }])); - - editor.handleInput('/'); - await flushAutocomplete(); - expect(editor.isShowingAutocomplete()).toBe(true); - renderFrame(editor); - - editor.handleInput(''); - expect(editor.isShowingAutocomplete()).toBe(false); - - renderFrame(editor); - await flushAutocomplete(); - expect(requestRender).not.toHaveBeenCalledWith(true); - }); - - it('forces a full re-render when Backspace deletes the slash and the menu closes asynchronously', async () => { - vi.stubEnv('TMUX', ''); - const { editor, requestRender } = makeEditorWithRenderSpy(50); - const provider: AutocompleteProvider = { - getSuggestions: vi.fn(async (lines, cursorLine, cursorCol) => { - const text = (lines[cursorLine] ?? '').slice(0, cursorCol); - if (!text.startsWith('/')) return { items: [], prefix: text }; - return { items: [{ value: 'help', label: 'help' }], prefix: '/' }; - }), - applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })), - }; - editor.setAutocompleteProvider(provider); - - editor.handleInput('/'); - await flushAutocomplete(); - expect(editor.isShowingAutocomplete()).toBe(true); - renderFrame(editor); // record wasShowing = true - - editor.handleInput(''); // Backspace deletes the '/' - await flushAutocomplete(); - await new Promise((resolve) => setTimeout(resolve, 0)); // let async cancelAutocomplete settle - expect(editor.isShowingAutocomplete()).toBe(false); - - renderFrame(editor); // close edge -> schedule helper - await flushAutocomplete(); - expect(requestRender).toHaveBeenCalledWith(true); - }); -}); From ed7a4cc095e1619e4dbb6c2c77c89a52e312b085 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Fri, 31 Jul 2026 12:11:26 +0800 Subject: [PATCH 04/20] feat(kap-server): add session-less POST /workspace/fs:search route (#2437) * feat(kap-server): let fs:search resolve a workspace ref for draft sessions - fs:search accepts a workspace id or absolute root in the session_id slot so the @ file mention works before the session exists - kimi-web searchFiles falls back to the active workspace id in draft state * fix(agent-core-v2): report empty thinking level for unbound main agent - sessionLegacyService.status returns thinking_level '' when the main agent has no bound model (mirroring model: undefined), so clients fall back to the catalog default instead of folding in the wire model's 'off' zero value - add regression test for a never-bound main agent status - add web changesets: draft @ file mention, new-session thinking level * perf(minidb): make text index rebuilds async and non-blocking - TextIndex.build() yields to the event loop during tokenization and batches postings writes (~1 MiB), so large rebuilds no longer hard-block the host process - writes landing mid-build are queued and replayed onto the new base at swap time, keeping the rebuilt index exact - PostingsFile.rebuildSync renamed to async rebuild with a synchronous commit section (beforeRename hook + atomic rename) - onCompacted hook is now awaited (sync or async); open-time compaction runs in the background so open() returns without blocking on the snapshot rewrite and postings rebuild - compaction skips the postings rebuild when the index's write buffer is clean (needsRebuild) - createTextIndex registers before building so concurrent writes feed the build queue; dropTextIndex throws while a build is in flight * refactor(agent-core-v2): rename workspaceHandler to sessionLifecycle - rename IWorkspaceHandlerService to ISessionLifecycleService and move src/workspace/workspaceHandler/ to src/workspace/sessionLifecycle/; update all consumers (gateway, sessionExport, sessionLegacy, sessionLookup, kap-server, klient, node-sdk, kimi-inspect, kimi-code) - rename IStateService to IAppStateService and add the Workspace-scope IWorkspaceStateService, so the state domain spans all four scope tiers - add cascading StateRegistry.inspect(): each tier injects the parent tier's registry and folds App to current scope into one StateInspection tree; check-domain-layers gains a Rule 2b exemption for state-on-state imports * feat(kap-server): add session-less POST /workspace/fs:search route Carry the workspace reference (registered id or absolute root) in the request body and resolve it to the same Workspace-scope fs service the session route uses, so clients no longer borrow the session route's {session_id} slot. kimi-web's @ file mention now calls this route with the workspace ref instead of a session id; the session-route fallback stays for wire compatibility. * refactor(agent-core-v2): register workspace-scope service state into IWorkspaceStateService - move workspaceDirs / workspaceInstructions / workspaceSkillCatalog / workspaceTrust runtime state from bare instance fields into the workspace state container - extend gen-state-manifest.mts to scan app/workspace scopes, emitting AppStateSnapshot / WorkspaceStateSnapshot alongside Session/Agent - regenerate docs/state-manifest.d.ts and update AGENTS.md + agent-core-dev skill - update affected tests to register the state services and assert the new state keys --- .agents/skills/agent-core-dev/align.md | 2 +- .agents/skills/agent-core-dev/design.md | 16 +- .../agent-core-dev/domain-boundaries.md | 2 +- .../skills/agent-core-dev/edge-exposure.md | 4 +- .../agent-core-dev/service-authoring.md | 2 +- .changeset/web-draft-at-mention.md | 5 + .changeset/web-new-session-thinking-level.md | 5 + AGENTS.md | 4 +- apps/kimi-code/src/cli/v2/run-v2-print.ts | 4 +- apps/kimi-code/test/cli/v2-run-print.test.ts | 10 +- apps/kimi-inspect/src/App.tsx | 4 +- apps/kimi-inspect/src/channel/client.ts | 2 +- .../src/components/ModelCatalogView.tsx | 4 +- apps/kimi-inspect/src/components/Sidebar.tsx | 4 +- apps/kimi-web/src/api/daemon/client.ts | 6 +- apps/kimi-web/src/api/types.ts | 3 +- .../composables/client/useWorkspaceState.ts | 15 +- packages/agent-core-v2/AGENTS.md | 4 +- .../agent-core-v2/docs/rw-model-design.md | 6 +- .../agent-core-v2/docs/state-manifest.d.ts | 276 +++++++++++++++++- .../scripts/check-domain-layers.mjs | 41 ++- .../scripts/gen-state-manifest.mts | 34 ++- .../src/_base/state/stateRegistry.ts | 39 ++- .../src/agent/plan/configSection.ts | 2 +- .../agent-core-v2/src/agent/plan/planOps.ts | 2 +- .../src/agent/state/agentState.ts | 5 +- .../src/agent/state/agentStateService.ts | 13 +- .../src/app/bootstrap/bootstrapService.ts | 2 +- .../agent-core-v2/src/app/gateway/gateway.ts | 2 +- .../src/app/gateway/gatewayService.ts | 6 +- .../app/sessionExport/sessionExportService.ts | 8 +- .../src/app/sessionIndex/sessionIndex.ts | 2 +- .../src/app/sessionLegacy/sessionLegacy.ts | 4 +- .../app/sessionLegacy/sessionLegacyService.ts | 10 +- .../src/app/sessionLegacy/sessionProtocol.ts | 3 + .../src/app/state/{state.ts => appState.ts} | 13 +- .../src/app/state/appStateService.ts | 26 ++ .../src/app/state/stateService.ts | 18 -- .../app/workspaceLifecycle/sessionLookup.ts | 22 +- .../workspaceLifecycle/workspaceLifecycle.ts | 2 +- .../workspaceLifecycleService.ts | 6 +- packages/agent-core-v2/src/index.ts | 12 +- .../src/os/interface/hostEnvironment.ts | 2 +- .../src/session/agentLifecycle/mainAgent.ts | 2 +- packages/agent-core-v2/src/session/errors.ts | 2 +- .../externalHooks/externalHooksService.ts | 2 +- .../src/session/mcp/sessionMcpHandle.ts | 2 +- .../session/process/processRunnerService.ts | 2 +- .../agentProfileCatalogSeed.ts | 2 +- .../session/sessionContext/sessionContext.ts | 2 +- .../instructionsProvider.ts | 2 +- .../sessionLifecycleHooks.ts | 2 +- .../sessionSkillCatalog/skillCatalogData.ts | 2 +- .../sessionToolPolicyGate.ts | 2 +- .../src/session/state/sessionState.ts | 5 +- .../src/session/state/sessionStateService.ts | 13 +- .../session/workspaceInfo/workspaceInfo.ts | 2 +- .../addressing.ts | 2 +- .../sessionLifecycle.ts} | 10 +- .../sessionLifecycleService.ts} | 14 +- .../src/workspace/state/workspaceState.ts | 21 ++ .../workspace/state/workspaceStateService.ts | 33 +++ .../workspaceDirs/workspaceDirsService.ts | 37 ++- .../workspaceInstructionsService.ts | 25 +- .../workspaceSkillCatalogService.ts | 37 ++- .../workspaceTrust/workspaceTrustService.ts | 22 +- .../test/_base/state/stateRegistry.test.ts | 87 +++++- .../test/app/gateway/gateway.test.ts | 6 +- .../app/messageLegacy/messageLegacy.test.ts | 4 +- .../app/sessionExport/sessionExport.test.ts | 6 +- .../app/sessionLegacy/sessionLegacy.test.ts | 69 ++++- .../workspaceLifecycle.test.ts | 40 ++- packages/agent-core-v2/test/harness/agent.ts | 10 + .../test/session/question/question.test.ts | 10 +- .../sessionActivityService.test.ts | 7 +- .../sessionLog/sessionLogService.test.ts | 21 +- .../sessionSkillCatalog/skillCatalog.test.ts | 3 + packages/agent-core-v2/test/state/stubs.ts | 20 +- .../sessionLifecycle.test.ts} | 34 ++- .../workspaceDirs/workspaceDirs.test.ts | 62 +++- .../instructions.test.ts | 49 +++- .../test/workspace/workspaceResources.test.ts | 22 +- .../skillCatalog.test.ts | 30 +- .../workspaceTrust/workspaceTrust.test.ts | 50 +++- packages/kap-server/src/routes/fs.ts | 117 +++++++- packages/kap-server/src/routes/sessions.ts | 20 +- .../apiSurface.snapshot.test.ts.snap | 4 + packages/kap-server/test/fs.test.ts | 105 ++++++- packages/kap-server/test/rpc.test.ts | 4 +- .../test/services/transcript.test.ts | 6 +- .../test/sessionEventBroadcaster.test.ts | 6 +- packages/kap-server/test/snapshot.test.ts | 4 +- packages/klient/src/contract/index.ts | 4 +- .../klient/src/contract/session/lifecycle.ts | 8 +- packages/klient/src/core/facade/global.ts | 2 +- packages/klient/src/core/facade/session.ts | 10 +- .../src/transports/memory/serviceRegistry.ts | 4 +- packages/klient/test/contract-parity.ts | 2 +- packages/minidb/src/compaction.ts | 13 +- packages/minidb/src/index.ts | 60 ++-- packages/minidb/src/text-index.ts | 239 ++++++++++----- packages/minidb/src/text-postings.ts | 51 +++- packages/minidb/test/compaction-fault.test.ts | 4 +- packages/minidb/test/compaction.test.ts | 26 ++ packages/minidb/test/text-index.test.ts | 106 ++++++- packages/node-sdk/src/sdk-rpc-client-v2.ts | 10 +- 106 files changed, 1785 insertions(+), 445 deletions(-) create mode 100644 .changeset/web-draft-at-mention.md create mode 100644 .changeset/web-new-session-thinking-level.md rename packages/agent-core-v2/src/app/state/{state.ts => appState.ts} (53%) create mode 100644 packages/agent-core-v2/src/app/state/appStateService.ts delete mode 100644 packages/agent-core-v2/src/app/state/stateService.ts rename packages/agent-core-v2/src/workspace/{workspaceHandler => sessionLifecycle}/addressing.ts (94%) rename packages/agent-core-v2/src/workspace/{workspaceHandler/workspaceHandler.ts => sessionLifecycle/sessionLifecycle.ts} (92%) rename packages/agent-core-v2/src/workspace/{workspaceHandler/workspaceHandlerService.ts => sessionLifecycle/sessionLifecycleService.ts} (99%) create mode 100644 packages/agent-core-v2/src/workspace/state/workspaceState.ts create mode 100644 packages/agent-core-v2/src/workspace/state/workspaceStateService.ts rename packages/agent-core-v2/test/workspace/{workspaceHandler/workspaceHandler.test.ts => sessionLifecycle/sessionLifecycle.test.ts} (97%) diff --git a/.agents/skills/agent-core-dev/align.md b/.agents/skills/agent-core-dev/align.md index ae3d2a8b6e..3f6e6778f5 100644 --- a/.agents/skills/agent-core-dev/align.md +++ b/.agents/skills/agent-core-dev/align.md @@ -63,7 +63,7 @@ Worked example — v1 `ISessionService` (one class, ~600 lines) holds: - this session's metadata → **per-session** unit → v2 `sessionMetaStore` (`ISessionMetaStore`, Session); - this session's activity / status → **per-session** unit → v2 `sessionActivity`; - this session's context projection → **per-session** unit → v2 `sessionContext`; -- child-agent lifecycle driven by a session → **per-session** unit → v2 `agentLifecycle`; create/close/archive/fork of the session itself → **per-workspace** unit → v2 `workspaceHandler` (Workspace, one per live workspace handler). +- child-agent lifecycle driven by a session → **per-session** unit → v2 `agentLifecycle`; create/close/archive/fork of the session itself → **per-workspace** unit → v2 `sessionLifecycle` (Workspace, one per live workspace handler). A v1 class that maps cleanly to one v1 decorator often becomes **three to five** v2 Services. That is expected and correct — do not try to keep the v1 class shape. diff --git a/.agents/skills/agent-core-dev/design.md b/.agents/skills/agent-core-dev/design.md index a7778e7b9b..fc7035f741 100644 --- a/.agents/skills/agent-core-dev/design.md +++ b/.agents/skills/agent-core-dev/design.md @@ -228,17 +228,17 @@ Read it as: - `──holds──►` = the ancestor owns a handle to the child scope (it stores the key, not the service). DI allows this. - `accessor.get(...)` = a **runtime borrow**, not a dependency edge. It must cross an `IScopeHandle`, run on demand, never be cached, and finish before the child scope is disposed. -Worked example — `workspaceHandler`: +Worked example — `sessionLifecycle`: ```text -domain: `workspaceHandler` (owning scope: Workspace) +domain: `sessionLifecycle` (owning scope: Workspace) ├─ serves (who uses me) │ ├─ (inject) — (none) │ └─ (accessor) │ ├─ sessionLegacy @App(edge) — v1-compatible create/fork/archive/… │ └─ gateway / rpc @App(edge) — native v2 session lifecycle actions ├─ exposes (interfaces I provide, by scope) -│ ├─ Workspace : IWorkspaceHandlerService — owns this workspace's live session scope tree +│ ├─ Workspace : ISessionLifecycleService — owns this workspace's live session scope tree │ ├─ Session : — — (per-session state lives in sessionMetadata / agentLifecycle / …) │ └─ Agent : — — (per-agent state lives in agentLifecycle) └─ depends (what I inject) @@ -252,17 +252,17 @@ domain: `workspaceHandler` (owning scope: Workspace) └─ event @App direct — broadcasts session-level facts (e.g. archived) ``` -Cross-scope borrow for `workspaceHandler`: +Cross-scope borrow for `sessionLifecycle`: ```text App scope WorkspaceLifecycleService ──holds──► IScopeHandle(workspaceId) (one per live handler) │ - │ accessor.get(IWorkspaceHandlerService) + │ accessor.get(ISessionLifecycleService) │ └── resolve runs inside the Workspace scope ▼ Workspace scope (workspaceId) - WorkspaceHandlerService ──holds──► IScopeHandle(sessionId) + SessionLifecycleService ──holds──► IScopeHandle(sessionId) │ │ accessor.get(ISessionMetadata) … │ └── resolve runs inside the Session scope @@ -274,8 +274,8 @@ App scope How the three lenses shaped it: - **Scope (§2)** → the live registry of one workspace's session scopes is per-handler, so it is Workspace-scoped; the process-wide handler registry lives in the App-scoped `workspaceLifecycle`; per-session data stays in Session-scoped services, reached through the handle's `accessor`. -- **Dependency direction (§5)** → `workspaceHandler` is consumed by the edge via `accessor` borrows; it never imports the edge. Every downward arrow lands on a peer or a more foundational Service. -- **Extension points (§4)** → new per-session behavior plugs into the Session-scoped services (`sessionMetadata`, `agentLifecycle`, `sessionActivity`); new transports stay at the edge. Neither edits `workspaceHandler`. +- **Dependency direction (§5)** → `sessionLifecycle` is consumed by the edge via `accessor` borrows; it never imports the edge. Every downward arrow lands on a peer or a more foundational Service. +- **Extension points (§4)** → new per-session behavior plugs into the Session-scoped services (`sessionMetadata`, `agentLifecycle`, `sessionActivity`); new transports stay at the edge. Neither edits `sessionLifecycle`. For a multi-scope split, the `exposes` block fills more than one scope — see the `records` pattern in §3. diff --git a/.agents/skills/agent-core-dev/domain-boundaries.md b/.agents/skills/agent-core-dev/domain-boundaries.md index 8f97847a92..cd3eb8ee64 100644 --- a/.agents/skills/agent-core-dev/domain-boundaries.md +++ b/.agents/skills/agent-core-dev/domain-boundaries.md @@ -82,7 +82,7 @@ The `session` domain owns only Session-level identity, metadata, lifecycle comma |---|---|---| | `sessionId`, `workspaceId`, `sessionDir`, `metaScope` | `sessionContext` | Seeded facts; no IO | | `SessionMeta` | `sessionMetadata` | Durable atomic document; entity-like | -| Open session scope registry | `workspaceHandler` | Workspace-scope live handles, one registry per workspace handler (the process-wide handler registry is `workspaceLifecycle`); not the persisted entity table | +| Open session scope registry | `sessionLifecycle` | Workspace-scope live handles, one registry per workspace handler (the process-wide handler registry is `workspaceLifecycle`); not the persisted entity table | | Session commands such as `archive()` | `session` | Orchestrates metadata, agent teardown, and events | | Persisted session list / get / count | `sessionIndex` | Backend-neutral read model | | Running / idle / awaiting status | `sessionActivity` | Derived from interactions and active turns; owns no state | diff --git a/.agents/skills/agent-core-dev/edge-exposure.md b/.agents/skills/agent-core-dev/edge-exposure.md index db03a8e79c..9105bb9488 100644 --- a/.agents/skills/agent-core-dev/edge-exposure.md +++ b/.agents/skills/agent-core-dev/edge-exposure.md @@ -85,7 +85,7 @@ Read = `GET`, write = `POST`. `sid` = `session_id`, `aid` = `agent_id`. | `session` | `setArchived` | ISessionMetadata.setArchived | POST | | `session` | `status` | ISessionActivity.status | GET | | `session` | `isIdle` | ISessionActivity.isIdle | GET | -| `session` | `archive` | IWorkspaceHandlerService.archive | POST | +| `session` | `archive` | ISessionLifecycleService.archive | POST | | `approvals` | `listPending` | IApprovalService.listPending | GET | | `approvals` | `decide` | IApprovalService.decide | POST | | `questions` | `listPending` | IQuestionService.listPending | GET | @@ -128,7 +128,7 @@ These fail §2 and must be wrapped in a facade that takes ids and returns data: | Service | Why not direct | Facade shape | |---|---|---| -| IWorkspaceHandlerService | returns `IScopeHandle` | `sessions.create` / `fork` / `close` / `archive` → wire Session | +| ISessionLifecycleService | returns `IScopeHandle` | `sessions.create` / `fork` / `close` / `archive` → wire Session | | IAgentPromptService / IAgentTurnService | returns `Turn` handle | `prompts.submit` / `steer` / `abort` / `undo` | | ILLMRequester | `AsyncIterable` stream | stream over WS, not RPC | | ISubagentHost | `SubagentHandle` | `subagents.spawn` / `resume` → info | diff --git a/.agents/skills/agent-core-dev/service-authoring.md b/.agents/skills/agent-core-dev/service-authoring.md index 5bd569a08a..dd4ba7e355 100644 --- a/.agents/skills/agent-core-dev/service-authoring.md +++ b/.agents/skills/agent-core-dev/service-authoring.md @@ -204,7 +204,7 @@ A scoped Service may expose a factory method that returns a **new** instance of ### Runtime state goes into the per-scope state container -Session/Agent-scope Services register their runtime state into the scope's state container (`ISessionStateService` / `IAgentStateService`, both over `_base`'s `StateRegistry`) instead of holding it in bare instance fields, so per-scope state lives in one observable place (`snapshot()` / `onDidChange`) and dies with the scope. Reference: `session/interaction/interactionService.ts`. +Workspace/Session/Agent-scope Services register their runtime state into the scope's state container (`IWorkspaceStateService` / `ISessionStateService` / `IAgentStateService`, all over `_base`'s `StateRegistry`) instead of holding it in bare instance fields, so per-scope state lives in one observable place (`snapshot()` / `onDidChange`) and dies with the scope. Reference: `session/interaction/interactionService.ts`. - Declare keys in the domain file and export them: `export const interactionPendingKey = defineState>('interaction.pending', () => new Map())` — `.` naming, factory initializers. - Inject `@ISessionStateService private readonly states` (or the Agent token) and `this.states.register(key)` per key at the top of the constructor. diff --git a/.changeset/web-draft-at-mention.md b/.changeset/web-draft-at-mention.md new file mode 100644 index 0000000000..8ac2990006 --- /dev/null +++ b/.changeset/web-draft-at-mention.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Make the @ file mention work in a new-session draft, before the first prompt creates the session. diff --git a/.changeset/web-new-session-thinking-level.md b/.changeset/web-new-session-thinking-level.md new file mode 100644 index 0000000000..9099b39912 --- /dev/null +++ b/.changeset/web-new-session-thinking-level.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix new sessions showing the thinking level (e.g. Max) while the first message actually ran with thinking off. diff --git a/AGENTS.md b/AGENTS.md index 9cd3a8dbf5..e36a0df62a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,14 +19,14 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. - `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session chat, and Service panels (data + trigger buttons) for the Session and Agent scopes. A left icon rail (`src/components/NavRail.tsx`) switches top-level views: the Chat workspace, the global message search (`src/components/SearchView.tsx` — cross-session full-text search over `POST /api/v1/search`, cursor-paged via a manual Load more; an exact-match checkbox maps to the API's `mode: 'literal'` substring search, which ignores sort and orders newest-first; a `live`/`index` badge on the results shows which server route served them (in-memory session transcript vs the persisted index)), the Model Catalog (`src/components/ModelCatalogView.tsx` — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies), and App Services (`src/components/AppServicesView.tsx` — the app-scope Service reflection, full width, joined by the Workspace Services view (`src/components/WorkspaceServicesView.tsx`) — the workspace-scope counterpart with a left sidebar directory browser (`src/components/WorkspaceDirBrowser.tsx` — server-side fs browsing over the App-scope `IHostFolderBrowser`, marking entries that are registered workspaces with their `IWorkspaceTrust` trust state, and registering a picked folder on demand via `IWorkspaceService.createOrTouch`), its proxies riding the `/workspace/:id` route, which materializes the handler on demand via `IWorkspaceLifecycleService.handlerFor`; the Agent scope stays in the Chat view's right dock (`src/components/RightPanel.tsx`) across two tabs: the `Agent` tab (`Inspector`: agent switcher + a Plan lookup card — `PlanCard` in `src/components/Inspector.tsx` — querying `GET /sessions/{id}/transcript/plan` (one tool_call_id, or every plan of the agent) via `src/transcript/api.ts`'s `fetchTranscriptPlan` — plus the agent Service panels) and the `State` tab (every key an Agent Service registered into the agent-state container, polled live via `IAgentStateService.snapshot()` — the same live diff-tree view as the session State tab, sharing `StateCard` from `src/components/StateCard.tsx`), while the Session scope has its own column right next to the session-list sidebar (`src/components/SessionPane.tsx`) with two tabs: Services (the pending-interactions card — `src/components/InteractionsCard.tsx` — plus the session Service panels) and State (every key a Session Service registered into the session-state container, read on demand via `ISessionStateService.snapshot()`)). Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v1/debug`), typed by `agent-core-v2` Service interfaces; `GET /api/v1/debug/channels` loads the whole wire protocol 1:1 (every scoped Service, no whitelist). There is no Service-event push channel: panels fetch/refresh on demand (`Sidebar` polls react-query on a 15 s interval), and a connection failure shows a blocking "Debug surface unavailable" screen instead of falling back anywhere. Session-level coarse status is the one exception: `src/activity/` holds a second `/api/v1/ws` client (`GlobalEventsWs`) that subscribes to nothing and consumes the server-pushed global facts — `event.session.work_changed` updates a per-session activity map (`SessionActivityHub` + subscribe/version store, seeded on connect/reconnect from `GET /api/v1/sessions`), while `event.session.created` / `session.meta.updated` invalidate the `['sessions']` query; the `Sidebar` session rows render `running` / `approval` / `question` / `failed` badges from it via `useSessionActivities`. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime. The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory and carries an in-chat search bar (`src/components/ChatSearchBar.tsx`): it searches the current session via `POST /api/v1/search` with `container: { session_id }` (usually served by the live route, since selecting a session resumes it), and a result click funnels through the app shell's `openSearchHit` — the same agent-switch + `ChatJump` (page-back, scroll, flash) path the global search view uses; full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards), older history auto-pages with `before_turn` via an IntersectionObserver sentinel at the top of the scroll view, and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library); `/api/v1/ws` is an incremental channel (`transcript.ops`, grade `block` — the cheapest grade that still carries whole-state frame upserts, dropping per-token `append` frames; `transcript.reset` is ignored by the store, surfaced only to the audit recorder via the optional `onReset` handler). The channel tracks the op-batch watermark: a dedicated `subscribe_v2` control frame carries the per-agent grades and the `transcript_since` cursor, a seq gap / reconnect / `resync_required` / append gap triggers a point-to-point catch-up (`fetchTranscriptOps` → `GET .../transcript/ops?since_seq=`), and any legacy/incomplete answer falls back to the full REST refresh. Convergence reuses `@moonshot-ai/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally). The Transcript audit panel (`src/components/audit/`, the `Audit` tab of the chat view's right dock — `src/components/RightPanel.tsx`, fed the trail by `ChatView`'s `onTrailChange`) replays how the visible store was built: an `AuditTrail` (`src/audit/`) records every step — each REST page (request + replace/prepend), every WS frame (`transcript.ops` live/buffered/flushed/catchup, `transcript.reset`), loss signals, and prompt/cancel actions — with the resulting immutable `AgentState` per entry; the panel offers a draggable timeline plus a Diff tab (structural diff vs the previous entry: added/modified/removed colored, long strings tail-truncated, all fields kept), a full State view, and the raw Event payload. - `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. -- `packages/agent-core-v2`: the DI × Scope agent engine (the v2 port behind kap-server). Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (`_base/di/scope.ts`). The `workspace/` domain owns one Workspace scope per materialized workspace handler: the App-scope `IWorkspaceLifecycleService` keeps the live handler registry (create-or-get + join, handlers never closed), and each handler's `IWorkspaceHandlerService` owns session create/resume/fork/close as its child scopes — there is no App-level session lifecycle facade, callers compose `ISessionIndex` → `handlerFor` → the handler. Workspace-scope services hold the handler-shared resources loaded once per handler and refreshed by fs watch: skills / AGENTS.md (`workspaceSkillCatalog` / `workspaceInstructions`), the workspace agent-profile loader (`workspaceAgentProfileLoader` — agent profiles follow the Contribution / Registry / Catalog extension point: the domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) register `AgentProfileContribution`s into the App-scope `IAgentProfileRegistry`, tagged with the handler's `workspaceId`; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles, and each Session-scope `sessionAgentProfileCatalog` projects the registry directly (name-level dedup + builtin-override rule in the projection), seeded with only the workspace key), one shared MCP connection set (`workspaceMcp`, pure connection orchestration over the scope-agnostic `mcpCore` layer; the effective server set is owned by `workspaceMcpConfig` — mcp.json files + plugin contributions, fs-watch refreshed — and MCP persistence, the `[mcp]` config section plus OAuth credentials, lives in `app/mcpConfig`, the same wrapper shape as `kosongConfig` over kosong), fs / fs-watch / process runner / git (`workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit`), the additional-directory set (`workspaceDirs`, backed by `.kimi-code/local.toml`), the os-level tool veto (`workspaceToolPolicy`), and the trust marker (`workspaceTrust` — persisted under the home, keyed by `encodeWorkDirKey(root)`; while a workspace is untrusted, `workspaceMcpConfig` skips the project-level `.mcp.json` / `.kimi-code/mcp.json` files, and the state flips through kap-server's `GET|POST /workspaces/{id}/trust` + `POST /workspaces/{id}/untrust` routes). Session/Agent scopes consume these through session-domain seed contracts with change events (`session/mcp`, `session/workspaceInfo`, `session/sessionSkillCatalog` data, …) — Session/Agent never import the Workspace domain. See `packages/agent-core-v2/AGENTS.md` and use the `agent-core-dev` skill (`.agents/skills/agent-core-dev/SKILL.md`) when developing here. +- `packages/agent-core-v2`: the DI × Scope agent engine (the v2 port behind kap-server). Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (`_base/di/scope.ts`). The `workspace/` domain owns one Workspace scope per materialized workspace handler: the App-scope `IWorkspaceLifecycleService` keeps the live handler registry (create-or-get + join, handlers never closed), and each handler's `ISessionLifecycleService` owns session create/resume/fork/close as its child scopes — there is no App-level session lifecycle facade, callers compose `ISessionIndex` → `handlerFor` → the handler. Workspace-scope services hold the handler-shared resources loaded once per handler and refreshed by fs watch: skills / AGENTS.md (`workspaceSkillCatalog` / `workspaceInstructions`), the workspace agent-profile loader (`workspaceAgentProfileLoader` — agent profiles follow the Contribution / Registry / Catalog extension point: the domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) register `AgentProfileContribution`s into the App-scope `IAgentProfileRegistry`, tagged with the handler's `workspaceId`; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles, and each Session-scope `sessionAgentProfileCatalog` projects the registry directly (name-level dedup + builtin-override rule in the projection), seeded with only the workspace key), one shared MCP connection set (`workspaceMcp`, pure connection orchestration over the scope-agnostic `mcpCore` layer; the effective server set is owned by `workspaceMcpConfig` — mcp.json files + plugin contributions, fs-watch refreshed — and MCP persistence, the `[mcp]` config section plus OAuth credentials, lives in `app/mcpConfig`, the same wrapper shape as `kosongConfig` over kosong), fs / fs-watch / process runner / git (`workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit`), the additional-directory set (`workspaceDirs`, backed by `.kimi-code/local.toml`), the os-level tool veto (`workspaceToolPolicy`), and the trust marker (`workspaceTrust` — persisted under the home, keyed by `encodeWorkDirKey(root)`; while a workspace is untrusted, `workspaceMcpConfig` skips the project-level `.mcp.json` / `.kimi-code/mcp.json` files, and the state flips through kap-server's `GET|POST /workspaces/{id}/trust` + `POST /workspaces/{id}/untrust` routes). Session/Agent scopes consume these through session-domain seed contracts with change events (`session/mcp`, `session/workspaceInfo`, `session/sessionSkillCatalog` data, …) — Session/Agent never import the Workspace domain. See `packages/agent-core-v2/AGENTS.md` and use the `agent-core-dev` skill (`.agents/skills/agent-core-dev/SKILL.md`) when developing here. - `packages/node-sdk`: the public TypeScript SDK and harness. - `packages/kosong`: the LLM / provider abstraction layer. - `packages/kaos`: the execution environment and file/process abstractions. - `packages/oauth`: Kimi OAuth and managed auth utilities. - `packages/telemetry`: shared client-side telemetry infrastructure. - `packages/transcript`: the isomorphic transcript rendering data layer — agent-granular L1 store, idempotent L2 operations, `off/turn/block/delta` L3 subscription granularity, framework-free L4 view registry, and turn-cursor pagination. Pure TypeScript (browser-safe, no engine imports) and the sole owner of all transcript contract types (`src/contract/`); consumed by `packages/kap-server` (engine events → transcript, REST + WS surface; live stores backfill history from the persisted per-agent wire records — main on first attach, any agent on demand, cold sessions rebuild any agent — with 0-based turn ordinals matching the engine's). The cold rebuild is a two-level fold over `wire.jsonl` as the single source of truth: `history/groupTurns.ts` (context messages → turn tree) plus `history/foldFacts.ts` (non-context records → tasks, interactions, todos, goal/plan/swarm meta, and end-appended markers/taskrefs; interactions left pending at shutdown fold to `cancelled`). Plan content is a recorded fact too: each ExitPlanMode review submission offloads the document to `agents//plan//v.md` and persists a reference-only `plan.revision` record (`{id, version, path, sha256, bytes}`), which projects — live and cold — to a `plan.revision` marker and the `modes.plan` badge (`{reviewPath, version}`). It also owns the op-batch sequencing contract (`transcriptSeqSchema` in `contract/schema.ts`): a per-(session, agent) monotonic batch `seq` on `transcript.ops` / `transcript.reset` / the REST transcript response, the `transcript_since` subscription cursor, and the `GET .../transcript/ops` catch-up response shape — every field optional so pre-seq peers fall back to loss-signal-driven refreshes. Beyond the timeline, the model carries wire-equivalent detail: steps carry `usage` / `finishReason` / `timing` (LLM latencies) / `retry` / interrupt reason, turns carry `durationMs` / `error` / `usage`, tool frames carry the streamed `inputText` and the latest `progress`, tasks carry subagent `resultSummary` / `error` / `stateReason` / `usage`, `meta.agent` mirrors the agent status slices (model / usage / context / permission / phase), a global `prompts` entity (op `prompt.upsert`) tracks the prompt queue, and `hook.result` lands as a `'hook'` marker. These live-projected fields are NOT backfilled by the cold rebuild (known limitation). -- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2` — four scopes, App/Workspace/Session/Agent; session create/resume/fork routes compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler's `IWorkspaceHandlerService`, and the fs routes resolve session → handler → the Workspace-scope fs services). Exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist, Workspace scope addressable alongside App/Session/Agent; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Its transcript surface implements the op-batch sequencing contract: `TranscriptService.dispatchOps` assigns every dispatched batch a per-agent consecutive `seq` and retains it in a bounded in-memory journal (`TRANSCRIPT_OPS_JOURNAL_CAPACITY`, dies with the live store); WS `transcript.ops`/`transcript.reset` payloads carry the seq/watermark, a `transcript_since` subscription cursor (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) replays journaled batches instead of a baseline reset when the journal covers it, and `GET /sessions/{id}/transcript/ops?since_seq=` serves point-to-point catch-up (`complete: false` = journal can't cover or session cold → caller falls back to a full refresh). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. The baseline `transcript.reset` itself is items-empty (`TRANSCRIPT_RESET_TAIL_TURNS = 0`): it carries only global state + the watermark + `has_more_older`, because history always pages in over REST. When a WS connection subscribes to the transcript protocol (grade ≠ `off` for an agent), the broadcaster suppresses the transcript-projected `session_event` types for that connection × agent (`TRANSCRIPT_PROJECTED_EVENT_TYPES` + `suppressedByTranscript` in `sessionEventBroadcaster.ts`; cursor replay via `getBufferedSince` applies the same filter). Suppression is only a per-connection send view — the journal still records everything, and connections without transcript grades are unaffected. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. The global search surface is `POST /api/v1/search` (`src/search/` + `src/routes/search.ts`): a cross-session full-text search over user messages, assistant text, and session titles, backed by a single minidb database at `/search-index` (`IGlobalSearchService`, App scope — the write-lock holder is the indexer, other processes open read-only and catch up via WAL). It serves two modes: `terms` (the default — minidb's inverted text index over ASCII words + CJK uni/bigrams, no positions, term-level AND) and `literal` (substring-exact search: a hashed 2/3-gram index supplies candidates, every candidate's text is then confirmed with `includes`, so hits carry zero false positives; literal ignores `sort` and returns newest-first, and a candidate set truncated at `LITERAL_CANDIDATE_CAP` is flagged `incomplete: 'candidate_cap'`). When `container.session_id` is provided and that session is live in this process (`TranscriptService.forSessionLive` returns a store, wired via `setLiveTranscriptSource` in `start.ts`), BOTH modes instead scan the in-memory transcript store (turn prompts + assistant text frames, history established via `whenReady`/`ensureAgentHistory`) — no index involved; terms-mode live hits are scored Σ log(1+tf) (comparable only within a route, per the `GlobalSearchSource` contract), live-route errors never fall back to the index, and the response's `source: 'live' | 'index'` field (also mixed into the page-token fingerprint, so a mid-pagination route flip invalidates the old token) tells the caller which route served the page. +- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2` — four scopes, App/Workspace/Session/Agent; session create/resume/fork routes compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler's `ISessionLifecycleService`, and the fs routes resolve session → handler → the Workspace-scope fs services, with one exception: `fs:search` also accepts a workspace reference (registered id or absolute root) in the `{session_id}` slot, so a not-yet-created draft session's `@` file mention resolves the workspace handler directly; the first-class session-less form is `POST /api/v1/workspace/fs:search` (the workspace reference travels in the body)). Exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist, Workspace scope addressable alongside App/Session/Agent; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Its transcript surface implements the op-batch sequencing contract: `TranscriptService.dispatchOps` assigns every dispatched batch a per-agent consecutive `seq` and retains it in a bounded in-memory journal (`TRANSCRIPT_OPS_JOURNAL_CAPACITY`, dies with the live store); WS `transcript.ops`/`transcript.reset` payloads carry the seq/watermark, a `transcript_since` subscription cursor (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) replays journaled batches instead of a baseline reset when the journal covers it, and `GET /sessions/{id}/transcript/ops?since_seq=` serves point-to-point catch-up (`complete: false` = journal can't cover or session cold → caller falls back to a full refresh). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. The baseline `transcript.reset` itself is items-empty (`TRANSCRIPT_RESET_TAIL_TURNS = 0`): it carries only global state + the watermark + `has_more_older`, because history always pages in over REST. When a WS connection subscribes to the transcript protocol (grade ≠ `off` for an agent), the broadcaster suppresses the transcript-projected `session_event` types for that connection × agent (`TRANSCRIPT_PROJECTED_EVENT_TYPES` + `suppressedByTranscript` in `sessionEventBroadcaster.ts`; cursor replay via `getBufferedSince` applies the same filter). Suppression is only a per-connection send view — the journal still records everything, and connections without transcript grades are unaffected. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. The global search surface is `POST /api/v1/search` (`src/search/` + `src/routes/search.ts`): a cross-session full-text search over user messages, assistant text, and session titles, backed by a single minidb database at `/search-index` (`IGlobalSearchService`, App scope — the write-lock holder is the indexer, other processes open read-only and catch up via WAL). It serves two modes: `terms` (the default — minidb's inverted text index over ASCII words + CJK uni/bigrams, no positions, term-level AND) and `literal` (substring-exact search: a hashed 2/3-gram index supplies candidates, every candidate's text is then confirmed with `includes`, so hits carry zero false positives; literal ignores `sort` and returns newest-first, and a candidate set truncated at `LITERAL_CANDIDATE_CAP` is flagged `incomplete: 'candidate_cap'`). When `container.session_id` is provided and that session is live in this process (`TranscriptService.forSessionLive` returns a store, wired via `setLiveTranscriptSource` in `start.ts`), BOTH modes instead scan the in-memory transcript store (turn prompts + assistant text frames, history established via `whenReady`/`ensureAgentHistory`) — no index involved; terms-mode live hits are scored Σ log(1+tf) (comparable only within a route, per the `GlobalSearchSource` contract), live-route errors never fall back to the index, and the response's `source: 'live' | 'index'` field (also mixed into the page-token fingerprint, so a mid-pagination route flip invalidates the old token) tells the caller which route served the page. - `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 with aggregated `global.*` / `session(id).*` / `agent(id).*` methods, zod validation on every call, and klient-level typed event forwarding. Transport is chosen once at creation via subpath entry (`@moonshot-ai/klient/ipc|memory`); both return the same `Klient`. The package also hosts the e2e suites: the legacy `/api/v1` live suites (`test/e2e/legacy/`) and the docker e2e runner (`pnpm --filter @moonshot-ai/klient docker:e2e`). See `packages/klient/AGENTS.md`. - `packages/server-e2e`: live e2e tests and scenarios against a running server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). See `packages/server-e2e/AGENTS.md`. - `packages/tree-sitter-bash`: a pure-TypeScript bash parser (no runtime deps, no wasm) that produces a syntax tree with tree-sitter-bash 0.25.0 named-node type names and UTF-16 code-unit offsets. `parse(source, { timeoutMs, maxNodes })` runs under a deterministic budget (default 50 ms / 50k nodes, plus per-chain recursion depth caps) and returns a discriminated `ParseResult` (`{ ok, rootNode, hasError }` or `{ ok: false, reason: 'aborted' }`) — callers must treat aborted/hasError trees as "cannot analyze" and degrade. Parser only, no safety judgments; consumers (e.g. Bash tool permission matching) live elsewhere. Known deviations from the reference are tracked in the package README's "Known differences" section, pinned by differential fixtures tested against the real `tree-sitter-bash` wasm (dev-only). diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index 456979bdf8..d9f6237cf4 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -32,7 +32,7 @@ import { IOAuthToolkit, ISessionCronService, ISessionIndex, - IWorkspaceHandlerService, + ISessionLifecycleService, IWorkspaceLifecycleService, ITelemetryService, PRINT_MAX_TURNS_DEFAULT, @@ -377,7 +377,7 @@ async function resolveNativeSession( const model = requireConfiguredModel(opts.model, defaultModel); const handler = await workspaceLifecycle.handlerFor({ root: workDir }); - const session = await handler.accessor.get(IWorkspaceHandlerService).create({ + const session = await handler.accessor.get(ISessionLifecycleService).create({ workDir, additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, mainAgentBinding: { diff --git a/apps/kimi-code/test/cli/v2-run-print.test.ts b/apps/kimi-code/test/cli/v2-run-print.test.ts index 54ccbb0527..fab72a136b 100644 --- a/apps/kimi-code/test/cli/v2-run-print.test.ts +++ b/apps/kimi-code/test/cli/v2-run-print.test.ts @@ -20,7 +20,7 @@ import { IOAuthToolkit, ISessionCronService, ISessionIndex, - IWorkspaceHandlerService, + ISessionLifecycleService, IWorkspaceLifecycleService, ISkillCatalogRuntimeOptions, ITelemetryService, @@ -182,7 +182,7 @@ function makeFakeHarness() { const handlerServices = new Map([ [ - IWorkspaceHandlerService, + ISessionLifecycleService, { create: vi.fn(async () => session), resume: vi.fn(async () => session), @@ -345,7 +345,7 @@ describe('runV2Print', () => { const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions); expect(seeded?.[1]).toMatchObject({ explicitFiles: ['/agents/reviewer.md'] }); - const lifecycle = handlerServices.get(IWorkspaceHandlerService) as { + const lifecycle = handlerServices.get(ISessionLifecycleService) as { create: ReturnType; }; expect(lifecycle.create).toHaveBeenCalledWith({ @@ -380,7 +380,7 @@ describe('runV2Print', () => { const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions); expect(seeded?.[1]).toMatchObject({ explicitFiles: [agentFile] }); - const lifecycle = handlerServices.get(IWorkspaceHandlerService) as { + const lifecycle = handlerServices.get(ISessionLifecycleService) as { create: ReturnType; }; expect(lifecycle.create).toHaveBeenCalledWith({ @@ -396,7 +396,7 @@ describe('runV2Print', () => { const stdout = writer(); const stderr = writer(); const { app, handlerServices } = makeFakeHarness(); - const lifecycle = handlerServices.get(IWorkspaceHandlerService) as { + const lifecycle = handlerServices.get(ISessionLifecycleService) as { create: ReturnType; }; lifecycle.create.mockRejectedValueOnce(new Error('Unknown agent profile')); diff --git a/apps/kimi-inspect/src/App.tsx b/apps/kimi-inspect/src/App.tsx index bb634b04f0..2624dcf54f 100644 --- a/apps/kimi-inspect/src/App.tsx +++ b/apps/kimi-inspect/src/App.tsx @@ -17,7 +17,7 @@ */ import { ISessionIndex } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; -import { IWorkspaceHandlerService } from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler'; +import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/workspace/sessionLifecycle/sessionLifecycle'; import { useEffect, useState } from 'react'; import type { AuditTrail } from './audit/trail'; @@ -62,7 +62,7 @@ export function App() { .get(sessionId) .then((summary) => { if (summary === undefined) throw new Error(`session ${sessionId} does not exist`); - return klient.workspace(summary.workspaceId).service(IWorkspaceHandlerService).resume(sessionId); + return klient.workspace(summary.workspaceId).service(ISessionLifecycleService).resume(sessionId); }) .then(() => { if (!cancelled) setReady(true); diff --git a/apps/kimi-inspect/src/channel/client.ts b/apps/kimi-inspect/src/channel/client.ts index 6f3b36458e..280f3ed829 100644 --- a/apps/kimi-inspect/src/channel/client.ts +++ b/apps/kimi-inspect/src/channel/client.ts @@ -6,7 +6,7 @@ * * const client = createInspectClient({ url: 'http://127.0.0.1:58627' }); * await client.core(ISessionIndex).list({}); - * await client.workspace('wd_1').service(IWorkspaceHandlerService).resume('s1'); + * await client.workspace('wd_1').service(ISessionLifecycleService).resume('s1'); * await client.session('s1').service(ISessionMetadata).read(); * await client.session('s1').agent('main').service(IAgentRPCService).cancel({}); * diff --git a/apps/kimi-inspect/src/components/ModelCatalogView.tsx b/apps/kimi-inspect/src/components/ModelCatalogView.tsx index 49a592b5d8..7d61f0f0a3 100644 --- a/apps/kimi-inspect/src/components/ModelCatalogView.tsx +++ b/apps/kimi-inspect/src/components/ModelCatalogView.tsx @@ -19,7 +19,7 @@ import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; import { ISessionIndex } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; -import { IWorkspaceHandlerService } from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler'; +import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/workspace/sessionLifecycle/sessionLifecycle'; import type { InspectionSource } from '@moonshot-ai/agent-core-v2/kosong/contract/inspection'; import type { TokenUsage } from '@moonshot-ai/agent-core-v2/kosong/contract/usage'; import { @@ -410,7 +410,7 @@ function ModelSection({ if (summary !== undefined) { await klient .workspace(summary.workspaceId) - .service(IWorkspaceHandlerService) + .service(ISessionLifecycleService) .resume(sessionId); } await klient diff --git a/apps/kimi-inspect/src/components/Sidebar.tsx b/apps/kimi-inspect/src/components/Sidebar.tsx index 69461161fc..dfbadfb264 100644 --- a/apps/kimi-inspect/src/components/Sidebar.tsx +++ b/apps/kimi-inspect/src/components/Sidebar.tsx @@ -13,7 +13,7 @@ import { ISessionIndex, type SessionSummary, } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; -import { IWorkspaceHandlerService } from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler'; +import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/workspace/sessionLifecycle/sessionLifecycle'; import { IWorkspaceService, type Workspace, @@ -113,7 +113,7 @@ export function Sidebar({ if (summary !== undefined) { await klient .workspace(summary.workspaceId) - .service(IWorkspaceHandlerService) + .service(ISessionLifecycleService) .resume(sessionId); } await klient.session(sessionId).agent('main').service(IAgentProfileService).setModel(model); diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index d8b1833edf..3dd6cd7042 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -968,7 +968,7 @@ export class DaemonKimiWebApi implements KimiWebApi { } async searchFiles( - sessionId: string, + workspace: string, input: { query: string; limit?: number }, ): Promise<{ items: Array<{ @@ -980,10 +980,10 @@ export class DaemonKimiWebApi implements KimiWebApi { }>; truncated: boolean; }> { - const body: Record = { query: input.query }; + const body: Record = { workspace, query: input.query }; if (input.limit !== undefined) body['limit'] = input.limit; const data = await this.http.post( - `/sessions/${encodeURIComponent(sessionId)}/fs:search`, + `/workspace/fs:search`, body, ); return { diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 6c4679f165..5413f7f587 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -747,7 +747,8 @@ export interface KimiWebApi { closeTerminal(sessionId: string, terminalId: string): Promise<{ closed: true }>; listDirectory(sessionId: string, input: { path?: string; depth?: number; includeGitStatus?: boolean }): Promise<{ items: FsEntry[]; childrenByPath?: Record; truncated: boolean }>; readFile(sessionId: string, input: { path: string; offset?: number; length?: number }): Promise<{ path: string; content: string; encoding: 'utf-8' | 'base64'; size: number; truncated: boolean; etag: string; mime: string; languageId?: string; lineCount?: number; isBinary: boolean }>; - searchFiles(sessionId: string, input: { query: string; limit?: number }): Promise<{ items: Array<{ path: string; name: string; kind: FsKind; score: number; matchPositions: number[] }>; truncated: boolean }>; + /** Search files in a workspace (no session required) — POST /workspace/fs:search. `workspace` accepts a registered workspace id or an absolute root. */ + searchFiles(workspace: string, input: { query: string; limit?: number }): Promise<{ items: Array<{ path: string; name: string; kind: FsKind; score: number; matchPositions: number[] }>; truncated: boolean }>; grepFiles(sessionId: string, input: { pattern: string; regex?: boolean; caseSensitive?: boolean }): Promise<{ files: Array<{ path: string; matches: Array<{ line: number; col: number; text: string; before: string[]; after: string[] }> }>; filesScanned: number; truncated: boolean; elapsedMs: number }>; getGitStatus(sessionId: string, paths?: string[]): Promise<{ branch: string; ahead: number; behind: number; entries: Record; additions: number; deletions: number; pullRequest: { number: number; state: string; url: string } | null }>; getFileDiff(sessionId: string, path: string): Promise<{ path: string; diff: string }>; diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 03a541fe0a..42e01e8e98 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -2733,15 +2733,20 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta } /** - * Search files in the active session using the daemon searchFiles endpoint. - * Returns {path, name}[] — defensive, returns [] on error or no active session. + * Search files in the active workspace via the daemon's workspace fs:search + * endpoint — no session id involved, so `@` works unchanged before the first + * prompt. The workspace ref mirrors what selectSession syncs: the active + * session's workspace, else the draft's active workspace (a registered id or + * an absolute root — the daemon resolves both). Returns {path, name}[] — + * defensive, returns [] on error or when no workspace is active. */ async function searchFiles(query: string): Promise> { - const sid = rawState.activeSessionId; - if (!sid) return []; + const session = rawState.sessions.find((s) => s.id === rawState.activeSessionId); + const ref = session === undefined ? rawState.activeWorkspaceId : workspaceIdForSession(session); + if (!ref) return []; try { const api = getKimiWebApi(); - const result = await api.searchFiles(sid, { query, limit: 20 }); + const result = await api.searchFiles(ref, { query, limit: 20 }); return result.items.map((item) => ({ path: item.path, name: item.name })); } catch { return []; diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index f8c82bbb7a..c6f09df52e 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -4,7 +4,7 @@ ## Scopes -Four `LifecycleScope` tiers — `App` (0) / `Workspace` (1) / `Session` (2) / `Agent` (3) (`src/_base/di/scope.ts`). The `workspace/` domain owns the Workspace tier: the App-scope `workspaceLifecycle` holds the live handler registry (one handler per workspaceId, create-or-get + join, never closed), and each handler's `workspaceHandler` owns the session lifecycle (create/resume/fork/close) as its child scopes. Workspace-scope services (`workspaceSkillCatalog` / `workspaceAgentProfileLoader` / `workspaceInstructions` / `workspaceMcp` / `workspaceDirs` / `workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit` / `workspaceToolPolicy` / `workspaceTrust`) hold the handler-shared resources — loaded once at handler materialization, then refreshed by fs watch — and sessions consume them through session-domain seed contracts with change events. Agent profiles follow the Contribution / Registry / Catalog extension point instead of a workspace catalog: the `workspaceAgentProfileLoader` domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit runtime files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) register `AgentProfileContribution`s into the App-scope `IAgentProfileRegistry`, tagged with the handler's `workspaceId` (the registry dedups per source id; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles), and each Session-scope `sessionAgentProfileCatalog` projects the registry into the merged, name-deduped read view directly — its seed carries only the workspace key. `workspaceTrust` records the per-workspace trust marker (persisted under the home, keyed by `encodeWorkDirKey(root)`); while untrusted, `workspaceMcpConfig` skips the project-level MCP config files (`.mcp.json`, `.kimi-code/mcp.json`). Dependency red line: **Session/Agent never import the Workspace domain**; the App-level `ISessionLifecycleService` / `ISessionMcpService` / `ISessionFsService` are gone — compose `sessionIndex` → `workspaceLifecycle.handlerFor` → the handler instead. +Four `LifecycleScope` tiers — `App` (0) / `Workspace` (1) / `Session` (2) / `Agent` (3) (`src/_base/di/scope.ts`). The `workspace/` domain owns the Workspace tier: the App-scope `workspaceLifecycle` holds the live handler registry (one handler per workspaceId, create-or-get + join, never closed), and each handler's `sessionLifecycle` owns the session lifecycle (create/resume/fork/close) as its child scopes. Workspace-scope services (`workspaceSkillCatalog` / `workspaceAgentProfileLoader` / `workspaceInstructions` / `workspaceMcp` / `workspaceDirs` / `workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit` / `workspaceToolPolicy` / `workspaceTrust`) hold the handler-shared resources — loaded once at handler materialization, then refreshed by fs watch — and sessions consume them through session-domain seed contracts with change events. Agent profiles follow the Contribution / Registry / Catalog extension point instead of a workspace catalog: the `workspaceAgentProfileLoader` domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit runtime files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) register `AgentProfileContribution`s into the App-scope `IAgentProfileRegistry`, tagged with the handler's `workspaceId` (the registry dedups per source id; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles), and each Session-scope `sessionAgentProfileCatalog` projects the registry into the merged, name-deduped read view directly — its seed carries only the workspace key. `workspaceTrust` records the per-workspace trust marker (persisted under the home, keyed by `encodeWorkDirKey(root)`); while untrusted, `workspaceMcpConfig` skips the project-level MCP config files (`.mcp.json`, `.kimi-code/mcp.json`). Dependency red line: **Session/Agent never import the Workspace domain**; the old App-level session-lifecycle facade and `ISessionMcpService` / `ISessionFsService` are gone — compose `sessionIndex` → `workspaceLifecycle.handlerFor` → the handler instead. ## Examples @@ -72,4 +72,4 @@ Per-domain references live in `docs/`. - [`docs/di-testing.md`](docs/di-testing.md) — Read **before writing or touching any DI/Scope test**: picking the right harness (`InstantiationService` vs `TestInstantiationService` vs `createScopedTestHost`), declaring deps with `@IService`, stubbing collaborators, and teardown via `DisposableStore`. - [`docs/config-manifest.toml`](docs/config-manifest.toml) — Generated list of every registered config section, in the on-disk `config.toml` shape (owner, scope, defaults, env bindings, schema fields). Do not edit by hand; regenerate with `pnpm gen:config-manifest` after adding or removing a `registerConfigSection` call — `test/app/config/configManifest.test.ts` enforces freshness. - [`docs/wire-manifest.d.ts`](docs/wire-manifest.d.ts) — Generated declaration file listing every registered wire record type as a payload interface (model, persist policy, `toEvent`, cross-reducers in the doc comment; payload fields in real TS type syntax), plus a `WirePayloadMap`. Do not edit by hand; regenerate with `pnpm gen:wire-manifest` after adding or removing a `defineOp` call — `test/wire/wireManifest.test.ts` enforces freshness and checks the file parses. -- [`docs/state-manifest.d.ts`](docs/state-manifest.d.ts) — Generated declaration file listing every state key registered into `ISessionStateService` / `IAgentStateService`, as `SessionStateSnapshot` / `AgentStateSnapshot` interfaces (keys grouped by defining file), plus the `SessionStateKey` / `AgentStateKey` unions. Self-contained: every value type is expanded fully inline with each named type marked by a `/* TypeName — source/file.ts */` comment (recursion stops with a `recursive` marker) — no imports, no helper declarations. Do not edit by hand; regenerate with `pnpm gen:state-manifest` after adding or removing a `states.register(...)` call — `test/state/stateManifest.test.ts` enforces freshness and checks the file parses. +- [`docs/state-manifest.d.ts`](docs/state-manifest.d.ts) — Generated declaration file listing every state key registered into `IAppStateService` / `IWorkspaceStateService` / `ISessionStateService` / `IAgentStateService`, as `AppStateSnapshot` / `WorkspaceStateSnapshot` / `SessionStateSnapshot` / `AgentStateSnapshot` interfaces (keys grouped by defining file), plus the `AppStateKey` / `WorkspaceStateKey` / `SessionStateKey` / `AgentStateKey` unions. Self-contained: every value type is expanded fully inline with each named type marked by a `/* TypeName — source/file.ts */` comment (recursion stops with a `recursive` marker) — no imports, no helper declarations. Do not edit by hand; regenerate with `pnpm gen:state-manifest` after adding or removing a `states.register(...)` call — `test/state/stateManifest.test.ts` enforces freshness and checks the file parses. diff --git a/packages/agent-core-v2/docs/rw-model-design.md b/packages/agent-core-v2/docs/rw-model-design.md index 766946ee75..5e5ad829de 100644 --- a/packages/agent-core-v2/docs/rw-model-design.md +++ b/packages/agent-core-v2/docs/rw-model-design.md @@ -65,7 +65,7 @@ - W3 Session 域借 main agent 的 wire 写(todo/cron),main 缺失时**静默丢写** (`sessionTodoService.ts:99-100`),且要 `as never` 绕过类型。 - W4 fork 直接在 appendLogStore 层改写 wire log,绕过全部写模型 - (`workspaceHandlerService.ts` 的 `fork` / `copyAgentWire`)。 + (`sessionLifecycleService.ts` 的 `fork` / `copyAgentWire`)。 - W5 restore 期 append 在 wireRecord 层被静默吞掉(`wireRecordService.ts:81`), 但 recordService 仍然 foldViews、仍然跑 facet——"进内存不进磁盘"完全隐式。 @@ -98,7 +98,7 @@ onChange 处理器若 append 会无检测地重入。 - L3 restore 正确性依赖三重隐式契约:DI 构造顺序 + hook 注册顺序 + "resumer 先于 hooks";`doResume` 需手动预热 contextMemory - (现 `workspaceHandlerService.ts` 的 `doResume` / `materializeSession`)。 + (现 `sessionLifecycleService.ts` 的 `doResume` / `materializeSession`)。 - L4 相位规则(restoring / postRestoring / live)在 append/signal/push/hook 四条通道上各不相同,没有一处集中定义。 @@ -189,7 +189,7 @@ 逻辑 seq 顺序,因此边缘 journal 的 seq 与核心逻辑 seq 单调一致。 - fork 保持现实现(复制 main 的 wire log);接口上表达为 `stream.forkInto(target)`,实现仍走 appendLogStore(W4 的接口层收口: - 唯一入口,不再散落在 workspaceHandler 里手写)。 + 唯一入口,不再散落在 sessionLifecycle 里手写)。 - App scope 一条逻辑流(config/model catalog/session 生命周期),取代 `IEventService`(V4)——App 流本就无持久化,纯接口替换。 - **Topic = 流上的类型化过滤视角**,不是独立机制。订阅方用 diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index dfe4bd4f11..fed980fbf1 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1,11 +1,13 @@ -// Session & Agent State Manifest +// App, Workspace, Session & Agent State Manifest // // Generated by scripts/gen-state-manifest.mts — do not edit by hand. // Regenerate with: pnpm --filter @moonshot-ai/agent-core-v2 gen:state-manifest // -// Every state key registered into the Session-scope ISessionStateService or the -// Agent-scope IAgentStateService (see src/_base/state/stateRegistry.ts), collected -// statically from the `states.register(...)` call sites — a key defined via +// Every state key registered into the App-scope IAppStateService, the +// Workspace-scope IWorkspaceStateService, the Session-scope +// ISessionStateService, or the Agent-scope IAgentStateService (see +// src/_base/state/stateRegistry.ts), collected statically from the +// `states.register(...)` call sites — a key defined via // defineState but never registered does not appear here. Each entry shows the // compile-time StateKey value type fully expanded inline, so the manifest is // self-contained (no imports, no helper declarations). A named type is marked @@ -21,7 +23,15 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (Session: 18 keys · Agent: 67 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 67 keys) +// App +// Workspace +// workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts +// workspaceDirs.fileDirs src/workspace/workspaceDirs/workspaceDirsService.ts +// workspaceInstructions.current src/workspace/workspaceInstructions/workspaceInstructionsService.ts +// workspaceSkillCatalog.contributions src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts +// workspaceSkillCatalog.merged src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts +// workspaceTrust.trusted src/workspace/workspaceTrust/workspaceTrustService.ts // Session // cron.inFlight src/session/cron/sessionCronServiceImpl.ts // cron.lastSeenAt src/session/cron/sessionCronServiceImpl.ts @@ -110,6 +120,260 @@ // usage.currentTurn src/agent/usage/usageService.ts // usage.currentTurnId src/agent/usage/usageService.ts +/** App-scope keys registered into IAppStateService. */ +export interface AppStateSnapshot { +} + +export type AppStateKey = keyof AppStateSnapshot; + +/** Workspace-scope keys registered into IWorkspaceStateService. */ +export interface WorkspaceStateSnapshot { + // src/workspace/workspaceDirs/workspaceDirsService.ts + 'workspaceDirs.ephemeralDirs': readonly string[]; + 'workspaceDirs.fileDirs': readonly string[]; + // src/workspace/workspaceInstructions/workspaceInstructionsService.ts + 'workspaceInstructions.current': /* WorkspaceInstructionsSnapshot — packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructions.ts */ { + readonly agentsMd: string | undefined; + readonly agentsMdWarning: string | undefined; + }; + // src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts + 'workspaceSkillCatalog.contributions': Map; + 'workspaceSkillCatalog.merged': /* InMemorySkillCatalog — packages/agent-core-v2/src/app/skillCatalog/registry.ts */ { + registerBuiltinSkill: (skill: /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/app/skillCatalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + }) => void; + register: (skill: /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/app/skillCatalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + }, options?: { + readonly replace?: boolean; + }) => void; + recordSkipped: (skills: readonly /* SkippedSkill — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly path: string; + readonly type: string; + readonly reason: string; + }[]) => void; + addRoots: (roots: readonly string[]) => void; + getSkill: (name: string) => /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/app/skillCatalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + } | undefined; + getPluginSkill: (pluginId: string, name: string) => /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/app/skillCatalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + } | undefined; + renderSkillPrompt: (skill: /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/app/skillCatalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + }, rawArgs: string, context?: { + readonly sessionId?: string; + }) => string; + listSkills: () => readonly /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/app/skillCatalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + }[]; + listInvocableSkills: () => readonly /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/app/skillCatalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + }[]; + getSkillRoots: () => readonly string[]; + getSkippedByPolicy: () => readonly /* SkippedSkill — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly path: string; + readonly type: string; + readonly reason: string; + }[]; + getKimiSkillsDescription: () => string; + getModelSkillListing: () => string; + }; + // src/workspace/workspaceTrust/workspaceTrustService.ts + 'workspaceTrust.trusted': boolean; +} + +export type WorkspaceStateKey = keyof WorkspaceStateSnapshot; + /** Session-scope keys registered into ISessionStateService. */ export interface SessionStateSnapshot { // src/session/cron/sessionCronServiceImpl.ts @@ -740,7 +1004,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map value type fully expanded inline, so the manifest is', '// self-contained (no imports, no helper declarations). A named type is marked', diff --git a/packages/agent-core-v2/src/_base/state/stateRegistry.ts b/packages/agent-core-v2/src/_base/state/stateRegistry.ts index 2603dd3218..a5fed30141 100644 --- a/packages/agent-core-v2/src/_base/state/stateRegistry.ts +++ b/packages/agent-core-v2/src/_base/state/stateRegistry.ts @@ -3,9 +3,10 @@ * * Owns the typed `StateKey` / `defineState(name, initial)` descriptor (the * state counterpart of wire's `defineModel`), the `IStateRegistry` base - * interface shared by the per-scope state services (`IStateService` / - * `ISessionStateService` / `IAgentStateService`), and the `StateRegistry` - * implementation backing them: a `Map`-backed store where keys are declared + * interface shared by the per-scope state services (`IAppStateService` / + * `IWorkspaceStateService` / `ISessionStateService` / `IAgentStateService`), + * and the `StateRegistry` implementation backing them: a `Map`-backed store + * where keys are declared * up front (`register`), read and replaced (`get` / `set`), and observed * (`onDidChange(key)` per key, `onDidChangeAny` globally). Two exports serve * debugging: `entries()` returns the live key/value references for in-process @@ -18,6 +19,13 @@ * fan the copy out until the heap is exhausted. Misuse (duplicate registration, reading or writing an * unregistered key) is a caller bug and raises `BugIndicatingError`. * + * Cascading inspection: each scope's state service keeps a reference to the + * parent scope's registry (`inspectParent`, assigned from the injected + * parent-tier state service; App is the root) and declares its tier name + * (`inspectScope`). `inspect()` folds that chain into a `StateInspection` + * tree — this scope's `snapshot()` plus the ancestors' — so one RPC call + * from any scope tier exports the whole App → … → current-scope state path. + * * Values are stored as-is — the container does not freeze or clone, so * replacing the whole value via `set` is the recommended update style; * mutating a held `Map` / `Set` in place bypasses change notification. @@ -43,6 +51,17 @@ export interface StateChange { readonly value: unknown; } +/** + * One scope tier's contribution to a cascading state inspection: the tier + * name, its JSON-safe `snapshot()`, and the parent tiers' inspections + * (absent at the App root). + */ +export interface StateInspection { + readonly scope: string; + readonly state: Record; + readonly parent?: StateInspection; +} + export interface IStateRegistry { register(key: StateKey): void; has(key: StateKey): boolean; @@ -52,6 +71,7 @@ export interface IStateRegistry { readonly onDidChangeAny: Event; entries(): readonly [string, unknown][]; snapshot(): Record; + inspect(): StateInspection; } export class StateRegistry extends Disposable implements IStateRegistry { @@ -60,6 +80,11 @@ export class StateRegistry extends Disposable implements IStateRegistry { private readonly anyEmitter = this._register(new Emitter()); readonly onDidChangeAny: Event = this.anyEmitter.event; + /** Scope-tier name reported by `inspect()`; each scoped binding sets it. */ + protected readonly inspectScope: string = 'unknown'; + /** The parent scope's registry for the `inspect()` cascade; root = none. */ + protected inspectParent?: IStateRegistry; + register(key: StateKey): void { if (this.values.has(key.name)) { throw new BugIndicatingError(`state key '${key.name}' is already registered`); @@ -107,6 +132,14 @@ export class StateRegistry extends Disposable implements IStateRegistry { } return out; } + + inspect(): StateInspection { + return { + scope: this.inspectScope, + state: this.snapshot(), + parent: this.inspectParent?.inspect(), + }; + } } function toJsonSafe(value: unknown, seen: WeakSet): unknown { diff --git a/packages/agent-core-v2/src/agent/plan/configSection.ts b/packages/agent-core-v2/src/agent/plan/configSection.ts index 18c1844553..04b1cfce0a 100644 --- a/packages/agent-core-v2/src/agent/plan/configSection.ts +++ b/packages/agent-core-v2/src/agent/plan/configSection.ts @@ -4,7 +4,7 @@ * Top-level boolean preference (`default_plan_mode` on disk, v1-compatible): * when `true`, every freshly created session starts in plan mode. Resumed / * forked sessions restore plan state from wire records and ignore this. Read by - * `workspaceHandler` at session creation; runtime plan state lives on the wire + * `sessionLifecycle` at session creation; runtime plan state lives on the wire * `PlanModel`, not here. */ diff --git a/packages/agent-core-v2/src/agent/plan/planOps.ts b/packages/agent-core-v2/src/agent/plan/planOps.ts index 31087362f6..1781af0184 100644 --- a/packages/agent-core-v2/src/agent/plan/planOps.ts +++ b/packages/agent-core-v2/src/agent/plan/planOps.ts @@ -26,7 +26,7 @@ * `agent.status.updated` planMode slice — are NOT part of `apply`: they run * after `wire.dispatch` on the live path, and `wire.replay` rebuilds the * Model silently from the persisted `plan_mode.*` / `plan.revision` records - * (seeded by `workspaceHandler`). The legacy `toReplay: plan_updated` + * (seeded by `sessionLifecycle`). The legacy `toReplay: plan_updated` * projection is dropped (inert — nothing reads it). `plan.revision` carries * a `toEvent` so the live transcript projector can map it onto a marker plus * the plan badge; replay never emits it. Consumed by the Agent-scope diff --git a/packages/agent-core-v2/src/agent/state/agentState.ts b/packages/agent-core-v2/src/agent/state/agentState.ts index e0d513f549..036866e2ad 100644 --- a/packages/agent-core-v2/src/agent/state/agentState.ts +++ b/packages/agent-core-v2/src/agent/state/agentState.ts @@ -5,8 +5,9 @@ * services declare their plain-data state as typed keys (`defineState` from * `_base`) and read/write them through this container, so per-agent shared * state lives in one observable place and dies with the agent. Shares the - * `IStateRegistry` method set with its App/Session counterparts. Bound at - * Agent scope. + * `IStateRegistry` method set with its App/Workspace/Session counterparts; + * its `inspect()` cascade continues into the Session tier. Bound at Agent + * scope. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/agent/state/agentStateService.ts b/packages/agent-core-v2/src/agent/state/agentStateService.ts index 564f18a923..bfa590f9a6 100644 --- a/packages/agent-core-v2/src/agent/state/agentStateService.ts +++ b/packages/agent-core-v2/src/agent/state/agentStateService.ts @@ -2,17 +2,26 @@ * `state` domain (L1) — `IAgentStateService` implementation. * * Thin per-scope binding over the `_base` `StateRegistry`; the container owns - * construction and disposal, so registered state dies with the scope. Bound - * at Agent scope. + * construction and disposal, so registered state dies with the scope. Injects + * the Session-tier state service as its `inspect()` cascade parent (the + * parameter is optional so tests can construct a bare container; DI always + * injects). Bound at Agent scope. */ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { StateRegistry } from '#/_base/state/stateRegistry'; +import { ISessionStateService } from '#/session/state/sessionState'; import { IAgentStateService } from './agentState'; export class AgentStateService extends StateRegistry implements IAgentStateService { declare readonly _serviceBrand: undefined; + protected override readonly inspectScope = 'agent'; + + constructor(@ISessionStateService sessionState?: ISessionStateService) { + super(); + this.inspectParent = sessionState; + } } registerScopedService( diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts index 13dd69b334..30b3436647 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts @@ -6,7 +6,7 @@ * `scope(name)` values and `configKey` are computed once at construction so * business code can read them synchronously. Session/agent persistence * addressing is NOT here — it derives from the workspace handler's - * persistence scope (`workspaceHandler` addressing). + * persistence scope (`sessionLifecycle` addressing). * * Bound at App scope. */ diff --git a/packages/agent-core-v2/src/app/gateway/gateway.ts b/packages/agent-core-v2/src/app/gateway/gateway.ts index 80dab0fbe4..5e255e66bf 100644 --- a/packages/agent-core-v2/src/app/gateway/gateway.ts +++ b/packages/agent-core-v2/src/app/gateway/gateway.ts @@ -3,7 +3,7 @@ * * Defines the public contracts of the gateway layer: the `IRestGateway` / * `IWSGateway` entry points. Session scope creation is owned by the workspace - * handler (`workspaceHandler`); the gateway resolves sessions through the live + * handler (`sessionLifecycle`); the gateway resolves sessions through the live * handler registry (`workspaceLifecycle`). * App-scoped — shared across the application. */ diff --git a/packages/agent-core-v2/src/app/gateway/gatewayService.ts b/packages/agent-core-v2/src/app/gateway/gatewayService.ts index 8fec17b534..c20f4e1809 100644 --- a/packages/agent-core-v2/src/app/gateway/gatewayService.ts +++ b/packages/agent-core-v2/src/app/gateway/gatewayService.ts @@ -2,7 +2,7 @@ * `gateway` domain (L7) — `IRestGateway` / `IWSGateway` implementations. * * Owns the REST/WS entry points; resolves sessions through the live handler - * registry (`workspaceLifecycle` → the handler's `IWorkspaceHandlerService`), + * registry (`workspaceLifecycle` → the handler's `ISessionLifecycleService`), * agents through `agentLifecycle`, drives turns through `prompt` / `loop`, * and flushes logs through `log`. Bound at App scope. * @@ -20,7 +20,7 @@ import { import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ILogService } from '#/_base/log/log'; import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { IAgentLoopService } from '#/agent/loop/loop'; @@ -45,7 +45,7 @@ export class RestGateway implements IRestGateway { private liveSession(sessionId: string) { for (const handler of this.workspaceLifecycle.handlers.list()) { - const handle = handler.accessor.get(IWorkspaceHandlerService).get(sessionId); + const handle = handler.accessor.get(ISessionLifecycleService).get(sessionId); if (handle !== undefined) return handle; } return undefined; diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts index 7024d1ef11..a1fc8b565c 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts @@ -2,7 +2,7 @@ * `sessionExport` domain (L6) — `ISessionExportService` implementation. * * Coordinates live session flushing through the live handler registry - * (`workspaceLifecycle` → the handler's `IWorkspaceHandlerService`), derives + * (`workspaceLifecycle` → the handler's `ISessionLifecycleService`), derives * session paths from the handler-chain addressing, reads persisted summaries * through `sessionIndex`, and packages diagnostic files through the local * zip writer. Bound at App scope. @@ -22,8 +22,8 @@ import { IWorkspaceService } from '#/app/workspace/workspace'; import { sessionDirOf, workspacePersistenceScope, -} from '#/workspace/workspaceHandler/addressing'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +} from '#/workspace/sessionLifecycle/addressing'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { ErrorCodes, Error2 } from '#/errors'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; @@ -151,7 +151,7 @@ export class SessionExportService implements ISessionExportService { private liveSession(sessionId: string): ISessionScopeHandle | undefined { for (const handler of this.workspaceLifecycle.handlers.list()) { - const handle = handler.accessor.get(IWorkspaceHandlerService).get(sessionId); + const handle = handler.accessor.get(ISessionLifecycleService).get(sessionId); if (handle !== undefined) return handle; } return undefined; diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts index 743e85717c..2c196b6b91 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts @@ -5,7 +5,7 @@ * query facade over the set of persisted sessions (open or closed). It * enumerates sessions and derives session identity (`workspaceId`), returning * data (`SessionSummary`) or counts — never filesystem paths or live handles. - * Writes (create / archive) live in `workspaceHandler` / `session`; the index + * Writes (create / archive) live in `sessionLifecycle` / `session`; the index * is a read model. Backends are deployment-specific (local filesystem today; * database / query store on a server). */ diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts index 555969dede..3886beff1d 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts @@ -5,12 +5,12 @@ * metadata merge, and the cross-domain `agent_config` patch), * `GET /sessions/{id}/status` (`status`), and `GET /sessions/{id}/goal` * (`goal`) on top of the native v2 services - * (`IWorkspaceHandlerService`, `IAgentProfileService`, …). + * (`ISessionLifecycleService`, `IAgentProfileService`, …). * * The thin pass-through actions (`fork` / `compact` / `abort` / `archive`), the * `:undo` action, and the `/sessions/{id}/children` endpoints are deliberately * NOT wrapped here: the edge route calls the native services directly — - * `IWorkspaceHandlerService.fork` / `archive` / `createChild`, + * `ISessionLifecycleService.fork` / `archive` / `createChild`, * `IAgentFullCompactionService.begin`, `IAgentRPCService.cancel`, * `IAgentPromptService.undo`, and `ISessionIndex.list({ childOf })` — because * none of them carries v1-only projection worth centralizing beyond what the diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index c49b754797..d5e833b51d 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -4,13 +4,13 @@ * Stateless App-scope dispatcher: each method resolves the target session (and * its main agent) per call through the shared `sessionLookup` composition * (`sessionIndex` → `workspaceLifecycle.handlerFor` → the handler's - * `IWorkspaceHandlerService`), delegates to the native v2 services, and projects + * `ISessionLifecycleService`), delegates to the native v2 services, and projects * the result into the v1 wire shape. Only `updateProfile` (the cross-domain * `agent_config` patch), `status` (the best-effort status rollup), and `goal` * (the current-goal read) live here; * the `:undo`, `fork`-as-child, and child-listing actions were pushed down into * the native services (`IAgentPromptService.undo`, - * `IWorkspaceHandlerService.createChild`, `ISessionIndex.list({ childOf })`) and + * `ISessionLifecycleService.createChild`, `ISessionIndex.list({ childOf })`) and * are called by the edge route directly. No business logic is duplicated here; * the real work stays in the native services. */ @@ -204,7 +204,11 @@ export class SessionLegacyService implements ISessionLegacyService { return { busy: this.readBusy(sessionId), model: model === '' ? undefined : model, - thinking_level: profile.getEffectiveThinkingLevel(), + // An unbound agent has no thinking level to report: the effective value + // would be the wire model's zero value ('off'), which clients fold in as + // the session's real pick. Report '' instead — same "nothing to report" + // convention as `model` above — so they fall back to the catalog default. + thinking_level: model === '' ? '' : profile.getEffectiveThinkingLevel(), permission: permission.mode, plan_mode: planData !== null, swarm_mode: swarm.isActive, diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts index 4922c50730..09d1bc897d 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts @@ -82,6 +82,9 @@ export type UpdateSessionProfileRequest = z.infer = - createDecorator('stateService'); +export const IAppStateService: ServiceIdentifier = + createDecorator('appStateService'); diff --git a/packages/agent-core-v2/src/app/state/appStateService.ts b/packages/agent-core-v2/src/app/state/appStateService.ts new file mode 100644 index 0000000000..4d2a83663e --- /dev/null +++ b/packages/agent-core-v2/src/app/state/appStateService.ts @@ -0,0 +1,26 @@ +/** + * `state` domain (L1) — `IAppStateService` implementation. + * + * Thin per-scope binding over the `_base` `StateRegistry`; the container owns + * construction and disposal, so registered state dies with the scope. The + * root of the four-tier `inspect()` cascade — the only tier without an + * `inspectParent`. Bound at App scope. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { StateRegistry } from '#/_base/state/stateRegistry'; + +import { IAppStateService } from './appState'; + +export class AppStateService extends StateRegistry implements IAppStateService { + declare readonly _serviceBrand: undefined; + protected override readonly inspectScope = 'app'; +} + +registerScopedService( + LifecycleScope.App, + IAppStateService, + AppStateService, + ScopeActivation.OnScopeCreated, + 'state', +); diff --git a/packages/agent-core-v2/src/app/state/stateService.ts b/packages/agent-core-v2/src/app/state/stateService.ts deleted file mode 100644 index fd2dfb318d..0000000000 --- a/packages/agent-core-v2/src/app/state/stateService.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * `state` domain (L1) — `IStateService` implementation. - * - * Thin per-scope binding over the `_base` `StateRegistry`; the container owns - * construction and disposal, so registered state dies with the scope. Bound - * at App scope. - */ - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { StateRegistry } from '#/_base/state/stateRegistry'; - -import { IStateService } from './state'; - -export class StateService extends StateRegistry implements IStateService { - declare readonly _serviceBrand: undefined; -} - -registerScopedService(LifecycleScope.App, IStateService, StateService, ScopeActivation.OnScopeCreated, 'state'); diff --git a/packages/agent-core-v2/src/app/workspaceLifecycle/sessionLookup.ts b/packages/agent-core-v2/src/app/workspaceLifecycle/sessionLookup.ts index 3249ecd2f3..aa1cdd5e7b 100644 --- a/packages/agent-core-v2/src/app/workspaceLifecycle/sessionLookup.ts +++ b/packages/agent-core-v2/src/app/workspaceLifecycle/sessionLookup.ts @@ -2,7 +2,7 @@ * `workspaceLifecycle` domain (L6) — pure session-lookup helpers over the handler chain. * * The explicit `sessionIndex` → `IWorkspaceLifecycleService.handlerFor` → - * handler `IWorkspaceHandlerService` composition, shared by every caller + * handler `ISessionLifecycleService` composition, shared by every caller * that addresses a session by id from outside the Workspace scope (edge * routes, in-process SDKs). These are plain functions over a STABLE * accessor (a `Scope` / scope-handle `accessor`, never a transient @@ -18,9 +18,9 @@ import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ErrorCodes, isError2 } from '#/errors'; import { - IWorkspaceHandlerService, + ISessionLifecycleService, type ResumeSessionOptions, -} from '#/workspace/workspaceHandler/workspaceHandler'; +} from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IWorkspaceLifecycleService } from './workspaceLifecycle'; @@ -70,7 +70,7 @@ export async function resumeSessionById( throw error; } if (handler === undefined) return undefined; - return handler.accessor.get(IWorkspaceHandlerService).resume(sessionId, opts); + return handler.accessor.get(ISessionLifecycleService).resume(sessionId, opts); } /** The live handler holding `sessionId`, without materializing anything. */ @@ -79,7 +79,7 @@ export function liveHandlerForSession( sessionId: string, ): IWorkspaceScopeHandle | undefined { for (const handler of accessor.get(IWorkspaceLifecycleService).handlers.list()) { - if (handler.accessor.get(IWorkspaceHandlerService).get(sessionId) !== undefined) { + if (handler.accessor.get(ISessionLifecycleService).get(sessionId) !== undefined) { return handler; } } @@ -92,7 +92,7 @@ export function getLiveSessionById( sessionId: string, ): ISessionScopeHandle | undefined { return liveHandlerForSession(accessor, sessionId)?.accessor - .get(IWorkspaceHandlerService) + .get(ISessionLifecycleService) .get(sessionId); } @@ -103,28 +103,28 @@ export async function closeSessionById( ): Promise { const handler = liveHandlerForSession(accessor, sessionId); if (handler === undefined) return; - await handler.accessor.get(IWorkspaceHandlerService).close(sessionId); + await handler.accessor.get(ISessionLifecycleService).close(sessionId); } /** - * Subscribe `follow` to the `IWorkspaceHandlerService` of every handler — + * Subscribe `follow` to the `ISessionLifecycleService` of every handler — * present and future (handlers are never closed, so subscriptions stay * valid for the App lifetime). For App-scope observers of per-handler * events (e.g. `onDidCloseSession`). */ export function followWorkspaceHandlers( accessor: ServicesAccessor, - follow: (service: IWorkspaceHandlerService) => IDisposable, + follow: (service: ISessionLifecycleService) => IDisposable, ): IDisposable { const lifecycle = accessor.get(IWorkspaceLifecycleService); const store = new DisposableStore(); for (const handler of lifecycle.handlers.list()) { - store.add(follow(handler.accessor.get(IWorkspaceHandlerService))); + store.add(follow(handler.accessor.get(ISessionLifecycleService))); } store.add( lifecycle.onDidMaterializeHandler((handler) => { if (!store.isDisposed) { - store.add(follow(handler.accessor.get(IWorkspaceHandlerService))); + store.add(follow(handler.accessor.get(ISessionLifecycleService))); } }), ); diff --git a/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycle.ts b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycle.ts index 01ab161954..d9595b96b6 100644 --- a/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycle.ts +++ b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycle.ts @@ -14,7 +14,7 @@ * `onDidMaterializeHandler` for App-scope observers that must follow every * handler's per-handler services. There is deliberately NO App-scope * session lifecycle entry point — session create/resume/fork lives on the - * handler's `IWorkspaceHandlerService`; callers compose `sessionIndex` → + * handler's `ISessionLifecycleService`; callers compose `sessionIndex` → * `handlerFor` → handler (see `sessionLookup`). */ diff --git a/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts index 744fb61f9c..400afb9c6d 100644 --- a/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts +++ b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts @@ -36,8 +36,8 @@ import { workspaceContextSeed, type IWorkspaceContext, } from '#/workspace/workspaceContext/workspaceContext'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; -import { workspacePersistenceScope } from '#/workspace/workspaceHandler/addressing'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; +import { workspacePersistenceScope } from '#/workspace/sessionLifecycle/addressing'; import { IWorkspaceLifecycleService, @@ -68,7 +68,7 @@ export class WorkspaceLifecycleService extends Disposable implements IWorkspaceL const handler = this.live.get(workspaceId); if (handler === undefined) return []; return handler.accessor - .get(IWorkspaceHandlerService) + .get(ISessionLifecycleService) .list() .map((session) => session.id); }, diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 88391356e9..bbfe4a348e 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -66,8 +66,10 @@ export { IEventBus, type DomainEvent } from '#/app/event/eventBus'; export { IEventService, type DomainEvent as GlobalEvent } from '#/app/event/event'; export * from '#/_base/state/stateRegistry'; export * from '#/_base/contribution/registry'; -export * from '#/app/state/state'; -import '#/app/state/stateService'; +export * from '#/app/state/appState'; +import '#/app/state/appStateService'; +export * from '#/workspace/state/workspaceState'; +import '#/workspace/state/workspaceStateService'; export * from '#/session/state/sessionState'; import '#/session/state/sessionStateService'; export * from '#/agent/state/agentState'; @@ -358,9 +360,9 @@ export * from '#/app/workspaceLifecycle/workspaceLifecycle'; export * from '#/app/workspaceLifecycle/workspaceLifecycleService'; export * from '#/app/workspaceLifecycle/sessionLookup'; export * from '#/workspace/workspaceContext/workspaceContext'; -export * from '#/workspace/workspaceHandler/workspaceHandler'; -export * from '#/workspace/workspaceHandler/workspaceHandlerService'; -export * from '#/workspace/workspaceHandler/addressing'; +export * from '#/workspace/sessionLifecycle/sessionLifecycle'; +export * from '#/workspace/sessionLifecycle/sessionLifecycleService'; +export * from '#/workspace/sessionLifecycle/addressing'; export * from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; export * from '#/session/externalHooks/externalHooks'; export * from '#/session/externalHooks/externalHooksService'; diff --git a/packages/agent-core-v2/src/os/interface/hostEnvironment.ts b/packages/agent-core-v2/src/os/interface/hostEnvironment.ts index ec024f33b3..4d0ea45efe 100644 --- a/packages/agent-core-v2/src/os/interface/hostEnvironment.ts +++ b/packages/agent-core-v2/src/os/interface/hostEnvironment.ts @@ -11,7 +11,7 @@ * * Async initialization: probing (`ready`) discovers the shell path — on * Windows this may run `git.exe --exec-path`. The composition root - * (`workspaceLifecycle` / `workspaceHandler`) `await`s `ready` before creating + * (`workspaceLifecycle` / `sessionLifecycle`) `await`s `ready` before creating * any Session scope, so * every Session/Agent-scope consumer reads the sync fields safely. * diff --git a/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts b/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts index 17e4c55672..4d81f6f56e 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts @@ -12,7 +12,7 @@ * and returns an already-created main agent as-is — so concurrent * bootstrappers always receive the same, fully-bootstrapped handle (activity * lane `idle`). Session services activated when their scope is created (cron, - * external hooks) are materialized by `workspaceHandler.materializeSession`; + * external hooks) are materialized by `sessionLifecycle.materializeSession`; * the default permission posture is * applied in `bindBootstrap`. * diff --git a/packages/agent-core-v2/src/session/errors.ts b/packages/agent-core-v2/src/session/errors.ts index d39a72fc1d..5413a89c54 100644 --- a/packages/agent-core-v2/src/session/errors.ts +++ b/packages/agent-core-v2/src/session/errors.ts @@ -1,6 +1,6 @@ /** * `session` domain error codes — shared across the session layer - * (`workspaceHandler` / `sessionLegacy` / `messageLegacy`). + * (`sessionLifecycle` / `sessionLegacy` / `messageLegacy`). */ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; diff --git a/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts index f31af7adc0..876748dfa9 100644 --- a/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts +++ b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts @@ -3,7 +3,7 @@ * commands. * * Registers with the per-session `sessionLifecycleHooks` slots (seeded by - * the Workspace-scope `workspaceHandler`, which runs them around + * the Workspace-scope `sessionLifecycle`, which runs them around * create/close) to run `SessionStart` and `SessionEnd` external commands * for the current `sessionContext`, and * observes the requester-side agent-run hook slot (`onWillStartAgentTask`) and diff --git a/packages/agent-core-v2/src/session/mcp/sessionMcpHandle.ts b/packages/agent-core-v2/src/session/mcp/sessionMcpHandle.ts index 1f40ac1a34..ee5ccd74eb 100644 --- a/packages/agent-core-v2/src/session/mcp/sessionMcpHandle.ts +++ b/packages/agent-core-v2/src/session/mcp/sessionMcpHandle.ts @@ -8,7 +8,7 @@ * connections exist) plus the initial-connect readiness promise. The contract * carries no IO of its own — connecting, reloading and watching MCP config * files live on the workspace side. Seeded into the Session scope by - * `workspaceHandler` when the session is materialized; the Agent-scope `mcp` + * `sessionLifecycle` when the session is materialized; the Agent-scope `mcp` * mirror resolves it upward through the scope tree. Session-scoped. */ diff --git a/packages/agent-core-v2/src/session/process/processRunnerService.ts b/packages/agent-core-v2/src/session/process/processRunnerService.ts index cc2aaafa39..c9b7a22bee 100644 --- a/packages/agent-core-v2/src/session/process/processRunnerService.ts +++ b/packages/agent-core-v2/src/session/process/processRunnerService.ts @@ -11,7 +11,7 @@ * This Session-scope registration is the DEFAULT for scopes built without a * workspace handler (test hosts, harness agents). Real sessions get the * handler-shared Workspace-scope runner (`workspaceProcess`) as a scope seed - * from `workspaceHandler`, which shadows this registration — same pattern as + * from `sessionLifecycle`, which shadows this registration — same pattern as * the other workspace-capability injection contracts. */ diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/agentProfileCatalogSeed.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/agentProfileCatalogSeed.ts index 5e8db0716e..e3498508de 100644 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/agentProfileCatalogSeed.ts +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/agentProfileCatalogSeed.ts @@ -10,7 +10,7 @@ * view is seeded anymore. The key travels as a seed (rather than being * recomputed from the session's workDir) because the handler's id may be * folded from an alias spelling of the root. Seeded into the Session scope by - * `workspaceHandler` when the session is materialized. Session-scoped. + * `sessionLifecycle` when the session is materialized. Session-scoped. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts b/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts index 2b6704029b..8dd6ced37b 100644 --- a/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts +++ b/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts @@ -7,7 +7,7 @@ * `scope(subKey?)` * helper that returns the session's persistence scope (or a child under it, * e.g. `scope('agents/main/cron')`). Seeded into the Session scope by - * `workspaceHandler` when the session is created. + * `sessionLifecycle` when the session is created. * * `cwd` is the default root the `process` runner spawns in and the seed the * `workspaceContext` derives its read-only `workDir` from. Pure facts — no diff --git a/packages/agent-core-v2/src/session/sessionInstructions/instructionsProvider.ts b/packages/agent-core-v2/src/session/sessionInstructions/instructionsProvider.ts index 183b6301cb..a033942d95 100644 --- a/packages/agent-core-v2/src/session/sessionInstructions/instructionsProvider.ts +++ b/packages/agent-core-v2/src/session/sessionInstructions/instructionsProvider.ts @@ -8,7 +8,7 @@ * watched instruction file invalidates the snapshot. The contract carries no * IO — loading and watching live on the workspace side; consumers (the * agent's `profile` service) read the seed and re-read it off `onDidChange`. - * Seeded into the Session scope by `workspaceHandler` when the session is + * Seeded into the Session scope by `sessionLifecycle` when the session is * materialized. Session-scoped. */ diff --git a/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts b/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts index be9d54975a..d6a32aa74f 100644 --- a/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts +++ b/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts @@ -2,7 +2,7 @@ * `sessionLifecycleHooks` domain (L1) — per-session lifecycle hook slots. * * Defines the `ISessionLifecycleHooks` seed: one ordered hook-slots instance - * per session, created by the Workspace-scope `workspaceHandler` when it + * per session, created by the Workspace-scope `sessionLifecycle` when it * materializes the session, seeded into the Session scope, and run by the * handler around the session's create (`onDidCreateSession`) and close * (`onWillCloseSession`). Session-scope consumers (e.g. `externalHooks`) diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogData.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogData.ts index c9e353ca37..2761c2a5b2 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogData.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogData.ts @@ -7,7 +7,7 @@ * source-keyed change event. The contract carries no IO — discovery, merging * and rescanning all live on the workspace side; the Session-scope * `ISessionSkillCatalog` business view reads this seed and refreshes itself - * off `onDidChange`. Seeded into the Session scope by `workspaceHandler` when + * off `onDidChange`. Seeded into the Session scope by `sessionLifecycle` when * the session is materialized. Session-scoped. */ diff --git a/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts index d089e7e4fb..76ce203c5e 100644 --- a/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts +++ b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts @@ -8,7 +8,7 @@ * workspace config live on the workspace side; the Agent-scope `toolPolicy` * and `toolActivation` read this seed and apply the veto (it outranks every * Agent-side policy layer). Seeded into the Session scope by - * `workspaceHandler` when the session is materialized; a no-op default + * `sessionLifecycle` when the session is materialized; a no-op default * registration keeps scopes built without a handler (tests) resolvable. * Session-scoped. */ diff --git a/packages/agent-core-v2/src/session/state/sessionState.ts b/packages/agent-core-v2/src/session/state/sessionState.ts index 33d0ddffcc..2874d36814 100644 --- a/packages/agent-core-v2/src/session/state/sessionState.ts +++ b/packages/agent-core-v2/src/session/state/sessionState.ts @@ -5,8 +5,9 @@ * Session-tier services declare their plain-data state as typed keys * (`defineState` from `_base`) and read/write them through this container, so * per-session shared state lives in one observable place and dies with the - * session. Shares the `IStateRegistry` method set with its App/Agent - * counterparts. Bound at Session scope. + * session. Shares the `IStateRegistry` method set with its + * App/Workspace/Agent counterparts; its `inspect()` cascade continues into + * the Workspace tier. Bound at Session scope. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/session/state/sessionStateService.ts b/packages/agent-core-v2/src/session/state/sessionStateService.ts index 1625cea246..54d0a5ccd7 100644 --- a/packages/agent-core-v2/src/session/state/sessionStateService.ts +++ b/packages/agent-core-v2/src/session/state/sessionStateService.ts @@ -2,17 +2,26 @@ * `state` domain (L1) — `ISessionStateService` implementation. * * Thin per-scope binding over the `_base` `StateRegistry`; the container owns - * construction and disposal, so registered state dies with the scope. Bound - * at Session scope. + * construction and disposal, so registered state dies with the scope. Injects + * the Workspace-tier state service as its `inspect()` cascade parent (the + * parameter is optional so tests can construct a bare container; DI always + * injects). Bound at Session scope. */ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { StateRegistry } from '#/_base/state/stateRegistry'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; import { ISessionStateService } from './sessionState'; export class SessionStateService extends StateRegistry implements ISessionStateService { declare readonly _serviceBrand: undefined; + protected override readonly inspectScope = 'session'; + + constructor(@IWorkspaceStateService workspaceState?: IWorkspaceStateService) { + super(); + this.inspectParent = workspaceState; + } } registerScopedService( diff --git a/packages/agent-core-v2/src/session/workspaceInfo/workspaceInfo.ts b/packages/agent-core-v2/src/session/workspaceInfo/workspaceInfo.ts index 209397ce1e..6d1b11ea59 100644 --- a/packages/agent-core-v2/src/session/workspaceInfo/workspaceInfo.ts +++ b/packages/agent-core-v2/src/session/workspaceInfo/workspaceInfo.ts @@ -8,7 +8,7 @@ * (`.kimi-code/local.toml`), caller-dir merging and file watching all live * on the workspace side; the Session-scope `workspaceContext` read view * reads this seed and refreshes itself off `onDidChange`. Seeded into the - * Session scope by `workspaceHandler` when the session is materialized. + * Session scope by `sessionLifecycle` when the session is materialized. * Session-scoped. */ diff --git a/packages/agent-core-v2/src/workspace/workspaceHandler/addressing.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/addressing.ts similarity index 94% rename from packages/agent-core-v2/src/workspace/workspaceHandler/addressing.ts rename to packages/agent-core-v2/src/workspace/sessionLifecycle/addressing.ts index 83cc605a94..fb40bdfaa0 100644 --- a/packages/agent-core-v2/src/workspace/workspaceHandler/addressing.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/addressing.ts @@ -1,5 +1,5 @@ /** - * `workspaceHandler` domain (L6) — persistence addressing along the handler chain. + * `sessionLifecycle` domain (L6) — persistence addressing along the handler chain. * * Pure functions deriving the persistence scope strings and on-disk * directories from the handler's `persistenceScope` (`sessions/{wd_id}`): diff --git a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandler.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts similarity index 92% rename from packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandler.ts rename to packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts index a97aa1c9a1..d81b92711e 100644 --- a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandler.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts @@ -1,9 +1,9 @@ /** - * `workspaceHandler` domain (L6) — per-handler session lifecycle contract. + * `sessionLifecycle` domain (L6) — per-handler session lifecycle contract. * * Defines the public contract of one workspace handler: the * `CreateSessionOptions`, `ForkSessionOptions`, `CreateChildSessionOptions`, - * `ResumeSessionOptions`, and the `IWorkspaceHandlerService` used to create + * `ResumeSessionOptions`, and the `ISessionLifecycleService` used to create * sessions (`create`), look up the live ones (`get` / `list`), close them * (`close`), archive/restore them, fork them (`fork`), and fork-then-tag * them as direct children (`createChild`) — always as child scopes of THIS @@ -89,7 +89,7 @@ export interface SessionForkedEvent { readonly handle: ISessionScopeHandle; } -export interface IWorkspaceHandlerService { +export interface ISessionLifecycleService { readonly _serviceBrand: undefined; readonly onDidCreateSession: Event; @@ -107,5 +107,5 @@ export interface IWorkspaceHandlerService { createChild(opts: CreateChildSessionOptions): Promise; } -export const IWorkspaceHandlerService: ServiceIdentifier = - createDecorator('workspaceHandlerService'); +export const ISessionLifecycleService: ServiceIdentifier = + createDecorator('sessionLifecycleService'); diff --git a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts similarity index 99% rename from packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts rename to packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 09d18a235f..ee7f5a17b1 100644 --- a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -1,5 +1,5 @@ /** - * `workspaceHandler` domain (L6) — `IWorkspaceHandlerService` implementation. + * `sessionLifecycle` domain (L6) — `ISessionLifecycleService` implementation. * * Owns the registry of THIS handler's open Session child scopes, creating * them through the DI scope tree (children of the handler's Workspace @@ -146,14 +146,14 @@ import { type SessionCreatedEvent, type SessionForkedEvent, type SessionWillCloseEvent, - IWorkspaceHandlerService, -} from './workspaceHandler'; + ISessionLifecycleService, +} from './sessionLifecycle'; type MaterializeSessionOptions = Omit & { readonly sessionId: string; }; -export class WorkspaceHandlerService extends Disposable implements IWorkspaceHandlerService { +export class SessionLifecycleService extends Disposable implements ISessionLifecycleService { declare readonly _serviceBrand: undefined; private readonly sessions = new Map(); private readonly _onDidCreateSession = this._register(new Emitter()); @@ -682,10 +682,10 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan registerScopedService( LifecycleScope.Workspace, - IWorkspaceHandlerService, - WorkspaceHandlerService, + ISessionLifecycleService, + SessionLifecycleService, ScopeActivation.OnScopeCreated, - 'workspaceHandler', + 'sessionLifecycle', ); async function collect(iterable: AsyncIterable): Promise { diff --git a/packages/agent-core-v2/src/workspace/state/workspaceState.ts b/packages/agent-core-v2/src/workspace/state/workspaceState.ts new file mode 100644 index 0000000000..a2bf979bb4 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/state/workspaceState.ts @@ -0,0 +1,21 @@ +/** + * `state` domain (L1) — Workspace-scope keyed state container contract. + * + * Defines `IWorkspaceStateService`, the Workspace-scope state service: + * Workspace-tier services declare their plain-data state as typed keys + * (`defineState` from `_base`) and read/write them through this container, so + * per-handler shared state lives in one observable place and dies with the + * workspace handler. Shares the `IStateRegistry` method set with its + * App/Session/Agent counterparts; its `inspect()` cascade continues into the + * App tier. Bound at Workspace scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IStateRegistry } from '#/_base/state/stateRegistry'; + +export interface IWorkspaceStateService extends IStateRegistry { + readonly _serviceBrand: undefined; +} + +export const IWorkspaceStateService: ServiceIdentifier = + createDecorator('workspaceStateService'); diff --git a/packages/agent-core-v2/src/workspace/state/workspaceStateService.ts b/packages/agent-core-v2/src/workspace/state/workspaceStateService.ts new file mode 100644 index 0000000000..6622952151 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/state/workspaceStateService.ts @@ -0,0 +1,33 @@ +/** + * `state` domain (L1) — `IWorkspaceStateService` implementation. + * + * Thin per-scope binding over the `_base` `StateRegistry`; the container owns + * construction and disposal, so registered state dies with the scope. Injects + * the App-tier state service as its `inspect()` cascade parent (the parameter + * is optional so tests can construct a bare container; DI always injects). + * Bound at Workspace scope. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { StateRegistry } from '#/_base/state/stateRegistry'; +import { IAppStateService } from '#/app/state/appState'; + +import { IWorkspaceStateService } from './workspaceState'; + +export class WorkspaceStateService extends StateRegistry implements IWorkspaceStateService { + declare readonly _serviceBrand: undefined; + protected override readonly inspectScope = 'workspace'; + + constructor(@IAppStateService appState?: IAppStateService) { + super(); + this.inspectParent = appState; + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceStateService, + WorkspaceStateService, + ScopeActivation.OnScopeCreated, + 'state', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts index 2207d31abc..2bf0ed466d 100644 --- a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts @@ -11,18 +11,23 @@ * mutation serializes on one tail queue; the change event fires only when * the combined list actually changed. The set reaches every session of the * handler through the `ISessionWorkspaceInfo` seed (`sessionInfo()`), a - * live read view over this service. Bound at Workspace scope. + * live read view over this service. The plain-data state (`fileDirs`, + * `ephemeralDirs`) is registered into `workspaceState` + * (`IWorkspaceStateService`) and read/written through it. Bound at + * Workspace scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; +import { defineState } from '#/_base/state/stateRegistry'; import { TimeoutTimer } from '#/_base/utils/timer'; import { subtreeWatchFilter } from '#/_base/utils/paths'; import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import type { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { @@ -33,11 +38,18 @@ import { const WATCH_DEBOUNCE_MS = 200; +export const workspaceDirsFileDirsKey = defineState( + 'workspaceDirs.fileDirs', + () => [], +); +export const workspaceDirsEphemeralDirsKey = defineState( + 'workspaceDirs.ephemeralDirs', + () => [], +); + export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs { declare readonly _serviceBrand: undefined; - private fileDirs: readonly string[] = []; - private ephemeralDirs: readonly string[] = []; private projectRoot: string; private configPath: string; readonly ready: Promise; @@ -51,14 +63,33 @@ export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs { @IProjectLocalConfigService private readonly localConfig: IProjectLocalConfigService, @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, @ILogService private readonly log: ILogService, + @IWorkspaceStateService private readonly states: IWorkspaceStateService, ) { super(); + this.states.register(workspaceDirsFileDirsKey); + this.states.register(workspaceDirsEphemeralDirsKey); this.projectRoot = workspace.cwd; this.configPath = ''; this.ready = this.enqueue(() => this.reloadFromDisk()); void this.ready.then(() => this.watchLocalToml()); } + private get fileDirs(): readonly string[] { + return this.states.get(workspaceDirsFileDirsKey); + } + + private set fileDirs(value: readonly string[]) { + this.states.set(workspaceDirsFileDirsKey, value); + } + + private get ephemeralDirs(): readonly string[] { + return this.states.get(workspaceDirsEphemeralDirsKey); + } + + private set ephemeralDirs(value: readonly string[]) { + this.states.set(workspaceDirsEphemeralDirsKey, value); + } + get additionalDirs(): readonly string[] { return [...new Set([...this.fileDirs, ...this.ephemeralDirs])]; } diff --git a/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts index 91962bbd75..fb444f1a8f 100644 --- a/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts @@ -11,7 +11,9 @@ * through `hostFsWatch` and reloads debounced; the change event fires only * when the combined content or warning actually changed. The snapshot is shared by every session of * the handler through the `ISessionInstructionsProvider` seed - * (`sessionProvider()`), a live read view over this service. Bound at + * (`sessionProvider()`), a live read view over this service. The plain-data + * state (`current`) is registered into `workspaceState` + * (`IWorkspaceStateService`) and read/written through it. Bound at * Workspace scope. */ @@ -19,6 +21,7 @@ import { Disposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; +import { defineState } from '#/_base/state/stateRegistry'; import { TimeoutTimer } from '#/_base/utils/timer'; import { subtreeWatchFilter } from '#/_base/utils/paths'; import { agentsMdWatchRoots, loadAgentsMdForRoots } from '#/agent/profile/context'; @@ -27,6 +30,7 @@ import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import type { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { @@ -36,16 +40,17 @@ import { const WATCH_DEBOUNCE_MS = 200; +export const workspaceInstructionsCurrentKey = defineState( + 'workspaceInstructions.current', + () => ({ agentsMd: undefined, agentsMdWarning: undefined }), +); + export class WorkspaceInstructionsService extends Disposable implements IWorkspaceInstructionsService { declare readonly _serviceBrand: undefined; - private current: WorkspaceInstructionsSnapshot = { - agentsMd: undefined, - agentsMdWarning: undefined, - }; readonly ready: Promise; private readonly onDidChangeEmitter = this._register(new Emitter()); readonly onDidChange: Event = this.onDidChangeEmitter.event; @@ -59,12 +64,22 @@ export class WorkspaceInstructionsService @IBootstrapService private readonly bootstrap: IBootstrapService, @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, @ILogService private readonly log: ILogService, + @IWorkspaceStateService private readonly states: IWorkspaceStateService, ) { super(); + this.states.register(workspaceInstructionsCurrentKey); this.ready = this.reload(); void this.watchCandidateFiles(); } + private get current(): WorkspaceInstructionsSnapshot { + return this.states.get(workspaceInstructionsCurrentKey); + } + + private set current(value: WorkspaceInstructionsSnapshot) { + this.states.set(workspaceInstructionsCurrentKey, value); + } + get snapshot(): WorkspaceInstructionsSnapshot { return this.current; } diff --git a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts index 148c1bb534..e2d13ae1fb 100644 --- a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts @@ -9,18 +9,22 @@ * event — no full rescan ever leaves the build-time load. The merged view is * shared by every session of the handler through the * `ISessionSkillCatalogData` seed (`sessionData()`), a live read view over - * this service. Bound at Workspace scope. + * this service. The plain-data state (`contributions`, `merged`) is + * registered into `workspaceState` (`IWorkspaceStateService`) and + * read/written through it. Bound at Workspace scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/_base/state/stateRegistry'; import { IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; import type { ISkillSource, SkillContribution } from '#/app/skillCatalog/skillSource'; import type { SkillCatalog } from '#/app/skillCatalog/types'; import { IUserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; import type { ISessionSkillCatalogData } from '#/session/sessionSkillCatalog/skillCatalogData'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; import { IExplicitFileSkillSource } from './explicitFileSkillSource'; import { IExtraFileSkillSource } from './extraFileSkillSource'; @@ -28,15 +32,18 @@ import { IPluginSkillSource } from './pluginSkillSource'; import { IWorkspaceRootSkillSource } from './rootFileSkillSource'; import { IWorkspaceSkillCatalog } from './workspaceSkillCatalog'; +export const workspaceSkillCatalogContributionsKey = defineState< + Map +>('workspaceSkillCatalog.contributions', () => new Map()); +export const workspaceSkillCatalogMergedKey = defineState( + 'workspaceSkillCatalog.merged', + () => new InMemorySkillCatalog(), +); + export class WorkspaceSkillCatalogService extends Disposable implements IWorkspaceSkillCatalog { declare readonly _serviceBrand: undefined; private readonly sources: readonly ISkillSource[]; - private readonly contributions = new Map< - string, - { readonly c: SkillContribution; readonly priority: number } - >(); - private merged = new InMemorySkillCatalog(); private readonly sourceLoadTails = new Map>(); readonly ready: Promise; private readonly onDidChangeEmitter = this._register(new Emitter()); @@ -49,8 +56,11 @@ export class WorkspaceSkillCatalogService extends Disposable implements IWorkspa @IExtraFileSkillSource extra: IExtraFileSkillSource, @IWorkspaceRootSkillSource workspace: IWorkspaceRootSkillSource, @IPluginSkillSource plugin: IPluginSkillSource, + @IWorkspaceStateService private readonly states: IWorkspaceStateService, ) { super(); + this.states.register(workspaceSkillCatalogContributionsKey); + this.states.register(workspaceSkillCatalogMergedKey); this.sources = [builtin, user, explicit, extra, workspace, plugin].toSorted( (a, b) => a.priority - b.priority, ); @@ -65,6 +75,21 @@ export class WorkspaceSkillCatalogService extends Disposable implements IWorkspa this.ready = this.loadAll(); } + private get contributions(): Map< + string, + { readonly c: SkillContribution; readonly priority: number } + > { + return this.states.get(workspaceSkillCatalogContributionsKey); + } + + private get merged(): InMemorySkillCatalog { + return this.states.get(workspaceSkillCatalogMergedKey); + } + + private set merged(value: InMemorySkillCatalog) { + this.states.set(workspaceSkillCatalogMergedKey, value); + } + get catalog(): SkillCatalog { return this.merged; } diff --git a/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts index bbcc3282be..481ad56628 100644 --- a/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts @@ -12,14 +12,18 @@ * goes through this service, so the view is in-process: another process * flipping the same record is picked up only on restart (a `docs.watch` * sync can join when a second writer exists). A read failure resolves to - * untrusted. Bound at Workspace scope. + * untrusted. The plain-data state (`trusted`) is registered into + * `workspaceState` (`IWorkspaceStateService`) and read/written through it. + * Bound at Workspace scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter } from '#/_base/event'; +import { defineState } from '#/_base/state/stateRegistry'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IWorkspaceTrust, type WorkspaceTrustChange } from './workspaceTrust'; @@ -31,26 +35,40 @@ interface TrustRecord { readonly trustedAt: number; } +export const workspaceTrustTrustedKey = defineState( + 'workspaceTrust.trusted', + () => false, +); + export class WorkspaceTrustService extends Disposable implements IWorkspaceTrust { declare readonly _serviceBrand: undefined; readonly ready: Promise; private readonly root: string; private readonly storeKey: string; - private trusted = false; private readonly changeEmitter = this._register(new Emitter()); readonly onDidChange = this.changeEmitter.event; constructor( @IWorkspaceContext workspace: IWorkspaceContext, @IAtomicDocumentStore private readonly docs: IAtomicDocumentStore, + @IWorkspaceStateService private readonly states: IWorkspaceStateService, ) { super(); + this.states.register(workspaceTrustTrustedKey); this.root = workspace.cwd; this.storeKey = encodeWorkDirKey(workspace.cwd); this.ready = this.initialize(); } + private get trusted(): boolean { + return this.states.get(workspaceTrustTrustedKey); + } + + private set trusted(value: boolean) { + this.states.set(workspaceTrustTrustedKey, value); + } + isTrusted(): boolean { return this.trusted; } diff --git a/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts b/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts index 40c7a2db28..af8ef6c40c 100644 --- a/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts +++ b/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts @@ -9,8 +9,10 @@ import { import { createScopedTestHost, type ScopedTestHost } from '#/_base/di/test'; import { BugIndicatingError } from '#/_base/errors/errors'; import { defineState, StateRegistry, type StateChange } from '#/_base/state/stateRegistry'; -import { IStateService } from '#/app/state/state'; -import { StateService } from '#/app/state/stateService'; +import { IAppStateService } from '#/app/state/appState'; +import { AppStateService } from '#/app/state/appStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; import { IAgentStateService } from '#/agent/state/agentState'; @@ -168,7 +170,20 @@ describe('state services (scoped)', () => { beforeEach(() => { _clearScopedRegistryForTests(); - registerScopedService(LifecycleScope.App, IStateService, StateService, ScopeActivation.OnScopeCreated, 'state'); + registerScopedService( + LifecycleScope.App, + IAppStateService, + AppStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceStateService, + WorkspaceStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); registerScopedService( LifecycleScope.Session, ISessionStateService, @@ -188,30 +203,80 @@ describe('state services (scoped)', () => { afterEach(() => host.dispose()); + function createChain() { + const workspace = host.child(LifecycleScope.Workspace, 'w1'); + const session = host.childOf(workspace, LifecycleScope.Session, 's1'); + const agent = host.childOf(session, LifecycleScope.Agent, 'main'); + return { workspace, session, agent }; + } + it('resolves a distinct state service per scope tier', () => { - const appState = host.app.accessor.get(IStateService); - const session = host.child(LifecycleScope.Session, 's1'); + const appState = host.app.accessor.get(IAppStateService); + const { workspace, session, agent } = createChain(); + const workspaceState = workspace.accessor.get(IWorkspaceStateService); const sessionState = session.accessor.get(ISessionStateService); - const agent = host.childOf(session, LifecycleScope.Agent, 'main'); const agentState = agent.accessor.get(IAgentStateService); - expect(appState).not.toBe(sessionState); + expect(appState).not.toBe(workspaceState); + expect(workspaceState).not.toBe(sessionState); expect(sessionState).not.toBe(agentState); }); it('keeps registered state invisible to sibling scope tiers', () => { const sessionKey = defineState('test.sessionOnly', () => 'seed'); - const session = host.child(LifecycleScope.Session, 's1'); + const { workspace, session, agent } = createChain(); const sessionState = session.accessor.get(ISessionStateService); sessionState.register(sessionKey); sessionState.set(sessionKey, 'live'); expect(sessionState.get(sessionKey)).toBe('live'); - const agent = host.childOf(session, LifecycleScope.Agent, 'main'); expect(agent.accessor.get(IAgentStateService).has(sessionKey)).toBe(false); - expect(host.app.accessor.get(IStateService).has(sessionKey)).toBe(false); + expect(workspace.accessor.get(IWorkspaceStateService).has(sessionKey)).toBe(false); + expect(host.app.accessor.get(IAppStateService).has(sessionKey)).toBe(false); }); it('resolves the same instance within one scope', () => { - const session = host.child(LifecycleScope.Session, 's1'); + const { session } = createChain(); expect(session.accessor.get(ISessionStateService)).toBe(session.accessor.get(ISessionStateService)); }); + + it('omits the parent link when a registry has no cascade parent', () => { + const loneKey = defineState('test.lone', () => 0); + const registry = new StateRegistry(); + registry.register(loneKey); + expect(registry.inspect()).toEqual({ + scope: 'unknown', + state: { 'test.lone': 0 }, + parent: undefined, + }); + }); + + it('cascades inspect from the agent tier up to the app root', () => { + const appKey = defineState('test.appOnly', () => 'a'); + const workspaceKey = defineState('test.workspaceOnly', () => 'w'); + const sessionKey = defineState('test.sessionCascade', () => 's'); + const agentKey = defineState('test.agentOnly', () => 'g'); + host.app.accessor.get(IAppStateService).register(appKey); + const { workspace, session, agent } = createChain(); + workspace.accessor.get(IWorkspaceStateService).register(workspaceKey); + session.accessor.get(ISessionStateService).register(sessionKey); + const agentState = agent.accessor.get(IAgentStateService); + agentState.register(agentKey); + + expect(agentState.inspect()).toEqual({ + scope: 'agent', + state: { 'test.agentOnly': 'g' }, + parent: { + scope: 'session', + state: { 'test.sessionCascade': 's' }, + parent: { + scope: 'workspace', + state: { 'test.workspaceOnly': 'w' }, + parent: { + scope: 'app', + state: { 'test.appOnly': 'a' }, + parent: undefined, + }, + }, + }, + }); + }); }); diff --git a/packages/agent-core-v2/test/app/gateway/gateway.test.ts b/packages/agent-core-v2/test/app/gateway/gateway.test.ts index f5fe1623b4..b7316d4f8b 100644 --- a/packages/agent-core-v2/test/app/gateway/gateway.test.ts +++ b/packages/agent-core-v2/test/app/gateway/gateway.test.ts @@ -12,7 +12,7 @@ import { RestGateway } from '#/app/gateway/gatewayService'; import { ILogService } from '#/_base/log/log'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IAgentLoopService } from '#/agent/loop/loop'; import { createHooks } from '#/hooks'; import { stubLog } from '../../_base/log/stubs'; @@ -88,7 +88,7 @@ describe('RestGateway', () => { dispose: () => {}, }; - const handlerService: IWorkspaceHandlerService = { + const sessionLifecycle: ISessionLifecycleService = { _serviceBrand: undefined, onDidCreateSession: () => ({ dispose: () => {} }), onDidCloseSession: () => ({ dispose: () => {} }), @@ -107,7 +107,7 @@ describe('RestGateway', () => { const handlerHandle = { id: 'wd_stub', kind: LifecycleScope.Workspace, - accessor: makeAccessor([[IWorkspaceHandlerService, handlerService]]), + accessor: makeAccessor([[ISessionLifecycleService, sessionLifecycle]]), dispose: () => {}, } as const; ix.stub(IWorkspaceLifecycleService, { diff --git a/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts b/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts index 5d23169b6a..f31118c6eb 100644 --- a/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts +++ b/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts @@ -12,7 +12,7 @@ import { type IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IWireService } from '#/wire/wire'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionCronService } from '#/session/cron/sessionCronService'; @@ -81,7 +81,7 @@ function buildService(opts: { kind: LifecycleScope.Workspace, accessor: { get: (token: unknown): unknown => { - if (token === IWorkspaceHandlerService) { + if (token === ISessionLifecycleService) { return { resume: (sessionId: string) => Promise.resolve(sessionId === opts.summary.id ? sessionHandle : undefined), diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index dca3deefc7..f3fa2039f8 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -44,7 +44,7 @@ import { import { writeExportZip } from '#/app/sessionExport/zip'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IWorkspaceService } from '#/app/workspace/workspace'; import { Error2 } from '#/errors'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; @@ -910,7 +910,7 @@ function registerSessionExportServices( kind: LifecycleScope.Workspace, accessor: accessorFrom([ [ - IWorkspaceHandlerService, + ISessionLifecycleService, { _serviceBrand: undefined, onDidCreateSession: noopEvent, @@ -932,7 +932,7 @@ function registerSessionExportServices( createChild: async () => { throw new Error('createChild should not be called by session export'); }, - } satisfies IWorkspaceHandlerService, + } satisfies ISessionLifecycleService, ], ]), dispose: () => {}, diff --git a/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts b/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts index f24257df05..a3a0a9d8ab 100644 --- a/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts +++ b/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts @@ -19,12 +19,13 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo import { IAgentPlanService } from '#/agent/plan/plan'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; +import { IConfigService } from '#/app/config/config'; import { UNKNOWN_CAPABILITY } from '#/kosong/contract/capability'; import { ISessionLegacyService } from '#/app/sessionLegacy/sessionLegacy'; import { SessionLegacyService } from '#/app/sessionLegacy/sessionLegacyService'; import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IAgentActivityView } from '#/agent/activityView/activityView'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -51,7 +52,7 @@ function stubSessionChain(ix: TestInstantiationService, session: ISessionScopeHa kind: LifecycleScope.Workspace, accessor: accessor([ [ - IWorkspaceHandlerService, + ISessionLifecycleService, { resume: () => Promise.resolve(session), get: () => session, @@ -156,6 +157,70 @@ describe('Session legacy status (best-effort runtime state)', () => { }); }); + it('reports an empty thinking level for a never-bound main agent', async () => { + // A fresh session's main agent is materialized unbound (no Profile / Model + // — see kap-server's ensureMainAgent). The wire model's initial + // thinkingLevel is the zero value 'off'; reporting it would make clients + // fold a level nobody chose into the session's real state, so the status + // edge must report '' (mirroring `model: undefined`) instead. + const profile = { + _serviceBrand: undefined, + data: () => ({ + cwd: '/workspace', + modelAlias: undefined, + modelCapabilities: UNKNOWN_CAPABILITY, + thinkingLevel: 'off', + systemPrompt: '', + }), + getModel: () => '', + getModelCapabilities: () => UNKNOWN_CAPABILITY, + getEffectiveThinkingLevel: () => 'off', + } as unknown as IAgentProfileService; + const agent: IAgentScopeHandle = { + id: 'main', + kind: LifecycleScope.Agent, + accessor: accessor([ + [IAgentProfileService, profile], + [IAgentContextSizeService, { get: () => ({ size: 0, measured: 0, estimated: 0 }) }], + [IAgentPermissionModeService, { mode: 'manual' }], + [IAgentPlanService, { status: () => Promise.resolve(null) }], + [IAgentSwarmService, { isActive: false }], + // Unbound: assembleStatus resolves the default model's context cap, + // which reads the `defaultModel` config section first. + [IConfigService, { get: () => undefined }], + [ + IAgentActivityView, + { state: () => ({ lifecycle: 'ready', background: [] }) }, + ], + ]), + dispose: () => {}, + }; + const agents = { + create: () => Promise.resolve(agent), + whenReady: () => Promise.resolve(agent), + list: () => [agent], + } as unknown as IAgentLifecycleService; + const session: ISessionScopeHandle = { + id: 'session-unbound', + kind: LifecycleScope.Session, + accessor: accessor([ + [IAgentLifecycleService, agents], + [ISessionCronService, { _serviceBrand: undefined }], + ]), + dispose: () => {}, + }; + stubSessionChain(ix, session); + ix.set(ISessionLegacyService, new SyncDescriptor(SessionLegacyService)); + + const status = await ix.get(ISessionLegacyService).status('session-unbound'); + + expect(status).toMatchObject({ + busy: false, + model: undefined, + thinking_level: '', + }); + }); + it('uses the input cap as the status denominator and clamps usage to 1', async () => { const profile = { _serviceBrand: undefined, diff --git a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts index 49adc95bfe..25c041495a 100644 --- a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts @@ -33,6 +33,10 @@ import { IExtraAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoade import { IExplicitAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader'; import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; +import { IAppStateService } from '#/app/state/appState'; +import { AppStateService } from '#/app/state/appStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; @@ -41,8 +45,8 @@ import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; import { Error2, ErrorCodes } from '#/errors'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; -import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; +import { SessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycleService'; import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs'; @@ -254,10 +258,10 @@ describe('WorkspaceLifecycleService', () => { ); registerScopedService( LifecycleScope.Workspace, - IWorkspaceHandlerService, - WorkspaceHandlerService, + ISessionLifecycleService, + SessionLifecycleService, ScopeActivation.OnScopeCreated, - 'workspaceHandler', + 'sessionLifecycle', ); registerScopedService( LifecycleScope.Workspace, @@ -273,6 +277,20 @@ describe('WorkspaceLifecycleService', () => { ScopeActivation.OnScopeCreated, 'workspaceDirs', ); + registerScopedService( + LifecycleScope.App, + IAppStateService, + AppStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceStateService, + WorkspaceStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); registerScopedService( LifecycleScope.App, IHostFileSystem, @@ -390,7 +408,7 @@ describe('WorkspaceLifecycleService', () => { ]); expect(handlerA).toBe(handlerB); - const sessions = handlerA.accessor.get(IWorkspaceHandlerService); + const sessions = handlerA.accessor.get(ISessionLifecycleService); const [s1, s2] = await Promise.all([ sessions.create({ sessionId: 's1', workDir: '/tmp/proj' }), sessions.create({ sessionId: 's2', workDir: '/tmp/proj' }), @@ -493,7 +511,7 @@ describe('WorkspaceLifecycleService', () => { it('getLiveSessionById finds only live sessions', async () => { const lifecycle = build(); const handler = await lifecycle.handlerFor({ root: '/tmp/proj' }); - const sessions = handler.accessor.get(IWorkspaceHandlerService); + const sessions = handler.accessor.get(ISessionLifecycleService); await sessions.create({ sessionId: 's1', workDir: '/tmp/proj' }); expect(getLiveSessionById(host!.app.accessor, 's1')?.id).toBe('s1'); @@ -508,13 +526,13 @@ describe('WorkspaceLifecycleService', () => { ); const first = await lifecycle.handlerFor({ root: '/tmp/proj' }); - await first.accessor.get(IWorkspaceHandlerService).create({ sessionId: 's1', workDir: '/tmp/proj' }); + await first.accessor.get(ISessionLifecycleService).create({ sessionId: 's1', workDir: '/tmp/proj' }); // Materialized AFTER the follow subscription — still observed. const second = await lifecycle.handlerFor({ root: '/tmp/other' }); - await second.accessor.get(IWorkspaceHandlerService).create({ sessionId: 's2', workDir: '/tmp/other' }); + await second.accessor.get(ISessionLifecycleService).create({ sessionId: 's2', workDir: '/tmp/other' }); - await first.accessor.get(IWorkspaceHandlerService).close('s1'); - await second.accessor.get(IWorkspaceHandlerService).close('s2'); + await first.accessor.get(ISessionLifecycleService).close('s1'); + await second.accessor.get(ISessionLifecycleService).close('s2'); expect(closed.toSorted()).toEqual(['s1', 's2']); sub.dispose(); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 1faa14ee23..68e855533a 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -14,6 +14,7 @@ import { escapeXmlAttr } from '#/_base/utils/xml-escape'; import type { AgentTaskInfo } from '#/agent/task/task'; import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { AgentBlobServiceImpl } from '#/agent/blob/agentBlobServiceImpl'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { CHECKPOINTED_MODELS, type Checkpointed } from '#/agent/contextMemory/conversationTime'; @@ -123,6 +124,7 @@ import { IAgentLoopContinuationService, IAgentSwarmService, AgentSwarmService, + IAppStateService, ITelemetryService, IHostTerminalService, IAgentToolRegistryService, @@ -130,6 +132,7 @@ import { IAgentUserToolService, IAgentUsageService, ISessionWorkspaceContext, + IWorkspaceStateService, AgentLLMRequesterService, LifecycleScope, AgentMcpService, @@ -1209,6 +1212,13 @@ export class AgentTestContext { additionalDirs: [], onDidChange: Event.None as Event, } satisfies ISessionWorkspaceInfo); + // The harness skips the Workspace scope entirely, so the session + // state service's cascade parent is seeded directly: a workspace + // state instance chained onto the App-scope root. + reg.defineInstance( + IWorkspaceStateService, + new WorkspaceStateService(this.root.accessor.get(IAppStateService)), + ); reg.defineInstance(IAgentLifecycleService, { _serviceBrand: undefined, onDidCreate: Event.None as Event, diff --git a/packages/agent-core-v2/test/session/question/question.test.ts b/packages/agent-core-v2/test/session/question/question.test.ts index e7eb60e711..58cd695558 100644 --- a/packages/agent-core-v2/test/session/question/question.test.ts +++ b/packages/agent-core-v2/test/session/question/question.test.ts @@ -16,6 +16,8 @@ import { type QuestionRequest, ISessionQuestionService } from '#/session/questio import { SessionQuestionService } from '#/session/question/questionService'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; const noopEventBus: IEventBus = { _serviceBrand: undefined, @@ -49,7 +51,9 @@ describe('ISessionQuestionService (Session scope facade over the interaction ker disposables = new DisposableStore(); host = createScopedTestHost([stubPair(IEventBus, noopEventBus)]); - session = host.child(LifecycleScope.Session, 'session-a'); + session = host.child(LifecycleScope.Session, 'session-a', [ + stubPair(IWorkspaceStateService, new WorkspaceStateService()), + ]); }); afterEach(() => { @@ -170,7 +174,9 @@ describe('ISessionQuestionService (Session scope facade over the interaction ker }); it('Session scope isolates brokers: a question parked in A is invisible to B', () => { - const sessionB = host.child(LifecycleScope.Session, 'session-b'); + const sessionB = host.child(LifecycleScope.Session, 'session-b', [ + stubPair(IWorkspaceStateService, new WorkspaceStateService()), + ]); const questionsA = session.accessor.get(ISessionQuestionService); const questionsB = sessionB.accessor.get(ISessionQuestionService); diff --git a/packages/agent-core-v2/test/session/sessionActivity/sessionActivityService.test.ts b/packages/agent-core-v2/test/session/sessionActivity/sessionActivityService.test.ts index 782a3b9409..1ca53a71d5 100644 --- a/packages/agent-core-v2/test/session/sessionActivity/sessionActivityService.test.ts +++ b/packages/agent-core-v2/test/session/sessionActivity/sessionActivityService.test.ts @@ -23,6 +23,8 @@ import { import { SessionActivityView } from '#/session/sessionActivity/sessionActivityService'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; class FakeBus implements IEventBus { declare readonly _serviceBrand: undefined; @@ -151,7 +153,9 @@ describe('ISessionActivityView (Session scope aggregate of agent activity + inte disposables = new DisposableStore(); host = createScopedTestHost(); - session = host.child(LifecycleScope.Session, 'session-a'); + session = host.child(LifecycleScope.Session, 'session-a', [ + stubPair(IWorkspaceStateService, new WorkspaceStateService()), + ]); lifecycle = session.accessor.get(IAgentLifecycleService) as unknown as FakeAgentLifecycle; }); @@ -187,6 +191,7 @@ describe('ISessionActivityView (Session scope aggregate of agent activity + inte main.activity = turnActive(1); const seededSession = host.child(LifecycleScope.Session, 'session-seeded', [ stubPair(IAgentLifecycleService, seededLifecycle), + stubPair(IWorkspaceStateService, new WorkspaceStateService()), ]); const view = seededSession.accessor.get(ISessionActivityView); expect(view.state().busy).toBe(true); diff --git a/packages/agent-core-v2/test/session/sessionLog/sessionLogService.test.ts b/packages/agent-core-v2/test/session/sessionLog/sessionLogService.test.ts index 5d0c1ce6d4..bd1c524a2d 100644 --- a/packages/agent-core-v2/test/session/sessionLog/sessionLogService.test.ts +++ b/packages/agent-core-v2/test/session/sessionLog/sessionLogService.test.ts @@ -22,6 +22,8 @@ import { SessionLogService } from '#/session/sessionLog/sessionLogService'; import { makeSessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; let homeDir: string; let sessionDir: string; @@ -59,14 +61,17 @@ function buildHost() { } function testSessionSeed() { - return sessionContextSeed(makeSessionContext({ - sessionId: 's1', - workspaceId: 'test-workspace', - sessionDir, - sessionScope: 'sessions/test-workspace/s1', - metaScope: 'sessions/test-workspace/s1/session-meta', - cwd: sessionDir, - })); + return [ + ...sessionContextSeed(makeSessionContext({ + sessionId: 's1', + workspaceId: 'test-workspace', + sessionDir, + sessionScope: 'sessions/test-workspace/s1', + metaScope: 'sessions/test-workspace/s1/session-meta', + cwd: sessionDir, + })), + [IWorkspaceStateService, new WorkspaceStateService()] as const, + ]; } async function readSessionLog(): Promise { diff --git a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts index 840b05b200..3935b8a56f 100644 --- a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts @@ -24,6 +24,8 @@ import { ISessionSkillCatalogData } from '#/session/sessionSkillCatalog/skillCat import { SessionSkillCatalogService } from '#/session/sessionSkillCatalog/skillCatalogService'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { stubSkill } from '../../app/skillCatalog/stubs'; @@ -71,6 +73,7 @@ describe('SessionSkillCatalogService (seed view)', () => { const host = createScopedTestHost([]); const session = host.child(LifecycleScope.Session, 's1', [ stubPair(ISessionSkillCatalogData, data), + stubPair(IWorkspaceStateService, new WorkspaceStateService()), ]); return { host, catalog: session.accessor.get(ISessionSkillCatalog) }; } diff --git a/packages/agent-core-v2/test/state/stubs.ts b/packages/agent-core-v2/test/state/stubs.ts index 646c20f4b5..f4351049e0 100644 --- a/packages/agent-core-v2/test/state/stubs.ts +++ b/packages/agent-core-v2/test/state/stubs.ts @@ -1,18 +1,26 @@ /** * Test doubles for the `state` domain: registers real `StateRegistry` - * instances for the three per-scope state service tokens. + * instances for the four per-scope state service tokens, chained so each + * tier's `inspect()` cascade resolves its parent. */ import type { ServiceRegistration } from '#/_base/di/test'; import { AgentStateService } from '#/agent/state/agentStateService'; import { IAgentStateService } from '#/agent/state/agentState'; -import { StateService } from '#/app/state/stateService'; -import { IStateService } from '#/app/state/state'; +import { AppStateService } from '#/app/state/appStateService'; +import { IAppStateService } from '#/app/state/appState'; import { SessionStateService } from '#/session/state/sessionStateService'; import { ISessionStateService } from '#/session/state/sessionState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; export function registerStateServices(reg: ServiceRegistration): void { - reg.defineInstance(IStateService, new StateService()); - reg.defineInstance(ISessionStateService, new SessionStateService()); - reg.defineInstance(IAgentStateService, new AgentStateService()); + const app = new AppStateService(); + const workspace = new WorkspaceStateService(app); + const session = new SessionStateService(workspace); + const agent = new AgentStateService(session); + reg.defineInstance(IAppStateService, app); + reg.defineInstance(IWorkspaceStateService, workspace); + reg.defineInstance(ISessionStateService, session); + reg.defineInstance(IAgentStateService, agent); } diff --git a/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts similarity index 97% rename from packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts rename to packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts index 9beaa25c89..1611d33a1e 100644 --- a/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts @@ -46,8 +46,8 @@ import { CRON_SESSION_TAG, type CronTask } from '#/app/cron/cronTask'; import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; import { WorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycleService'; import { resumeSessionById } from '#/app/workspaceLifecycle/sessionLookup'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; -import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; +import { SessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycleService'; import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; import { IAgentActivityView } from '#/agent/activityView/activityView'; @@ -67,6 +67,10 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceCo import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; +import { IAppStateService } from '#/app/state/appState'; +import { AppStateService } from '#/app/state/appStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -473,7 +477,7 @@ class RecordingSessionExternalHooksService } } -describe('WorkspaceHandlerService', () => { +describe('SessionLifecycleService', () => { let host: ScopedTestHost | undefined; let telemetryRecords: TelemetryRecord[]; let tmpRoots: string[]; @@ -490,12 +494,26 @@ describe('WorkspaceHandlerService', () => { ScopeActivation.OnDemand, 'workspaceLifecycle', ); + registerScopedService( + LifecycleScope.App, + IAppStateService, + AppStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceStateService, + WorkspaceStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); registerScopedService( LifecycleScope.Workspace, - IWorkspaceHandlerService, - WorkspaceHandlerService, + ISessionLifecycleService, + SessionLifecycleService, ScopeActivation.OnScopeCreated, - 'workspaceHandler', + 'sessionLifecycle', ); registerScopedService( LifecycleScope.Workspace, @@ -540,7 +558,7 @@ describe('WorkspaceHandlerService', () => { */ async function build( extra: ReturnType[] = [], - ): Promise { + ): Promise { host = createScopedTestHost([ stubPair(IBootstrapService, bootstrapStub()), stubPair(ISessionMetadata, metadataStub()), @@ -578,7 +596,7 @@ describe('WorkspaceHandlerService', () => { ]); const lifecycle = host.app.accessor.get(IWorkspaceLifecycleService); const handler = await lifecycle.handlerFor({ root: '/tmp/proj' }); - return handler.accessor.get(IWorkspaceHandlerService); + return handler.accessor.get(ISessionLifecycleService); } async function makeTmpRoot(): Promise { diff --git a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts index 89df2cadbb..2bda70bf0c 100644 --- a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts @@ -2,7 +2,7 @@ * Scenario: workspace-level add-dir (the phase-3.5 behavior contract). * * Drives the REAL handler chain (WorkspaceLifecycleService → - * WorkspaceHandlerService) with the real `WorkspaceDirsService`, the real + * SessionLifecycleService) with the real `WorkspaceDirsService`, the real * node-fs `FileProjectLocalConfigService`, the real fs watch service, and * the real Session-scope `workspaceContext` view, and proves: * - a persisted `addDir` writes `.kimi-code/local.toml` and refreshes every @@ -61,6 +61,10 @@ import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolic import { ISessionProcessRunner } from '#/session/process/processRunner'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; +import { IAppStateService } from '#/app/state/appState'; +import { AppStateService } from '#/app/state/appStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; import { IWorkspaceAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; @@ -69,9 +73,13 @@ import { IExplicitAgentProfileLoader } from '#/workspace/workspaceAgentProfileLo import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; -import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; -import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService'; +import { + WorkspaceDirsService, + workspaceDirsEphemeralDirsKey, + workspaceDirsFileDirsKey, +} from '#/workspace/workspaceDirs/workspaceDirsService'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; +import { SessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycleService'; import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; @@ -196,10 +204,10 @@ describe('workspace add-dir (handler chain)', () => { ); registerScopedService( LifecycleScope.Workspace, - IWorkspaceHandlerService, - WorkspaceHandlerService, + ISessionLifecycleService, + SessionLifecycleService, ScopeActivation.OnScopeCreated, - 'workspaceHandler', + 'sessionLifecycle', ); registerScopedService( LifecycleScope.Workspace, @@ -215,6 +223,20 @@ describe('workspace add-dir (handler chain)', () => { ScopeActivation.OnScopeCreated, 'workspaceDirs', ); + registerScopedService( + LifecycleScope.App, + IAppStateService, + AppStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceStateService, + WorkspaceStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); registerScopedService( LifecycleScope.Session, ISessionStateService, @@ -363,10 +385,10 @@ describe('workspace add-dir (handler chain)', () => { async function handlerFor( host: ScopedTestHost, root: string, - ): Promise<{ service: IWorkspaceHandlerService; dirs: IWorkspaceDirs }> { + ): Promise<{ service: ISessionLifecycleService; dirs: IWorkspaceDirs }> { const handler = await host.app.accessor.get(IWorkspaceLifecycleService).handlerFor({ root }); return { - service: handler.accessor.get(IWorkspaceHandlerService), + service: handler.accessor.get(ISessionLifecycleService), dirs: handler.accessor.get(IWorkspaceDirs), }; } @@ -468,6 +490,28 @@ describe('workspace add-dir (handler chain)', () => { } }, 15_000); + it('registers the additional-directory sets into the workspace state container', async () => { + const homeDir = await makeRoot('kimi-add-dir-home-'); + const root = await makeProjectRoot(); + const persisted = await makeRoot('kimi-add-dir-persisted-'); + const ephemeral = await makeRoot('kimi-add-dir-ephemeral-'); + const host = buildHost(homeDir); + const handler = await host.app.accessor.get(IWorkspaceLifecycleService).handlerFor({ root }); + const dirs = handler.accessor.get(IWorkspaceDirs); + const states = handler.accessor.get(IWorkspaceStateService); + + expect(states.get(workspaceDirsFileDirsKey)).toEqual([]); + expect(states.get(workspaceDirsEphemeralDirsKey)).toEqual([]); + + await dirs.addDir({ path: persisted, persist: true }); + expect(states.get(workspaceDirsFileDirsKey)).toEqual([persisted]); + expect(states.get(workspaceDirsEphemeralDirsKey)).toEqual([]); + + await dirs.addDir({ path: ephemeral, persist: false }); + expect(states.get(workspaceDirsFileDirsKey)).toEqual([persisted]); + expect(states.get(workspaceDirsEphemeralDirsKey)).toEqual([ephemeral]); + }); + it('unions caller additionalDirs from create options into the shared set', async () => { const homeDir = await makeRoot('kimi-add-dir-home-'); const root = await makeProjectRoot(); diff --git a/packages/agent-core-v2/test/workspace/workspaceInstructions/instructions.test.ts b/packages/agent-core-v2/test/workspace/workspaceInstructions/instructions.test.ts index 9ef2fbe289..e814cabccb 100644 --- a/packages/agent-core-v2/test/workspace/workspaceInstructions/instructions.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceInstructions/instructions.test.ts @@ -1,6 +1,7 @@ /** - * Scenario: workspace AGENTS.md instructions — build-time snapshot and - * watch-driven refresh. + * Scenario: workspace AGENTS.md instructions — build-time snapshot, + * watch-driven refresh, and the `workspaceInstructions.current` state + * registration. * * Exercises the real `WorkspaceInstructionsService` against real temp * instruction files with a manually-fired fs-watch stub. Run: @@ -28,11 +29,16 @@ import { type HostFsChange, type IHostFsWatchHandle, } from '#/os/interface/hostFsWatch'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; -import { WorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructionsService'; +import { + WorkspaceInstructionsService, + workspaceInstructionsCurrentKey, +} from '#/workspace/workspaceInstructions/workspaceInstructionsService'; import { stubLog } from '../../_base/log/stubs'; +import { registerStateServices } from '../../state/stubs'; describe('WorkspaceInstructionsService', () => { let workDir: string; @@ -82,10 +88,14 @@ describe('WorkspaceInstructionsService', () => { } } - function createService(): IWorkspaceInstructionsService { + function createService(): { + service: IWorkspaceInstructionsService; + states: IWorkspaceStateService; + } { const ix = createServices(disposables, { strict: true, additionalServices: (reg) => { + registerStateServices(reg); reg.definePartialInstance(IWorkspaceContext, { cwd: workDir }); reg.defineInstance(IHostFileSystem, new HostFileSystem()); reg.definePartialInstance(IHostEnvironment, { homeDir: osHomeDir }); @@ -95,14 +105,14 @@ describe('WorkspaceInstructionsService', () => { reg.define(IWorkspaceInstructionsService, WorkspaceInstructionsService); }, }); - return ix.get(IWorkspaceInstructionsService); + return { service: ix.get(IWorkspaceInstructionsService), states: ix.get(IWorkspaceStateService) }; } it('loads the AGENTS.md snapshot at build and projects it through the session provider', async () => { await writeFile(join(workDir, 'AGENTS.md'), 'project instructions', 'utf8'); await writeFile(join(brandHomeDir, 'AGENTS.md'), 'brand instructions', 'utf8'); - const service = createService(); + const { service } = createService(); await service.ready; expect(service.snapshot.agentsMd).toContain('brand instructions'); @@ -115,7 +125,7 @@ describe('WorkspaceInstructionsService', () => { it('refreshes the snapshot and fires onDidChange when a watched file changes', async () => { const file = join(workDir, 'AGENTS.md'); await writeFile(file, 'old instructions', 'utf8'); - const service = createService(); + const { service } = createService(); await service.ready; expect(service.snapshot.agentsMd).toContain('old instructions'); @@ -134,7 +144,7 @@ describe('WorkspaceInstructionsService', () => { }); it('picks up a newly created AGENTS.md through the watch', async () => { - const service = createService(); + const { service } = createService(); await service.ready; expect(service.snapshot.agentsMd).toBe(''); @@ -155,7 +165,7 @@ describe('WorkspaceInstructionsService', () => { it('does not fire when a reload produces identical content', async () => { const file = join(workDir, 'AGENTS.md'); await writeFile(file, 'stable', 'utf8'); - const service = createService(); + const { service } = createService(); await service.ready; let fired = 0; @@ -167,4 +177,25 @@ describe('WorkspaceInstructionsService', () => { expect(fired).toBe(0); }); + + it('registers the snapshot into the workspace state container and tracks reloads', async () => { + const file = join(workDir, 'AGENTS.md'); + await writeFile(file, 'state instructions', 'utf8'); + const { service, states } = createService(); + await service.ready; + + expect(states.get(workspaceInstructionsCurrentKey).agentsMd).toContain('state instructions'); + + const changed = new Promise((resolvePromise) => { + const d = service.onDidChange(() => { + d.dispose(); + resolvePromise(); + }); + }); + await writeFile(file, 'updated instructions', 'utf8'); + fireWatch(file); + await changed; + + expect(states.get(workspaceInstructionsCurrentKey).agentsMd).toContain('updated instructions'); + }); }); diff --git a/packages/agent-core-v2/test/workspace/workspaceResources.test.ts b/packages/agent-core-v2/test/workspace/workspaceResources.test.ts index db2e24d1aa..2b188fa404 100644 --- a/packages/agent-core-v2/test/workspace/workspaceResources.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceResources.test.ts @@ -3,7 +3,7 @@ * phase-3 behavior contract). * * Drives the REAL handler chain (WorkspaceLifecycleService → - * WorkspaceHandlerService) with the real Workspace-scope resource services + * SessionLifecycleService) with the real Workspace-scope resource services * and proves: one shared MCP connection manager (and one initial connect) * for two sessions of the same workspace, no skill rescan when the second * session lists skills, and the fs-watch fan-out refreshing a live session's @@ -70,10 +70,14 @@ import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog import { SessionSkillCatalogService } from '#/session/sessionSkillCatalog/skillCatalogService'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; +import { IAppStateService } from '#/app/state/appState'; +import { AppStateService } from '#/app/state/appStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; import { ISessionProcessRunner } from '#/session/process/processRunner'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; -import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; +import { SessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycleService'; import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; import { IWorkspaceAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; @@ -170,10 +174,10 @@ describe('workspace resource sharing (handler chain)', () => { ); registerScopedService( LifecycleScope.Workspace, - IWorkspaceHandlerService, - WorkspaceHandlerService, + ISessionLifecycleService, + SessionLifecycleService, ScopeActivation.OnScopeCreated, - 'workspaceHandler', + 'sessionLifecycle', ); registerScopedService( LifecycleScope.Workspace, @@ -247,6 +251,8 @@ describe('workspace resource sharing (handler chain)', () => { 'workspaceDirs', ); registerScopedService(LifecycleScope.Session, ISessionSkillCatalog, SessionSkillCatalogService, ScopeActivation.OnScopeCreated, 'sessionSkillCatalog'); + registerScopedService(LifecycleScope.App, IAppStateService, AppStateService, ScopeActivation.OnScopeCreated, 'state'); + registerScopedService(LifecycleScope.Workspace, IWorkspaceStateService, WorkspaceStateService, ScopeActivation.OnScopeCreated, 'state'); registerScopedService(LifecycleScope.Session, ISessionStateService, SessionStateService, ScopeActivation.OnScopeCreated, 'state'); registerScopedService(LifecycleScope.App, IBuiltinSkillSource, BuiltinSkillSource, ScopeActivation.OnDemand, 'skillCatalog'); registerScopedService(LifecycleScope.App, IUserFileSkillSource, UserFileSkillSource, ScopeActivation.OnDemand, 'skillCatalog'); @@ -378,11 +384,11 @@ describe('workspace resource sharing (handler chain)', () => { ]); } - async function handlerFor(root: string): Promise { + async function handlerFor(root: string): Promise { const handler = await (host as ScopedTestHost).app.accessor .get(IWorkspaceLifecycleService) .handlerFor({ root }); - return handler.accessor.get(IWorkspaceHandlerService); + return handler.accessor.get(ISessionLifecycleService); } it('runs one shared MCP manager and one initial connect for two concurrent sessions', async () => { diff --git a/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts index 6931f4c3ce..5c4b92dcb4 100644 --- a/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts @@ -27,6 +27,10 @@ import { PluginService } from '#/app/plugin/pluginService'; import type { ReloadSummary } from '#/app/plugin/types'; import { IProviderService } from '#/kosong/provider/provider'; import { IHostFsWatchService, type HostFsChange, type IHostFsWatchHandle } from '#/os/interface/hostFsWatch'; +import { IAppStateService } from '#/app/state/appState'; +import { AppStateService } from '#/app/state/appStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IConfigService } from '#/app/config/config'; import { @@ -39,7 +43,11 @@ import { IUserFileSkillSource, UserFileSkillSource } from '#/app/skillCatalog/us import { InMemorySkillDiscovery } from '#/app/skillCatalog/inMemorySkillDiscovery'; import type { SkillContribution } from '#/app/skillCatalog/skillSource'; import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; -import { WorkspaceSkillCatalogService } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalogService'; +import { + WorkspaceSkillCatalogService, + workspaceSkillCatalogContributionsKey, + workspaceSkillCatalogMergedKey, +} from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalogService'; import { ExplicitFileSkillSource, IExplicitFileSkillSource } from '#/workspace/workspaceSkillCatalog/explicitFileSkillSource'; import { ExtraFileSkillSource, IExtraFileSkillSource } from '#/workspace/workspaceSkillCatalog/extraFileSkillSource'; import { IWorkspaceRootSkillSource, WorkspaceRootSkillSource } from '#/workspace/workspaceSkillCatalog/rootFileSkillSource'; @@ -231,6 +239,8 @@ describe('WorkspaceSkillCatalogService', () => { registerScopedService(LifecycleScope.Workspace, IExtraFileSkillSource, ExtraFileSkillSource); registerScopedService(LifecycleScope.Workspace, IWorkspaceRootSkillSource, WorkspaceRootSkillSource); registerScopedService(LifecycleScope.Workspace, IPluginSkillSource, PluginSkillSource); + registerScopedService(LifecycleScope.App, IAppStateService, AppStateService); + registerScopedService(LifecycleScope.Workspace, IWorkspaceStateService, WorkspaceStateService); }); it('merges global and project skills; project wins on name collision', async () => { @@ -257,6 +267,24 @@ describe('WorkspaceSkillCatalogService', () => { host.dispose(); }); + it('registers contributions and the merged view into the workspace state container', async () => { + const store = new InMemorySkillDiscovery(); + store.setProjectSkills([stubSkill('project-only')]); + const ws = workspaceContextStub('/work'); + const { host, workspace } = makeHost(store, ws); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + + const states = workspace.accessor.get(IWorkspaceStateService); + const contributions = states.get(workspaceSkillCatalogContributionsKey); + expect([...contributions.keys()]).toContain('workspace'); + expect(states.get(workspaceSkillCatalogMergedKey)).toBe(catalog.catalog); + // A class instance collapses to a marker in the JSON-safe snapshot. + expect(states.snapshot()['workspaceSkillCatalog.merged']).toBe('(InMemorySkillCatalog)'); + host.dispose(); + }); + it('orders project, user and plugin skills as project > user > plugin', async () => { const store = new InMemorySkillDiscovery(); store.setUserSkills([ diff --git a/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts b/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts index d21b6d734a..dcf874cfc1 100644 --- a/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts @@ -1,7 +1,8 @@ /** * Scenario: workspace trust — explicit trust/untrust flips persisted outside * the workspace, idempotency and the change event, per-root independence, - * and marker survival across a restart. + * marker survival across a restart, and the `workspaceTrust.trusted` state + * registration. * * Exercises the real `WorkspaceTrustService` against the real node-fs * `JsonAtomicDocumentStore` over a temp home. Run: @@ -21,12 +22,18 @@ import { createServices } from '#/_base/di/test'; import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IWorkspaceTrust, type WorkspaceTrustChange, } from '#/workspace/workspaceTrust/workspaceTrust'; -import { WorkspaceTrustService } from '#/workspace/workspaceTrust/workspaceTrustService'; +import { + WorkspaceTrustService, + workspaceTrustTrustedKey, +} from '#/workspace/workspaceTrust/workspaceTrustService'; + +import { registerStateServices } from '../../state/stubs'; describe('WorkspaceTrustService', () => { let homeDir: string; @@ -47,10 +54,14 @@ describe('WorkspaceTrustService', () => { ]); }); - function createService(root: string, events?: WorkspaceTrustChange[]): IWorkspaceTrust { + function createService( + root: string, + events?: WorkspaceTrustChange[], + ): { service: IWorkspaceTrust; states: IWorkspaceStateService } { const ix = createServices(disposables, { strict: true, additionalServices: (reg) => { + registerStateServices(reg); reg.definePartialInstance(IWorkspaceContext, { cwd: root }); reg.defineInstance( IAtomicDocumentStore, @@ -63,11 +74,11 @@ describe('WorkspaceTrustService', () => { if (events !== undefined) { service.onDidChange((change) => events.push(change)); } - return service; + return { service, states: ix.get(IWorkspaceStateService) }; } it('defaults to untrusted when no marker exists', async () => { - const service = createService(cwd); + const { service } = createService(cwd); await service.ready; expect(service.isTrusted()).toBe(false); @@ -76,7 +87,7 @@ describe('WorkspaceTrustService', () => { it('trust() flips the state, fires once, and stays idempotent', async () => { const events: WorkspaceTrustChange[] = []; - const service = createService(cwd, events); + const { service } = createService(cwd, events); await service.ready; await service.trust(); @@ -89,7 +100,7 @@ describe('WorkspaceTrustService', () => { it('untrust() revokes the state and both directions stay idempotent', async () => { const events: WorkspaceTrustChange[] = []; - const service = createService(cwd, events); + const { service } = createService(cwd, events); await service.ready; await service.untrust(); @@ -102,11 +113,11 @@ describe('WorkspaceTrustService', () => { }); it('keeps the marker across a restart', async () => { - const first = createService(cwd); + const { service: first } = createService(cwd); await first.ready; await first.trust(); - const second = createService(cwd); + const { service: second } = createService(cwd); await second.ready; expect(second.isTrusted()).toBe(true); @@ -115,11 +126,11 @@ describe('WorkspaceTrustService', () => { it('tracks different roots independently', async () => { const other = mkdtempSync(join(tmpdir(), 'kimi-workspace-trust-other-')); try { - const first = createService(cwd); + const { service: first } = createService(cwd); await first.ready; await first.trust(); - const second = createService(other); + const { service: second } = createService(other); await second.ready; expect(second.isTrusted()).toBe(false); @@ -127,4 +138,21 @@ describe('WorkspaceTrustService', () => { await rm(other, { recursive: true, force: true }); } }); + + it('registers the trusted flag into the workspace state container', async () => { + const { service, states } = createService(cwd); + await service.ready; + + expect(states.has(workspaceTrustTrustedKey)).toBe(true); + expect(states.get(workspaceTrustTrustedKey)).toBe(false); + + const seen: boolean[] = []; + states.onDidChange(workspaceTrustTrustedKey)((value) => seen.push(value)); + await service.trust(); + await service.untrust(); + + expect(seen).toEqual([true, false]); + expect(states.get(workspaceTrustTrustedKey)).toBe(false); + expect(states.snapshot()['workspaceTrust.trusted']).toBe(false); + }); }); diff --git a/packages/kap-server/src/routes/fs.ts b/packages/kap-server/src/routes/fs.ts index d01a9cb546..033527e728 100644 --- a/packages/kap-server/src/routes/fs.ts +++ b/packages/kap-server/src/routes/fs.ts @@ -10,13 +10,30 @@ * "session → handler → workspace fs" chain (chdir is gone, so the handler * root is the one fixed fs root). The wire schema comes from the engine's own * `workspaceFs` domain contract (`agent-core-v2`). + * + * Draft-session fallback: a client composing the first prompt of a new + * session (e.g. kimi-web's new-session draft) has no session id yet, so it + * passes the workspace reference — registered workspace id or absolute root — + * in the `{session_id}` slot. Only `fs:search` serves those (the `@` file + * mention must work before the session exists): the route resolves the + * workspace's handler directly and uses the same Workspace-scope fs service a + * real session would resolve to. URL and wire schema are unchanged. + * + * First-class workspace search: `POST /workspace/fs:search` carries the same + * workspace reference in the body (`workspace`), so a session-less client + * searches without borrowing the `{session_id}` slot. kimi-web's `@` mention + * uses this route; the session-route fallback above predates it and stays for + * wire compatibility. */ import { createReadStream } from 'node:fs'; +import { isAbsolute } from 'node:path'; import { ErrorCodes, IWorkspaceFsService, + IWorkspaceLifecycleService, + IWorkspaceService, getLiveSessionById, resumeSessionById, isError2, @@ -32,6 +49,7 @@ import { fsMkdirRequestSchema, fsReadRequestSchema, fsSearchRequestSchema, + fsSearchResponseSchema, fsStatManyRequestSchema, fsStatRequestSchema, } from '@moonshot-ai/agent-core-v2/workspace/workspaceFs/fs'; @@ -85,6 +103,17 @@ const sessionIdAndTailParamSchema = z.object({ tail: z.string().min(1), }); +/** + * Body for `POST /workspace/fs:search`: the engine's fs-search request plus + * the workspace reference (registered workspace id or absolute root) the + * session route would otherwise carry in its `{session_id}` slot. + */ +const workspaceFsSearchBodySchema = fsSearchRequestSchema.extend({ + workspace: z.string().min(1), +}); + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + const FS_ACTIONS = [ 'list', 'read', @@ -113,6 +142,32 @@ function resolveFs(core: Scope, sessionId: string): IWorkspaceFsService { return session.accessor.get(IWorkspaceFsService); } +/** + * Workspace fallback for `fs:search` (see the file header): resolve a + * workspace reference — registered id, or an absolute root registered on the + * spot — to its handler's `IWorkspaceFsService`. `undefined` when the ref is + * neither a known workspace nor an existing absolute directory. + */ +async function resolveWorkspaceFs( + core: Scope, + ref: string, +): Promise { + const workspaces = core.accessor.get(IWorkspaceService); + let ws = await workspaces.get(ref); + if (ws === undefined) { + if (!isAbsolute(ref)) return undefined; + try { + ws = await workspaces.createOrTouch(ref); + } catch { + return undefined; + } + } + const handler = await core.accessor + .get(IWorkspaceLifecycleService) + .handlerFor({ workspaceId: ws.id, root: ws.root }); + return handler.accessor.get(IWorkspaceFsService); +} + export function registerFsRoutes(app: FsRouteHost, core: Scope): void { const fsActionRoute = defineRoute( { @@ -161,7 +216,13 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { // which reads the persisted cwd. `resume` returns undefined only when the // session is unknown or its workspace is gone. const session = await resumeSessionById(core.accessor, session_id); - if (session === undefined) { + // Draft-session fallback (file header): no session yet, but the client + // addressed a workspace — `fs:search` resolves it directly. + const workspaceFs = + session === undefined && fsAction === 'search' + ? await resolveWorkspaceFs(core, session_id) + : undefined; + if (session === undefined && workspaceFs === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), ); @@ -189,7 +250,7 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { await handleMkdir(core, session_id, req, reply); return; case 'search': - await handleSearch(core, session_id, req, reply); + await handleSearch(workspaceFs ?? resolveFs(core, session_id), req, reply); return; case 'grep': await handleGrep(core, session_id, req, reply); @@ -221,6 +282,54 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { fsActionRoute.handler as unknown as Parameters[2], ); + // Session-less workspace file search (file header): the `@` file mention of + // a not-yet-created session addresses the workspace directly instead of + // borrowing the session route's `{session_id}` slot. Declared with a double + // colon so find-my-way serves it on the wire as `/workspace/fs:search` + // (same convention as `/fs::browse` in `workspaceFs.ts`). + const workspaceSearchRoute = defineRoute( + { + method: 'POST', + path: '/workspace/fs::search', + body: workspaceFsSearchBodySchema, + success: { data: fsSearchResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + [ErrorCode.FS_TOO_MANY_RESULTS]: {}, + }, + description: + 'Search files in a workspace without a session. `workspace` accepts a registered workspace id or an absolute root (registered on the spot).', + tags: ['fs'], + operationId: 'workspaceFsSearch', + }, + async (req, reply) => { + const { workspace, ...searchRequest } = req.body; + const fs = await resolveWorkspaceFs(core, workspace); + if (fs === undefined) { + reply.send( + errEnvelope( + ErrorCode.WORKSPACE_NOT_FOUND, + `workspace ${workspace} does not exist`, + req.id, + ), + ); + return; + } + try { + const data = await fs.search(searchRequest); + reply.send(okEnvelope(data, req.id)); + } catch (err) { + sendMappedError(reply, req, err); + } + }, + ); + app.post( + workspaceSearchRoute.path, + workspaceSearchRoute.options, + workspaceSearchRoute.handler as unknown as Parameters[2], + ); + const downloadRoute = defineRoute( { method: 'GET', @@ -404,13 +513,13 @@ async function handleMkdir(core: Scope, sessionId: string, req: Req, reply: Repl reply.send(okEnvelope(data, req.id)); } -async function handleSearch(core: Scope, sessionId: string, req: Req, reply: Reply): Promise { +async function handleSearch(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { const parsed = fsSearchRequestSchema.safeParse(req.body ?? {}); if (!parsed.success) { reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); return; } - const data = await resolveFs(core, sessionId).search(parsed.data); + const data = await fs.search(parsed.data); reply.send(okEnvelope(data, req.id)); } diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 8bf59b8c07..ce666c946c 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -19,14 +19,14 @@ * The `POST /sessions/{tail}` actions split into two groups. The thin * pass-throughs — `fork` / `compact` / `abort` / `archive` / `restore` — call * the native v2 services directly (the workspace handler's - * `IWorkspaceHandlerService.fork` / `archive` / `restore`, reached through the + * `ISessionLifecycleService.fork` / `archive` / `restore`, reached through the * `sessionIndex` → `IWorkspaceLifecycleService.handlerFor` composition, * `IAgentFullCompactionService.begin`, `IAgentRPCService.cancel`); there is no * v1-only projection to centralize, so no adapter is involved. `undo` likewise * calls `IAgentConversationUndoService.undo` directly (it throws * `session.undo_unavailable` with a structured reason) and only borrows * `ISessionLegacyService.status` for the cross-domain status rollup. The - * `/sessions/{id}/children` endpoints call `IWorkspaceHandlerService.createChild` + * `/sessions/{id}/children` endpoints call `ISessionLifecycleService.createChild` * and `ISessionIndex.list({ childOf })` directly — the child markers and * parent-title default live in the lifecycle, and the child filter lives in the * index. Only `POST /sessions/{id}/profile` (`updateProfile`), @@ -92,7 +92,7 @@ import { ISessionSecondaryModelWarningService, IEventService, IWorkspaceAliases, - IWorkspaceHandlerService, + ISessionLifecycleService, IWorkspaceLifecycleService, IWorkspaceService, getLiveSessionById, @@ -318,7 +318,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void // Ensure the workspace is registered so `metadata.cwd` is resolvable on // read (gap G3 — v2 does not store workDir on the session). The session // is created through the workspace's handler (`handlerFor` → the - // handler's `IWorkspaceHandlerService`) — there is no App-scope session + // handler's `ISessionLifecycleService`) — there is no App-scope session // lifecycle entry point. try { const touched = await registry.createOrTouch(workDir); @@ -326,7 +326,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void const handler = await core.accessor.get(IWorkspaceLifecycleService).handlerFor({ root: workDir, }); - const handle = await handler.accessor.get(IWorkspaceHandlerService).create({ + const handle = await handler.accessor.get(ISessionLifecycleService).create({ workDir, }); if (typeof body.title === 'string') { @@ -670,7 +670,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void `session ${parsed.id} does not exist`, ); } - const handle = await forkHandler.accessor.get(IWorkspaceHandlerService).fork({ + const handle = await forkHandler.accessor.get(ISessionLifecycleService).fork({ sourceSessionId: parsed.id, title: body.title, metadata: body.metadata, @@ -770,7 +770,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void const restored = restoreHandler === undefined ? undefined - : await restoreHandler.accessor.get(IWorkspaceHandlerService).restore(parsed.id); + : await restoreHandler.accessor.get(ISessionLifecycleService).restore(parsed.id); if (restored === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`); } @@ -793,11 +793,11 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void const archived = archiveHandler === undefined ? undefined - : await archiveHandler.accessor.get(IWorkspaceHandlerService).resume(parsed.id); + : await archiveHandler.accessor.get(ISessionLifecycleService).resume(parsed.id); if (archived === undefined || archiveHandler === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`); } - await archiveHandler.accessor.get(IWorkspaceHandlerService).archive(parsed.id); + await archiveHandler.accessor.get(ISessionLifecycleService).archive(parsed.id); requestLog(req)?.info({ session_id: parsed.id, action: 'archive' }, 'session action completed'); reply.send(okEnvelope({ archived: true }, req.id)); } catch (error) { @@ -920,7 +920,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void if (childHandler === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${session_id} does not exist`); } - const handle = await childHandler.accessor.get(IWorkspaceHandlerService).createChild({ + const handle = await childHandler.accessor.get(ISessionLifecycleService).createChild({ sourceSessionId: session_id, title: req.body.title, metadata: req.body.metadata, diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index 0457902050..0db6f9b9bf 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -392,6 +392,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "POST", "/api/v1/shutdown", ], + [ + "POST", + "/api/v1/workspace/fs:search", + ], [ "POST", "/api/v1/workspaces", diff --git a/packages/kap-server/test/fs.test.ts b/packages/kap-server/test/fs.test.ts index 6cc18ecd1f..ce2b7b8188 100644 --- a/packages/kap-server/test/fs.test.ts +++ b/packages/kap-server/test/fs.test.ts @@ -28,7 +28,7 @@ interface FsEntryWire { mime?: string; } -describe('server-v2 /api/v1/sessions/{sid}/fs:*', () => { +describe('server-v2 /api/v1 fs routes', () => { let server: RunningServer | undefined; let home: string | undefined; /** Session work dir — kept separate from the server homeDir so the server's @@ -80,11 +80,13 @@ describe('server-v2 /api/v1/sessions/{sid}/fs:*', () => { server = undefined; } if (home !== undefined) { - await rm(home, { recursive: true, force: true }); + // maxRetries: the async query-store shard writer can still be flushing + // after close (ENOTEMPTY on macOS) — same retry pattern as sessions.test.ts. + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); home = undefined; } if (work !== undefined) { - await rm(work, { recursive: true, force: true }); + await rm(work, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); work = undefined; } }); @@ -209,6 +211,42 @@ describe('server-v2 /api/v1/sessions/{sid}/fs:*', () => { expect(body.data.items.map((i) => i.path)).toContain('alpha.ts'); }); + it('fs:search resolves a registered workspace id when no session exists', async () => { + await writeFile(join(work!, 'gamma.ts'), ''); + // Register the workspace without creating any session (the kimi-web + // new-session draft addresses the workspace directly). + const res = await fetch(`${base}/api/v1/workspaces`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ root: work }), + } as never); + const created = (await res.json()) as Envelope<{ id: string }>; + expect(created.code).toBe(0); + const body = await postFs<{ items: { path: string }[]; truncated: boolean }>( + created.data.id, + 'search', + { query: 'gamma' }, + ); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('gamma.ts'); + }); + + it('fs:search resolves an unregistered workspace root path', async () => { + await writeFile(join(work!, 'delta.ts'), ''); + const body = await postFs<{ items: { path: string }[]; truncated: boolean }>( + encodeURIComponent(work!), + 'search', + { query: 'delta' }, + ); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('delta.ts'); + }); + + it('fs:search still maps an unknown ref to SESSION_NOT_FOUND', async () => { + const body = await postFs('does-not-exist', 'search', { query: 'x' }); + expect(body.code).toBe(ErrorCode.SESSION_NOT_FOUND); + }); + it('fs:grep finds matching lines', async () => { await writeFile(join(work!, 'a.txt'), 'hello world\nfoo bar\n'); const id = await createSession(); @@ -305,4 +343,65 @@ describe('server-v2 /api/v1/sessions/{sid}/fs:*', () => { } as never); expect(cached.status).toBe(304); }); + + // ------------------------------------------------------------------------- + // POST /api/v1/workspace/fs:search — session-less workspace file search. + // ------------------------------------------------------------------------- + + async function postWorkspaceSearch(body: unknown): Promise> { + const res = await fetch(`${base}/api/v1/workspace/fs:search`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify(body), + } as never); + return (await res.json()) as Envelope; + } + + it('workspace fs:search finds files by registered workspace id', async () => { + await writeFile(join(work!, 'epsilon.ts'), ''); + const res = await fetch(`${base}/api/v1/workspaces`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ root: work }), + } as never); + const created = (await res.json()) as Envelope<{ id: string }>; + expect(created.code).toBe(0); + + const body = await postWorkspaceSearch<{ items: { path: string }[]; truncated: boolean }>({ + workspace: created.data.id, + query: 'epsilon', + }); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('epsilon.ts'); + }); + + it('workspace fs:search finds files by absolute root path', async () => { + await writeFile(join(work!, 'zeta.ts'), ''); + const body = await postWorkspaceSearch<{ items: { path: string }[]; truncated: boolean }>({ + workspace: work, + query: 'zeta', + }); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('zeta.ts'); + }); + + it('workspace fs:search lists top-level entries for an empty query', async () => { + await writeFile(join(work!, 'eta.ts'), ''); + const body = await postWorkspaceSearch<{ items: { path: string }[]; truncated: boolean }>({ + workspace: work, + query: '', + }); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('eta.ts'); + }); + + it('workspace fs:search maps an unknown ref to WORKSPACE_NOT_FOUND', async () => { + const body = await postWorkspaceSearch({ workspace: 'does-not-exist', query: 'x' }); + expect(body.code).toBe(ErrorCode.WORKSPACE_NOT_FOUND); + }); + + it('workspace fs:search rejects a missing workspace field with VALIDATION_FAILED', async () => { + const body = await postWorkspaceSearch({ query: 'x' }); + expect(body.code).toBe(ErrorCode.VALIDATION_FAILED); + }); }); diff --git a/packages/kap-server/test/rpc.test.ts b/packages/kap-server/test/rpc.test.ts index be903e643d..0240a32f85 100644 --- a/packages/kap-server/test/rpc.test.ts +++ b/packages/kap-server/test/rpc.test.ts @@ -13,7 +13,7 @@ import { IPluginService, ISessionIndex, ISessionMetadata, - IWorkspaceHandlerService, + ISessionLifecycleService, IWorkspaceService, getLiveSessionById, } from '@moonshot-ai/agent-core-v2'; @@ -296,7 +296,7 @@ describe('server-v2 /api/v1/debug RPC', () => { const workspaceId = (await server!.core.accessor.get(ISessionIndex).get(id))!.workspaceId; const { body } = await call( 'POST', - rpc('workspace', IWorkspaceHandlerService, 'archive', { wid: workspaceId }), + rpc('workspace', ISessionLifecycleService, 'archive', { wid: workspaceId }), id, ); expect(body.code).toBe(0); diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 83f8fa5d44..7cb3e37204 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -17,7 +17,7 @@ import { ISessionIndex, ISessionInteractionService, ISessionMetadata, - IWorkspaceHandlerService, + ISessionLifecycleService, IWorkspaceLifecycleService, LifecycleScope, SessionInteractionService, @@ -1885,7 +1885,7 @@ describe('bindSessionTranscript', () => { } function fakeCoreWithAgents(interactions: SessionInteractionService, agents: FakeAgents): Scope { - const handlerService = { + const sessionLifecycle = { onDidCloseSession: () => ({ dispose: () => undefined }), onDidArchiveSession: () => ({ dispose: () => undefined }), get: (sid: string) => (sid === 's1' ? fakeSession(interactions, agents) : undefined), @@ -1894,7 +1894,7 @@ describe('bindSessionTranscript', () => { id: 'ws', kind: LifecycleScope.Workspace, accessor: { - get: (t: unknown) => (t === IWorkspaceHandlerService ? handlerService : undefined), + get: (t: unknown) => (t === ISessionLifecycleService ? sessionLifecycle : undefined), }, dispose: () => undefined, }; diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 92b76b589b..234ed7ce94 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -30,7 +30,7 @@ import { ISessionInteractionService, IWireService, ISessionMetadata, - IWorkspaceHandlerService, + ISessionLifecycleService, IWorkspaceLifecycleService, MAIN_AGENT_ID, SECONDARY_DERIVED_MODEL_ID, @@ -367,7 +367,7 @@ function makeCore( }; return { id: sid, kind: LifecycleScope.Session, accessor: sessionAccessor, dispose: () => {} }; }; - const handlerService = { + const sessionLifecycle = { // Inert lifecycle events (TranscriptService subscribes on construction). onDidCloseSession: () => ({ dispose: () => {} }), onDidArchiveSession: () => ({ dispose: () => {} }), @@ -377,7 +377,7 @@ function makeCore( id: 'wd', kind: LifecycleScope.Workspace, accessor: { - get: (t: unknown) => (t === IWorkspaceHandlerService ? handlerService : undefined), + get: (t: unknown) => (t === ISessionLifecycleService ? sessionLifecycle : undefined), }, dispose: () => {}, }; diff --git a/packages/kap-server/test/snapshot.test.ts b/packages/kap-server/test/snapshot.test.ts index 5a268fda60..a850ff10ed 100644 --- a/packages/kap-server/test/snapshot.test.ts +++ b/packages/kap-server/test/snapshot.test.ts @@ -18,7 +18,7 @@ import { ISessionContext, ISessionIndex, ISessionMetadata, - IWorkspaceHandlerService, + ISessionLifecycleService, IWorkspaceLifecycleService, IWorkspaceService, getLiveSessionById, @@ -82,7 +82,7 @@ describe('server-v2 snapshot route enrichment', () => { const handler = { accessor: fakeAccessor([ [ - IWorkspaceHandlerService, + ISessionLifecycleService, { resume: async () => session, get: () => undefined }, ], ]), diff --git a/packages/klient/src/contract/index.ts b/packages/klient/src/contract/index.ts index b98836ec96..94d8f5a4a4 100644 --- a/packages/klient/src/contract/index.ts +++ b/packages/klient/src/contract/index.ts @@ -31,7 +31,7 @@ import { workspacesContract } from './global/workspaces.js'; import { sessionApprovalContract } from './session/approval.js'; import { sessionInteractionContract } from './session/interaction.js'; import { - workspaceHandlerContract, + sessionLifecycleContract, workspaceLifecycleContract, } from './session/lifecycle.js'; import { sessionMetadataContract } from './session/metadata.js'; @@ -54,7 +54,7 @@ export const globalContract: KlientContract = { bootstrapService: envContract, // workspace scope (+ the app-registered handler registry) workspaceLifecycleService: workspaceLifecycleContract, - workspaceHandlerService: workspaceHandlerContract, + sessionLifecycleService: sessionLifecycleContract, // session scope sessionMetadata: sessionMetadataContract, sessionInteractionService: sessionInteractionContract, diff --git a/packages/klient/src/contract/session/lifecycle.ts b/packages/klient/src/contract/session/lifecycle.ts index 0941330312..e2fbde99b2 100644 --- a/packages/klient/src/contract/session/lifecycle.ts +++ b/packages/klient/src/contract/session/lifecycle.ts @@ -1,11 +1,11 @@ /** - * `workspaceHandlerService` / `workspaceLifecycleService` — session + * `sessionLifecycleService` / `workspaceLifecycleService` — session * lifecycle after the Workspace-domain split. The App-scope * `workspaceLifecycleService` materializes one handler per workspace - * (`handlerFor`); the Workspace-scope `workspaceHandlerService` owns that + * (`handlerFor`); the Workspace-scope `sessionLifecycleService` owns that * workspace's sessions (create/resume/close/archive/restore/fork/ * createChild). Mirrors `agent-core-v2/app/workspaceLifecycle/*` and - * `agent-core-v2/workspace/workspaceHandler/*`. The engine returns scope + * `agent-core-v2/workspace/sessionLifecycle/*`. The engine returns scope * handles; over JSON only the plain data fields survive, so the wire keeps * `{ id, kind }` (loose — extra fields may appear in-process). */ @@ -47,7 +47,7 @@ export const workspaceLifecycleContract = { handlerFor: { input: z.tuple([workspaceRefSchema]), output: handleWireSchema }, } satisfies ServiceContract; -export const workspaceHandlerContract = { +export const sessionLifecycleContract = { create: { input: z.tuple([createSessionOptionsSchema]), output: handleWireSchema }, resume: { input: z.tuple([z.string()]), output: maybe(handleWireSchema) }, close: { input: z.tuple([z.string()]), output: noResult }, diff --git a/packages/klient/src/core/facade/global.ts b/packages/klient/src/core/facade/global.ts index d21a5dbbe4..04371c86a3 100644 --- a/packages/klient/src/core/facade/global.ts +++ b/packages/klient/src/core/facade/global.ts @@ -277,7 +277,7 @@ export function createGlobalFacade(scoped: ScopedCaller, scopedStream: ScopedStr const handler = (await scoped({}, 'workspaceLifecycleService', 'handlerFor', [ { root: workDir }, ])) as { id: string }; - const handle = (await scoped({ workspaceId: handler.id }, 'workspaceHandlerService', 'create', [ + const handle = (await scoped({ workspaceId: handler.id }, 'sessionLifecycleService', 'create', [ { workDir, additionalDirs }, ])) as { id: string }; const scope = { sessionId: handle.id }; diff --git a/packages/klient/src/core/facade/session.ts b/packages/klient/src/core/facade/session.ts index 9df21846b2..b4daa19496 100644 --- a/packages/klient/src/core/facade/session.ts +++ b/packages/klient/src/core/facade/session.ts @@ -34,7 +34,7 @@ const NOT_FOUND = 40404; export type { ScopedCaller } from './global.js'; -/** What `workspaceHandlerService.create/fork/createChild` leaves on the wire. */ +/** What `sessionLifecycleService.create/fork/createChild` leaves on the wire. */ interface HandleWire { readonly id: string; } @@ -105,7 +105,7 @@ export function createSessionFacade(call: ScopedCaller, sessionId: string): Sess if (workspaceId === undefined) { throw new RPCError(NOT_FOUND, `session not found: ${sessionId}`); } - const handle = (await call({ workspaceId }, 'workspaceHandlerService', method, [ + const handle = (await call({ workspaceId }, 'sessionLifecycleService', method, [ { sourceSessionId: sessionId, title: input.title, metadata: input.metadata }, ])) as HandleWire; return call({ sessionId: handle.id }, 'sessionMetadata', 'read', []) as Promise; @@ -145,17 +145,17 @@ export function createSessionFacade(call: ScopedCaller, sessionId: string): Sess close: async () => { const workspaceId = await resolveWorkspaceId(); if (workspaceId === undefined) return; - await call({ workspaceId }, 'workspaceHandlerService', 'close', [sessionId]); + await call({ workspaceId }, 'sessionLifecycleService', 'close', [sessionId]); }, archive: async () => { const workspaceId = await resolveWorkspaceId(); if (workspaceId === undefined) return; - await call({ workspaceId }, 'workspaceHandlerService', 'archive', [sessionId]); + await call({ workspaceId }, 'sessionLifecycleService', 'archive', [sessionId]); }, restore: async () => { const workspaceId = await resolveWorkspaceId(); if (workspaceId === undefined) return false; - const handle = (await call({ workspaceId }, 'workspaceHandlerService', 'restore', [ + const handle = (await call({ workspaceId }, 'sessionLifecycleService', 'restore', [ sessionId, ])) as HandleWire | null; return handle !== null; diff --git a/packages/klient/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts index b06d1c571a..e7277567f1 100644 --- a/packages/klient/src/transports/memory/serviceRegistry.ts +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -23,7 +23,7 @@ import { IBootstrapService } from '@moonshot-ai/agent-core-v2/app/bootstrap/boot import { IEventService } from '@moonshot-ai/agent-core-v2/app/event/event'; import { IHostFolderBrowser } from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; import { IWorkspaceLifecycleService } from '@moonshot-ai/agent-core-v2/app/workspaceLifecycle/workspaceLifecycle'; -import { IWorkspaceHandlerService } from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler'; +import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/workspace/sessionLifecycle/sessionLifecycle'; import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval'; @@ -52,7 +52,7 @@ export const serviceTokens: Readonly>> hostFolderBrowser: IHostFolderBrowser, bootstrapService: IBootstrapService, workspaceLifecycleService: IWorkspaceLifecycleService, - workspaceHandlerService: IWorkspaceHandlerService, + sessionLifecycleService: ISessionLifecycleService, sessionMetadata: ISessionMetadata, sessionInteractionService: ISessionInteractionService, sessionApprovalService: ISessionApprovalService, diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index 2185a86c51..7d7a17942a 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -44,7 +44,7 @@ import type { CreateChildSessionOptions, CreateSessionOptions, ForkSessionOptions, -} from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler'; +} from '@moonshot-ai/agent-core-v2/workspace/sessionLifecycle/sessionLifecycle'; import type { ApprovalRequest, ApprovalResponse, diff --git a/packages/minidb/src/compaction.ts b/packages/minidb/src/compaction.ts index 3da409fb16..9b09ef1484 100644 --- a/packages/minidb/src/compaction.ts +++ b/packages/minidb/src/compaction.ts @@ -33,7 +33,8 @@ // finishes; the pause scales with the tail the pre-copy did // not drain — the same bounded end-of-rewrite pause Redis // accepts for its AOF diff flush. -// 4. bookkeeping — stats + onCompacted() (rebuild derived text postings). +// 4. bookkeeping — stats + awaiting onCompacted() (rebuild derived text +// postings — yields to the event loop, writers unaffected). // // Crash safety: recovery is `load db.snapshot` + `replay db.wal`, last-writer // wins. We rename the snapshot BEFORE the WAL. If a crash lands between the two @@ -71,10 +72,10 @@ export interface CompactionTarget { * remapped value pointers read from the new files. On Windows it is also * closed before the rotation renames (see rotateReplace). */ valueReader?: { reopenBoth(): void; close?(): void }; - /** Optional hook invoked after the snapshot + WAL rotation succeeds, so the - * owner can rewrite derived on-disk state (e.g. text postings) against the - * new live set. */ - onCompacted?: () => void; + /** Optional hook invoked (and awaited) after the snapshot + WAL rotation + * succeeds, so the owner can rewrite derived on-disk state (e.g. text + * postings) against the new live set. */ + onCompacted?: () => void | Promise; } export function shouldCompact(db: CompactionTarget): boolean { @@ -161,7 +162,7 @@ export async function compact(db: CompactionTarget): Promise { await runCompaction(db); // The onCompacted hook is part of the compaction: a run whose hook // throws is counted as a compactError, not a successful compaction. - db.onCompacted?.(); + await db.onCompacted?.(); db.stats.compactions++; db.lastCompactError = null; } catch (err) { diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts index 7458909712..53f91e69ae 100644 --- a/packages/minidb/src/index.ts +++ b/packages/minidb/src/index.ts @@ -286,9 +286,10 @@ export class MiniDb { /** Hook called by compaction after the store snapshot + WAL are rotated, so * derived on-disk state (text postings) can be rewritten against the new - * live set. Structural part of the CompactionTarget interface. */ - onCompacted = (): void => { - this.rebuildTextPostings(); + * live set. Structural part of the CompactionTarget interface; the + * compaction awaits it, so it may be sync or async. */ + onCompacted: () => void | Promise = async (): Promise => { + await this.rebuildTextPostings(); }; static async open(opts: OpenOptions): Promise> { @@ -395,12 +396,20 @@ export class MiniDb { await db.loadIndexDefinitions(); await db.loadCompoundIndexDefinitions(); await db.loadTextIndexDefinitions(); - db.rebuildAllIndexes(); + await db.rebuildAllIndexes(); // A read-only instance never compacts: rotation would rename the live // writer's snapshot/WAL out from under it and lose its acknowledged data. - if (!db.readOnly && db.autoCompact && shouldCompact(db)) await compact(db); + // The writer's open-time compaction is fire-and-forget (same as + // maybeAutoCompact): recovery already applied the full WAL, so the db is + // complete and consistent the moment open() returns. Awaiting the + // compaction here blocked open() on the whole snapshot rewrite + text + // postings rebuild — tens of seconds of stalled startup on a large db. + if (!db.readOnly && db.autoCompact && shouldCompact(db)) compact(db).catch(() => {}); } catch (err) { + // A background open-time compaction may still be in flight: settle it + // before tearing down the WAL/store/handles it touches. + if (db.compacting && db._compactDone) await db._compactDone.catch(() => {}); // Release every resource acquired so far: an open that fails after the // WAL/store are set up must not leak a file handle or keep the everysec / // active-expire timers running. @@ -490,19 +499,24 @@ export class MiniDb { return path.join(this.dir, `db.text-${safe}.postings`); } - /** Rebuild every text index's on-disk postings from the live Store. Drops - * the in-memory delta + tombstones and reclaims orphaned postings records. - * Invoked after compaction (postings are pure derived state, so this is - * only for space/latency, never for correctness). */ - private rebuildTextPostings(): void { - for (const ti of this.text.values()) ti.build(this.textRecords()); + /** Rebuild every dirty text index's on-disk postings from the live Store. + * Drops the in-memory delta + tombstones and reclaims orphaned postings + * records. Invoked after compaction (postings are pure derived state, so + * this is only for space/latency, never for correctness). Indexes with an + * empty write buffer are skipped: the open-time build just produced a + * fresh base, so a compaction landing right after open must not redo the + * exact same (expensive) pass. */ + private async rebuildTextPostings(): Promise { + for (const ti of this.text.values()) { + if (ti.needsRebuild()) await ti.build(this.textRecords()); + } } - private rebuildAllIndexes(): void { + private async rebuildAllIndexes(): Promise { this.indexes.rebuild(this._liveRecordsRaw()); this.dt.rebuild([...this.liveRecords()].map(({ key, dt }) => ({ key: this.pk(key), dt }))); this.compound.rebuild(this.liveRecords()); - for (const [, ti] of this.text) ti.build(this.textRecords()); + for (const [, ti] of this.text) await ti.build(this.textRecords()); } private *_liveRecordsRaw(): Generator<{ key: Buffer; value: unknown }> { @@ -1246,11 +1260,20 @@ export class MiniDb { if (this.text.has(name)) throw new Error(`text index "${name}" already exists`); const ti = new TextIndex({ fields, ...textIndexTokenizers(tokenizer), postingsPath: this.textPostingsPath(name) }); const def: TextIndexDef = { name, fields: fields ?? null, tokenizer }; - // Build BEFORE registering: a failed build must leave no phantom index - // behind — a registered-but-unbuilt index would both poison every write - // path that walks this.text and make a retry fail with "already exists". - ti.build(this.textRecords()); + // Register BEFORE building: the build yields to the event loop, and + // registering makes concurrent writes feed the index's build queue, which + // the build replays onto the new base — so the finished index reflects + // every write whenever it landed. Until the build completes, searches on + // the index see only its post-registration delta. A failed build unwinds + // the registration, so a retry cannot hit a phantom "already exists". this.text.set(name, ti); + try { + await ti.build(this.textRecords()); + } catch (e) { + this.text.delete(name); + ti.close(); + throw e; + } this.textDefs.push(def); try { await this.persistTextIndexDefinitions(); @@ -1269,6 +1292,9 @@ export class MiniDb { this.ensureOpen(); this.ensureWritable(); const ti = this.text.get(name); + // Dropping mid-build would orphan the in-flight postings write (the file + // is removed while the build is still producing it). + if (ti?.building) throw new Error(`text index "${name}" is still building`); const ok = this.text.delete(name); if (ti) { ti.close(); diff --git a/packages/minidb/src/text-index.ts b/packages/minidb/src/text-index.ts index 5080840e1f..3830b9a0a5 100644 --- a/packages/minidb/src/text-index.ts +++ b/packages/minidb/src/text-index.ts @@ -13,6 +13,11 @@ // LRU cache), merges the in-memory `delta`, drops tombstones, and scores // by TF-IDF. Synchronous by design so db.search()/db.query() keep their // synchronous API. +// - Builds: open and compaction rebuild the whole index from the Store. +// `build()` is async and yields to the event loop periodically, so a big +// rebuild never hard-blocks the host process; writes landing mid-build +// keep applying to the live view (searches stay correct) and are queued +// for a synchronous replay onto the new base at swap time. // - Durability: the postings file is a pure derived cache of the Store; it is // rebuilt from the Store on open and on compaction. The Store (snapshot + // WAL) is the source of truth, so a crash never loses postings — they are @@ -31,6 +36,12 @@ const CJK = /[\u3400-\u9fff\u3040-\u30ff\uff00-\uffef]+/g; // pathological document cannot destroy the index. const MAX_TERM_CHARS = 0xffff; +const yieldToLoop = (): Promise => new Promise((r) => setImmediate(r)); +// `build()` yields to the event loop at the first of these two watermarks, so +// many-small-docs and few-huge-docs corpora are both bounded per slice. +const BUILD_YIELD_DOCS = 2048; +const BUILD_YIELD_TOKENS = 500_000; + /** Tokenize text into terms (lowercased latin words + CJK uni/bigrams). */ export function tokenize(str: unknown): string[] { const s = String(str).toLowerCase(); @@ -93,6 +104,11 @@ export interface SearchOptions { const EMPTY_MAP: ReadonlyMap = new Map(); +/** One write that landed while a `build()` was in flight (see buildQueue). */ +type BuildOp = + | { readonly kind: 'add'; readonly key: string; readonly doc: unknown } + | { readonly kind: 'remove'; readonly key: string }; + export class TextIndex { private readonly fields: readonly string[] | null; private readonly tokenizer: (text: string) => string[]; @@ -114,6 +130,17 @@ export class TextIndex { private deltaCount = 0; private readonly removed = new Set(); // tombstoned docIDs + /** + * Ops that landed while a `build()` was in flight. The ops ALSO apply to + * the live view as usual (searches stay correct during the build); the + * queue exists so the freshly-staged base can replay them at swap time — + * the staged iteration may have missed them (already-visited key) or seen + * them (unvisited key), so replaying the exact op stream onto the new base + * is what keeps the rebuild precise instead of eventually consistent. + * Null outside a build. + */ + private buildQueue: BuildOp[] | null = null; + // Memory-base mode (no postingsPath): base postings kept in RAM. private memBase: Map> | null = null; @@ -158,99 +185,158 @@ export class TextIndex { return n; } + /** Whether a `build()` is currently in flight. */ + get building(): boolean { + return this.buildQueue !== null; + } + + /** + * Whether a postings rebuild would change anything: the write buffer + * (delta + tombstones) is non-empty. Right after a build both are empty, so + * a compaction landing immediately after an open-time build does not redo + * the exact same pass. A build in flight also counts as fresh — its queue + * replay already folds every concurrent mutation into the new base. + */ + needsRebuild(): boolean { + if (this.buildQueue !== null) return false; + return this.deltaCount > 0 || this.removed.size > 0; + } + /** * Rebuild the index from scratch over `entries` (the live Store view). * Assigns fresh dense docIDs, writes a new postings file (disk mode) or * replaces the in-memory base (memory mode), and clears the delta + * tombstones. Called on open and on compaction. * + * Async and event-loop friendly: the tokenization pass yields every + * BUILD_YIELD_DOCS docs / BUILD_YIELD_TOKENS tokens and the postings write + * batches its I/O, so a large rebuild never hard-blocks the host process + * for many seconds the way the old fully-synchronous build did. Mutations + * arriving mid-build keep applying to the live view (searches stay correct) + * and are recorded in `buildQueue`; once the new base is swapped in, the + * queue is replayed synchronously, so the result is exactly as if those ops + * had arrived after the rebuild. + * * Atomic on failure: everything is staged off to the side first and swapped * in only after the new postings file is durably renamed (disk mode), so a - * failed rebuild (e.g. a transient ENOSPC/EMFILE inside rebuildSync) leaves - * the PREVIOUS index fully functional instead of silently emptying it until - * the next successful build. + * failed rebuild (e.g. a transient ENOSPC/EMFILE inside PostingsFile.rebuild) + * leaves the PREVIOUS index fully functional instead of silently emptying it + * until the next successful build. */ - build(entries: Iterable<{ key: string; value: unknown }>): void { - // Staged state. - const agg = new Map>(); // term -> (docID -> freq) - const newKeys: (string | undefined)[] = []; // docID -> key - const newKeyToId = new Map(); // key -> docID - const newDocLen = new Map(); // docID -> token count - let n = 0; - for (const { key, value } of entries) { - const docID = newKeys.length; - newKeys.push(key); - newKeyToId.set(key, docID); - const tokens = this.tokenizer(this.extract(value)); - const counts = new Map(); - for (const t of tokens) counts.set(t, (counts.get(t) ?? 0) + 1); - for (const [t, c] of counts) { - let m = agg.get(t); - if (!m) agg.set(t, (m = new Map())); - m.set(docID, c); // docIDs increase monotonically -> insertion order is sorted + async build(entries: Iterable<{ key: string; value: unknown }>): Promise { + if (this.buildQueue !== null) throw new Error('text index build already in progress'); + const queue: BuildOp[] = []; + this.buildQueue = queue; + try { + // Staged state. + const agg = new Map>(); // term -> (docID -> freq) + const newKeys: (string | undefined)[] = []; // docID -> key + const newKeyToId = new Map(); // key -> docID + const newDocLen = new Map(); // docID -> token count + let n = 0; + let docsSinceYield = 0; + let tokensSinceYield = 0; + for (const { key, value } of entries) { + const docID = newKeys.length; + newKeys.push(key); + newKeyToId.set(key, docID); + const tokens = this.tokenizer(this.extract(value)); + const counts = new Map(); + for (const t of tokens) counts.set(t, (counts.get(t) ?? 0) + 1); + for (const [t, c] of counts) { + let m = agg.get(t); + if (!m) agg.set(t, (m = new Map())); + m.set(docID, c); // docIDs increase monotonically -> insertion order is sorted + } + newDocLen.set(docID, tokens.length); + n++; + docsSinceYield++; + tokensSinceYield += tokens.length; + if (docsSinceYield >= BUILD_YIELD_DOCS || tokensSinceYield >= BUILD_YIELD_TOKENS) { + docsSinceYield = 0; + tokensSinceYield = 0; + await yieldToLoop(); + } } - newDocLen.set(docID, tokens.length); - n++; - } - if (this.path) { - // Disk mode: write the new postings file (tmp + fsync + atomic rename in - // rebuildSync). The old read handle is closed before the rename (an open - // fd would block the rename on Windows), so the in-memory state below is - // NOT touched until the new file is in place — if rebuildSync throws, - // the old file is still intact and is simply re-attached. - const oldPf = this.pf; - if (oldPf) { - oldPf.close(); - this.pf = null; - } - let dict: Map; - try { - dict = PostingsFile.rebuildSync(this.path, aggToSorted(agg)); - } catch (e) { - // rebuildSync failed before the atomic rename, so the old file is - // intact: re-attach it and keep serving the previous index until the - // next successful build. - if (oldPf) { - try { - this.pf = PostingsFile.open(this.path); - } catch { - /* old handle unrecoverable; the next successful build fixes it */ + if (this.path) { + // Disk mode: write the new postings file (tmp + fsync + atomic rename + // in PostingsFile.rebuild). The old read handle is closed only at the + // rename — and only on Windows, where an open fd would block it (POSIX + // keeps the old inode readable through the rename, so searches never + // lose the base). A rebuild that throws before its commit leaves the + // old file/handle untouched; a commit-time failure re-attaches it. + const oldPf = this.pf; + let dict: Map; + try { + dict = await PostingsFile.rebuild(this.path, aggToSorted(agg), { + beforeRename: + process.platform === 'win32' && oldPf !== null + ? () => { + oldPf.close(); + if (this.pf === oldPf) this.pf = null; + } + : undefined, + }); + } catch (e) { + if (oldPf !== null && !oldPf.open) { + try { + this.pf = PostingsFile.open(this.path); + } catch { + /* old handle unrecoverable; the next successful build fixes it */ + } } + throw e; } - throw e; + // The rename happened — the old postings are replaced on disk, so from + // here the swap commits to the new index. A failed reopen (EMFILE & co.) + // is not special-cased: readBase treats a null pf as an empty base, so + // reads degrade to delta-only until the next build instead of reading + // through a stale dictionary. + const newPf = PostingsFile.open(this.path); + this.postings.clear(); + for (const [t, e] of dict) this.postings.set(t, e); + oldPf?.close(); + this.pf = newPf; + } else { + // Memory mode: pure in-memory staging, no fallible I/O involved. + this.memBase = agg; } - // The rename happened — the old postings are replaced on disk, so from - // here the swap commits to the new index. A failed reopen (EMFILE & co.) - // is not special-cased: readBase treats a null pf as an empty base, so - // reads degrade to delta-only until the next build instead of reading - // through a stale dictionary. - const newPf = PostingsFile.open(this.path); - this.postings.clear(); - for (const [t, e] of dict) this.postings.set(t, e); - this.pf = newPf; - } else { - // Memory mode: pure in-memory staging, no fallible I/O involved. - this.memBase = agg; - } - // Swap in the staged per-doc state and drop the write buffer. - this.docLen.clear(); - for (const [id, len] of newDocLen) this.docLen.set(id, len); - this.keys.length = 0; - for (const k of newKeys) this.keys.push(k); - this.keyToId.clear(); - for (const [k, id] of newKeyToId) this.keyToId.set(k, id); - this.delta.clear(); - this.deltaCount = 0; - this.removed.clear(); - this.cache.clear(); - this.N = n; + // Swap in the staged per-doc state, drop the write buffer, and replay + // the ops that landed mid-build onto the new base — one synchronous + // segment, so no mutation can interleave mid-swap. + this.docLen.clear(); + for (const [id, len] of newDocLen) this.docLen.set(id, len); + this.keys.length = 0; + for (const k of newKeys) this.keys.push(k); + this.keyToId.clear(); + for (const [k, id] of newKeyToId) this.keyToId.set(k, id); + this.delta.clear(); + this.deltaCount = 0; + this.removed.clear(); + this.cache.clear(); + this.N = n; + this.buildQueue = null; + for (const op of queue) { + if (op.kind === 'add') this.add(op.key, op.doc); + else this.remove(op.key); + } + } catch (e) { + // Staging never touched the live view, so the previous index is intact; + // the queued ops were already applied to it — just disarm the queue. + if (this.buildQueue === queue) this.buildQueue = null; + throw e; + } } /** Add or replace a document. Overwrites tombstone the old docID. */ add(key: string, doc: unknown): void { - if (this.keyToId.has(key)) this.remove(key); + this.buildQueue?.push({ kind: 'add', key, doc }); + // The overwrite's internal remove must NOT queue a second op: replaying + // the queue applies the add (which itself displaces the old docID), and a + // queued remove would then delete the freshly-added doc. + if (this.keyToId.has(key)) this.removeInner(key); const docID = this.keys.length; this.keys.push(key); this.keyToId.set(key, docID); @@ -269,6 +355,11 @@ export class TextIndex { /** Remove a document by key (tombstone its docID). */ remove(key: string): void { + this.buildQueue?.push({ kind: 'remove', key }); + this.removeInner(key); + } + + private removeInner(key: string): void { const id = this.keyToId.get(key); if (id === undefined) return; this.removed.add(id); diff --git a/packages/minidb/src/text-postings.ts b/packages/minidb/src/text-postings.ts index 4687d3db55..116e52120e 100644 --- a/packages/minidb/src/text-postings.ts +++ b/packages/minidb/src/text-postings.ts @@ -22,11 +22,15 @@ // and gives ~5-10x compression for dense docID ranges. import fs from 'node:fs'; +import fsp from 'node:fs/promises'; import path from 'node:path'; import { crc32 } from './crc32.js'; const HEADER_LEN = 2 + 4 + 4; // termLen + df + payloadLen (term is variable) const CRC_LEN = 4; +// Coalesce record writes into ~1 MiB writev batches; each batch await is also +// the rebuild's event-loop yield point. +const FLUSH_BYTES = 1 << 20; // ---- varint (unsigned LEB128, uint32) ------------------------------------ @@ -147,7 +151,8 @@ export interface PostingEntry { * Append-only postings file with synchronous positioned reads. Synchronous I/O * is deliberate: `TextIndex.search()` is synchronous (so `db.search()` / * `db.query()` keep their sync API), and hot records are served from the OS - * page cache or the in-memory LRU cache anyway. + * page cache or the in-memory LRU cache anyway. Rewrites ({@link rebuild}) are + * the async counterpart — they run in the background of a live database. */ export class PostingsFile { private fd: number | null = null; @@ -157,7 +162,7 @@ export class PostingsFile { /** * Open an existing postings file for positioned reads. Throws if the file is * missing — callers treat a missing file as an empty index. Read-only: the - * file is only ever rewritten wholesale by {@link rebuildSync}, so the fd + * file is only ever rewritten wholesale by {@link rebuild}, so the fd * stays valid until the next rebuild (which the caller must close + reopen). */ static open(filePath: string): PostingsFile { @@ -197,33 +202,53 @@ export class PostingsFile { * atomically renames over ``. Returns the new term dictionary. The old * file (if any) is replaced only after the new one is fully durable, so a * crash mid-build leaves the previous file intact. + * + * Async so a large rebuild does not starve the event loop: record writes are + * coalesced into ~1 MiB writev batches (each batch await is a yield point). + * The commit section is SYNCHRONOUS — `hooks.beforeRename` (e.g. closing the + * previous read handle, required on Windows) and the rename itself run as + * one atomic step, so a reader swaps over without an interleavable gap. */ - static rebuildSync( + static async rebuild( filePath: string, iter: Iterable<{ term: string; entries: readonly (readonly [number, number])[] }>, - ): Map { + hooks: { beforeRename?: () => void } = {}, + ): Promise> { const tmp = filePath + '.tmp'; - const fd = fs.openSync(tmp, 'w'); const dict = new Map(); let off = 0; + let batch: Buffer[] = []; + let batchBytes = 0; + const fh = await fsp.open(tmp, 'w'); + const flushBatch = async (): Promise => { + if (batch.length === 0) return; + const buf = Buffer.concat(batch); + batch = []; + batchBytes = 0; + let written = 0; + while (written < buf.length) { + const { bytesWritten } = await fh.write(buf, written); + if (bytesWritten === 0) throw new Error('postings: rebuild write made no progress'); + written += bytesWritten; + } + }; try { for (const { term, entries } of iter) { if (entries.length === 0) continue; const payload = encodePostingList(entries); const rec = encodeRecord(term, entries.length, payload); - let written = 0; - while (written < rec.length) { - const w = fs.writeSync(fd, rec, written, rec.length - written, off + written); - if (w === 0) throw new Error('postings: rebuild write made no progress'); - written += w; - } dict.set(term, { off, len: rec.length, df: entries.length }); + batch.push(rec); + batchBytes += rec.length; off += rec.length; + if (batchBytes >= FLUSH_BYTES) await flushBatch(); } - fs.fsyncSync(fd); + await flushBatch(); + await fh.sync(); } finally { - fs.closeSync(fd); + await fh.close(); } + hooks.beforeRename?.(); fs.renameSync(tmp, filePath); // Best-effort directory fsync so the rename survives a crash. try { diff --git a/packages/minidb/test/compaction-fault.test.ts b/packages/minidb/test/compaction-fault.test.ts index bea39dfa50..bb544f8456 100644 --- a/packages/minidb/test/compaction-fault.test.ts +++ b/packages/minidb/test/compaction-fault.test.ts @@ -344,9 +344,9 @@ test('a compaction whose onCompacted hook throws counts as a compactError, not a const hook = db.onCompacted; let failHook = true; - db.onCompacted = () => { + db.onCompacted = async () => { if (failHook) throw new Error('injected hook failure'); - hook(); + await hook(); }; await assert.rejects(db.compact(), /injected hook failure/); // The hook is part of the compaction: compactions counts only fully diff --git a/packages/minidb/test/compaction.test.ts b/packages/minidb/test/compaction.test.ts index c2e64480b1..e936587492 100644 --- a/packages/minidb/test/compaction.test.ts +++ b/packages/minidb/test/compaction.test.ts @@ -80,6 +80,32 @@ test('auto-compaction triggers when the WAL crosses the threshold', async () => } }); +test('open-time compaction runs in the background — open() returns with the full dataset', async () => { + const dir = await tmpDir(); + try { + // Grow the WAL well past the threshold without any compaction. + let db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + for (let i = 0; i < 200; i++) await db.set(`k${i}`, `v${i}`.padEnd(50, 'x')); + await db.close(); + + // Reopen with a 1 KiB threshold: the WAL far exceeds it, so compaction + // fires — but open() must NOT block on the rewrite. The recovered store + // is already complete and consistent the moment open() resolves. + db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', compactThresholdBytes: 1024 }); + assert.equal(db.size, 200); + for (let i = 0; i < 200; i++) assert.equal(db.get(`k${i}`), `v${i}`.padEnd(50, 'x')); + + // The background compaction finishes on its own and shrinks the WAL. + if (db.compacting) await db._compactDone; + assert.ok(db.stats.compactions >= 1, 'open-time background compaction ran'); + assert.ok((await fs.stat(path.join(dir, 'db.wal'))).size < 1024, 'WAL shrank'); + assert.equal(db.size, 200); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + test('del-then-compact drops tombstoned keys from the snapshot', async () => { const dir = await tmpDir(); try { diff --git a/packages/minidb/test/text-index.test.ts b/packages/minidb/test/text-index.test.ts index 9489d8233e..85f73b22f7 100644 --- a/packages/minidb/test/text-index.test.ts +++ b/packages/minidb/test/text-index.test.ts @@ -69,7 +69,7 @@ test('PostingsFile: rebuild + positioned read', async () => { const dir = await tmpDir(); try { const p = path.join(dir, 'x.postings'); - const dict = PostingsFile.rebuildSync(p, [ + const dict = await PostingsFile.rebuild(p, [ { term: 'hello', entries: [ @@ -103,7 +103,7 @@ test('PostingsFile: rebuild + positioned read', async () => { pf.close(); // rebuild is atomic: a second rebuild replaces the file and dict. - const dict2 = PostingsFile.rebuildSync(p, [{ term: 'only', entries: [[7, 1]] }]); + const dict2 = await PostingsFile.rebuild(p, [{ term: 'only', entries: [[7, 1]] }]); assert.equal(dict2.size, 1); const pf2 = PostingsFile.open(p); assert.deepEqual(pf2.read(dict2.get('only')!), [[7, 1]]); @@ -117,7 +117,7 @@ test('PostingsFile: corrupt record throws on read', async () => { const dir = await tmpDir(); try { const p = path.join(dir, 'x.postings'); - const dict = PostingsFile.rebuildSync(p, [{ term: 'a', entries: [[1, 1]] }]); + const dict = await PostingsFile.rebuild(p, [{ term: 'a', entries: [[1, 1]] }]); // flip a byte in the file payload const e = dict.get('a')!; const fd = fssync.openSync(p, 'r+'); @@ -190,7 +190,7 @@ test('TextIndex: build persists to disk + merges delta after build', async () => try { const p = path.join(dir, 't.postings'); const ti = new TextIndex({ postingsPath: p }); - ti.build([ + await ti.build([ { key: 'a', value: { bio: 'hello world' } }, { key: 'b', value: { bio: '我住在北京' } }, ]); @@ -206,7 +206,7 @@ test('TextIndex: build persists to disk + merges delta after build', async () => // (delta is volatile by design; the db rebuilds from the Store on open). const ti2 = new TextIndex({ postingsPath: p }); // rebuild base from the file's perspective by re-reading the same entries - ti2.build([ + await ti2.build([ { key: 'a', value: { bio: 'hello world' } }, { key: 'b', value: { bio: '我住在北京' } }, ]); @@ -217,6 +217,42 @@ test('TextIndex: build persists to disk + merges delta after build', async () => } }); +test('TextIndex: writes landing mid-build are replayed onto the new base', async () => { + const dir = await tmpDir(); + try { + const p = path.join(dir, 't.postings'); + const ti = new TextIndex({ postingsPath: p }); + // More docs than BUILD_YIELD_DOCS (2048), so the build is guaranteed to + // yield at least once before its swap — the setImmediate below then lands + // strictly inside the build window. + const docs = Array.from({ length: 3000 }, (_, i) => ({ + key: `d${i}`, + value: { bio: `hello doc${i}` }, + })); + const buildP = ti.build(docs); + let landedMidBuild = false; + setImmediate(() => { + landedMidBuild = ti.building; + ti.add('extra', { bio: 'hello extra' }); // new key + ti.add('d0', { bio: 'goodbye replaced' }); // overwrite a staged key + ti.remove('d1'); // delete a staged key + }); + await buildP; + assert.ok(landedMidBuild, 'writes landed while the build was in flight'); + + // Live view during the build stayed correct, and the queue replay made + // the new base exact: 3000 staged + extra − replaced-d0 − removed-d1. + assert.equal(ti.N, 3000); + assert.deepEqual(ti.search('extra').map((h) => h.key), ['extra']); + assert.deepEqual(ti.search('goodbye').map((h) => h.key), ['d0']); + assert.deepEqual(ti.search('doc1').map((h) => h.key), []); + assert.equal(ti.search('hello', { limit: 10_000 }).length, 2999); + ti.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + test('TextIndex: memory-only mode (no postingsPath)', () => { const ti = new TextIndex(); // memory base ti.add('a', { bio: 'hello world' }); @@ -277,6 +313,64 @@ test('MiniDb: compaction rebuilds postings (file reclaimed)', async () => { } }); +test('MiniDb: compaction skips the postings rebuild when the index is clean', async () => { + const dir = await tmpDir(); + // Count TextIndex.build calls to prove which compactions rebuilt postings. + const orig = TextIndex.prototype.build; + let builds = 0; + TextIndex.prototype.build = async function (this: TextIndex, ...args) { + builds++; + return orig.apply(this, args); + } as typeof orig; + try { + const db = await MiniDb.open({ dir, valueCodec: 'json', autoCompact: false }); + await db.createTextIndex('bio', { fields: ['bio'] }); // build #1 + await db.set('a', { bio: 'hello world' }); + await db.compact(); // delta dirty -> rebuild #2 + await db.compact(); // clean now -> rebuild skipped + assert.equal(builds, 2); + assert.deepEqual(db.search('bio', 'hello').map((h) => h.key), ['a']); + await db.close(); + } finally { + TextIndex.prototype.build = orig; + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('MiniDb: writes during a compaction postings rebuild stay consistent', async () => { + const dir = await tmpDir(); + try { + const db = await MiniDb.open({ dir, valueCodec: 'json', autoCompact: false }); + await db.createTextIndex('bio', { fields: ['bio'] }); + // More docs than the snapshot yield cadence, so the compaction is still + // running when the setImmediate writes below land. + for (let i = 0; i < 3000; i++) await db.set(`d${i}`, { bio: `hello doc${i}` }); + + const compactP = db.compact(); + setImmediate(() => { + void db.set('extra', { bio: 'hello extra' }); + void db.set('d0', { bio: 'goodbye replaced' }); + void db.del('d1'); + }); + await compactP; + assert.equal(db.stats.compactions, 1); + + assert.equal(db.search('bio', 'hello', { limit: 10_000 }).length, 2999); + assert.deepEqual(db.search('bio', 'extra').map((h) => h.key), ['extra']); + assert.deepEqual(db.search('bio', 'goodbye').map((h) => h.key), ['d0']); + assert.deepEqual(db.search('bio', 'doc1').map((h) => h.key), []); + await db.close(); + + // The mid-compaction writes are durable and consistent across a reopen. + const db2 = await MiniDb.open({ dir, valueCodec: 'json' }); + assert.equal(db2.search('bio', 'hello', { limit: 10_000 }).length, 2999); + assert.deepEqual(db2.search('bio', 'extra').map((h) => h.key), ['extra']); + await db2.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + // ---- trigram (n-gram literal tokenizer) ------------------------------------ test('trigram: normalization (case, NFKC, code points)', () => { @@ -379,7 +473,7 @@ test('TextIndex: n-gram tokenizer delta add/remove/overwrite', async () => { tokenizer: createNgramTokenizer(), queryTokenizer: createNgramTokenizer({ forQuery: true }), }); - ti.build([{ key: 'a', value: { text: 'C++ guide' } }]); + await ti.build([{ key: 'a', value: { text: 'C++ guide' } }]); assert.deepEqual(ti.search('c++').map((h) => h.key), ['a']); // writes after build land in the delta and stay searchable diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index d4b93dea4f..66886ad70b 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -197,7 +197,7 @@ import { IWorkspaceAliases, hostRequestHeadersSeed, IWorkspaceDirs, - IWorkspaceHandlerService, + ISessionLifecycleService, IWorkspaceLifecycleService, closeSessionById, followWorkspaceHandlers, @@ -932,7 +932,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { /** * v1 semantics: register the workDir as a workspace and create the session - * (the handler's `IWorkspaceHandlerService.create` does both; the klient facade + * (the handler's `ISessionLifecycleService.create` does both; the klient facade * wrapper is bypassed because it takes neither an explicit session id nor * caller metadata). The `model` / `thinking` / `permission` options are the * main-agent configuration v1 applies eagerly at creation: supplying any of @@ -960,7 +960,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { const handler = await this.engineAccessor .get(IWorkspaceLifecycleService) .handlerFor({ root: workDir }); - const handle = await handler.accessor.get(IWorkspaceHandlerService).create({ + const handle = await handler.accessor.get(ISessionLifecycleService).create({ sessionId: input.id, workDir, additionalDirs: input.additionalDirs, @@ -1016,7 +1016,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } /** - * Through `engineAccessor` (the handler chain's `IWorkspaceHandlerService.fork`) because the + * Through `engineAccessor` (the handler chain's `ISessionLifecycleService.fork`) because the * klient facade fork takes no explicit target id. Known gaps vs v1: the * engine's fork is unconditional — it never rejects an in-flight source * turn (v1's SESSION_FORK_ACTIVE_TURN) — and `turnIndex` truncation has no @@ -1033,7 +1033,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } const forkHandler = await handlerForSession(this.engineAccessor, input.id); if (forkHandler === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); - const handle = await forkHandler.accessor.get(IWorkspaceHandlerService).fork({ + const handle = await forkHandler.accessor.get(ISessionLifecycleService).fork({ sourceSessionId: input.id, newSessionId: input.forkId, title: input.title, From 95a656ca6183b56d6e66182de32633c8538b5260 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Fri, 31 Jul 2026 13:09:56 +0800 Subject: [PATCH 05/20] fix(agent-core): strip the no-op subagent model parameter while the secondary-model experiment is off (#2449) The Agent/AgentSwarm tool schemas always advertised a \`model\` choice parameter, so the secondary-model concept entered the prompt even with the experiment disabled. Gate the advertised JSON schema on the flag in both engines: off (the default) drops the parameter, on keeps it, and spawn-time resolution already falls back to the caller's model either way. Also scrub ambient KIMI_CODE_EXPERIMENTAL_* env vars in both packages' vitest setup so flag-dependent tool schemas in llm.tools_snapshot stay deterministic regardless of the developer shell. --- .../agent/tools/agent-swarm/agentSwarmTool.ts | 17 ++++- .../src/agent/tools/agent/agentTool.ts | 16 ++++- .../src/session/subagent/configSection.ts | 31 ++++++++- .../test/agent/loop/loop.test.ts | 4 +- packages/agent-core-v2/test/setup.ts | 13 ++++ packages/agent-core-v2/test/tool/tool.test.ts | 64 +++++++++++++++++-- packages/agent-core-v2/vitest.config.ts | 1 + packages/agent-core/src/agent/tool/index.ts | 2 + .../src/session/subagent-binding.ts | 28 ++++++++ .../builtin/collaboration/agent-swarm.ts | 13 +++- .../src/tools/builtin/collaboration/agent.ts | 14 +++- packages/agent-core/test/setup.ts | 13 ++++ packages/agent-core/test/tools/agent.test.ts | 15 ++++- .../test/tools/builtin-current.test.ts | 23 +++++++ packages/agent-core/vitest.config.ts | 1 + 15 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 packages/agent-core-v2/test/setup.ts create mode 100644 packages/agent-core/test/setup.ts diff --git a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts b/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts index c69c08ef45..9f4ee7cbd8 100644 --- a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts @@ -45,7 +45,9 @@ import { buildSubagentModelDescriptions, resolveSubagentBinding, resolveSubagentTimeoutMs, + stripSubagentModelParameter, } from '#/session/subagent/configSection'; +import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import { AgentSwarmToolInputSchema, IAgentSwarmTool, @@ -57,6 +59,9 @@ import AGENT_SWARM_DESCRIPTION from './agent-swarm.md?raw'; const DEFAULT_SUBAGENT_TYPE = 'coder'; +const AGENT_SWARM_PARAMETERS = toInputJsonSchema(AgentSwarmToolInputSchema); +const AGENT_SWARM_PARAMETERS_NO_MODEL = stripSubagentModelParameter(AGENT_SWARM_PARAMETERS); + interface AgentSwarmSpawnSpec { readonly kind: 'spawn'; readonly index: number; @@ -86,7 +91,17 @@ interface SwarmRunResult { export class AgentSwarmTool implements IAgentSwarmTool { declare readonly _serviceBrand: undefined; readonly name = 'AgentSwarm' as const; - readonly parameters: Record = toInputJsonSchema(AgentSwarmToolInputSchema); + + /** + * The `model` choice only exists while the `secondary-model` experiment is + * on; off, the advertised schema drops it so the concept never enters the + * prompt. Read live per request (same as `description`). + */ + get parameters(): Record { + return this.flags.enabled(SECONDARY_MODEL_FLAG_ID) + ? AGENT_SWARM_PARAMETERS + : AGENT_SWARM_PARAMETERS_NO_MODEL; + } private readonly callerAgentId: string; diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index ff249b2f59..b840749dbf 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -84,6 +84,7 @@ import { formatSubagentTimeoutDescription, resolveSubagentBinding, resolveSubagentTimeoutMs, + stripSubagentModelParameter, wrapSubagentModelError, } from '#/session/subagent/configSection'; import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; @@ -104,10 +105,23 @@ import AGENT_BACKGROUND_DISABLED_DESCRIPTION from './agent-background-disabled.m import AGENT_BACKGROUND_DESCRIPTION from './agent-background-enabled.md?raw'; import AGENT_DESCRIPTION_BASE from './agent.md?raw'; +const SUBAGENT_TOOL_PARAMETERS = toInputJsonSchema(SubagentToolInputSchema); +const SUBAGENT_TOOL_PARAMETERS_NO_MODEL = stripSubagentModelParameter(SUBAGENT_TOOL_PARAMETERS); + export class SubagentTool implements ISubagentTool { declare readonly _serviceBrand: undefined; readonly name: string = 'Agent'; - readonly parameters: Record = toInputJsonSchema(SubagentToolInputSchema); + + /** + * The `model` choice only exists while the `secondary-model` experiment is + * on; off, the advertised schema drops it so the concept never enters the + * prompt. Read live per request (same as `description`). + */ + get parameters(): Record { + return this.flags.enabled(SECONDARY_MODEL_FLAG_ID) + ? SUBAGENT_TOOL_PARAMETERS + : SUBAGENT_TOOL_PARAMETERS_NO_MODEL; + } private readonly callerAgentId: string; private readonly canRunInBackground: () => boolean; diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index a850f5d221..43b1e9e789 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -25,7 +25,9 @@ * rather than inheriting the caller's level. Both tools resolve spawn * bindings through `resolveSubagentBinding`, advertise the pair via * `buildSubagentModelDescriptions`, and wrap spawn failures with - * `wrapSubagentModelError`. Self-registered at module load via + * `wrapSubagentModelError`; while the experiment is off they also strip the + * no-op `model` parameter from their advertised schemas via + * `stripSubagentModelParameter`. Self-registered at module load via * `registerConfigSection`, so the `config` domain never imports this * domain's types. */ @@ -34,6 +36,7 @@ import { z } from 'zod'; import { Error2, ErrorCodes, isError2 } from '#/errors'; import type { AgentModelPreference } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { isPlainObject } from '#/app/config/toml'; import type { IFlagService } from '#/app/flag/flag'; import { SECONDARY_MODEL_ENV, @@ -143,6 +146,32 @@ export function buildSubagentModelDescriptions( ].join('\n'); } +/** + * Strip the `model` property from a subagent collaboration tool's advertised + * JSON schema. While the `secondary-model` experiment is off the parameter is + * a silent no-op, so the schema the model sees (and the args validator + * compiled from the same advertised schema) drops it entirely — the + * secondary-model concept never enters the prompt, and a stray `model` + * argument is rejected instead of silently inheriting the caller's model. + * Returns the input unchanged when there is no `model` property; otherwise a + * shallow copy — the input is never mutated, so callers can keep both + * variants as shared constants. + */ +export function stripSubagentModelParameter( + parameters: Record, +): Record { + const properties = parameters['properties']; + if (!isPlainObject(properties) || !('model' in properties)) return parameters; + const nextProperties = { ...properties }; + delete nextProperties['model']; + const next: Record = { ...parameters, properties: nextProperties }; + const required = parameters['required']; + if (Array.isArray(required) && required.includes('model')) { + next['required'] = required.filter((entry) => entry !== 'model'); + } + return next; +} + export function wrapSubagentModelError( error: unknown, boundModel: string, diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 783a8b49cb..9d0242ec1d 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -104,8 +104,8 @@ describe('Agent loop', () => { [emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "" } [emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "