@@ -19,6 +19,8 @@ import {
1919 formatSubAgentReport ,
2020 nextToolCallStreak ,
2121 parseSubAgentReport ,
22+ repetitionStopDetail ,
23+ stopReasonFromReport ,
2224 appendDeadlineParentHint ,
2325 appendNeverActedParentHint ,
2426 appendSubAgentParentHints ,
@@ -770,6 +772,45 @@ describe("sub-agent stop helpers", () => {
770772 ) ;
771773 } ) ;
772774
775+ test ( "forcedStopReport carries a machine-readable Stopped line the parent sees verbatim" , ( ) => {
776+ const repetition = forcedStopReport (
777+ "repetition" ,
778+ 'Looped window (repeated 1363x): Groaning. ' ,
779+ 'window "Groaning. " × 1363' ,
780+ ) ;
781+ expect ( repetition . startsWith ( 'Stopped: repetition — window "Groaning. " × 1363\n' ) ) . toBe ( true ) ;
782+ expect ( parseSubAgentReport ( repetition ) . stopped ) . toBe ( 'repetition — window "Groaning. " × 1363' ) ;
783+ expect ( stopReasonFromReport ( repetition ) ) . toBe ( 'repetition — window "Groaning. " × 1363' ) ;
784+ // Survives runSubAgent's parse/format normalization round-trip.
785+ const roundTripped = formatSubAgentReport ( parseSubAgentReport ( repetition ) ) ;
786+ expect ( stopReasonFromReport ( roundTripped ) ) . toBe ( 'repetition — window "Groaning. " × 1363' ) ;
787+ // Classifiers and hints still fire on the unchanged Summary text.
788+ expect ( appendSubAgentParentHints ( repetition ) ) . toContain ( "degenerated into a loop" ) ;
789+
790+ const cancelled = forcedStopReport ( "cancelled" , "partial" , "Session closed" ) ;
791+ expect ( stopReasonFromReport ( cancelled ) ) . toBe ( "cancelled — Session closed" ) ;
792+ // Without a detail the line is the bare reason token.
793+ expect ( stopReasonFromReport ( forcedStopReport ( "cancelled" , "partial" ) ) ) . toBe ( "cancelled" ) ;
794+ expect ( stopReasonFromReport ( forcedStopReport ( "turn-budget" , "x" , "30/30 turns" ) ) ) . toBe (
795+ "turn-budget — 30/30 turns" ,
796+ ) ;
797+
798+ // A nested forced-stop quoted in Findings must not leak its Stopped line
799+ // as the outer report's reason.
800+ const nested = forcedStopReport ( "never-acted" , forcedStopReport ( "cancelled" , "inner" , "inner reason" ) ) ;
801+ expect ( stopReasonFromReport ( nested ) ) . toBe ( "never-acted" ) ;
802+ // A clean report has no Stopped line.
803+ expect ( stopReasonFromReport ( "## Summary\nDone.\n\n## Findings\nx" ) ) . toBe ( null ) ;
804+ } ) ;
805+
806+ test ( "repetitionStopDetail formats the looped window snippet and repeat count" , ( ) => {
807+ expect ( repetitionStopDetail ( { window : "Groaning. " , repeats : 1363 } ) ) . toBe (
808+ 'window "Groaning. " × 1363' ,
809+ ) ;
810+ const long = repetitionStopDetail ( { window : "x" . repeat ( 500 ) , repeats : 7 } ) ;
811+ expect ( long ) . toBe ( `window "${ "x" . repeat ( 80 ) } " × 7` ) ;
812+ } ) ;
813+
773814 test ( "createSubAgentRunController aborts on an explicit deadline and reports deadlineHit" , async ( ) => {
774815 const ctl = createSubAgentRunController ( undefined , 20 ) ;
775816 expect ( ctl . signal . aborted ) . toBe ( false ) ;
@@ -1849,6 +1890,29 @@ describe("createTaskTool", () => {
18491890 expect ( row ?. status ) . toBe ( "cancelled" ) ;
18501891 } ) ;
18511892
1893+ test ( "pre-progress cancel surfaces the recorded cancel reason to the parent" , async ( ) => {
1894+ const sessions = createSubAgentSessionStore ( ) ;
1895+ const tool = createTaskTool ( {
1896+ permissionGate : testPermissionGate ,
1897+ cwd : "/repo" ,
1898+ getWorkdirBase : ( ) => "/repo/.corbits" ,
1899+ provider,
1900+ sessions,
1901+ run : async ( ) => {
1902+ const row = sessions . list ( ) . find ( ( s ) => s . description === "reasoned" ) ;
1903+ if ( row !== undefined ) sessions . cancel ( row . id , "Session closed" ) ;
1904+ const err = new Error ( "aborted" ) ;
1905+ err . name = "AbortError" ;
1906+ throw err ;
1907+ } ,
1908+ } ) ;
1909+ const out = await callTask ( tool , { description : "reasoned" , prompt : "x" , intent : "explore" } ) ;
1910+ expect ( out ) . toContain ( "Stopped: cancelled — Session closed" ) ;
1911+ const row = sessions . list ( ) . find ( ( s ) => s . description === "reasoned" ) ;
1912+ expect ( row ?. status ) . toBe ( "cancelled" ) ;
1913+ expect ( row ?. stopReason ) . toBe ( "cancelled — Session closed" ) ;
1914+ } ) ;
1915+
18521916 test ( "pre-progress AbortError still surfaces as bare cancel" , async ( ) => {
18531917 const tool = createTaskTool ( {
18541918 permissionGate : testPermissionGate ,
0 commit comments