// useAcpStream wraps the ACP WebSocket per spec §10.3: // - connect + auto-reconnect once with exponential backoff (1s/3s, then give up) // - ?since=lastEventID resumption // - send session/prompt / session/cancel (generates client- ID) // - accumulate events into a ref array // - expose sessionStatus + connection-state status import { ref, onScopeDispose, type Ref } from 'vue' import { openSessionWS, acpApi, type AcpEvent, type PermissionRequest } from '@/api/acp' import { useAuthStore } from '@/stores/auth' export type StreamStatus = 'idle' | 'connecting' | 'streaming' | 'closed' | 'error' export type SessionStatus = 'starting' | 'running' | 'crashed' | 'exited' export interface UseAcpStreamReturn { status: Ref sessionStatus: Ref events: Ref pendingPermissions: Ref prompt: (text: string, agentSessionID: string) => void cancel: (agentSessionID: string) => void reconnect: () => void close: () => void } // 把控制帧(ID=0)的 payload 归一为对象,无论 WS 传来字符串还是已解析对象。 function decodePayload(raw: unknown): Record { if (typeof raw === 'string') { try { return JSON.parse(raw) as Record } catch { return {} } } if (raw && typeof raw === 'object') return raw as Record return {} } interface ServerStateEvent { kind: 'state' status: SessionStatus } interface SessionTerminatedPayload { status?: 'crashed' | 'exited' } export function useAcpStream(sessionId: string): UseAcpStreamReturn { const status = ref('idle') const sessionStatus = ref('starting') const events = ref([]) const pendingPermissions = ref([]) const lastEventID = ref(0) function upsertPermission(p: PermissionRequest) { if (!pendingPermissions.value.some((x) => x.id === p.id)) { pendingPermissions.value.push(p) } } function removePermission(reqID: string) { pendingPermissions.value = pendingPermissions.value.filter((x) => x.id !== reqID) } let ws: WebSocket | null = null let retried = 0 let manualClose = false let reconnectTimer: ReturnType | null = null const auth = useAuthStore() function connect() { if (ws || manualClose) return status.value = 'connecting' const token = auth.token ?? '' const sock = openSessionWS(sessionId, lastEventID.value, token) ws = sock sock.onopen = () => { status.value = 'streaming' retried = 0 // 控制帧(permission_*)不落 acp_events,重连不补发;连上后主动拉取当前待审。 acpApi.sessions .permissions(sessionId) .then((list) => { for (const p of list) upsertPermission(p) }) .catch(() => {}) } sock.onmessage = (ev: MessageEvent) => { let parsed: unknown try { parsed = JSON.parse(ev.data as string) } catch { return } if (!parsed || typeof parsed !== 'object') return const obj = parsed as Record // 权限请求/决议控制帧(不进 events 流,单独维护 pendingPermissions)。 if (obj.kind === 'permission_request') { const p = decodePayload(obj.payload) upsertPermission({ id: String(p.request_id ?? ''), session_id: String(p.session_id ?? ''), tool_name: String(p.tool_name ?? ''), tool_call: p.tool_call, options: Array.isArray(p.options) ? (p.options as { id: string; name?: string }[]) : [], status: String(p.status ?? 'pending'), created_at: String(p.created_at ?? ''), }) return } if (obj.kind === 'permission_resolved') { const p = decodePayload(obj.payload) removePermission(String(p.request_id ?? '')) return } // Initial state event from server if (obj.kind === 'state') { const s = (parsed as ServerStateEvent).status if (s) sessionStatus.value = s return } // Final session_terminated event if (obj.kind === 'session_terminated') { let payloadObj: SessionTerminatedPayload = {} const rawPayload = obj.payload if (typeof rawPayload === 'string') { try { payloadObj = JSON.parse(rawPayload) as SessionTerminatedPayload } catch { payloadObj = {} } } else if (rawPayload && typeof rawPayload === 'object') { payloadObj = rawPayload as SessionTerminatedPayload } sessionStatus.value = payloadObj.status === 'crashed' ? 'crashed' : 'exited' events.value.push(parsed as AcpEvent) return } // Regular event envelope const ev2 = parsed as AcpEvent if (typeof ev2.id === 'number' && ev2.id > lastEventID.value) { lastEventID.value = ev2.id } events.value.push(ev2) } sock.onerror = () => { status.value = 'error' } sock.onclose = () => { ws = null if (manualClose) { status.value = 'closed' return } if (retried < 2) { const delay = retried === 0 ? 1000 : 3000 retried += 1 reconnectTimer = setTimeout(connect, delay) } else { status.value = 'closed' } } } function send(method: string, params: Record) { if (!ws || ws.readyState !== WebSocket.OPEN) return const id = 'client-' + crypto.randomUUID() const msg = { jsonrpc: '2.0', id, method, params } ws.send(JSON.stringify(msg)) } function prompt(text: string, agentSessionID: string) { send('session/prompt', { sessionId: agentSessionID, prompt: [{ type: 'text', text }], }) } function cancel(agentSessionID: string) { send('session/cancel', { sessionId: agentSessionID }) } function reconnect() { if (ws) ws.close() retried = 0 manualClose = false connect() } function close() { manualClose = true if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null } if (ws) ws.close() status.value = 'closed' } connect() onScopeDispose(close) return { status, sessionStatus, events, pendingPermissions, prompt, cancel, reconnect, close } }