@@ -1709,4 +1709,181 @@ describe("ptyService", () => {
17091709 ) ;
17101710 } ) ;
17111711 } ) ;
1712+
1713+ describe ( "chat terminal contract" , ( ) => {
1714+ /**
1715+ * Augment the harness's session service with the methods listTerminals/activeForChat rely on.
1716+ * Returned `service` is a fresh ptyService bound to the augmented sessionService, so we can
1717+ * exercise the chat-linked terminal surface end-to-end.
1718+ */
1719+ function createChatHarness ( ) {
1720+ const harness = createHarness ( ) ;
1721+ const sessionStore = new Map < string , any > ( ) ;
1722+
1723+ const sessionService = {
1724+ ...harness . sessionService ,
1725+ create : vi . fn ( ( args : any ) => {
1726+ sessionStore . set ( args . sessionId , {
1727+ ...args ,
1728+ id : args . sessionId ,
1729+ status : "running" ,
1730+ laneId : args . laneId ,
1731+ laneName : "Test lane" ,
1732+ ptyId : args . ptyId ?? null ,
1733+ title : args . title ,
1734+ transcriptPath : args . transcriptPath ,
1735+ startedAt : args . startedAt ,
1736+ endedAt : null ,
1737+ exitCode : null ,
1738+ chatSessionId : args . chatSessionId ?? null ,
1739+ } ) ;
1740+ } ) ,
1741+ end : vi . fn ( ( args : any ) => {
1742+ const s = sessionStore . get ( args . sessionId ) ;
1743+ if ( s ) {
1744+ s . status = args . status ;
1745+ s . exitCode = args . exitCode ;
1746+ s . endedAt = args . endedAt ;
1747+ s . ptyId = null ;
1748+ }
1749+ } ) ,
1750+ get : vi . fn ( ( id : string ) => sessionStore . get ( id ) ?? null ) ,
1751+ list : vi . fn ( ( args : { laneId ?: string ; limit ?: number } = { } ) => {
1752+ const all = Array . from ( sessionStore . values ( ) ) as any [ ] ;
1753+ return all
1754+ . filter ( ( s ) => ( args . laneId ? s . laneId === args . laneId : true ) )
1755+ . slice ( 0 , args . limit ?? all . length ) ;
1756+ } ) ,
1757+ setChatSessionId : vi . fn ( ( sessionId : string , chatSessionId : string | null ) => {
1758+ const s = sessionStore . get ( sessionId ) ;
1759+ if ( s ) s . chatSessionId = chatSessionId ;
1760+ } ) ,
1761+ readTranscriptTail : vi . fn ( async (
1762+ _path : string ,
1763+ _max : number ,
1764+ _opts ?: { raw ?: boolean } ,
1765+ ) => "transcript-bytes" ) ,
1766+ } ;
1767+
1768+ const service = createPtyService ( {
1769+ projectRoot : "/tmp/test-project" ,
1770+ transcriptsDir : "/tmp/transcripts" ,
1771+ laneService : harness . laneService as any ,
1772+ sessionService : sessionService as any ,
1773+ logger : harness . logger as any ,
1774+ broadcastData : harness . broadcastData ,
1775+ broadcastExit : harness . broadcastExit ,
1776+ onSessionEnded : harness . onSessionEnded ,
1777+ onSessionRuntimeSignal : harness . onSessionRuntimeSignal ,
1778+ loadPty : harness . loadPty as any ,
1779+ } ) ;
1780+
1781+ return { ...harness , sessionService, sessionStore, service } ;
1782+ }
1783+
1784+ it ( "propagates chatSessionId through sessionService.create on a fresh terminal" , async ( ) => {
1785+ const { service, sessionService } = createChatHarness ( ) ;
1786+
1787+ const created = await service . create ( {
1788+ laneId : "lane-1" ,
1789+ title : "Chat-linked terminal" ,
1790+ cols : 80 ,
1791+ rows : 24 ,
1792+ chatSessionId : "chat-42" ,
1793+ } ) ;
1794+
1795+ expect ( sessionService . create ) . toHaveBeenCalledWith (
1796+ expect . objectContaining ( {
1797+ sessionId : created . sessionId ,
1798+ chatSessionId : "chat-42" ,
1799+ } ) ,
1800+ ) ;
1801+ const active = service . activeForChat ( { chatSessionId : "chat-42" } ) ;
1802+ expect ( active ) . not . toBeNull ( ) ;
1803+ expect ( active ! . terminalId ) . toBe ( created . sessionId ) ;
1804+ expect ( active ! . chatSessionId ) . toBe ( "chat-42" ) ;
1805+ expect ( active ! . active ) . toBe ( true ) ;
1806+ } ) ;
1807+
1808+ it ( "listTerminals filters to the requested chat session and orders active first" , async ( ) => {
1809+ const { service } = createChatHarness ( ) ;
1810+
1811+ const a = await service . create ( { laneId : "lane-1" , title : "A" , cols : 80 , rows : 24 , chatSessionId : "chat-1" } ) ;
1812+ const b = await service . create ( { laneId : "lane-1" , title : "B" , cols : 80 , rows : 24 , chatSessionId : "chat-1" } ) ;
1813+ await service . create ( { laneId : "lane-1" , title : "C" , cols : 80 , rows : 24 , chatSessionId : "chat-other" } ) ;
1814+
1815+ const list = service . listTerminals ( { chatSessionId : "chat-1" } ) ;
1816+ const ids = list . map ( ( s ) => s . terminalId ) ;
1817+ expect ( ids ) . toContain ( a . sessionId ) ;
1818+ expect ( ids ) . toContain ( b . sessionId ) ;
1819+ expect ( ids ) . not . toContain ( expect . stringMatching ( / c h a t - o t h e r / ) ) ;
1820+ // The most recently created terminal is the active one for the chat and must sort first.
1821+ expect ( list [ 0 ] ?. terminalId ) . toBe ( b . sessionId ) ;
1822+ expect ( list [ 0 ] ?. active ) . toBe ( true ) ;
1823+ } ) ;
1824+
1825+ it ( "readTerminal returns transcript bytes from `since` and reports nextSince" , async ( ) => {
1826+ const { service, sessionService } = createChatHarness ( ) ;
1827+ const created = await service . create ( {
1828+ laneId : "lane-1" ,
1829+ title : "Reader" ,
1830+ cols : 80 ,
1831+ rows : 24 ,
1832+ chatSessionId : "chat-7" ,
1833+ } ) ;
1834+ sessionService . readTranscriptTail . mockResolvedValueOnce ( "0123456789" ) ;
1835+
1836+ const read = await service . readTerminal ( { chatSessionId : "chat-7" , since : 4 , maxBytes : 1024 } ) ;
1837+ expect ( read . terminalId ) . toBe ( created . sessionId ) ;
1838+ expect ( read . data ) . toBe ( "456789" ) ;
1839+ expect ( read . nextSince ) . toBe ( 4 + "456789" . length ) ;
1840+ } ) ;
1841+
1842+ it ( "writeTerminal routes data via the active chat terminal and the underlying PTY" , async ( ) => {
1843+ const { service, mockPty } = createChatHarness ( ) ;
1844+ await service . create ( {
1845+ laneId : "lane-1" ,
1846+ title : "Writer" ,
1847+ cols : 80 ,
1848+ rows : 24 ,
1849+ chatSessionId : "chat-write" ,
1850+ } ) ;
1851+
1852+ const result = service . writeTerminal ( { chatSessionId : "chat-write" , data : "y\n" } ) ;
1853+ expect ( result ) . toEqual ( { ok : true } ) ;
1854+ expect ( mockPty . write ) . toHaveBeenCalledWith ( "y\n" ) ;
1855+ } ) ;
1856+
1857+ it ( "signalTerminal sends ^C for SIGINT and forwards SIGTERM to pty.kill" , async ( ) => {
1858+ const { service, mockPty } = createChatHarness ( ) ;
1859+ await service . create ( {
1860+ laneId : "lane-1" ,
1861+ title : "Signal" ,
1862+ cols : 80 ,
1863+ rows : 24 ,
1864+ chatSessionId : "chat-signal" ,
1865+ } ) ;
1866+
1867+ service . signalTerminal ( { chatSessionId : "chat-signal" , signal : "SIGINT" } ) ;
1868+ expect ( mockPty . write ) . toHaveBeenCalledWith ( "\x03" ) ;
1869+
1870+ service . signalTerminal ( { chatSessionId : "chat-signal" , signal : "SIGTERM" } ) ;
1871+ expect ( mockPty . kill ) . toHaveBeenCalledWith ( "SIGTERM" ) ;
1872+ } ) ;
1873+
1874+ it ( "fails loudly when chat terminal calls cannot resolve a target" , async ( ) => {
1875+ const { service } = createChatHarness ( ) ;
1876+
1877+ await expect ( service . readTerminal ( { chatSessionId : "no-such-chat" } ) ) . rejects . toThrow (
1878+ / t e r m i n a l \. r e a d r e q u i r e s / ,
1879+ ) ;
1880+ expect ( ( ) => service . writeTerminal ( { chatSessionId : "no-such-chat" , data : "x" } ) ) . toThrow (
1881+ / t e r m i n a l \. w r i t e r e q u i r e s / ,
1882+ ) ;
1883+ expect ( ( ) => service . signalTerminal ( { chatSessionId : "no-such-chat" , signal : "SIGINT" } ) ) . toThrow (
1884+ / N o r u n n i n g t e r m i n a l / ,
1885+ ) ;
1886+ expect ( service . activeForChat ( { chatSessionId : "no-such-chat" } ) ) . toBeNull ( ) ;
1887+ } ) ;
1888+ } ) ;
17121889} ) ;
0 commit comments