@@ -3,6 +3,18 @@ import { mkdir, rm } from "node:fs/promises";
33import type { GlobalSettings , Project , Task , TaskDiffResponse } from "../../shared/types" ;
44import { buildTaskDialogSubject , getPreparingStageProgress , resolveTaskCompareBaseBranch } from "../../shared/types" ;
55import { ENV_UNSET } from "../../shared/agent-accounts" ;
6+ import {
7+ activatePane ,
8+ closePane ,
9+ createSplitTree ,
10+ getPaneRects ,
11+ listPaneIds ,
12+ serializeSplitTree ,
13+ setSplitRatio ,
14+ splitPane ,
15+ type SplitOrientation ,
16+ type SplitTree ,
17+ } from "../../shared/split-tree" ;
618
719// ---- Mocks ----
820
@@ -139,6 +151,32 @@ vi.mock("../system-clipboard", () => ({
139151 writeSystemClipboard : vi . fn ( ( ) => "pbcopy" ) ,
140152} ) ) ;
141153
154+ // The native pane runtime spawns real host processes, so it is mocked wholesale.
155+ // Unmarked tasks are tmux and never reach it; the native suites drive these
156+ // doubles directly.
157+ const mockNativePanes = {
158+ nativeTaskPanesState : vi . fn ( async ( ) => null as any ) ,
159+ nativeTaskPaneLayout : vi . fn ( async ( ..._args : any [ ] ) => null as any ) ,
160+ nativeTaskPanesAlive : vi . fn ( async ( ) => false ) ,
161+ nativeTaskPaneCommands : vi . fn ( async ( ) => [ ] as any [ ] ) ,
162+ nativeTaskPaneCommandsOf : vi . fn ( ( ) => [ ] as any [ ] ) ,
163+ splitNativeTaskPane : vi . fn ( async ( ..._args : any [ ] ) => null as any ) ,
164+ closeNativeTaskPane : vi . fn ( async ( ..._args : any [ ] ) => ( { sessionTornDown : false , state : null } ) as any ) ,
165+ focusNativeTaskPane : vi . fn ( async ( ..._args : any [ ] ) => null as any ) ,
166+ setNativeTaskPaneLayout : vi . fn ( async ( ..._args : any [ ] ) => null as any ) ,
167+ } ;
168+ vi . mock ( "../native-task-panes" , ( ) => mockNativePanes ) ;
169+
170+ const mockSendPromptToNativePane = vi . fn ( async ( ) => true ) ;
171+ vi . mock ( "../agent-prompt-native" , ( ) => ( {
172+ sendPromptToNativePane : ( ...args : any [ ] ) => mockSendPromptToNativePane ( ...( args as [ ] ) ) ,
173+ sendPromptToNativeAgentPane : vi . fn ( async ( ) => true ) ,
174+ resolveNativeAgentPane : vi . fn ( async ( ) => null ) ,
175+ deliverNativePromptAsOwner : vi . fn ( async ( ) => true ) ,
176+ NATIVE_AGENT_PANE_ID : "pane-1" ,
177+ NATIVE_PROMPT_DELIVERY_METHOD : "_native.deliverPrompt" ,
178+ } ) ) ;
179+
142180vi . mock ( "../agents" , ( ) => ( {
143181 ensureClaudeTrust : vi . fn ( ) ,
144182 ensureCodexTrust : vi . fn ( ) ,
@@ -343,6 +381,7 @@ const {
343381 portableReadKey,
344382} = await import ( "../rpc-handlers" ) ;
345383const {
384+ nativeHunterColumnRatios,
346385 tmuxAction,
347386 tmuxKillPane,
348387 tmuxPaneCount,
@@ -8604,6 +8643,315 @@ describe("handlers.spawnBugHuntersInTask", () => {
86048643 ) . rejects . toThrow ( "no worktree" ) ;
86058644 } ) ;
86068645} ) ;
8646+ // ================================================================
8647+ // handlers.spawnBugHuntersInTask on a NATIVE task (seq 1394)
8648+ //
8649+ // Same entry point the Find bugs dialog calls, on a task whose terminal is
8650+ // native rather than tmux.
8651+ // ================================================================
8652+
8653+ describe ( "handlers.spawnBugHuntersInTask on a native task" , ( ) => {
8654+ const TASK_ID = "abcd1234-full-id" ;
8655+ const READINESS_DELAY_MS = 5100 ;
8656+
8657+ beforeEach ( ( ) => {
8658+ vi . clearAllMocks ( ) ;
8659+ vi . useFakeTimers ( ) ;
8660+ ( globalThis as any ) . Bun . write = vi . fn ( ) . mockResolvedValue ( undefined ) ;
8661+ mockSendPromptToNativePane . mockResolvedValue ( true ) ;
8662+ mockNativePanes . focusNativeTaskPane . mockResolvedValue ( null as any ) ;
8663+ mockNativePanes . closeNativeTaskPane . mockResolvedValue ( { sessionTornDown : false , state : null } as any ) ;
8664+ } ) ;
8665+ afterEach ( ( ) => {
8666+ vi . useRealTimers ( ) ;
8667+ } ) ;
8668+
8669+ /**
8670+ * A pane set that starts with the agent's pane and grows on every split. The
8671+ * layout is a REAL SplitTree driven by the shared split-tree functions, so the
8672+ * geometry assertions measure what the app would draw.
8673+ */
8674+ function arrangeNativePaneSet ( opts : {
8675+ failAtSplit ?: number ;
8676+ closeAlwaysFails ?: boolean ;
8677+ /** A pane set the task already had before Find bugs was clicked. */
8678+ seed ?: SplitTree ;
8679+ /** The layout read-back returns nothing, as if the coordinator vanished. */
8680+ layoutUnreadable ?: boolean ;
8681+ /** Publishing the evened-out column fails. */
8682+ publishFails ?: boolean ;
8683+ } = { } ) {
8684+ let tree = opts . seed ?? createSplitTree ( ) ;
8685+ const state = ( ) => ( {
8686+ taskId : TASK_ID ,
8687+ panes : listPaneIds ( tree ) . map ( ( paneId ) => ( { paneId, sessionId : `${ paneId } -s` , hostPid : 1 , shellPid : 2 , cols : 80 , rows : 24 , alive : true } ) ) ,
8688+ layout : serializeSplitTree ( tree ) ,
8689+ activePaneId : tree . activePaneId ,
8690+ } ) ;
8691+ mockNativePanes . nativeTaskPanesState . mockImplementation ( async ( ) => state ( ) as any ) ;
8692+ mockNativePanes . nativeTaskPaneLayout . mockImplementation ( async ( ) => ( opts . layoutUnreadable ? null : ( tree as any ) ) ) ;
8693+ mockNativePanes . setNativeTaskPaneLayout . mockImplementation ( async ( _taskId : string , next : SplitTree ) => {
8694+ if ( opts . publishFails ) throw new Error ( "coordinator refused the geometry" ) ;
8695+ tree = next ;
8696+ return state ( ) as any ;
8697+ } ) ;
8698+ mockNativePanes . focusNativeTaskPane . mockImplementation ( async ( _taskId : string , paneId : string ) => {
8699+ tree = activatePane ( tree , paneId ) ;
8700+ return state ( ) as any ;
8701+ } ) ;
8702+ mockNativePanes . closeNativeTaskPane . mockImplementation ( async ( _taskId : string , paneId : string ) => {
8703+ // A close that "fails" leaves the pane in the set — how a host that refuses
8704+ // to die looks to the caller.
8705+ if ( ! opts . closeAlwaysFails ) tree = closePane ( tree , paneId ) ;
8706+ const next = state ( ) ;
8707+ return { sessionTornDown : next . panes . length === 0 , state : next } as any ;
8708+ } ) ;
8709+ let splitCount = 0 ;
8710+ mockNativePanes . splitNativeTaskPane . mockImplementation ( async ( _taskId : string , fromPaneId : string , orientation : SplitOrientation ) => {
8711+ splitCount += 1 ;
8712+ if ( opts . failAtSplit === splitCount ) throw new Error ( "host would not start" ) ;
8713+ const paneId = `pane-${ tree . nextPaneOrdinal } ` ;
8714+ tree = splitPane ( tree , fromPaneId , orientation ) ;
8715+ return { paneId, state : state ( ) } as any ;
8716+ } ) ;
8717+ return {
8718+ paneIds : ( ) => listPaneIds ( tree ) ,
8719+ activePaneId : ( ) => tree . activePaneId ,
8720+ rects : ( ) => getPaneRects ( tree ) ,
8721+ tree : ( ) => tree ,
8722+ } ;
8723+ }
8724+
8725+ /** Pane heights in whole percent, so a third reads as 33 rather than 0.3333…. */
8726+ function heightPercents ( rects : Map < string , { height : number } > ) : Record < string , number > {
8727+ const out : Record < string , number > = { } ;
8728+ for ( const [ paneId , rect ] of rects ) out [ paneId ] = Math . round ( rect . height * 100 ) ;
8729+ return out ;
8730+ }
8731+
8732+ function arrangeNativeTask ( ) {
8733+ const project = makeProject ( ) ;
8734+ const task = makeTask ( { id : TASK_ID , worktreePath : "/tmp/wt" , terminalBackend : "native" } as any ) ;
8735+ ( data . getProject as any ) . mockResolvedValue ( project ) ;
8736+ ( data . getTask as any ) . mockResolvedValue ( task ) ;
8737+ ( agents . resolveCommandForAgent as any ) . mockResolvedValue ( { command : "claude" , extraEnv : { } , agent : { baseCommand : "claude" } } ) ;
8738+ return { project, task } ;
8739+ }
8740+
8741+ /** Launch and let the readiness delay elapse — native delivery is awaited. */
8742+ function launchHunters ( count : number ) : Promise < { spawned : number } > {
8743+ const launch = handlers . spawnBugHuntersInTask ( {
8744+ taskId : TASK_ID , projectId : "proj-1" , agentId : "builtin-claude" , configId : "claude-default" , count,
8745+ } ) ;
8746+ const settled = launch . catch ( ( ) => undefined ) ;
8747+ return vi . advanceTimersByTimeAsync ( READINESS_DELAY_MS ) . then ( ( ) => settled ) . then ( ( ) => launch ) ;
8748+ }
8749+
8750+ it ( "opens the requested number of native panes and never calls tmux" , async ( ) => {
8751+ arrangeNativeTask ( ) ;
8752+ const set = arrangeNativePaneSet ( ) ;
8753+
8754+ const result = await launchHunters ( 3 ) ;
8755+
8756+ expect ( result ) . toEqual ( { spawned : 3 } ) ;
8757+ expect ( mockNativePanes . splitNativeTaskPane ) . toHaveBeenCalledTimes ( 3 ) ;
8758+ // Zero tmux process spawns — no split-window, and no probe of the default socket.
8759+ expect ( mockSpawn ) . not . toHaveBeenCalled ( ) ;
8760+
8761+ // First hunter splits off the agent's pane to the right; each later hunter
8762+ // splits the previous hunter's pane downwards, so the right column stacks.
8763+ const anchors = mockNativePanes . splitNativeTaskPane . mock . calls . map ( ( c : any [ ] ) => [ c [ 1 ] , c [ 2 ] ] ) ;
8764+ expect ( anchors ) . toEqual ( [
8765+ [ "pane-1" , "horizontal" ] ,
8766+ [ "pane-2" , "vertical" ] ,
8767+ [ "pane-3" , "vertical" ] ,
8768+ ] ) ;
8769+ expect ( set . paneIds ( ) ) . toEqual ( [ "pane-1" , "pane-2" , "pane-3" , "pane-4" ] ) ;
8770+ // The agent keeps the keyboard: the last split made a hunter pane active.
8771+ expect ( set . activePaneId ( ) ) . toBe ( "pane-1" ) ;
8772+ } ) ;
8773+
8774+ // A bare chain of SplitTree splits lands at 50/25/25, while tmux deliberately
8775+ // produces equal thirds. The launch re-publishes the ratios of its own splits.
8776+ it ( "leaves the hunter column in equal parts, like the tmux path, and does not move the agent's pane" , async ( ) => {
8777+ arrangeNativeTask ( ) ;
8778+ const set = arrangeNativePaneSet ( ) ;
8779+
8780+ await launchHunters ( 3 ) ;
8781+
8782+ const rects = set . rects ( ) ;
8783+ expect ( rects . get ( "pane-1" ) ) . toEqual ( { x : 0 , y : 0 , width : 0.5 , height : 1 } ) ;
8784+ expect ( heightPercents ( rects ) ) . toEqual ( { "pane-1" : 100 , "pane-2" : 33 , "pane-3" : 33 , "pane-4" : 33 } ) ;
8785+ expect ( [ ...rects ] . filter ( ( [ id ] ) => id !== "pane-1" ) . map ( ( [ id , r ] ) => [ id , Math . round ( r . y * 100 ) , Math . round ( r . x * 100 ) ] ) ) . toEqual ( [
8786+ [ "pane-2" , 0 , 50 ] ,
8787+ [ "pane-3" , 33 , 50 ] ,
8788+ [ "pane-4" , 67 , 50 ] ,
8789+ ] ) ;
8790+ } ) ;
8791+
8792+ it ( "evens out a full column of six hunters too" , async ( ) => {
8793+ arrangeNativeTask ( ) ;
8794+ const set = arrangeNativePaneSet ( ) ;
8795+
8796+ const result = await launchHunters ( 6 ) ;
8797+
8798+ expect ( result ) . toEqual ( { spawned : 6 } ) ;
8799+ const rects = set . rects ( ) ;
8800+ expect ( rects . get ( "pane-1" ) ) . toEqual ( { x : 0 , y : 0 , width : 0.5 , height : 1 } ) ;
8801+ expect ( heightPercents ( rects ) ) . toEqual ( {
8802+ "pane-1" : 100 , "pane-2" : 17 , "pane-3" : 17 , "pane-4" : 17 , "pane-5" : 17 , "pane-6" : 17 , "pane-7" : 17 ,
8803+ } ) ;
8804+ } ) ;
8805+
8806+ it ( "computes the column ratios that make each hunter equal" , ( ) => {
8807+ expect ( nativeHunterColumnRatios ( 1 ) ) . toEqual ( [ ] ) ;
8808+ expect ( nativeHunterColumnRatios ( 3 ) ) . toEqual ( [ 1 / 3 , 1 / 2 ] ) ;
8809+ expect ( nativeHunterColumnRatios ( 6 ) ) . toEqual ( [ 1 / 6 , 1 / 5 , 1 / 4 , 1 / 3 , 1 / 2 ] ) ;
8810+ } ) ;
8811+
8812+ it ( "delivers the hunter prompt through the native pane, not through send-keys" , async ( ) => {
8813+ arrangeNativeTask ( ) ;
8814+ arrangeNativePaneSet ( ) ;
8815+
8816+ await launchHunters ( 2 ) ;
8817+
8818+ expect ( mockSendPromptToNativePane ) . toHaveBeenCalledTimes ( 2 ) ;
8819+ const [ , paneId , prompt ] = mockSendPromptToNativePane . mock . calls [ 0 ] as any [ ] ;
8820+ expect ( paneId ) . toBe ( "pane-2" ) ;
8821+ expect ( prompt ) . toContain ( "/dev3-bug-hunter" ) ;
8822+ expect ( mockSpawn ) . not . toHaveBeenCalled ( ) ;
8823+ } ) ;
8824+
8825+ it ( "fails the launch when a prompt is reported undelivered" , async ( ) => {
8826+ arrangeNativeTask ( ) ;
8827+ const set = arrangeNativePaneSet ( ) ;
8828+ mockSendPromptToNativePane . mockResolvedValueOnce ( true ) . mockResolvedValueOnce ( false ) ;
8829+
8830+ // Not "only 2 of 2 could be started" — the panes DID open, the prompt did not land.
8831+ await expect ( launchHunters ( 2 ) ) . rejects . toThrow (
8832+ "All 2 hunter panes opened, but the hunt prompt never reached pane-3; launch rolled back." ,
8833+ ) ;
8834+
8835+ expect ( set . paneIds ( ) ) . toEqual ( [ "pane-1" ] ) ;
8836+ expect ( set . activePaneId ( ) ) . toBe ( "pane-1" ) ;
8837+ } ) ;
8838+
8839+ it ( "fails the launch when a prompt delivery throws" , async ( ) => {
8840+ arrangeNativeTask ( ) ;
8841+ const set = arrangeNativePaneSet ( ) ;
8842+ mockSendPromptToNativePane . mockRejectedValue ( new Error ( "writer lease is gone" ) ) ;
8843+
8844+ await expect ( launchHunters ( 2 ) ) . rejects . toThrow ( / w r i t e r l e a s e i s g o n e / ) ;
8845+
8846+ expect ( set . paneIds ( ) ) . toEqual ( [ "pane-1" ] ) ;
8847+ expect ( set . activePaneId ( ) ) . toBe ( "pane-1" ) ;
8848+ } ) ;
8849+
8850+ it ( "rolls the whole launch back when a later split fails, instead of leaving a partial set" , async ( ) => {
8851+ arrangeNativeTask ( ) ;
8852+ const set = arrangeNativePaneSet ( { failAtSplit : 3 } ) ;
8853+
8854+ await expect ( launchHunters ( 4 ) ) . rejects . toThrow ( "Only 2 of 4 bug hunters could be started" ) ;
8855+
8856+ // Both panes that did open were closed again; the agent's pane is untouched.
8857+ expect ( mockNativePanes . closeNativeTaskPane . mock . calls . map ( ( c : any [ ] ) => c [ 1 ] ) ) . toEqual ( [ "pane-2" , "pane-3" ] ) ;
8858+ expect ( set . paneIds ( ) ) . toEqual ( [ "pane-1" ] ) ;
8859+ expect ( set . activePaneId ( ) ) . toBe ( "pane-1" ) ;
8860+ } ) ;
8861+
8862+ // "Rolled back" must never be printed over panes that are still on screen.
8863+ it ( "names the panes it could not close when the rollback itself fails" , async ( ) => {
8864+ arrangeNativeTask ( ) ;
8865+ const set = arrangeNativePaneSet ( { failAtSplit : 3 , closeAlwaysFails : true } ) ;
8866+
8867+ await expect ( launchHunters ( 4 ) ) . rejects . toThrow (
8868+ / t h e r o l l b a c k c o u l d n o t c l o s e p a n e - 2 , p a n e - 3 — t h o s e p a n e s a r e s t i l l o p e n , c l o s e t h e m b y h a n d \. / ,
8869+ ) ;
8870+
8871+ expect ( set . paneIds ( ) ) . toEqual ( [ "pane-1" , "pane-2" , "pane-3" ] ) ;
8872+ expect ( set . activePaneId ( ) ) . toBe ( "pane-1" ) ;
8873+ } ) ;
8874+
8875+ // Geometry is part of the result, not a nice-to-have: reporting a launched
8876+ // column that is actually drawn 50/25/25 is the silent wrong answer.
8877+ it ( "fails the launch when the layout cannot be read back" , async ( ) => {
8878+ arrangeNativeTask ( ) ;
8879+ const set = arrangeNativePaneSet ( { layoutUnreadable : true } ) ;
8880+
8881+ await expect ( launchHunters ( 3 ) ) . rejects . toThrow (
8882+ "All 3 hunter panes opened, but the column could not be laid out evenly " +
8883+ "(Error: the task's native pane layout could not be read back); launch rolled back." ,
8884+ ) ;
8885+
8886+ expect ( set . paneIds ( ) ) . toEqual ( [ "pane-1" ] ) ;
8887+ expect ( set . activePaneId ( ) ) . toBe ( "pane-1" ) ;
8888+ } ) ;
8889+
8890+ it ( "fails the launch when the evened-out column cannot be published" , async ( ) => {
8891+ arrangeNativeTask ( ) ;
8892+ const set = arrangeNativePaneSet ( { publishFails : true } ) ;
8893+
8894+ await expect ( launchHunters ( 3 ) ) . rejects . toThrow (
8895+ / t h e c o l u m n c o u l d n o t b e l a i d o u t e v e n l y \( E r r o r : c o o r d i n a t o r r e f u s e d t h e g e o m e t r y \) ; l a u n c h r o l l e d b a c k \. / ,
8896+ ) ;
8897+
8898+ expect ( set . paneIds ( ) ) . toEqual ( [ "pane-1" ] ) ;
8899+ expect ( set . activePaneId ( ) ) . toBe ( "pane-1" ) ;
8900+ } ) ;
8901+
8902+ it ( "fails the launch when a split it made is gone from the layout" , async ( ) => {
8903+ arrangeNativeTask ( ) ;
8904+ const set = arrangeNativePaneSet ( ) ;
8905+ // The layout read-back no longer contains the hunter panes at all.
8906+ mockNativePanes . nativeTaskPaneLayout . mockResolvedValue ( createSplitTree ( ) as any ) ;
8907+
8908+ await expect ( launchHunters ( 3 ) ) . rejects . toThrow ( / t h e s p l i t o p e n e d o f f p a n e - 2 i s n o l o n g e r i n t h e l a y o u t / ) ;
8909+
8910+ expect ( set . paneIds ( ) ) . toEqual ( [ "pane-1" ] ) ;
8911+ } ) ;
8912+
8913+ it ( "evens out its own column without moving a pane the task already had" , async ( ) => {
8914+ arrangeNativeTask ( ) ;
8915+ // The task already runs a dev-server pane below the agent, at a ratio the user
8916+ // dragged to 70/30 — nothing about this launch may touch it.
8917+ let seed = splitPane ( createSplitTree ( ) , "pane-1" , "vertical" ) ;
8918+ const existingSplit = ( seed . root as { id : string } ) . id ;
8919+ seed = setSplitRatio ( seed , existingSplit , 0.7 ) ;
8920+ seed = activatePane ( seed , "pane-1" ) ;
8921+ const before = getPaneRects ( seed ) . get ( "pane-2" ) ;
8922+ const set = arrangeNativePaneSet ( { seed } ) ;
8923+
8924+ await launchHunters ( 3 ) ;
8925+
8926+ const rects = set . rects ( ) ;
8927+ // The pre-existing pane and its split ratio are byte-identical.
8928+ expect ( rects . get ( "pane-2" ) ) . toEqual ( before ) ;
8929+ expect ( ( set . tree ( ) . root as { ratio : number } ) . ratio ) . toBe ( 0.7 ) ;
8930+ // …while the hunter column this launch opened is exactly equal.
8931+ const hunterHeights = [ "pane-3" , "pane-4" , "pane-5" ] . map ( ( id ) => rects . get ( id ) ! . height ) ;
8932+ expect ( hunterHeights [ 0 ] ) . toBeCloseTo ( hunterHeights [ 1 ] , 10 ) ;
8933+ expect ( hunterHeights [ 1 ] ) . toBeCloseTo ( hunterHeights [ 2 ] , 10 ) ;
8934+ expect ( hunterHeights [ 0 ] * 3 ) . toBeCloseTo ( 0.7 , 10 ) ;
8935+ } ) ;
8936+
8937+ it ( "reports honestly when the native terminal is not running" , async ( ) => {
8938+ arrangeNativeTask ( ) ;
8939+ mockNativePanes . nativeTaskPanesState . mockResolvedValue ( null as any ) ;
8940+
8941+ await expect ( launchHunters ( 3 ) ) . rejects . toThrow ( "the task terminal is not running" ) ;
8942+ expect ( mockSpawn ) . not . toHaveBeenCalled ( ) ;
8943+ } ) ;
8944+
8945+ it ( "writes no tmux pane entry into sessionState" , async ( ) => {
8946+ arrangeNativeTask ( ) ;
8947+ arrangeNativePaneSet ( ) ;
8948+
8949+ await launchHunters ( 2 ) ;
8950+
8951+ const sessionStateWrites = ( data . updateTask as any ) . mock . calls . filter ( ( c : any [ ] ) => c [ 2 ] ?. sessionState ) ;
8952+ expect ( sessionStateWrites ) . toHaveLength ( 0 ) ;
8953+ } ) ;
8954+ } ) ;
86078955
86088956describe ( "resumeTask session-id healing" , ( ) => {
86098957 const WORKTREE = "/tmp/resume-wt" ;
0 commit comments