diff --git a/src/plugin/pty/manager.ts b/src/plugin/pty/manager.ts index e0550cf7..89a64f18 100644 --- a/src/plugin/pty/manager.ts +++ b/src/plugin/pty/manager.ts @@ -68,6 +68,31 @@ function notifyRawOutput(session: PTYSessionInfo, rawData: string): void { } } +type SessionRemovedCallback = (sessionId: string) => void + +export const sessionRemovedCallbacks: SessionRemovedCallback[] = [] + +export function registerSessionRemovedCallback(callback: SessionRemovedCallback): void { + sessionRemovedCallbacks.push(callback) +} + +export function removeSessionRemovedCallback(callback: SessionRemovedCallback): void { + const index = sessionRemovedCallbacks.indexOf(callback) + if (index !== -1) { + sessionRemovedCallbacks.splice(index, 1) + } +} + +function notifySessionRemoved(sessionId: string): void { + for (const callback of sessionRemovedCallbacks) { + try { + callback(sessionId) + } catch { + // Ignore callback errors + } + } +} + class PTYManager { private lifecycleManager = new SessionLifecycleManager() private outputManager = new OutputManager() @@ -88,7 +113,11 @@ class PTYManager { } clearAllSessions(): void { + const removedIds = this.lifecycleManager.listSessions().map((session) => session.id) this.lifecycleManager.clearAllSessions() + for (const id of removedIds) { + notifySessionRemoved(id) + } } spawn(opts: SpawnOptions): PTYSessionInfo { @@ -162,11 +191,22 @@ class PTYManager { } kill(id: string, cleanup: boolean = false): boolean { - return this.lifecycleManager.kill(id, cleanup) + const success = this.lifecycleManager.kill(id, cleanup) + if (success && cleanup) { + notifySessionRemoved(id) + } + return success } cleanupBySession(parentSessionId: string): void { + const removedIds = this.lifecycleManager + .listSessions() + .filter((session) => session.parentSessionId === parentSessionId) + .map((session) => session.id) this.lifecycleManager.cleanupBySession(parentSessionId) + for (const id of removedIds) { + notifySessionRemoved(id) + } } } diff --git a/src/plugin/pty/tools/kill.ts b/src/plugin/pty/tools/kill.ts index 36930cd1..50093423 100644 --- a/src/plugin/pty/tools/kill.ts +++ b/src/plugin/pty/tools/kill.ts @@ -10,7 +10,9 @@ export const ptyKill = tool({ cleanup: tool.schema .boolean() .optional() - .describe('If true, removes the session and frees the buffer (default: false)'), + .describe( + 'Deprecated: removing sessions is intended for humans via the web UI. If true, removes the session and frees the buffer (default: false)' + ), }, async execute(args) { const session = manager.get(args.id) diff --git a/src/plugin/pty/tools/kill.txt b/src/plugin/pty/tools/kill.txt index 6c928da0..18b7ddb2 100644 --- a/src/plugin/pty/tools/kill.txt +++ b/src/plugin/pty/tools/kill.txt @@ -2,24 +2,26 @@ Terminates a PTY session and optionally cleans up its buffer. Use this tool to: - Stop a running process (sends SIGTERM) -- Clean up an exited session to free memory -- Remove a session from the list Usage: - `id`: The PTY session ID (from pty_spawn or pty_list) -- `cleanup`: If true, removes the session and frees the buffer (default: false) +- `cleanup`: Deprecated — remove sessions from the human web UI instead (default: false) Behavior: - If the session is running, it will be killed (status becomes "killed") - If cleanup=false (default), the session remains in the list with its output buffer intact - If cleanup=true, the session is removed entirely and the buffer is freed -- Keeping sessions without cleanup allows you to compare logs between runs + +Deprecation: +- Removing sessions is intended for humans via the web UI. The `cleanup` flag + still works for backwards compatibility but will be removed in a future + release, so prefer killing the session and leaving it for the human to + discard. Finished sessions are pruned from the list by the human, not by tools. Tips: -- Use cleanup=false if you might want to read the output later -- Use cleanup=true when you're done with the session entirely +- Use cleanup=false and let the human remove finished sessions from the web UI +- Keeping sessions without cleanup allows you to compare logs between runs - To send Ctrl+C instead of killing, use pty_write with data="\x03" Examples: - Kill but keep logs: cleanup=false (or omit) -- Kill and remove: cleanup=true diff --git a/src/web/client/components/app.tsx b/src/web/client/components/app.tsx index a672d385..ad5b1158 100644 --- a/src/web/client/components/app.tsx +++ b/src/web/client/components/app.tsx @@ -17,6 +17,11 @@ export function App() { const [wsMessageCount, setWsMessageCount] = useState(0) const [sessionUpdateCount, setSessionUpdateCount] = useState(0) + const handleSessionRemoved = useCallback((sessionId: string) => { + setSessions((prevSessions) => prevSessions.filter((session) => session.id !== sessionId)) + setActiveSession((current) => (current?.id === sessionId ? null : current)) + }, []) + const { connected: wsConnected, subscribeWithRetry, @@ -63,6 +68,7 @@ export function App() { } }) }, []), + onSessionRemoved: handleSessionRemoved, }) // Update connected from wsConnected @@ -83,7 +89,14 @@ export function App() { return () => clearInterval(syncInterval) }, []) - const { handleSessionClick, handleSendInput, handleKillSession } = useSessionManager({ + const { + handleSessionClick, + handleSendInput, + handleKillSession, + handleKillSessionById, + handleRemoveSession, + handleClearFinished, + } = useSessionManager({ activeSession, setActiveSession, subscribeWithRetry, @@ -94,12 +107,57 @@ export function App() { }, []), }) + const removeSessionFromList = handleSessionRemoved + + const handleRemoveSessionClick = useCallback( + async (session: PTYSessionInfo) => { + const removed = await handleRemoveSession(session) + if (removed) { + removeSessionFromList(session.id) + } + }, + [handleRemoveSession, removeSessionFromList] + ) + + const handleClearFinishedClick = useCallback(async () => { + const finishedSessions = sessions.filter( + (session) => session.status !== 'running' && session.status !== 'killing' + ) + const cleared = await handleClearFinished(finishedSessions) + if (cleared) { + setSessions((prevSessions) => + prevSessions.filter( + (session) => session.status === 'running' || session.status === 'killing' + ) + ) + setActiveSession((current) => + current && (current.status === 'running' || current.status === 'killing') ? current : null + ) + } + }, [sessions, handleClearFinished]) + + const handleDownloadSession = useCallback(() => { + if (!activeSession) { + return + } + const blob = new Blob([rawOutput], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = `${activeSession.id}.log` + anchor.click() + URL.revokeObjectURL(url) + }, [activeSession, rawOutput]) + return (
@@ -107,9 +165,29 @@ export function App() { <>
{activeSession.description ?? activeSession.title}
- +
+ + {activeSession.status === 'running' ? ( + + ) : ( + + )} +
void + onKillSession: (session: PTYSessionInfo) => void + onRemoveSession: (session: PTYSessionInfo) => void + onClearFinished: () => void connected: boolean } -export function Sidebar({ sessions, activeSession, onSessionClick, connected }: SidebarProps) { +interface SessionSectionProps { + title: string + sessions: PTYSessionInfo[] + emptyText: string + activeSession: PTYSessionInfo | null + onSessionClick: (session: PTYSessionInfo) => void + onKillSession: (session: PTYSessionInfo) => void + onRemoveSession: (session: PTYSessionInfo) => void + action?: React.ReactNode +} + +/** A session is "live" until its process has actually exited. */ +function isLive(session: PTYSessionInfo): boolean { + return session.status === 'running' || session.status === 'killing' +} + +function sessionLabel(session: PTYSessionInfo): string { + return session.description ?? session.title +} + +function SessionItem({ + session, + activeSession, + onSessionClick, + onKillSession, + onRemoveSession, +}: { + session: PTYSessionInfo + activeSession: PTYSessionInfo | null + onSessionClick: (session: PTYSessionInfo) => void + onKillSession: (session: PTYSessionInfo) => void + onRemoveSession: (session: PTYSessionInfo) => void +}) { + const label = sessionLabel(session) + const canKill = session.status === 'running' + const isFinished = session.status === 'exited' || session.status === 'killed' + + return ( +
+ +
+ {canKill ? ( + + ) : null} + {isFinished ? ( + + ) : null} +
+
+ ) +} + +function SessionSection({ + title, + sessions, + emptyText, + activeSession, + onSessionClick, + onKillSession, + onRemoveSession, + action, +}: SessionSectionProps) { + return ( +
+
+ {title} + {sessions.length} + {action} +
+ {sessions.length === 0 ? ( +
{emptyText}
+ ) : ( + sessions.map((session) => ( + + )) + )} +
+ ) +} + +export function Sidebar({ + sessions, + activeSession, + onSessionClick, + onKillSession, + onRemoveSession, + onClearFinished, + connected, +}: SidebarProps) { + const liveSessions = sessions.filter(isLive) + const finishedSessions = sessions.filter((session) => !isLive(session)) + return (
@@ -18,28 +149,35 @@ export function Sidebar({ sessions, activeSession, onSessionClick, connected }:
{sessions.length === 0 ? ( -
- No active sessions -
+
No active sessions
) : ( - sessions.map((session) => ( - - )) + <> + + 0 ? ( + + ) : null + } + /> + )}
diff --git a/src/web/client/hooks/use-session-manager.ts b/src/web/client/hooks/use-session-manager.ts index 9411f9e7..c50cb222 100644 --- a/src/web/client/hooks/use-session-manager.ts +++ b/src/web/client/hooks/use-session-manager.ts @@ -12,6 +12,10 @@ interface UseSessionManagerOptions { onRawOutputUpdate?: (rawOutput: string) => void } +function sessionLabel(session: PTYSessionInfo): string { + return session.description ?? session.title +} + export function useSessionManager({ activeSession, setActiveSession, @@ -76,29 +80,89 @@ export function useSessionManager({ [activeSession, wsConnected, sendInput] ) + /** + * Kills a running session. The session is retained (with its buffer) so the + * transcript stays available; removing it is a human action in the web UI. + */ + const handleKillSessionById = useCallback(async (session: PTYSessionInfo): Promise => { + if (!confirm(`Are you sure you want to kill session "${sessionLabel(session)}"?`)) { + return false + } + + try { + await api.session.kill({ id: session.id }) + return true + } catch (error) { + console.error('Failed to kill session', error) + return false + } + }, []) + const handleKillSession = useCallback(async () => { if (!activeSession) { return } + await handleKillSessionById(activeSession) + }, [activeSession, handleKillSessionById]) + /** + * Human-only: discards a finished session entirely (buffer freed, dropped + * from the session list). + */ + const handleRemoveSession = useCallback(async (session: PTYSessionInfo): Promise => { if ( !confirm( - `Are you sure you want to kill session "${activeSession.description ?? activeSession.title}"?` + `Remove finished session "${sessionLabel(session)}"? Its output buffer will be discarded.` ) ) { - return + return false } try { - await api.session.kill({ id: activeSession.id }) + await api.session.cleanup({ id: session.id }) + return true + } catch (error) { + console.error('Failed to remove session', error) + return false + } + }, []) - // eslint-disable-next-line no-empty - } catch {} - }, [activeSession]) + /** + * Human-only: discards every finished session in one go. + */ + const handleClearFinished = useCallback( + async (finishedSessions: PTYSessionInfo[]): Promise => { + if (finishedSessions.length === 0) { + return false + } + + if ( + !confirm( + `Remove ${finishedSessions.length} finished session(s)? Their output buffers will be discarded.` + ) + ) { + return false + } + + const results = await Promise.allSettled( + finishedSessions.map((session) => api.session.cleanup({ id: session.id })) + ) + const failed = results.filter((result) => result.status === 'rejected') + if (failed.length > 0) { + console.error(`Failed to remove ${failed.length} finished session(s)`) + return false + } + return true + }, + [] + ) return { handleSessionClick, handleSendInput, handleKillSession, + handleKillSessionById, + handleRemoveSession, + handleClearFinished, } } diff --git a/src/web/client/hooks/use-web-socket.ts b/src/web/client/hooks/use-web-socket.ts index a8201eed..ec081b5f 100644 --- a/src/web/client/hooks/use-web-socket.ts +++ b/src/web/client/hooks/use-web-socket.ts @@ -4,6 +4,7 @@ import type { WSMessageServer, WSMessageServerRawData, WSMessageServerSessionList, + WSMessageServerSessionRemoved, WSMessageServerSessionUpdate, } from 'opencode-pty/web/shared/types' import { RETRY_DELAY, SKIP_AUTOSELECT_KEY } from 'opencode-pty/web/shared/constants' @@ -15,6 +16,7 @@ interface UseWebSocketOptions { onRawData?: (rawData: string) => void onSessionList: (sessions: PTYSessionInfo[], autoSelected: PTYSessionInfo | null) => void onSessionUpdate?: (updatedSession: PTYSessionInfo) => void + onSessionRemoved?: (sessionId: string) => void } export function useWebSocket({ @@ -22,6 +24,7 @@ export function useWebSocket({ onRawData, onSessionList, onSessionUpdate, + onSessionRemoved, }: UseWebSocketOptions) { const [connected, setConnected] = useState(false) @@ -89,6 +92,9 @@ export function useWebSocket({ } else if (data.type === 'session_update') { const sessionUpdateMsg = data as WSMessageServerSessionUpdate onSessionUpdate?.(sessionUpdateMsg.session) + } else if (data.type === 'session_removed') { + const sessionRemovedMsg = data as WSMessageServerSessionRemoved + onSessionRemoved?.(sessionRemovedMsg.sessionId) } else if (data.type === 'raw_data') { const rawDataMsg = data as WSMessageServerRawData const isForActiveSession = rawDataMsg.session.id === activeSessionRef.current?.id @@ -107,7 +113,7 @@ export function useWebSocket({ return () => { ws.close() } - }, [activeSession, onRawData, onSessionList, onSessionUpdate]) + }, [activeSession, onRawData, onSessionList, onSessionUpdate, onSessionRemoved]) const subscribe = (sessionId: string) => { if (wsRef.current?.readyState === WebSocket.OPEN) { diff --git a/src/web/client/index.css b/src/web/client/index.css index f102cda5..3c20984f 100644 --- a/src/web/client/index.css +++ b/src/web/client/index.css @@ -42,14 +42,65 @@ body { overflow-y: auto; padding: 8px; } -.session-item { - padding: 12px; +.session-empty { + padding: 16px; + color: #8b949e; + text-align: center; +} +.session-section { + margin-bottom: 16px; +} +.session-section-header { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 4px 8px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #8b949e; +} +.session-section-count { + color: #6e7681; +} +.session-section-empty { + padding: 4px; + font-size: 12px; + color: #6e7681; +} +.clear-finished-btn { + margin-left: auto; + padding: 2px 8px; + background: transparent; + border: 1px solid #30363d; + border-radius: 6px; + color: #f85149; + font-size: 11px; + font-weight: 600; + text-transform: none; + letter-spacing: normal; + cursor: pointer; +} +.clear-finished-btn:hover { + background: #da363322; + border-color: #da3633; +} +.session-row { + position: relative; margin-bottom: 8px; +} +.session-item { + width: 100%; + padding: 12px 76px 12px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; cursor: pointer; transition: all 0.2s; + font: inherit; + color: inherit; + text-align: left; } .session-item:hover { background: #30363d; @@ -58,6 +109,34 @@ body { border-color: #58a6ff; background: #1f6feb1a; } +.session-row.finished .session-item { + opacity: 0.75; +} +.session-actions { + position: absolute; + top: 8px; + right: 8px; + display: flex; + gap: 4px; +} +.session-action { + padding: 2px 8px; + border-radius: 6px; + border: 1px solid #30363d; + background: #0d1117; + color: #c9d1d9; + font-size: 11px; + font-weight: 600; + cursor: pointer; +} +.session-action-kill:hover { + border-color: #da3633; + color: #f85149; +} +.session-action-remove:hover { + border-color: #f85149; + color: #f85149; +} .session-title { font-weight: 600; margin-bottom: 4px; @@ -105,6 +184,41 @@ body { font-size: 16px; font-weight: 600; } +.output-actions { + display: flex; + align-items: center; + gap: 8px; +} +.download-btn { + padding: 8px 16px; + background: #21262d; + color: #c9d1d9; + border: 1px solid #30363d; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + font-size: 14px; +} +.download-btn:hover:not(:disabled) { + background: #30363d; +} +.download-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.remove-btn { + padding: 8px 16px; + background: #8957e5; + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + font-size: 14px; +} +.remove-btn:hover { + background: #a371f7; +} .kill-btn { padding: 8px 16px; background: #da3633; diff --git a/src/web/client/index.html b/src/web/client/index.html index bf9bfb1c..4b1f0544 100644 --- a/src/web/client/index.html +++ b/src/web/client/index.html @@ -4,197 +4,6 @@ PTY Sessions Monitor -
diff --git a/src/web/client/main.tsx b/src/web/client/main.tsx index 49ea2735..f5a3f7b1 100644 --- a/src/web/client/main.tsx +++ b/src/web/client/main.tsx @@ -1,5 +1,6 @@ import React from 'react' import ReactDOM from 'react-dom/client' +import './index.css' import { App } from './components/app.tsx' import { ErrorBoundary } from './components/error-boundary.tsx' diff --git a/src/web/server/callback-manager.ts b/src/web/server/callback-manager.ts index e9a8040b..0994a1d5 100644 --- a/src/web/server/callback-manager.ts +++ b/src/web/server/callback-manager.ts @@ -1,17 +1,24 @@ import { registerRawOutputCallback, + registerSessionRemovedCallback, registerSessionUpdateCallback, removeRawOutputCallback, + removeSessionRemovedCallback, removeSessionUpdateCallback, } from '../../plugin/pty/manager' import type { PTYSessionInfo } from '../../plugin/pty/types' -import type { WSMessageServerSessionUpdate, WSMessageServerRawData } from '../shared/types' +import type { + WSMessageServerSessionRemoved, + WSMessageServerSessionUpdate, + WSMessageServerRawData, +} from '../shared/types' export class CallbackManager implements Disposable { constructor(private server: Bun.Server) { this.server = server registerSessionUpdateCallback(this.sessionUpdateCallback) registerRawOutputCallback(this.rawOutputCallback) + registerSessionRemovedCallback(this.sessionRemovedCallback) } private sessionUpdateCallback = (session: PTYSessionInfo): void => { @@ -19,6 +26,11 @@ export class CallbackManager implements Disposable { this.server.publish('sessions:update', JSON.stringify(message)) } + private sessionRemovedCallback = (sessionId: string): void => { + const message: WSMessageServerSessionRemoved = { type: 'session_removed', sessionId } + this.server.publish('sessions:update', JSON.stringify(message)) + } + private rawOutputCallback = (session: PTYSessionInfo, rawData: string): void => { const message: WSMessageServerRawData = { type: 'raw_data', session, rawData } this.server.publish(`session:${session.id}`, JSON.stringify(message)) @@ -27,5 +39,6 @@ export class CallbackManager implements Disposable { [Symbol.dispose]() { removeSessionUpdateCallback(this.sessionUpdateCallback) removeRawOutputCallback(this.rawOutputCallback) + removeSessionRemovedCallback(this.sessionRemovedCallback) } } diff --git a/src/web/shared/types.ts b/src/web/shared/types.ts index aaa795b5..2759834e 100644 --- a/src/web/shared/types.ts +++ b/src/web/shared/types.ts @@ -60,6 +60,7 @@ export interface WSMessageServer { | 'readRawResponse' | 'session_list' | 'session_update' + | 'session_removed' | 'error' } @@ -95,6 +96,15 @@ export interface WSMessageServerSessionUpdate extends WSMessageServer { session: PTYSessionInfo } +/** + * Emitted when a session is permanently removed (buffer freed and dropped from + * the manager), e.g. when a human discards a finished session in the web UI. + */ +export interface WSMessageServerSessionRemoved extends WSMessageServer { + type: 'session_removed' + sessionId: string +} + export interface WSMessageServerError extends WSMessageServer { type: 'error' error: CustomError diff --git a/test/e2e/ui/app.pw.ts b/test/e2e/ui/app.pw.ts index 2518fbaf..b28aa7ca 100644 --- a/test/e2e/ui/app.pw.ts +++ b/test/e2e/ui/app.pw.ts @@ -269,4 +269,48 @@ extendedTest.describe('App Component', () => { expect(count).toBeGreaterThan(0) }) }) + + extendedTest.describe('Session Discarding', () => { + extendedTest( + 'removes a finished session from the sidebar (human-only)', + async ({ page, api }) => { + // Prevent autoselect so the sidebar state stays predictable + await page.evaluate(() => { + localStorage.setItem('skip-autoselect', 'true') + }) + + await api.sessions.create({ + command: 'echo', + args: ['finished'], + description: 'Finished session to discard', + }) + + // Wait until the session has actually exited + const deadline = Date.now() + 5000 + while (Date.now() < deadline) { + const sessions = await api.sessions.list() + const target = sessions.find((s) => s.description === 'Finished session to discard') + if (target && target.status !== 'running') { + break + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + + await page.reload() + + const sessionRow = page.locator('.session-row:has-text("Finished session to discard")') + await expect(sessionRow).toBeVisible({ timeout: 5000 }) + + // The confirmation dialog must be accepted for the removal to proceed + page.on('dialog', (dialog) => dialog.accept()) + await sessionRow.locator('.session-action-remove').click() + + // The row disappears immediately via the session_removed broadcast + await expect(sessionRow).toHaveCount(0, { timeout: 5000 }) + + const remaining = await api.sessions.list() + expect(remaining.some((s) => s.description === 'Finished session to discard')).toBe(false) + } + ) + }) }) diff --git a/test/utils.ts b/test/utils.ts index 4da56491..c89cb483 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -2,6 +2,7 @@ import { OpencodeClient } from '@opencode-ai/sdk' import { initManager, manager, + sessionRemovedCallbacks, sessionUpdateCallbacks, rawOutputCallbacks, } from '../src/plugin/pty/manager' @@ -11,6 +12,7 @@ import type { WSMessageServerSubscribedSession, WSMessageServerUnsubscribedSession, WSMessageServerSessionUpdate, + WSMessageServerSessionRemoved, WSMessageServerRawData, WSMessageServerReadRawResponse, WSMessageServerSessionList, @@ -34,6 +36,8 @@ export class ManagedTestClient implements Disposable { > = [] public readonly sessionUpdateCallbacks: Array<(message: WSMessageServerSessionUpdate) => void> = [] + public readonly sessionRemovedCallbacks: Array<(message: WSMessageServerSessionRemoved) => void> = + [] public readonly rawDataCallbacks: Array<(message: WSMessageServerRawData) => void> = [] public readonly readRawResponseCallbacks: Array< (message: WSMessageServerReadRawResponse) => void @@ -65,6 +69,11 @@ export class ManagedTestClient implements Disposable { callback(message as WSMessageServerSessionUpdate) }) break + case 'session_removed': + this.sessionRemovedCallbacks.forEach((callback) => { + callback(message as WSMessageServerSessionRemoved) + }) + break case 'raw_data': this.rawDataCallbacks.forEach((callback) => { callback(message as WSMessageServerRawData) @@ -171,5 +180,6 @@ export class ManagedTestServer implements Disposable { manager.clearAllSessions() sessionUpdateCallbacks.length = 0 rawOutputCallbacks.length = 0 + sessionRemovedCallbacks.length = 0 } } diff --git a/test/web-server.test.ts b/test/web-server.test.ts index 991bed31..4238fd66 100644 --- a/test/web-server.test.ts +++ b/test/web-server.test.ts @@ -238,6 +238,50 @@ describe('Web Server', () => { await sessionExitedPromise }, 1000) + it('should retain a killed session when no cleanup is requested', async () => { + const session = manager.spawn({ + command: 'cat', + args: [], + description: 'Retained after kill', + parentSessionId: 'test', + }) + + const response = await fetch( + `${managedTestServer.server.server.url}/api/sessions/${session.id}`, + { method: 'DELETE' } + ) + expect(response.status).toBe(200) + + const listResponse = await fetch(`${managedTestServer.server.server.url}/api/sessions`) + const sessions = (await listResponse.json()) as PTYSessionInfo[] + expect(sessions.some((s) => s.id === session.id)).toBe(true) + }, 1000) + + it('should discard a session via the cleanup endpoint', async () => { + const session = manager.spawn({ + command: 'cat', + args: [], + description: 'Discarded session', + parentSessionId: 'test', + }) + + const response = await fetch( + `${managedTestServer.server.server.url}/api/sessions/${session.id}/cleanup`, + { method: 'DELETE' } + ) + expect(response.status).toBe(200) + expect((await response.json()).success).toBe(true) + + const listResponse = await fetch(`${managedTestServer.server.server.url}/api/sessions`) + const sessions = (await listResponse.json()) as PTYSessionInfo[] + expect(sessions.some((s) => s.id === session.id)).toBe(false) + + const getResponse = await fetch( + `${managedTestServer.server.server.url}/api/sessions/${session.id}` + ) + expect(getResponse.status).toBe(404) + }, 1000) + it('should return session output', async () => { const title = crypto.randomUUID() const sessionExitedPromise = new Promise((resolve) => { diff --git a/test/websocket.test.ts b/test/websocket.test.ts index 19fd713e..dee34877 100644 --- a/test/websocket.test.ts +++ b/test/websocket.test.ts @@ -4,6 +4,7 @@ import type { CustomError, WSMessageServerError, WSMessageServerSessionList, + WSMessageServerSessionRemoved, WSMessageServerSessionUpdate, WSMessageServerSubscribedSession, WSMessageServerUnsubscribedSession, @@ -170,6 +171,31 @@ describe('WebSocket Functionality', () => { await sessionListPromise }, 1000) + it('should broadcast session_removed when a session is discarded', async () => { + await using managedTestClient = await ManagedTestClient.create( + managedTestServer.server.getWsUrl() + ) + const session = manager.spawn({ + command: 'echo', + args: ['done'], + description: 'Session to discard', + parentSessionId: managedTestServer.sessionId, + }) + + const removedPromise = new Promise((res) => { + managedTestClient.sessionRemovedCallbacks.push((message) => { + if (message.sessionId === session.id) { + res(message) + } + }) + }) + + manager.kill(session.id, true) + + const removed = await removedPromise + expect(removed.sessionId).toBe(session.id) + }, 1000) + it('should handle invalid message format', async () => { await using managedTestClient = await ManagedTestClient.create( managedTestServer.server.getWsUrl()