Files
agentic-coding-workflow/web/src/composables/useAcpStream.ts
T
2026-06-09 21:22:28 +08:00

214 lines
6.3 KiB
TypeScript

// 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-<uuid> 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<StreamStatus>
sessionStatus: Ref<SessionStatus>
events: Ref<AcpEvent[]>
pendingPermissions: Ref<PermissionRequest[]>
prompt: (text: string, agentSessionID: string) => void
cancel: (agentSessionID: string) => void
reconnect: () => void
close: () => void
}
// 把控制帧(ID=0)的 payload 归一为对象,无论 WS 传来字符串还是已解析对象。
function decodePayload(raw: unknown): Record<string, unknown> {
if (typeof raw === 'string') {
try {
return JSON.parse(raw) as Record<string, unknown>
} catch {
return {}
}
}
if (raw && typeof raw === 'object') return raw as Record<string, unknown>
return {}
}
interface ServerStateEvent {
kind: 'state'
status: SessionStatus
}
interface SessionTerminatedPayload {
status?: 'crashed' | 'exited'
}
export function useAcpStream(sessionId: string): UseAcpStreamReturn {
const status = ref<StreamStatus>('idle')
const sessionStatus = ref<SessionStatus>('starting')
const events = ref<AcpEvent[]>([])
const pendingPermissions = ref<PermissionRequest[]>([])
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<typeof setTimeout> | 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<string, unknown>
// 权限请求/决议控制帧(不进 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<string, unknown>) {
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 }
}