1- import { describe , expect , it , mock } from "bun:test" ;
1+ import { describe , expect , it , mock , spyOn } from "bun:test" ;
22import { EventEmitter } from "node:events" ;
33import { existsSync , readFileSync } from "node:fs" ;
44import { isAbsolute } from "node:path" ;
55import { PassThrough } from "node:stream" ;
6+ import * as loggerModule from "@magic-context/core/shared/logger" ;
67import type { SubagentRunOptions } from "@magic-context/core/shared/subagent-runner" ;
78
89import { __test , PiSubagentRunner } from "./subagent-runner" ;
@@ -13,6 +14,12 @@ const baseOptions: SubagentRunOptions = {
1314 userMessage : "summarize this session" ,
1415} ;
1516const TEST_SYSTEM_PROMPT_PATH = "/tmp/mc-pi-system-prompt.txt" ;
17+ const COLLISION_STDERR =
18+ "Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message." ;
19+ const ISOLATED_RETRY_LOG_MESSAGE =
20+ "pi subagent: a loaded Pi extension started an agent turn before the child's prompt could run; retrying with an isolated extension set (user extensions disabled for this run)" ;
21+ const ISOLATED_RETRY_MODEL_UNAVAILABLE_LOG_MESSAGE =
22+ "model unavailable in isolated retry: it is provided by a disabled extension; configure it through models.json or add a built-in/provider-configured fallback" ;
1623
1724type MockChild = ReturnType < typeof createMockChild > ;
1825
@@ -98,16 +105,30 @@ function createMockChild({ stdout = true }: { stdout?: boolean } = {}) {
98105}
99106
100107function runnerWith (
101- child : MockChild ,
108+ childOrChildren : MockChild | MockChild [ ] ,
102109 {
103110 piBinary = "pi-test" ,
104111 platform,
105- } : { piBinary ?: string ; platform ?: NodeJS . Platform } = { } ,
112+ extraArgs,
113+ } : {
114+ piBinary ?: string ;
115+ platform ?: NodeJS . Platform ;
116+ extraArgs ?: readonly string [ ] ;
117+ } = { } ,
106118) {
107- const spawnImpl = mock ( ( ) => child as never ) ;
119+ const remainingChildren = Array . isArray ( childOrChildren )
120+ ? [ ...childOrChildren ]
121+ : null ;
122+ const spawnImpl = mock ( ( ) => {
123+ if ( remainingChildren === null ) return childOrChildren as never ;
124+ const nextChild = remainingChildren . shift ( ) ;
125+ if ( ! nextChild ) throw new Error ( "unexpected extra spawn" ) ;
126+ return nextChild as never ;
127+ } ) ;
108128 const runner = new PiSubagentRunner ( {
109129 piBinary,
110130 platform,
131+ extraArgs,
111132 spawnImpl : spawnImpl as never ,
112133 } ) ;
113134 return { runner, spawnImpl } ;
@@ -132,6 +153,10 @@ function agentEnd(messages: unknown[]) {
132153 return { type : "agent_end" , messages } ;
133154}
134155
156+ function nextTick ( ) {
157+ return new Promise ( ( resolve ) => setTimeout ( resolve , 0 ) ) ;
158+ }
159+
135160describe ( "subagent-runner pure helpers" , ( ) => {
136161 it ( "extracts the last assistant text and status from mixed messages" , ( ) => {
137162 const result = __test . extractFinalAssistant ( [
@@ -205,6 +230,28 @@ describe("subagent-runner pure helpers", () => {
205230 expect ( args ) . toContain ( "--no-prompt-templates" ) ;
206231 } ) ;
207232
233+ it ( "isolated retry disables discovered extensions but keeps explicit --extension paths" , ( ) => {
234+ const args = buildArgsForTest (
235+ {
236+ ...baseOptions ,
237+ agent : "sidekick" ,
238+ model : "anthropic/claude-sonnet" ,
239+ } ,
240+ {
241+ disableDiscoveredExtensions : true ,
242+ subagentEntryPath : "/tmp/subagent-entry.js" ,
243+ } ,
244+ ) ;
245+
246+ expect ( args ) . toEqual (
247+ expect . arrayContaining ( [
248+ "--no-extensions" ,
249+ "--extension" ,
250+ "/tmp/subagent-entry.js" ,
251+ ] ) ,
252+ ) ;
253+ } ) ;
254+
208255 it ( "disables project context files so hidden subagents see only our prompt" , ( ) => {
209256 const args = buildArgsForTest ( {
210257 ...baseOptions ,
@@ -1161,6 +1208,267 @@ describe("PiSubagentRunner spawn lifecycle", () => {
11611208 }
11621209 } ) ;
11631210
1211+ it ( "retries once with --no-extensions after an extension turn collision" , async ( ) => {
1212+ const first = createMockChild ( ) ;
1213+ const second = createMockChild ( ) ;
1214+ const { runner, spawnImpl } = runnerWith ( [ first , second ] ) ;
1215+ const logSpy = spyOn ( loggerModule , "sessionLog" ) . mockImplementation (
1216+ ( ) => { } ,
1217+ ) ;
1218+
1219+ try {
1220+ const resultPromise = runner . run ( {
1221+ ...baseOptions ,
1222+ model : "anthropic/claude-sonnet" ,
1223+ } ) ;
1224+ first . writeStderr ( COLLISION_STDERR ) ;
1225+ first . emitClose ( 1 ) ;
1226+ await nextTick ( ) ;
1227+ second . writeStdoutLine (
1228+ agentEnd ( [
1229+ {
1230+ role : "assistant" ,
1231+ content : [ { type : "text" , text : "isolated success" } ] ,
1232+ stopReason : "stop" ,
1233+ } ,
1234+ ] ) ,
1235+ ) ;
1236+ second . emitClose ( 0 ) ;
1237+
1238+ expect ( await resultPromise ) . toEqual ( {
1239+ ok : true ,
1240+ assistantText : "isolated success" ,
1241+ toolCallCount : 0 ,
1242+ durationMs : expect . any ( Number ) ,
1243+ meta : { stderr : undefined } ,
1244+ } ) ;
1245+ expect ( spawnImpl ) . toHaveBeenCalledTimes ( 2 ) ;
1246+ expect ( spawnImpl . mock . calls [ 0 ] ?. [ 1 ] ) . not . toContain ( "--no-extensions" ) ;
1247+ expect ( spawnImpl . mock . calls [ 1 ] ?. [ 1 ] ) . toContain ( "--no-extensions" ) ;
1248+ expect (
1249+ logSpy . mock . calls . some (
1250+ ( call ) =>
1251+ call [ 0 ] === "pi-subagent" && call [ 1 ] === ISOLATED_RETRY_LOG_MESSAGE ,
1252+ ) ,
1253+ ) . toBe ( true ) ;
1254+ } finally {
1255+ logSpy . mockRestore ( ) ;
1256+ }
1257+ } ) ;
1258+
1259+ it ( "does not retry forever when the isolated retry hits the same collision" , async ( ) => {
1260+ const first = createMockChild ( ) ;
1261+ const second = createMockChild ( ) ;
1262+ const { runner, spawnImpl } = runnerWith ( [ first , second ] ) ;
1263+
1264+ const resultPromise = runner . run ( {
1265+ ...baseOptions ,
1266+ model : "anthropic/claude-sonnet" ,
1267+ } ) ;
1268+ first . writeStderr ( COLLISION_STDERR ) ;
1269+ first . emitClose ( 1 ) ;
1270+ await nextTick ( ) ;
1271+ second . writeStderr ( COLLISION_STDERR ) ;
1272+ second . emitClose ( 1 ) ;
1273+
1274+ const result = await resultPromise ;
1275+ expect ( result . ok ) . toBe ( false ) ;
1276+ if ( ! result . ok ) {
1277+ expect ( result . reason ) . toBe ( "non_zero_exit" ) ;
1278+ expect ( result . meta ) . toEqual ( {
1279+ stderr : COLLISION_STDERR ,
1280+ exitCode : 1 ,
1281+ signal : null ,
1282+ } ) ;
1283+ }
1284+ expect ( spawnImpl ) . toHaveBeenCalledTimes ( 2 ) ;
1285+ expect ( spawnImpl . mock . calls [ 1 ] ?. [ 1 ] ) . toContain ( "--no-extensions" ) ;
1286+ } ) ;
1287+
1288+ it ( "does not insert an isolated retry for unrelated failures" , async ( ) => {
1289+ const first = createMockChild ( ) ;
1290+ const second = createMockChild ( ) ;
1291+ const { runner, spawnImpl } = runnerWith ( [ first , second ] ) ;
1292+
1293+ const resultPromise = runner . run ( {
1294+ ...baseOptions ,
1295+ model : "anthropic/primary" ,
1296+ fallbackModels : [ "openai/fallback" ] ,
1297+ } ) ;
1298+ first . writeStderr ( "auth missing" ) ;
1299+ first . emitClose ( 1 ) ;
1300+ await nextTick ( ) ;
1301+ second . writeStdoutLine (
1302+ agentEnd ( [
1303+ {
1304+ role : "assistant" ,
1305+ content : [ { type : "text" , text : "fallback success" } ] ,
1306+ stopReason : "stop" ,
1307+ } ,
1308+ ] ) ,
1309+ ) ;
1310+ second . emitClose ( 0 ) ;
1311+
1312+ expect ( await resultPromise ) . toEqual ( {
1313+ ok : true ,
1314+ assistantText : "fallback success" ,
1315+ toolCallCount : 0 ,
1316+ durationMs : expect . any ( Number ) ,
1317+ meta : { stderr : undefined } ,
1318+ } ) ;
1319+ expect ( spawnImpl ) . toHaveBeenCalledTimes ( 2 ) ;
1320+ expect ( spawnImpl . mock . calls [ 0 ] ?. [ 1 ] ) . not . toContain ( "--no-extensions" ) ;
1321+ expect ( spawnImpl . mock . calls [ 1 ] ?. [ 1 ] ) . not . toContain ( "--no-extensions" ) ;
1322+ expect ( spawnImpl . mock . calls [ 1 ] ?. [ 1 ] ) . toEqual (
1323+ expect . arrayContaining ( [ "--model" , "openai-codex/fallback" ] ) ,
1324+ ) ;
1325+ } ) ;
1326+
1327+ it ( "does not start a retry loop when the spawn already disables extensions" , async ( ) => {
1328+ const first = createMockChild ( ) ;
1329+ const second = createMockChild ( ) ;
1330+ const { runner, spawnImpl } = runnerWith ( [ first , second ] , {
1331+ extraArgs : [ "--no-extensions" ] ,
1332+ } ) ;
1333+ const logSpy = spyOn ( loggerModule , "sessionLog" ) . mockImplementation (
1334+ ( ) => { } ,
1335+ ) ;
1336+
1337+ try {
1338+ const resultPromise = runner . run ( {
1339+ ...baseOptions ,
1340+ model : "anthropic/primary" ,
1341+ fallbackModels : [ "openai/fallback" ] ,
1342+ } ) ;
1343+ first . writeStderr ( COLLISION_STDERR ) ;
1344+ first . emitClose ( 1 ) ;
1345+ await nextTick ( ) ;
1346+ second . writeStdoutLine (
1347+ agentEnd ( [
1348+ {
1349+ role : "assistant" ,
1350+ content : [ { type : "text" , text : "fallback without retry loop" } ] ,
1351+ stopReason : "stop" ,
1352+ } ,
1353+ ] ) ,
1354+ ) ;
1355+ second . emitClose ( 0 ) ;
1356+
1357+ expect ( await resultPromise ) . toEqual ( {
1358+ ok : true ,
1359+ assistantText : "fallback without retry loop" ,
1360+ toolCallCount : 0 ,
1361+ durationMs : expect . any ( Number ) ,
1362+ meta : { stderr : undefined } ,
1363+ } ) ;
1364+ expect ( spawnImpl ) . toHaveBeenCalledTimes ( 2 ) ;
1365+ expect ( spawnImpl . mock . calls [ 0 ] ?. [ 1 ] ) . toContain ( "--no-extensions" ) ;
1366+ expect ( spawnImpl . mock . calls [ 1 ] ?. [ 1 ] ) . toContain ( "--no-extensions" ) ;
1367+ expect (
1368+ logSpy . mock . calls . some (
1369+ ( call ) => call [ 1 ] === ISOLATED_RETRY_LOG_MESSAGE ,
1370+ ) ,
1371+ ) . toBe ( false ) ;
1372+ } finally {
1373+ logSpy . mockRestore ( ) ;
1374+ }
1375+ } ) ;
1376+
1377+ it ( "logs model-unavailable guidance when the isolated retry loses an extension-only model" , async ( ) => {
1378+ const first = createMockChild ( ) ;
1379+ const second = createMockChild ( ) ;
1380+ const { runner } = runnerWith ( [ first , second ] ) ;
1381+ const logSpy = spyOn ( loggerModule , "sessionLog" ) . mockImplementation (
1382+ ( ) => { } ,
1383+ ) ;
1384+
1385+ try {
1386+ const resultPromise = runner . run ( {
1387+ ...baseOptions ,
1388+ model : "openai/extension-model" ,
1389+ } ) ;
1390+ first . writeStderr ( COLLISION_STDERR ) ;
1391+ first . emitClose ( 1 ) ;
1392+ await nextTick ( ) ;
1393+ second . writeStderr ( "Unknown model openai-codex/extension-model" ) ;
1394+ second . emitClose ( 1 ) ;
1395+
1396+ const result = await resultPromise ;
1397+ expect ( result . ok ) . toBe ( false ) ;
1398+ if ( ! result . ok ) {
1399+ expect ( result . reason ) . toBe ( "non_zero_exit" ) ;
1400+ expect ( result . error ) . toContain (
1401+ ISOLATED_RETRY_MODEL_UNAVAILABLE_LOG_MESSAGE ,
1402+ ) ;
1403+ expect ( result . error ) . toContain ( "Original failure:" ) ;
1404+ }
1405+ expect (
1406+ logSpy . mock . calls . some (
1407+ ( call ) => call [ 1 ] === ISOLATED_RETRY_LOG_MESSAGE ,
1408+ ) ,
1409+ ) . toBe ( true ) ;
1410+ expect (
1411+ logSpy . mock . calls . some (
1412+ ( call ) => call [ 1 ] === ISOLATED_RETRY_MODEL_UNAVAILABLE_LOG_MESSAGE ,
1413+ ) ,
1414+ ) . toBe ( true ) ;
1415+ } finally {
1416+ logSpy . mockRestore ( ) ;
1417+ }
1418+ } ) ;
1419+
1420+ it ( "does not keep isolated mode for the next run" , async ( ) => {
1421+ const first = createMockChild ( ) ;
1422+ const second = createMockChild ( ) ;
1423+ const third = createMockChild ( ) ;
1424+ const { runner, spawnImpl } = runnerWith ( [ first , second , third ] ) ;
1425+
1426+ const degradedRun = runner . run ( {
1427+ ...baseOptions ,
1428+ model : "anthropic/claude-sonnet" ,
1429+ } ) ;
1430+ first . writeStderr ( COLLISION_STDERR ) ;
1431+ first . emitClose ( 1 ) ;
1432+ await nextTick ( ) ;
1433+ second . writeStdoutLine (
1434+ agentEnd ( [
1435+ {
1436+ role : "assistant" ,
1437+ content : [ { type : "text" , text : "isolated success" } ] ,
1438+ stopReason : "stop" ,
1439+ } ,
1440+ ] ) ,
1441+ ) ;
1442+ second . emitClose ( 0 ) ;
1443+ await degradedRun ;
1444+
1445+ const freshRun = runner . run ( {
1446+ ...baseOptions ,
1447+ model : "anthropic/claude-sonnet" ,
1448+ } ) ;
1449+ third . writeStdoutLine (
1450+ agentEnd ( [
1451+ {
1452+ role : "assistant" ,
1453+ content : [ { type : "text" , text : "extensions restored" } ] ,
1454+ stopReason : "stop" ,
1455+ } ,
1456+ ] ) ,
1457+ ) ;
1458+ third . emitClose ( 0 ) ;
1459+
1460+ expect ( await freshRun ) . toEqual ( {
1461+ ok : true ,
1462+ assistantText : "extensions restored" ,
1463+ toolCallCount : 0 ,
1464+ durationMs : expect . any ( Number ) ,
1465+ meta : { stderr : undefined } ,
1466+ } ) ;
1467+ expect ( spawnImpl . mock . calls [ 0 ] ?. [ 1 ] ) . not . toContain ( "--no-extensions" ) ;
1468+ expect ( spawnImpl . mock . calls [ 1 ] ?. [ 1 ] ) . toContain ( "--no-extensions" ) ;
1469+ expect ( spawnImpl . mock . calls [ 2 ] ?. [ 1 ] ) . not . toContain ( "--no-extensions" ) ;
1470+ } ) ;
1471+
11641472 it ( "returns parse_failed when stdout is missing" , async ( ) => {
11651473 const child = createMockChild ( { stdout : false } ) ;
11661474 const { runner } = runnerWith ( child ) ;
0 commit comments