f0410d592d
The web host now composes the sandboxed product path (sandbox-local + sandbox-policy behind bash-sandbox/fs-sandbox, with user-approval and permission on top); BootHostOptions.sandbox carries the deployment defaults (workspace-write + ask). createApiProxy owns the approval pending registry: a ctx.approval ask becomes an answerable approval/requested mux frame with a stable rpcId, replayed verbatim on every mux open until settled; respond routes by the echoed rpcId, validates the ApprovalResponsePayload audit correlation, and broadcasts approval/resolved; the ask's abort signal withdraws the question as cancelled. session.permissions / session.setPermission project ctx.permission into a protocol-owned PermissionOption select; idle switches are held last-write-wins and flushed into the next prompted turn (the ACP bridge's anchoring pattern). The shared hasOpenTurn fold moved to dsh-session, deduplicating the private copies in user-approval, the ACP bridge, and the proxy. Client, per the designer draft: a pending approval takes over the composer (ApprovalPanel replaces the InputBar — amber strip, justification headline, paired command, one-shot refuse/allow, keyed by rpcId so a queued second approval remounts live; the resolved frame restores the composer); the sidebar session row shows an amber waiting-approval dot that outranks the running ring (manager-tracked approvalId set, idempotent under mux-open replays, cleared per connection generation, lit for uninstantiated sessions too); the permission selector is a composer bottom-row chip over an invisible native select, with a presentation-only title-case transform (workspace-write renders as Workspace Write; wire names untouched). Question placeholders stay in the message flow. The connection fixture mirrors the host behavior for keyless browser acceptance.
433 lines
24 KiB
TypeScript
433 lines
24 KiB
TypeScript
/**
|
|
* Fixture impl semantics: the demo data source must honor the same contract
|
|
* shapes as the real host (paging boundaries, rpcId echo, replay lifecycle,
|
|
* baseline replay, timing hooks) — this is the vitest-side drift detector for
|
|
* the hand-written fixture/host parallel implementations.
|
|
*/
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type { SessionId } from '../src/client/api.ts'
|
|
import { RpcId } from '../src/client/api.ts'
|
|
import type { HostFrame, MuxFrame, RpcMessage, RpcRequest } from '../src/client/api.ts'
|
|
import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts'
|
|
|
|
const sid = (id: string): SessionId => id as SessionId
|
|
const req = <P>(payload: P): RpcRequest<P> => ({ rpcId: RpcId(`t-${Math.abs(Math.sin(reqCount++)).toString(36).slice(2, 10)}`), payload })
|
|
let reqCount = 0
|
|
|
|
interface TimingHooks {
|
|
setHistoryDelay(ms: number): void
|
|
failNextHistory(): void
|
|
appendUser(id: string, msg: string): void
|
|
appendTitle(id: string, title: string): void
|
|
appendSilent(id: string, msg: string): void
|
|
breakStreams(): void
|
|
}
|
|
const timing = (): TimingHooks => (globalThis as Record<string, unknown>).__fxTiming as TimingHooks
|
|
|
|
/** Collect stream frames until the predicate or a soft cap; abort ends the stream. */
|
|
async function collect<F>(stream: AsyncIterable<RpcRequest<F>>, abort: AbortController, done: (frames: F[]) => boolean): Promise<F[]> {
|
|
const frames: F[] = []
|
|
for await (const envelope of stream) {
|
|
frames.push(envelope.payload)
|
|
if (done(frames) || frames.length > 500) {
|
|
abort.abort()
|
|
break
|
|
}
|
|
}
|
|
return frames
|
|
}
|
|
|
|
describe('createFixtureApi', () => {
|
|
it('serves the session list sorted by updatedAt desc and echoes rpcIds on every unary', async () => {
|
|
const api = createFixtureApi()
|
|
const request = req({})
|
|
const response = await api.sessions.list(request)
|
|
expect(response.rpcId).toBe(request.rpcId)
|
|
if (!response.result.ok) throw new Error('list failed')
|
|
expect(response.result.value.items.map(s => s.sessionId)).toEqual(['fx-alpha', 'fx-beta', 'fx-gamma'])
|
|
expect(response.result.value.items[1]?.parentSessionId).toBe('fx-alpha') // lineage material
|
|
})
|
|
|
|
it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => {
|
|
const api = createFixtureApi()
|
|
const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 }))
|
|
if (!tail.result.ok) throw new Error('history failed')
|
|
const tailPage = tail.result.value
|
|
expect(tailPage.hasMore).toBe(true)
|
|
expect(tailPage.events[0]?.event.type).toBe('turn/start') // cut lands on a turn boundary
|
|
const boundary = tailPage.events[0]?.event.seq ?? 0
|
|
expect(boundary).toBeGreaterThan(0)
|
|
const older = await api.sessions.history(req({ sessionId: sid('fx-alpha'), beforeSeq: boundary, maxMessages: 10 }))
|
|
if (!older.result.ok) throw new Error('older failed')
|
|
const olderTail = older.result.value.events.at(-1)?.event
|
|
expect((olderTail?.seq ?? -1) + 1).toBe(boundary) // pages stitch with no hole/overlap
|
|
// Out-of-range beforeSeq clamps instead of exploding.
|
|
const clamped = await api.sessions.history(req({ sessionId: sid('fx-alpha'), beforeSeq: -5, maxMessages: 10 }))
|
|
if (!clamped.result.ok) throw new Error('clamped failed')
|
|
expect(clamped.result.value.events).toEqual([])
|
|
// Unknown session: empty page, not an error (history of a bare id).
|
|
const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 }))
|
|
if (!empty.result.ok) throw new Error('empty failed')
|
|
expect(empty.result.value).toEqual({ events: [], hasMore: false })
|
|
})
|
|
|
|
it('create adds a session and pushes host/session-added to open host streams', async () => {
|
|
const api = createFixtureApi()
|
|
const abort = new AbortController()
|
|
const seen: HostFrame[] = []
|
|
const consuming = (async () => {
|
|
for await (const envelope of api.events.host(req({}), abort.signal)) {
|
|
seen.push(envelope.payload)
|
|
if (seen.length >= 1) abort.abort()
|
|
}
|
|
})()
|
|
await new Promise(resolve => setTimeout(resolve, 10)) // let the stream register
|
|
const created = await api.sessions.create(req({}))
|
|
if (!created.result.ok) throw new Error('create failed')
|
|
await consuming
|
|
if (!created.result.ok) throw new Error('create failed')
|
|
const createdId = created.result.value.sessionId
|
|
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId }])
|
|
const list = await api.sessions.list(req({}))
|
|
if (!list.result.ok) throw new Error('list failed')
|
|
expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true)
|
|
})
|
|
|
|
it('prompt replays a full streamed turn and cancel mid-replay freezes with (已中断)', async () => {
|
|
const api = createFixtureApi()
|
|
const created = await api.sessions.create(req({}))
|
|
if (!created.result.ok) throw new Error('create failed')
|
|
const id = created.result.value.sessionId
|
|
const abort = new AbortController()
|
|
const frames: MuxFrame[] = []
|
|
const consuming = (async () => {
|
|
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
|
frames.push(envelope.payload)
|
|
const last = envelope.payload
|
|
if (last.type === 'session/event' && last.event.type === 'turn/end') {
|
|
abort.abort()
|
|
}
|
|
}
|
|
})()
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
// Unknown session → session-not-found with the id echoed in details.
|
|
const missing = await api.sessions.prompt(req({ sessionId: sid('ghost'), mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] }))
|
|
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'ghost' } } })
|
|
// Real prompt: replay starts (running flips true), cancel freezes it.
|
|
const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'render markdown' }] }))
|
|
expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } })
|
|
await new Promise(resolve => setTimeout(resolve, 120)) // a couple of typewriter ticks
|
|
await api.sessions.cancel(req({ sessionId: id }))
|
|
await consuming
|
|
const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
|
|
expect(types).toContain('turn/start')
|
|
expect(types).toContain('user/message')
|
|
expect(types).toContain('assistant/chunk')
|
|
expect(types).toContain('assistant/message')
|
|
expect(types.at(-1)).toBe('turn/end')
|
|
const finalize = frames.find((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event' && f.event.type === 'assistant/message')
|
|
expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)')
|
|
// Idle cancel: no replay in flight, must not explode; running flips false.
|
|
const idleCancel = await api.sessions.cancel(req({ sessionId: id }))
|
|
expect(idleCancel.result).toMatchObject({ ok: true })
|
|
})
|
|
|
|
it('steer during a replay inserts a steering message and the replay continues to completion', async () => {
|
|
const api = createFixtureApi()
|
|
const created = await api.sessions.create(req({}))
|
|
if (!created.result.ok) throw new Error('create failed')
|
|
const id = created.result.value.sessionId
|
|
const abort = new AbortController()
|
|
const framesPromise = collect<MuxFrame>(api.events.mux(req({}), abort.signal), abort,
|
|
frames => frames.some(f => f.type === 'session/event' && f.event.type === 'turn/end'))
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '短' }] }))
|
|
await api.sessions.prompt(req({ sessionId: id, mode: 'steer' as const, content: [{ type: 'text' as const, text: '插话' }] }))
|
|
const frames = await framesPromise
|
|
const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
|
|
expect(types).toContain('steering/message')
|
|
expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn
|
|
})
|
|
|
|
it('mux open replays subscribed sessions and resident interactions with stable rpcIds', async () => {
|
|
const api = createFixtureApi()
|
|
const openOnce = async (): Promise<RpcRequest<MuxFrame>[]> => {
|
|
const abort = new AbortController()
|
|
const envelopes: RpcRequest<MuxFrame>[] = []
|
|
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
|
envelopes.push(envelope)
|
|
if (envelopes.length >= 4) abort.abort()
|
|
}
|
|
return envelopes
|
|
}
|
|
const first = await openOnce()
|
|
const second = await openOnce()
|
|
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
|
|
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
|
|
expect(first[1]?.payload).toMatchObject({ type: 'session/title', sessionId: 'fx-alpha', title: 'Fixture 历史会话' })
|
|
expect(first[2]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
|
expect(second[2]?.rpcId).toBe(first[2]?.rpcId) // stable rpcId across replays (host replay semantics)
|
|
expect(first[3]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
|
expect(second[3]?.rpcId).toBe(first[3]?.rpcId)
|
|
})
|
|
|
|
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
|
const api = createFixtureApi()
|
|
const abort = new AbortController()
|
|
const framesPromise = collect<MuxFrame>(api.events.mux(req({}), abort.signal), abort,
|
|
frames => frames.some(f => f.type === 'session/event' && f.event.type === 'turn/end'))
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
const created = await api.sessions.create(req({}))
|
|
if (!created.result.ok) throw new Error('create failed')
|
|
// steer while idle + a non-text content block (covers the '' arm of the text join).
|
|
await api.sessions.prompt(req({
|
|
sessionId: created.result.value.sessionId, mode: 'steer' as const,
|
|
content: [{ type: 'text' as const, text: '短' }, { type: 'image', data: 'x' } as never],
|
|
}))
|
|
const frames = await framesPromise
|
|
const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
|
|
expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not a steering insert
|
|
})
|
|
|
|
it('gamma interval flip emits host/session-status and a running log-less session subscribes at lastSeq -1', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const api = createFixtureApi()
|
|
const abort = new AbortController()
|
|
const hostSeen: HostFrame[] = []
|
|
const consuming = (async () => {
|
|
for await (const envelope of api.events.host(req({}), abort.signal)) hostSeen.push(envelope.payload)
|
|
})()
|
|
await vi.advanceTimersByTimeAsync(5001) // interval fires: fx-gamma flips running=true (no log exists)
|
|
expect(hostSeen).toContainEqual({ type: 'host/session-status', sessionId: sid('fx-gamma'), running: true })
|
|
// A mux stream opened now sees gamma in the baseline with lastSeq = -1 (empty log arm).
|
|
const mabort = new AbortController()
|
|
const baseline: MuxFrame[] = []
|
|
const muxConsuming = (async () => {
|
|
for await (const envelope of api.events.mux(req({}), mabort.signal)) {
|
|
baseline.push(envelope.payload)
|
|
if (baseline.length >= 3) mabort.abort()
|
|
}
|
|
})()
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
mabort.abort()
|
|
await muxConsuming
|
|
expect(baseline).toContainEqual({ type: 'session/subscribed', sessionId: sid('fx-gamma'), lastSeq: -1 })
|
|
abort.abort()
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
await consuming
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('respond resolves the resident question once and rejects duplicate or unrelated ids', async () => {
|
|
const api = createFixtureApi()
|
|
expect(await api.respond({ type: 'client-response', rpcId: RpcId('x'), result: { ok: true, value: {} } })).toEqual({ accepted: false, reason: 'not-pending' })
|
|
const abort = new AbortController()
|
|
let question: RpcRequest<MuxFrame> | undefined
|
|
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
|
if (envelope.payload.type !== 'question/requested') continue
|
|
question = envelope
|
|
abort.abort()
|
|
}
|
|
if (question === undefined) throw new Error('fixture question missing')
|
|
const response = { type: 'client-response' as const, rpcId: question.rpcId, result: { ok: true as const, value: {} } }
|
|
expect(await api.respond(response)).toEqual({ accepted: true })
|
|
expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' })
|
|
|
|
const replayAbort = new AbortController()
|
|
const replayed = await collect(api.events.mux(req({}), replayAbort.signal), replayAbort, frames => frames.length === 2)
|
|
expect(replayed.every(frame => frame.type !== 'question/requested')).toBe(true)
|
|
|
|
const cancelledApi = createFixtureApi()
|
|
const cancelAbort = new AbortController()
|
|
let cancelQuestion: RpcRequest<MuxFrame> | undefined
|
|
for await (const envelope of cancelledApi.events.mux(req({}), cancelAbort.signal)) {
|
|
if (envelope.payload.type !== 'question/requested') continue
|
|
cancelQuestion = envelope
|
|
cancelAbort.abort()
|
|
}
|
|
if (cancelQuestion === undefined) throw new Error('fixture cancellation question missing')
|
|
expect(await cancelledApi.respond({
|
|
type: 'client-response', rpcId: cancelQuestion.rpcId,
|
|
result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } },
|
|
})).toEqual({ accepted: true })
|
|
})
|
|
|
|
it('respond answers the resident approval once: routing, validation, resolved broadcast, then not-pending', async () => {
|
|
const api = createFixtureApi()
|
|
// Discover the resident approval's stable rpcId from the mux baseline.
|
|
const abort = new AbortController()
|
|
const seen: { rpcId: string; frame: MuxFrame }[] = []
|
|
const consuming = (async () => {
|
|
for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push({ rpcId: envelope.rpcId, frame: envelope.payload })
|
|
})()
|
|
await vi.waitFor(() => {
|
|
expect(seen.some(s => s.frame.type === 'approval/requested')).toBe(true)
|
|
})
|
|
const requested = seen.find(s => s.frame.type === 'approval/requested')
|
|
if (requested === undefined || requested.frame.type !== 'approval/requested') throw new Error('unreachable')
|
|
const approvalId = requested.frame.approvalId
|
|
|
|
// Routed but malformed answers.
|
|
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } }))
|
|
.toEqual({ accepted: false, reason: 'bad-response' })
|
|
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId: 'wrong', outcome: 'rejected' } } }))
|
|
.toEqual({ accepted: false, reason: 'bad-response' })
|
|
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId, outcome: 'maybe' } } }))
|
|
.toEqual({ accepted: false, reason: 'bad-response' })
|
|
// The real answer settles the question and broadcasts resolved.
|
|
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'allowed-once' } } }))
|
|
.toEqual({ accepted: true })
|
|
await vi.waitFor(() => {
|
|
expect(seen.some(s => s.frame.type === 'approval/resolved' && s.frame.outcome === 'allowed-once')).toBe(true)
|
|
})
|
|
// Settled: a duplicate answer is late, and a fresh mux open replays nothing.
|
|
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'rejected' } } }))
|
|
.toEqual({ accepted: false, reason: 'not-pending' })
|
|
abort.abort()
|
|
await consuming
|
|
const abort2 = new AbortController()
|
|
const replayed = await collect(api.events.mux(req({}), abort2.signal), abort2, frames => frames.length === 2)
|
|
expect(replayed.some(f => f.type === 'approval/requested')).toBe(false)
|
|
})
|
|
|
|
it('permissions/setPermission mirror the host select: read, switch, validation', async () => {
|
|
const api = createFixtureApi()
|
|
const read = await api.sessions.permissions(req({ sessionId: sid('fx-alpha') }))
|
|
expect(read.result).toMatchObject({ ok: true, value: { currentValue: 'workspace-write' } })
|
|
const switched = await api.sessions.setPermission(req({ sessionId: sid('fx-alpha'), value: 'danger-full-access' }))
|
|
expect(switched.result).toMatchObject({ ok: true, value: { currentValue: 'danger-full-access' } })
|
|
const reread = await api.sessions.permissions(req({ sessionId: sid('fx-alpha') }))
|
|
expect(reread.result).toMatchObject({ ok: true, value: { currentValue: 'danger-full-access' } })
|
|
// Validation: ghost session and unknown value.
|
|
const ghostRead = await api.sessions.permissions(req({ sessionId: sid('fx-ghost') }))
|
|
expect(ghostRead.result.ok).toBe(false)
|
|
const ghostSwitch = await api.sessions.setPermission(req({ sessionId: sid('fx-ghost'), value: 'workspace-write' }))
|
|
expect(ghostSwitch.result.ok).toBe(false)
|
|
const unknown = await api.sessions.setPermission(req({ sessionId: sid('fx-alpha'), value: 'nope' }))
|
|
expect(unknown.result.ok).toBe(false)
|
|
})
|
|
|
|
it('describe answers the fixture identity', async () => {
|
|
const api = createFixtureApi()
|
|
const response = await api.host.describe(req({}))
|
|
expect(response.result).toMatchObject({ ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1 } })
|
|
})
|
|
|
|
it('timing hooks: history delay + one-shot failure, silent append, and breakStreams end open generators', async () => {
|
|
const api = createFixtureApi()
|
|
const hooks = timing()
|
|
// One-shot transport failure after transit delay.
|
|
hooks.setHistoryDelay(5)
|
|
hooks.failNextHistory()
|
|
await expect(api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))).rejects.toThrow(/simulated history transport failure/)
|
|
hooks.setHistoryDelay(0)
|
|
// The failure was one-shot: the next call succeeds.
|
|
const ok = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))
|
|
expect(ok.result.ok).toBe(true)
|
|
// appendUser emits on the mux stream; appendSilent only lands in the log (lost frame).
|
|
const abort = new AbortController()
|
|
const seen: MuxFrame[] = []
|
|
const consuming = (async () => {
|
|
for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push(envelope.payload)
|
|
})()
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
hooks.appendSilent('fx-alpha', '静默丢帧')
|
|
hooks.appendUser('fx-alpha', '正常直播')
|
|
hooks.appendTitle('fx-alpha', 'Fixture 修订标题')
|
|
await vi.waitFor(() => {
|
|
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true)
|
|
expect(seen.some(f => f.type === 'session/title' && f.title === 'Fixture 修订标题')).toBe(true)
|
|
})
|
|
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false)
|
|
const rawTitleIndex = seen.findIndex(f => f.type === 'session/event' && (f.event as { type: string }).type === 'session/title')
|
|
const titleControlIndex = seen.findIndex(f => f.type === 'session/title' && f.title === 'Fixture 修订标题')
|
|
expect(titleControlIndex).toBe(rawTitleIndex + 1)
|
|
// But history serves the silent event (the client's repull finds it).
|
|
const repull = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))
|
|
if (!repull.result.ok) throw new Error('repull failed')
|
|
expect(JSON.stringify(repull.result.value.events)).toContain('静默丢帧')
|
|
// breakStreams force-ends BOTH stream kinds without the client abort.
|
|
const habort = new AbortController()
|
|
const hostConsuming = (async () => {
|
|
for await (const _ of api.events.host(req({}), habort.signal)) { /* drain */ }
|
|
})()
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
hooks.breakStreams()
|
|
await consuming // returns because the stream broke, not because we aborted
|
|
await hostConsuming
|
|
expect(abort.signal.aborted).toBe(false)
|
|
expect(habort.signal.aborted).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('doFetch is an unreachable tripwire (all protocol paths overridden)', () => {
|
|
const client = new FixtureApiClient()
|
|
// Protected at compile time only; reach it directly to pin the tripwire message.
|
|
expect(() => (client as unknown as { doFetch(): Promise<Response> }).doFetch()).toThrow(/doFetch must be unreachable/)
|
|
})
|
|
|
|
it('mints request ids, taps all four full forms, and never touches doFetch', async () => {
|
|
const client = new FixtureApiClient()
|
|
const tapped: RpcMessage[] = []
|
|
client.subscribeEnvelopes(batch => tapped.push(...batch))
|
|
const response = await client.sessions.list({})
|
|
expect(response.result.ok).toBe(true)
|
|
await client.respond({ type: 'client-response', rpcId: RpcId('r-x'), result: { ok: true, value: {} } })
|
|
await vi.waitFor(() => {
|
|
const kinds = tapped.map(m => m.type)
|
|
expect(kinds).toContain('client-request')
|
|
expect(kinds).toContain('server-response')
|
|
expect(kinds).toContain('client-response')
|
|
})
|
|
const request = tapped.find(m => m.type === 'client-request')
|
|
const reply = tapped.find(m => m.type === 'server-response')
|
|
expect(request?.rpcId).toBe(reply?.rpcId) // echo discipline holds through the fake carrier
|
|
})
|
|
|
|
it('covers the whole unary dispatch table', async () => {
|
|
const client = new FixtureApiClient()
|
|
const created = await client.sessions.create({})
|
|
if (!created.result.ok) throw new Error('create failed')
|
|
const id = created.result.value.sessionId
|
|
expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true)
|
|
expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true)
|
|
expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
|
|
expect((await client.sessions.permissions({ sessionId: id })).result.ok).toBe(true)
|
|
expect((await client.sessions.setPermission({ sessionId: id, value: 'danger-full-access' })).result.ok).toBe(true)
|
|
expect((await client.host.describe({})).result.ok).toBe(true)
|
|
})
|
|
|
|
it('fires onOpen at stream-iteration start and taps server-request full forms', async () => {
|
|
const client = new FixtureApiClient()
|
|
const tapped: RpcMessage[] = []
|
|
client.subscribeEnvelopes(batch => tapped.push(...batch))
|
|
const order: string[] = []
|
|
const abort = new AbortController()
|
|
for await (const envelope of client.events.mux({}, abort.signal, () => order.push('open'))) {
|
|
order.push(envelope.payload.type)
|
|
abort.abort()
|
|
}
|
|
expect(order[0]).toBe('open')
|
|
expect(order[1]).toBe('session/subscribed')
|
|
await vi.waitFor(() => {
|
|
expect(tapped.some(m => m.type === 'server-request')).toBe(true)
|
|
})
|
|
// Host stream side of the pair (same tap path).
|
|
const habort = new AbortController()
|
|
const hostOrder: string[] = []
|
|
const hostIterator = client.events.host({}, habort.signal, () => hostOrder.push('open'))[Symbol.asyncIterator]()
|
|
const raced = await Promise.race([hostIterator.next(), new Promise<'idle'>(resolve => setTimeout(() => { resolve('idle') }, 50))])
|
|
expect(hostOrder).toEqual(['open']) // established even though the host stream stays silent
|
|
habort.abort()
|
|
if (raced === 'idle') await hostIterator.return?.(undefined)
|
|
})
|
|
})
|