You've already forked agentic-coding-workflow
feat(web): useAcpStream composable (auto-reconnect + ?since= + prompt/cancel)
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
// 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, type AcpEvent } 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[]>
|
||||
prompt: (text: string, agentSessionID: string) => void
|
||||
cancel: (agentSessionID: string) => void
|
||||
reconnect: () => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
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 lastEventID = ref(0)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
// 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, prompt, cancel, reconnect, close }
|
||||
}
|
||||
Reference in New Issue
Block a user