11import test , { after } from "node:test" ;
22import assert from "node:assert/strict" ;
3- import type { Theme } from "@earendil-works/pi-coding-agent" ;
3+ import { mkdir , mkdtemp , readFile , rm , writeFile } from "node:fs/promises" ;
4+ import { tmpdir } from "node:os" ;
5+ import { join } from "node:path" ;
6+ import { AuthStorage , ModelRegistry , type Theme } from "@earendil-works/pi-coding-agent" ;
47import { Text } from "@earendil-works/pi-tui" ;
58import { registerHandoffCommand } from "./handoff/command.js" ;
69import { registerHandoffTool } from "./handoff/tool.js" ;
@@ -102,6 +105,7 @@ class MockPi {
102105 tools = new Map < string , any > ( ) ;
103106 handlers = new Map < string , Handler [ ] > ( ) ;
104107 activeTools : string [ ] = [ ] ;
108+ allToolNames : string [ ] | undefined ;
105109 toolSources = new Map < string , string > ( ) ;
106110 sentUserMessages : Array < { content : string ; options : any } > = [ ] ;
107111 appendedEntries : Array < { customType : string ; data : any } > = [ ] ;
@@ -137,8 +141,17 @@ class MockPi {
137141 this . toolSources . set ( name , source ) ;
138142 }
139143
144+ setAllTools ( tools : string [ ] ) {
145+ this . allToolNames = [ ...tools ] ;
146+ for ( const tool of tools ) {
147+ if ( ! this . toolSources . has ( tool ) ) {
148+ this . toolSources . set ( tool , "builtin" ) ;
149+ }
150+ }
151+ }
152+
140153 getAllTools ( ) {
141- return this . activeTools . map ( ( name ) => ( {
154+ return ( this . allToolNames ?? this . activeTools ) . map ( ( name ) => ( {
142155 name,
143156 description : "" ,
144157 parameters : { } ,
@@ -164,6 +177,43 @@ class MockPi {
164177 }
165178}
166179
180+ const EMPTY_USAGE = {
181+ input : 0 ,
182+ output : 0 ,
183+ cacheRead : 0 ,
184+ cacheWrite : 0 ,
185+ totalTokens : 0 ,
186+ cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 , total : 0 } ,
187+ } ;
188+
189+ function createTestAssistantMessage ( model : any , content : any [ ] , stopReason = "stop" ) {
190+ return {
191+ role : "assistant" ,
192+ content,
193+ api : model . api ,
194+ provider : model . provider ,
195+ model : model . id ,
196+ usage : EMPTY_USAGE ,
197+ stopReason,
198+ timestamp : Date . now ( ) ,
199+ } ;
200+ }
201+
202+ function createTestAssistantStream ( message : any ) : any {
203+ return {
204+ async * [ Symbol . asyncIterator ] ( ) {
205+ yield { type : "done" , reason : message . stopReason , message } ;
206+ } ,
207+ result : async ( ) => message ,
208+ } ;
209+ }
210+
211+ function messageText ( message : any ) : string {
212+ return ( message . content ?? [ ] )
213+ . map ( ( block : any ) => block . type === "text" ? block . text : JSON . stringify ( block ) )
214+ . join ( "\n" ) ;
215+ }
216+
167217// ── TUI indicator tests ───────────────────────────────────────────────
168218
169219function makeTUICtx (
@@ -883,10 +933,126 @@ test("nested spawn rerenders when stats become unavailable", () => {
883933 assert . equal ( after . some ( ( l : string ) => l . includes ( "initializing" ) ) , false ) ;
884934} ) ;
885935
886- test ( "spawn execute propagates only executable parent tools to child session" , async ( ) => {
936+ test ( "agentic e2e spawn child can use active registered non-builtin tool" , async ( ) => {
937+ const tempRoot = await mkdtemp ( join ( tmpdir ( ) , "pi-agenticoding-a10-" ) ) ;
938+ const tempCwd = join ( tempRoot , "project" ) ;
939+ const tempAgentDir = join ( tempRoot , "agent" ) ;
940+ const extensionDir = join ( tempCwd , ".pi" , "extensions" ) ;
941+ const sentinel = "AGENTIC_E2E_PROBE_OK" ;
942+ const oldAgentDir = process . env . PI_CODING_AGENT_DIR ;
943+ const oldOpenAiApiKey = process . env . OPENAI_API_KEY ;
944+ const parentRegistry = ModelRegistry . inMemory ( AuthStorage . inMemory ( ) ) ;
945+ let streamCallCount = 0 ;
946+
947+ try {
948+ await mkdir ( extensionDir , { recursive : true } ) ;
949+ await mkdir ( tempAgentDir , { recursive : true } ) ;
950+ await writeFile ( join ( tempCwd , "package.json" ) , JSON . stringify ( { type : "module" } ) ) ;
951+ await writeFile (
952+ join ( extensionDir , "agentic-e2e-probe.js" ) ,
953+ `
954+ export default function(pi) {
955+ pi.registerTool({
956+ name: "agentic_e2e_probe",
957+ label: "Agentic E2E Probe",
958+ description: "Return the deterministic Story 04 A10 sentinel.",
959+ promptSnippet: "Call agentic_e2e_probe to return the Story 04 A10 sentinel.",
960+ parameters: { type: "object", properties: {}, additionalProperties: false },
961+ async execute() {
962+ globalThis.__agenticE2eProbeCalls = (globalThis.__agenticE2eProbeCalls ?? 0) + 1;
963+ return {
964+ content: [{ type: "text", text: "${ sentinel } " }],
965+ details: { sentinel: "${ sentinel } " },
966+ };
967+ },
968+ });
969+ }
970+ ` ,
971+ ) ;
972+
973+ process . env . PI_CODING_AGENT_DIR = tempAgentDir ;
974+ process . env . OPENAI_API_KEY = "test-openai-key" ;
975+ ( globalThis as any ) . __agenticE2eProbeCalls = 0 ;
976+
977+ parentRegistry . registerProvider ( "openai" , {
978+ name : "Agentic E2E OpenAI-compatible provider" ,
979+ api : "agentic-e2e-api" ,
980+ apiKey : "test-openai-key" ,
981+ baseUrl : "http://localhost:0" ,
982+ streamSimple : ( model : any , context : any ) => {
983+ streamCallCount += 1 ;
984+ if ( streamCallCount === 1 ) {
985+ const promptText = context . messages . map ( messageText ) . join ( "\n" ) ;
986+ assert . match ( promptText , / a g e n t i c _ e 2 e _ p r o b e / ) ;
987+ assert . match ( promptText , new RegExp ( sentinel ) ) ;
988+ return createTestAssistantStream ( createTestAssistantMessage ( model , [
989+ { type : "toolCall" , id : "probe-call-1" , name : "agentic_e2e_probe" , arguments : { } } ,
990+ ] , "tool_calls" ) ) ;
991+ }
992+
993+ const probeResult = context . messages . find ( ( message : any ) =>
994+ message . role === "toolResult" &&
995+ message . toolName === "agentic_e2e_probe" &&
996+ messageText ( message ) . includes ( sentinel )
997+ ) ;
998+ const text = probeResult ? sentinel : "AGENTIC_E2E_PROBE_MISSING" ;
999+ return createTestAssistantStream ( createTestAssistantMessage ( model , [ { type : "text" , text } ] ) ) ;
1000+ } ,
1001+ models : [ {
1002+ id : "agentic-e2e-model" ,
1003+ name : "Agentic E2E Model" ,
1004+ reasoning : false ,
1005+ input : [ "text" ] ,
1006+ cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 } ,
1007+ contextWindow : 128000 ,
1008+ maxTokens : 1024 ,
1009+ } ] ,
1010+ } ) ;
1011+ const model = parentRegistry . find ( "openai" , "agentic-e2e-model" ) ;
1012+ assert . ok ( model ) ;
1013+
1014+ const pi = new MockPi ( ) ;
1015+ pi . setToolSource ( "agentic_e2e_probe" , "project" ) ;
1016+ pi . setActiveTools ( [ "read" , "agentic_e2e_probe" , "spawn" ] ) ;
1017+ pi . setAllTools ( [ "read" , "agentic_e2e_probe" , "spawn" ] ) ;
1018+ const state = createState ( ) ;
1019+ const childPrompt = `Use the agentic_e2e_probe tool and return ${ sentinel } .` ;
1020+
1021+ registerSpawnTool ( pi as any , state ) ;
1022+ const result = await pi . tools . get ( "spawn" ) . execute (
1023+ "spawn-e2e" ,
1024+ { prompt : childPrompt , thinking : "medium" } ,
1025+ undefined ,
1026+ undefined ,
1027+ { model, cwd : tempCwd } ,
1028+ ) ;
1029+
1030+ assert . equal ( result . content [ 0 ] . text , sentinel ) ;
1031+ assert . equal ( ( globalThis as any ) . __agenticE2eProbeCalls , 1 ) ;
1032+ assert . equal ( streamCallCount , 2 ) ;
1033+ } finally {
1034+ parentRegistry . unregisterProvider ( "openai" ) ;
1035+ if ( oldAgentDir === undefined ) {
1036+ delete process . env . PI_CODING_AGENT_DIR ;
1037+ } else {
1038+ process . env . PI_CODING_AGENT_DIR = oldAgentDir ;
1039+ }
1040+ if ( oldOpenAiApiKey === undefined ) {
1041+ delete process . env . OPENAI_API_KEY ;
1042+ } else {
1043+ process . env . OPENAI_API_KEY = oldOpenAiApiKey ;
1044+ }
1045+ delete ( globalThis as any ) . __agenticE2eProbeCalls ;
1046+ await rm ( tempRoot , { recursive : true , force : true } ) ;
1047+ }
1048+ } ) ;
1049+
1050+ test ( "spawn execute passes broad active registered tool formula to child session" , async ( ) => {
8871051 const pi = new MockPi ( ) ;
888- pi . setActiveTools ( [ "read" , "bash" , "spawn" , "handoff" , "future_tool" ] ) ;
889- pi . setToolSource ( "future_tool" , "project" ) ;
1052+ pi . setToolSource ( "project_search" , "project" ) ;
1053+ pi . setToolSource ( "inactive_registered" , "extension" ) ;
1054+ pi . setActiveTools ( [ "read" , "bash" , "spawn" , "handoff" , "project_search" , "phantom_tool" ] ) ;
1055+ pi . setAllTools ( [ "read" , "bash" , "spawn" , "handoff" , "project_search" , "inactive_registered" ] ) ;
8901056 const state = createState ( ) ;
8911057
8921058 let seenConfig : any ;
@@ -916,11 +1082,11 @@ test("spawn execute propagates only executable parent tools to child session", a
9161082 assert . equal ( seenConfig . model . id , "mock-model" ) ;
9171083 assert . equal ( seenConfig . thinkingLevel , "high" ) ;
9181084 assert . equal ( seenConfig . cwd , "/tmp" ) ;
919- assert . equal ( seenConfig . tools . includes ( "read" ) , true ) ;
920- assert . equal ( seenConfig . tools . includes ( "bash" ) , true ) ;
921- assert . equal ( seenConfig . tools . includes ( "future_tool" ) , false ) ;
922- assert . equal ( seenConfig . tools . includes ( "handoff" ) , false ) ;
923- assert . equal ( seenConfig . tools . includes ( "spawn" ) , false ) ;
1085+ assert . deepEqual (
1086+ new Set ( seenConfig . tools ) ,
1087+ new Set ( [ "read" , "bash" , "project_search" , "notebook_write" , "notebook_read" , "notebook_index" ] ) ,
1088+ ) ;
1089+ assert . deepEqual ( seenConfig . customTools . map ( ( tool : any ) => tool . name ) , [ "notebook_write" , "notebook_read" , "notebook_index" ] ) ;
9241090} ) ;
9251091
9261092test ( "spawn execute builds prompt with notebook pages and task" , async ( ) => {
@@ -1193,7 +1359,7 @@ test("spawn execute fails explicitly without a configured model", async () => {
11931359 ) ;
11941360} ) ;
11951361
1196- test ( "child tool set omits spawn " , ( ) => {
1362+ test ( "child tool names inherit active registered builtins and exclude recursive controls " , ( ) => {
11971363 const state = createState ( ) ;
11981364 const childTools = createChildTools ( new MockPi ( ) as any , state ) ;
11991365 assert . equal ( childTools . some ( t => t . name === "spawn" ) , false ) ;
@@ -1208,9 +1374,10 @@ test("child tool set omits spawn", () => {
12081374 { name : "future_tool" , sourceInfo : { source : "project" } } ,
12091375 ] as any ,
12101376 ) ;
1377+ assert . equal ( childToolNames . includes ( "read" ) , true ) ;
1378+ assert . equal ( childToolNames . includes ( "bash" ) , true ) ;
12111379 assert . equal ( childToolNames . includes ( "spawn" ) , false ) ;
12121380 assert . equal ( childToolNames . includes ( "handoff" ) , false ) ;
1213- assert . equal ( childToolNames . includes ( "future_tool" ) , false ) ;
12141381} ) ;
12151382
12161383test ( "spawn renderResult transfers session ownership out of shared state" , ( ) => {
@@ -1359,24 +1526,59 @@ test("executeSpawn suppresses stale child sessions after resetState during async
13591526 assert . equal ( state . liveChildSessions . get ( "spawn-1" ) , freshSession ) ;
13601527} ) ;
13611528
1362- test ( "child tool names inherit builtin parent tools, exclude handoff and spawn " , ( ) => {
1529+ test ( "child tool names inherit active registered MCP extension tools " , ( ) => {
13631530 const state = createState ( ) ;
13641531 const childTools = createChildTools ( new MockPi ( ) as any , state ) ;
13651532
13661533 const toolNames = buildChildToolNames (
1367- [ "read" , "bash " , "handoff" , "future_tool "] ,
1534+ [ "read" , "chunkhound_code_research " , "mcp_status " ] ,
13681535 childTools ,
13691536 [
13701537 { name : "read" , sourceInfo : { source : "builtin" } } ,
1371- { name : "bash" , sourceInfo : { source : "builtin" } } ,
1372- { name : "handoff" , sourceInfo : { source : "builtin" } } ,
1373- { name : "future_tool" , sourceInfo : { source : "project" } } ,
1538+ { name : "chunkhound_code_research" , sourceInfo : { source : "extension" } } ,
1539+ { name : "mcp_status" , sourceInfo : { source : "extension" } } ,
1540+ ] as any ,
1541+ ) ;
1542+
1543+ assert . equal ( toolNames . includes ( "chunkhound_code_research" ) , true ) ;
1544+ assert . equal ( toolNames . includes ( "mcp_status" ) , true ) ;
1545+ } ) ;
1546+
1547+ test ( "child tool names inherit active registered project package and local extension tools" , ( ) => {
1548+ const state = createState ( ) ;
1549+ const childTools = createChildTools ( new MockPi ( ) as any , state ) ;
1550+
1551+ const toolNames = buildChildToolNames (
1552+ [ "project_search" , "package_lint" , "local_helper" ] ,
1553+ childTools ,
1554+ [
1555+ { name : "project_search" , sourceInfo : { source : "project" } } ,
1556+ { name : "package_lint" , sourceInfo : { source : "package" } } ,
1557+ { name : "local_helper" , sourceInfo : { source : "local" } } ,
1558+ ] as any ,
1559+ ) ;
1560+
1561+ assert . equal ( toolNames . includes ( "project_search" ) , true ) ;
1562+ assert . equal ( toolNames . includes ( "package_lint" ) , true ) ;
1563+ assert . equal ( toolNames . includes ( "local_helper" ) , true ) ;
1564+ } ) ;
1565+
1566+ test ( "child tool names exclude inactive registered and active phantom tools" , ( ) => {
1567+ const state = createState ( ) ;
1568+ const childTools = createChildTools ( new MockPi ( ) as any , state ) ;
1569+
1570+ const toolNames = buildChildToolNames (
1571+ [ "read" , "active_phantom" ] ,
1572+ childTools ,
1573+ [
1574+ { name : "read" , sourceInfo : { source : "builtin" } } ,
1575+ { name : "inactive_registered" , sourceInfo : { source : "extension" } } ,
13741576 ] as any ,
13751577 ) ;
13761578
1377- assert . ok ( toolNames . includes ( "read" ) ) ;
1378- assert . ok ( toolNames . includes ( "bash" ) ) ;
1379- assert . equal ( toolNames . includes ( "future_tool " ) , false ) ;
1579+ assert . equal ( toolNames . includes ( "read" ) , true ) ;
1580+ assert . equal ( toolNames . includes ( "inactive_registered" ) , false ) ;
1581+ assert . equal ( toolNames . includes ( "active_phantom " ) , false ) ;
13801582 assert . ok ( toolNames . includes ( "notebook_write" ) ) ;
13811583 assert . ok ( toolNames . includes ( "notebook_read" ) ) ;
13821584 assert . ok ( toolNames . includes ( "notebook_index" ) ) ;
@@ -3608,6 +3810,10 @@ test("registerSpawnTool registers a tool with correct name and metadata", () =>
36083810 assert . equal ( tool . name , "spawn" ) ;
36093811 assert . equal ( tool . label , "Spawn" ) ;
36103812 assert . equal ( typeof tool . description , "string" ) ;
3813+ assert . match ( tool . description , / a c t i v e r e g i s t e r e d t o o l s e x e c u t a b l e i n t h e c h i l d s e s s i o n / ) ;
3814+ assert . match ( tool . description , / s h a r e d n o t e b o o k t o o l s / ) ;
3815+ assert . match ( tool . description , / c a n n o t s p a w n o r h a n d o f f / ) ;
3816+ assert . doesNotMatch ( tool . description , / s u p p o r t e d b u i l t - i n t o o l s / ) ;
36113817 assert . equal ( typeof tool . execute , "function" ) ;
36123818 assert . equal ( typeof tool . renderCall , "function" ) ;
36133819 assert . equal ( typeof tool . renderResult , "function" ) ;
@@ -3616,3 +3822,19 @@ test("registerSpawnTool registers a tool with correct name and metadata", () =>
36163822 assert . ok ( tool . parameters , "should have parameters" ) ;
36173823 assert . equal ( tool . executionMode , undefined , "spawn should not be sequential" ) ;
36183824} ) ;
3825+
3826+ test ( "spawn docs document active registered inheritance" , async ( ) => {
3827+ const readme = await readFile ( "README.md" , "utf8" ) ;
3828+ const changelog = await readFile ( "CHANGELOG.md" , "utf8" ) ;
3829+ const spawnSection = / # # # S p a w n — I s o l a t e N o i s e [ \s \S ] * ?# # # N o t e b o o k / . exec ( readme ) ?. [ 0 ] ?? "" ;
3830+ const unreleased = / # # \[ U n r e l e a s e d \] [ \s \S ] * ?# # \[ 0 \. 3 \. 0 \] / . exec ( changelog ) ?. [ 0 ] ?? "" ;
3831+
3832+ assert . match ( spawnSection , / a c t i v e r e g i s t e r e d t o o l s e x e c u t a b l e i n t h e c h i l d s e s s i o n / ) ;
3833+ assert . match ( spawnSection , / M C P \/ e x t e n s i o n t o o l s s u c h a s C h u n k H o u n d / ) ;
3834+ assert . match ( spawnSection , / [ C c ] h i l d - l o c a l n o t e b o o k t o o l s / ) ;
3835+ assert . match ( spawnSection , / c a n n o t s p a w n g r a n d c h i l d r e n o r h a n d o f f / ) ;
3836+ assert . doesNotMatch ( spawnSection , / b u i l t - i n t o o l s o n l y / ) ;
3837+ assert . match ( unreleased , / a c t i v e r e g i s t e r e d p a r e n t t o o l s / ) ;
3838+ assert . match ( unreleased , / s p a w n a n d h a n d o f f / ) ;
3839+ assert . match ( unreleased , / n o t e b o o k t o o l s / ) ;
3840+ } ) ;
0 commit comments