@@ -14,6 +14,28 @@ const ttyToolsAvailable = Bun.spawnSync(["bash", "-lc", "command -v script >/dev
1414 stderr : "ignore" ,
1515} ) . exitCode === 0 ;
1616
17+ interface HealthResponse {
18+ ok : boolean ;
19+ pid : number ;
20+ sessions : number ;
21+ }
22+
23+ interface ListedSessionSummary {
24+ sessionId : string ;
25+ title : string ;
26+ files : Array < {
27+ path : string ;
28+ } > ;
29+ }
30+
31+ interface FixtureFiles {
32+ dir : string ;
33+ before : string ;
34+ after : string ;
35+ transcript : string ;
36+ afterName : string ;
37+ }
38+
1739function cleanupTempDirs ( ) {
1840 while ( tempDirs . length > 0 ) {
1941 const dir = tempDirs . pop ( ) ;
@@ -37,18 +59,42 @@ function stripTerminalControl(text: string) {
3759 . replace ( / \x1b [ @ - _ ] / g, "" ) ;
3860}
3961
40- function createFixtureFiles ( ) {
41- const dir = mkdtempSync ( join ( tmpdir ( ) , " hunk-mcp-e2e-" ) ) ;
62+ function createFixtureFiles ( name : string , beforeLines : string [ ] , afterLines : string [ ] ) : FixtureFiles {
63+ const dir = mkdtempSync ( join ( tmpdir ( ) , ` hunk-mcp-e2e-${ name } -` ) ) ;
4264 tempDirs . push ( dir ) ;
4365
44- const before = join ( dir , "before.ts" ) ;
45- const after = join ( dir , "after.ts" ) ;
46- const transcript = join ( dir , "transcript.txt" ) ;
66+ const beforeName = `${ name } -before.ts` ;
67+ const afterName = `${ name } -after.ts` ;
68+ const before = join ( dir , beforeName ) ;
69+ const after = join ( dir , afterName ) ;
70+ const transcript = join ( dir , `${ name } -transcript.txt` ) ;
71+
72+ writeFileSync ( before , [ ...beforeLines , "" ] . join ( "\n" ) ) ;
73+ writeFileSync ( after , [ ...afterLines , "" ] . join ( "\n" ) ) ;
74+
75+ return { dir, before, after, transcript, afterName } ;
76+ }
4777
48- writeFileSync ( before , [ "export const alpha = 1;" , "export const keep = true;" , "" ] . join ( "\n" ) ) ;
49- writeFileSync ( after , [ "export const alpha = 2;" , "export const keep = true;" , "export const gamma = true;" , "" ] . join ( "\n" ) ) ;
78+ function spawnHunkSession ( fixture : FixtureFiles , port : number ) {
79+ const hunkCommand = [
80+ `(sleep 6; printf q) | timeout 8 script -q -f -e -c` ,
81+ shellQuote ( `bun run ${ shellQuote ( sourceEntrypoint ) } diff ${ shellQuote ( fixture . before ) } ${ shellQuote ( fixture . after ) } ` ) ,
82+ shellQuote ( fixture . transcript ) ,
83+ ] . join ( " " ) ;
5084
51- return { dir, before, after, transcript } ;
85+ return Bun . spawn ( [ "bash" , "-lc" , hunkCommand ] , {
86+ cwd : fixture . dir ,
87+ stdin : "ignore" ,
88+ stdout : "pipe" ,
89+ stderr : "pipe" ,
90+ env : {
91+ ...process . env ,
92+ TERM : "xterm-256color" ,
93+ COLUMNS : "120" ,
94+ LINES : "24" ,
95+ HUNK_MCP_PORT : String ( port ) ,
96+ } ,
97+ } ) ;
5298}
5399
54100async function waitUntil < T > ( label : string , fn : ( ) => Promise < T | null > , timeoutMs = 10_000 , intervalMs = 150 ) {
@@ -76,13 +122,22 @@ async function waitForHealth(port: number) {
76122 return null ;
77123 }
78124
79- return ( await response . json ( ) ) as { ok : boolean ; pid : number ; sessions : number } ;
125+ return ( await response . json ( ) ) as HealthResponse ;
80126 } catch {
81127 return null ;
82128 }
83129 } ) ;
84130}
85131
132+ async function listSessions ( client : Client ) {
133+ const result = await client . callTool ( {
134+ name : "list_sessions" ,
135+ arguments : { } ,
136+ } ) ;
137+
138+ return ( ( result . structuredContent as { sessions ?: ListedSessionSummary [ ] } | undefined ) ?. sessions ?? [ ] ) ;
139+ }
140+
86141afterEach ( ( ) => {
87142 cleanupTempDirs ( ) ;
88143} ) ;
@@ -93,60 +148,37 @@ describe("MCP end-to-end", () => {
93148 return ;
94149 }
95150
96- const fixture = createFixtureFiles ( ) ;
151+ const fixture = createFixtureFiles (
152+ "single" ,
153+ [ "export const alpha = 1;" , "export const keep = true;" ] ,
154+ [ "export const alpha = 2;" , "export const keep = true;" , "export const gamma = true;" ] ,
155+ ) ;
97156 const port = 48000 + Math . floor ( Math . random ( ) * 1000 ) ;
98- const hunkCommand = [
99- `(sleep 6; printf q) | timeout 8 script -q -f -e -c` ,
100- shellQuote ( `bun run ${ shellQuote ( sourceEntrypoint ) } diff ${ shellQuote ( fixture . before ) } ${ shellQuote ( fixture . after ) } ` ) ,
101- shellQuote ( fixture . transcript ) ,
102- ] . join ( " " ) ;
103- const hunkProc = Bun . spawn ( [ "bash" , "-lc" , hunkCommand ] , {
104- cwd : fixture . dir ,
105- stdin : "ignore" ,
106- stdout : "pipe" ,
107- stderr : "pipe" ,
108- env : {
109- ...process . env ,
110- TERM : "xterm-256color" ,
111- COLUMNS : "120" ,
112- LINES : "24" ,
113- HUNK_MCP_PORT : String ( port ) ,
114- } ,
115- } ) ;
157+ const hunkProc = spawnHunkSession ( fixture , port ) ;
116158
117159 let daemonPid : number | null = null ;
118- let client : Client | null = null ;
119160 let transport : StreamableHTTPClientTransport | null = null ;
120161
121162 try {
122163 const health = await waitForHealth ( port ) ;
123164 daemonPid = health . pid ;
124165 expect ( health . ok ) . toBe ( true ) ;
125166
126- client = new Client ( { name : "mcp-e2e-test" , version : "1.0.0" } ) ;
167+ const client = new Client ( { name : "mcp-e2e-test" , version : "1.0.0" } ) ;
127168 transport = new StreamableHTTPClientTransport ( new URL ( `http://127.0.0.1:${ port } /mcp` ) ) ;
128169 await client . connect ( transport ) ;
129170
130171 const listed = await waitUntil ( "registered Hunk session" , async ( ) => {
131- const result = await client ! . callTool ( {
132- name : "list_sessions" ,
133- arguments : { } ,
134- } ) ;
135- const sessions = ( result . structuredContent as { sessions ?: Array < { sessionId : string ; title : string } > } | undefined ) ?. sessions ;
136-
137- if ( ! sessions || sessions . length === 0 ) {
138- return null ;
139- }
140-
141- return sessions ;
172+ const sessions = await listSessions ( client ) ;
173+ return sessions . length > 0 ? sessions : null ;
142174 } ) ;
143175
144- const targetSession = listed . find ( ( session ) => session . title . includes ( "after.ts" ) ) ?? listed [ 0 ] ! ;
176+ const targetSession = listed . find ( ( session ) => session . files . some ( ( file ) => file . path === fixture . afterName ) ) ?? listed [ 0 ] ! ;
145177 const commentResult = await client . callTool ( {
146178 name : "comment" ,
147179 arguments : {
148180 sessionId : targetSession . sessionId ,
149- filePath : "after.ts" ,
181+ filePath : fixture . afterName ,
150182 side : "new" ,
151183 line : 2 ,
152184 summary : "MCP autostart note" ,
@@ -157,7 +189,7 @@ describe("MCP end-to-end", () => {
157189 } ) ;
158190
159191 const structured = commentResult . structuredContent as { result ?: { filePath ?: string ; line ?: number } } | undefined ;
160- expect ( structured ?. result ?. filePath ) . toBe ( "after.ts" ) ;
192+ expect ( structured ?. result ?. filePath ) . toBe ( fixture . afterName ) ;
161193 expect ( structured ?. result ?. line ) . toBe ( 2 ) ;
162194
163195 const hunkExitCode = await hunkProc . exited ;
@@ -183,4 +215,106 @@ describe("MCP end-to-end", () => {
183215 }
184216 }
185217 } , 20_000 ) ;
218+
219+ test ( "one daemon routes comments to the correct Hunk session when multiple local sessions are open" , async ( ) => {
220+ if ( ! ttyToolsAvailable ) {
221+ return ;
222+ }
223+
224+ const fixtureA = createFixtureFiles (
225+ "alpha" ,
226+ [ "export const alpha = 1;" , "export const shared = true;" ] ,
227+ [ "export const alpha = 2;" , "export const shared = true;" , "export const onlyAlpha = true;" ] ,
228+ ) ;
229+ const fixtureB = createFixtureFiles (
230+ "beta" ,
231+ [ "export const beta = 1;" , "export const shared = true;" ] ,
232+ [ "export const beta = 2;" , "export const shared = true;" , "export const onlyBeta = true;" ] ,
233+ ) ;
234+ const port = 49000 + Math . floor ( Math . random ( ) * 1000 ) ;
235+ const hunkProcA = spawnHunkSession ( fixtureA , port ) ;
236+ const hunkProcB = spawnHunkSession ( fixtureB , port ) ;
237+
238+ let daemonPid : number | null = null ;
239+ let transport : StreamableHTTPClientTransport | null = null ;
240+
241+ try {
242+ const health = await waitForHealth ( port ) ;
243+ daemonPid = health . pid ;
244+ expect ( health . ok ) . toBe ( true ) ;
245+
246+ const client = new Client ( { name : "mcp-multisession-test" , version : "1.0.0" } ) ;
247+ transport = new StreamableHTTPClientTransport ( new URL ( `http://127.0.0.1:${ port } /mcp` ) ) ;
248+ await client . connect ( transport ) ;
249+
250+ const sessions = await waitUntil ( "two registered Hunk sessions" , async ( ) => {
251+ const listed = await listSessions ( client ) ;
252+ return listed . length === 2 ? listed : null ;
253+ } ) ;
254+
255+ const sessionA = sessions . find ( ( session ) => session . files . some ( ( file ) => file . path === fixtureA . afterName ) ) ;
256+ const sessionB = sessions . find ( ( session ) => session . files . some ( ( file ) => file . path === fixtureB . afterName ) ) ;
257+ expect ( sessionA ) . toBeDefined ( ) ;
258+ expect ( sessionB ) . toBeDefined ( ) ;
259+
260+ await client . callTool ( {
261+ name : "comment" ,
262+ arguments : {
263+ sessionId : sessionA ! . sessionId ,
264+ filePath : fixtureA . afterName ,
265+ side : "new" ,
266+ line : 2 ,
267+ summary : "Alpha note" ,
268+ rationale : "Delivered only to the alpha Hunk session." ,
269+ author : "Pi" ,
270+ reveal : true ,
271+ } ,
272+ } ) ;
273+
274+ await client . callTool ( {
275+ name : "comment" ,
276+ arguments : {
277+ sessionId : sessionB ! . sessionId ,
278+ filePath : fixtureB . afterName ,
279+ side : "new" ,
280+ line : 2 ,
281+ summary : "Beta note" ,
282+ rationale : "Delivered only to the beta Hunk session." ,
283+ author : "Pi" ,
284+ reveal : true ,
285+ } ,
286+ } ) ;
287+
288+ const [ exitCodeA , exitCodeB ] = await Promise . all ( [ hunkProcA . exited , hunkProcB . exited ] ) ;
289+ expect ( [ 0 , 124 ] ) . toContain ( exitCodeA ) ;
290+ expect ( [ 0 , 124 ] ) . toContain ( exitCodeB ) ;
291+
292+ const transcriptA = stripTerminalControl ( await Bun . file ( fixtureA . transcript ) . text ( ) ) ;
293+ const transcriptB = stripTerminalControl ( await Bun . file ( fixtureB . transcript ) . text ( ) ) ;
294+
295+ expect ( transcriptA ) . toContain ( "Alpha note" ) ;
296+ expect ( transcriptA ) . toContain ( "Delivered only to the alpha" ) ;
297+ expect ( transcriptA ) . not . toContain ( "Beta note" ) ;
298+
299+ expect ( transcriptB ) . toContain ( "Beta note" ) ;
300+ expect ( transcriptB ) . toContain ( "Delivered only to the beta" ) ;
301+ expect ( transcriptB ) . not . toContain ( "Alpha note" ) ;
302+ } finally {
303+ if ( transport ) {
304+ await transport . close ( ) . catch ( ( ) => undefined ) ;
305+ }
306+
307+ hunkProcA . kill ( ) ;
308+ hunkProcB . kill ( ) ;
309+ await Promise . allSettled ( [ hunkProcA . exited , hunkProcB . exited ] ) ;
310+
311+ if ( daemonPid ) {
312+ try {
313+ process . kill ( daemonPid , "SIGTERM" ) ;
314+ } catch {
315+ // Ignore daemons that already exited during cleanup.
316+ }
317+ }
318+ }
319+ } , 20_000 ) ;
186320} ) ;
0 commit comments