Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # apps/cli/src/web.ts # apps/web/tests/smoke-fixture.e2e.ts # docs/architecture.i18n.yaml # packages/client/connection/src/client/fixture.ts # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/package.json # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts # packages/host/runtime/src/api-proxy.ts # packages/host/runtime/src/boot.ts # packages/host/webserver/tests/webserver.spec.ts # pnpm-lock.yaml
This commit is contained in:
@@ -1,289 +0,0 @@
|
||||
/**
|
||||
* ClientLoader: handoff protocol (single slot, id reconciliation), DI require
|
||||
* with export-surface re-registration, immediately-group barrier (parallel
|
||||
* fetch / topology execution / full-group barrier), status store, settled,
|
||||
* failure modes (missing handoff, unknown dep, cycle, unload stub).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createClientLoader } from '../src/client/loader/index.ts'
|
||||
import type { BootPluginEntry, ClientPluginHandoff } from '../src/client/loader/index.ts'
|
||||
|
||||
type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; __DSH_BOOT__?: { plugins: BootPluginEntry[] } }
|
||||
const win = globalThis as Win
|
||||
|
||||
afterEach(() => {
|
||||
delete win.DSHClientProxy
|
||||
delete win.__DSH_BOOT__
|
||||
})
|
||||
|
||||
interface FakeBundle {
|
||||
handoff: ClientPluginHandoff | null | ((require: (spec: string) => unknown) => Record<string, unknown>)
|
||||
}
|
||||
|
||||
interface Bench {
|
||||
loader: ReturnType<typeof createClientLoader>
|
||||
fetched: string[]
|
||||
executed: string[]
|
||||
fetchGate: Map<string, () => void>
|
||||
}
|
||||
|
||||
/** Build a loader over scripted fake bundles keyed by url; fetches resolve when released (or immediately). */
|
||||
function bench(
|
||||
plugins: BootPluginEntry[],
|
||||
bundles: Record<string, FakeBundle>,
|
||||
opts: { modules?: Record<string, unknown>; gated?: string[] } = {},
|
||||
): Bench {
|
||||
const ctx = new Context()
|
||||
const fetched: string[] = []
|
||||
const executed: string[] = []
|
||||
const fetchGate = new Map<string, () => void>()
|
||||
const loader = createClientLoader({
|
||||
ctx,
|
||||
modules: opts.modules ?? { react: { marker: 'react' } },
|
||||
boot: { plugins },
|
||||
fetchBundle: (url) => {
|
||||
fetched.push(url)
|
||||
if (opts.gated?.includes(url) === true) {
|
||||
return new Promise<string>((resolve) => { fetchGate.set(url, () => { resolve(url) }) })
|
||||
}
|
||||
return Promise.resolve(url)
|
||||
},
|
||||
executeBundle: (code) => {
|
||||
executed.push(code)
|
||||
const bundle = bundles[code]
|
||||
if (bundle === undefined) throw new Error(`no fake bundle for ${code}`)
|
||||
if (bundle.handoff === null) return // simulates a bundle that never calls loadPlugin
|
||||
if (typeof bundle.handoff === 'function') {
|
||||
win.DSHClientProxy?.loadPlugin({ id: code.replace('/client.js', '').replace('/plugins/', ''), factory: bundle.handoff })
|
||||
return
|
||||
}
|
||||
win.DSHClientProxy?.loadPlugin(bundle.handoff)
|
||||
},
|
||||
})
|
||||
return { loader, fetched, executed, fetchGate }
|
||||
}
|
||||
|
||||
const entry = (id: string, inject: string[] = [], immediately?: boolean): BootPluginEntry =>
|
||||
({ id, url: `/plugins/${id}/client.js`, inject, ...(immediately === true ? { immediately: true } : {}) })
|
||||
|
||||
const okBundle = (applied?: string[], exports: Record<string, unknown> = {}): FakeBundle => ({
|
||||
handoff: require => ({
|
||||
apply: (pluginCtx: Context) => { void pluginCtx; applied?.push('applied') },
|
||||
require,
|
||||
...exports,
|
||||
}),
|
||||
})
|
||||
|
||||
describe('load chain', () => {
|
||||
it('runs fetch→execute→handoff→factory(require)→apply→export re-registration→status active', async () => {
|
||||
const applied: string[] = []
|
||||
const b = bench(
|
||||
[entry('fake-base', [], true), entry('feature', ['fake-base'])],
|
||||
{
|
||||
'/plugins/fake-base/client.js': { handoff: () => ({ apply: () => { applied.push('fake-base') }, helper: 'base-helper' }) },
|
||||
'/plugins/feature/client.js': {
|
||||
handoff: (require) => {
|
||||
// Later loader requires the earlier one's export surface (inject topology guarantee).
|
||||
const fakeBase = ['fake','base'].join('-') // assembled so knip's static require() scan skips the fake id
|
||||
const base = require(fakeBase) as { helper: string }
|
||||
expect(base.helper).toBe('base-helper')
|
||||
expect((require('react') as { marker: string }).marker).toBe('react')
|
||||
return { apply: () => { applied.push('feature') } }
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
b.loader.start()
|
||||
await b.loader.settled()
|
||||
expect(applied).toEqual(['fake-base', 'feature'])
|
||||
expect(b.loader.status.getSnapshot()).toEqual({ 'fake-base': 'active', feature: 'active' })
|
||||
expect((b.loader.requireModule('fake-base') as { helper: string }).helper).toBe('base-helper')
|
||||
expect(() => b.loader.requireModule('ghost')).toThrow(/not available/)
|
||||
})
|
||||
|
||||
it('fetches the immediately group in parallel and holds the barrier before the rest', async () => {
|
||||
const b = bench(
|
||||
[entry('a', [], true), entry('b', ['a'], true), entry('later')],
|
||||
{
|
||||
'/plugins/a/client.js': okBundle(),
|
||||
'/plugins/b/client.js': okBundle(),
|
||||
'/plugins/later/client.js': okBundle(),
|
||||
},
|
||||
{ gated: ['/plugins/a/client.js'] },
|
||||
)
|
||||
b.loader.start()
|
||||
await Promise.resolve()
|
||||
// Both early fetches are in flight before any execution; the late plugin is not fetched yet.
|
||||
expect(b.fetched).toEqual(['/plugins/a/client.js', '/plugins/b/client.js'])
|
||||
expect(b.executed).toEqual([])
|
||||
b.fetchGate.get('/plugins/a/client.js')?.()
|
||||
await b.loader.settled()
|
||||
expect(b.executed).toEqual(['/plugins/a/client.js', '/plugins/b/client.js', '/plugins/later/client.js'])
|
||||
})
|
||||
|
||||
it('orders execution by inject topology within each group', async () => {
|
||||
const b = bench(
|
||||
[entry('z-ui', ['a-base']), entry('a-base')],
|
||||
{ '/plugins/a-base/client.js': okBundle(), '/plugins/z-ui/client.js': okBundle() },
|
||||
)
|
||||
b.loader.start()
|
||||
await b.loader.settled()
|
||||
expect(b.executed).toEqual(['/plugins/a-base/client.js', '/plugins/z-ui/client.js'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure modes (fail loud)', () => {
|
||||
it('rejects settled and marks failed when a bundle never calls loadPlugin', async () => {
|
||||
const b = bench([entry('silent')], { '/plugins/silent/client.js': { handoff: null } })
|
||||
b.loader.start()
|
||||
await expect(b.loader.settled()).rejects.toThrow(/without calling DSHClientProxy.loadPlugin/)
|
||||
expect(b.loader.status.getSnapshot().silent).toBe('failed')
|
||||
})
|
||||
|
||||
it('rejects on manifest/handoff id mismatch', async () => {
|
||||
const b = bench([entry('expected')], {
|
||||
'/plugins/expected/client.js': { handoff: { id: 'imposter', factory: () => ({ apply: () => {} }) } },
|
||||
})
|
||||
b.loader.start()
|
||||
await expect(b.loader.settled()).rejects.toThrow(/id mismatch/)
|
||||
})
|
||||
|
||||
it('rejects unknown inject targets, cycles, missing apply, unknown load ids, duplicate manifest ids', async () => {
|
||||
// Sequential benches: each loader owns the window proxy, so release it between them.
|
||||
const fresh = <T>(build: () => T): T => {
|
||||
delete win.DSHClientProxy
|
||||
return build()
|
||||
}
|
||||
|
||||
const missing = fresh(() => bench([entry('x', ['nope'])], { '/plugins/x/client.js': okBundle() }))
|
||||
missing.loader.start()
|
||||
await expect(missing.loader.settled()).rejects.toThrow(/injects unknown plugin "nope"/)
|
||||
|
||||
const cyclic = fresh(() => bench(
|
||||
[entry('p', ['q']), entry('q', ['p'])],
|
||||
{ '/plugins/p/client.js': okBundle(), '/plugins/q/client.js': okBundle() },
|
||||
))
|
||||
cyclic.loader.start()
|
||||
await expect(cyclic.loader.settled()).rejects.toThrow(/inject cycle/)
|
||||
|
||||
const applyless = fresh(() => bench([entry('noap')], { '/plugins/noap/client.js': { handoff: { id: 'noap', factory: () => ({}) } } }))
|
||||
applyless.loader.start()
|
||||
await expect(applyless.loader.settled()).rejects.toThrow(/exports no apply/)
|
||||
|
||||
const b = fresh(() => bench([entry('a')], { '/plugins/a/client.js': okBundle() }))
|
||||
await expect(b.loader.load('ghost')).rejects.toThrow(/unknown plugin "ghost"/)
|
||||
|
||||
expect(() => fresh(() => bench([entry('dup'), entry('dup')], {}))).toThrow(/duplicate manifest id/)
|
||||
})
|
||||
|
||||
it('throws on missing boot manifest, double proxy install, and pre-start settled', () => {
|
||||
expect(() => createClientLoader({ ctx: new Context(), modules: {} })).toThrow(/no boot manifest/)
|
||||
const b = bench([], {})
|
||||
expect(() => b.loader.settled()).toThrow(/settled\(\) before start\(\)/)
|
||||
// First bench installed the proxy; a second loader must refuse.
|
||||
expect(() => createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })).toThrow(/already installed/)
|
||||
})
|
||||
|
||||
it('direct load() before a dependency is active fails loud (same check start() sequences)', async () => {
|
||||
const b = bench(
|
||||
[entry('dep', [], true), entry('needy', ['dep'])],
|
||||
{ '/plugins/dep/client.js': okBundle(), '/plugins/needy/client.js': okBundle() },
|
||||
)
|
||||
await expect(b.loader.load('needy')).rejects.toThrow(/loaded before its dependency "dep" is active/)
|
||||
})
|
||||
|
||||
it('direct load() naming an unknown inject target fails loud', async () => {
|
||||
const b = bench([entry('solo', ['phantom'])], { '/plugins/solo/client.js': okBundle() })
|
||||
await expect(b.loader.load('solo')).rejects.toThrow(/injects unknown plugin "phantom"/)
|
||||
})
|
||||
|
||||
it('an immediately-group fetch failure surfaces through settled, not as an unhandled prefetch rejection', async () => {
|
||||
// The fire-and-forget prefetch swallow arm must absorb the early
|
||||
// rejection; the awaited load surfaces the same failure via settled().
|
||||
const ctx = new Context()
|
||||
delete win.DSHClientProxy
|
||||
const loader = createClientLoader({
|
||||
ctx,
|
||||
modules: {},
|
||||
boot: { plugins: [{ id: 'kaboom', url: '/plugins/kaboom/client.js', inject: [], immediately: true }] },
|
||||
fetchBundle: () => Promise.reject(new Error('bundle fetch exploded')),
|
||||
executeBundle: () => {},
|
||||
})
|
||||
loader.start()
|
||||
await expect(loader.settled()).rejects.toThrow(/bundle fetch exploded/)
|
||||
})
|
||||
|
||||
it('unload is the P-I stub', async () => {
|
||||
const b = bench([], {})
|
||||
await expect(b.loader.unload('x')).rejects.toThrow(/not implemented/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DOM default seams (stubbed globals)', () => {
|
||||
it('default fetchBundle uses fetch, rejects non-OK; default executeBundle injects an inline script; claimStyles tags orphans', async () => {
|
||||
const origFetch = globalThis.fetch
|
||||
const appended: { textContent?: string | null }[] = []
|
||||
const styleTag = {
|
||||
attrs: {} as Record<string, string>,
|
||||
setAttribute(k: string, v: string) { this.attrs[k] = v },
|
||||
}
|
||||
const fakeDoc = {
|
||||
createElement: () => {
|
||||
const el = { textContent: null as string | null }
|
||||
return el
|
||||
},
|
||||
head: { appendChild: (el: { textContent?: string | null }) => { appended.push(el) } },
|
||||
querySelectorAll: () => [styleTag],
|
||||
}
|
||||
const g = globalThis as { document?: unknown; fetch: typeof fetch }
|
||||
g.document = fakeDoc
|
||||
g.fetch = (url: URL | RequestInfo) => Promise.resolve(
|
||||
(typeof url === 'string' ? url : url instanceof URL ? url.href : url.url).includes('bad')
|
||||
? new Response('x', { status: 500 })
|
||||
: new Response('window.DSHClientProxy.loadPlugin(globalThis.__seamHandoff)', { status: 200 }),
|
||||
)
|
||||
try {
|
||||
delete win.DSHClientProxy
|
||||
const ctx = new Context()
|
||||
const loader = createClientLoader({
|
||||
ctx,
|
||||
modules: {},
|
||||
boot: { plugins: [
|
||||
{ id: 'seam-ok', url: '/plugins/seam-ok/client.js', inject: [] },
|
||||
{ id: 'seam-bad', url: '/plugins/bad/client.js', inject: [] },
|
||||
] },
|
||||
// NO seams injected (keys omitted, not undefined — exactOptional):
|
||||
// the DOM defaults are under test.
|
||||
})
|
||||
const seamHandoff: ClientPluginHandoff = {
|
||||
id: 'seam-ok',
|
||||
factory: () => ({ apply: () => {} }),
|
||||
}
|
||||
// Default executeBundle only APPENDS the script element (no execution in
|
||||
// our fake DOM), so drive the handoff manually before load resolves it.
|
||||
const loadOk = loader.load('seam-ok')
|
||||
await Promise.resolve()
|
||||
;(globalThis as Win).DSHClientProxy?.loadPlugin(seamHandoff)
|
||||
await loadOk
|
||||
expect(appended).toHaveLength(1)
|
||||
expect(appended[0]?.textContent).toContain('sourceURL=/plugins/seam-ok/client.js')
|
||||
expect(styleTag.attrs['data-plugin']).toBe('seam-ok')
|
||||
await expect(loader.load('seam-bad')).rejects.toThrow(/answered 500/)
|
||||
} finally {
|
||||
g.fetch = origFetch
|
||||
delete (globalThis as { document?: unknown }).document
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('handoff slot protocol', () => {
|
||||
it('rejects an overlapping loadPlugin before the loader claims the pending handoff', () => {
|
||||
delete win.DSHClientProxy
|
||||
createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })
|
||||
const proxy = (globalThis as Win).DSHClientProxy
|
||||
proxy?.loadPlugin({ id: 'first', factory: () => ({ apply: () => {} }) })
|
||||
expect(() => proxy?.loadPlugin({ id: 'second', factory: () => ({ apply: () => {} }) }))
|
||||
.toThrow(/overlapping loadPlugin handoff/)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type {
|
||||
HostFrame, IApiClient, MuxFrame, RpcError, RpcRequest, RpcResponse, SessionId,
|
||||
ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
@@ -96,8 +96,10 @@ export class FakeApiClient implements IApiClient {
|
||||
host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.hostConns, signal, onOpen),
|
||||
}
|
||||
|
||||
respond(): Promise<{ accepted: false; reason: 'not-pending' }> {
|
||||
return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
||||
onRespond: (message: ClientResponse) => Promise<RpcReceipt> = () => Promise.resolve({ accepted: true })
|
||||
|
||||
respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||
return this.record('respond', message, this.onRespond(message))
|
||||
}
|
||||
|
||||
/** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('instances', () => {
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } })
|
||||
const session = manager.get(S1)
|
||||
expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', approvalId: 'ap1' }])
|
||||
expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', payload: { approvalId: 'ap1' } }])
|
||||
// Buffer cleared: a second instantiation of another id gets nothing.
|
||||
expect(manager.get(S2).getSnapshot().pending).toEqual([])
|
||||
})
|
||||
@@ -48,7 +48,7 @@ describe('instances', () => {
|
||||
}
|
||||
const pending = manager.get(S1).getSnapshot().pending
|
||||
expect(pending).toHaveLength(32)
|
||||
expect(pending.map(p => p.rpcId)).toEqual(Array.from({ length: 32 }, (_, i) => `q${i + 8}`)) // oldest 8 dropped
|
||||
expect(pending.map(p => p.key)).toEqual(Array.from({ length: 32 }, (_, i) => `q:q${i + 8}`)) // oldest 8 dropped
|
||||
// Removed session: buffered frames must not replay on a future instantiation.
|
||||
manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } })
|
||||
manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
|
||||
@@ -89,6 +89,66 @@ describe('list lifecycle', () => {
|
||||
expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
|
||||
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
|
||||
})
|
||||
|
||||
it('retains monotonic title snapshots before list arrival, merges recency, and clears them on removal', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-new' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Newest', eventSeq: 4, updatedAt: 300 },
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-stale' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Stale', eventSeq: 3, updatedAt: 900 },
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-equal' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Equal', eventSeq: 4, updatedAt: 901 },
|
||||
})
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[],
|
||||
}))
|
||||
await manager.refreshList()
|
||||
|
||||
const titled = manager.getListSnapshot()
|
||||
expect(titled.items.map(item => item.sessionId)).toEqual([S1, S2])
|
||||
expect(titled.items[0]).toMatchObject({ title: 'Newest', updatedAt: 300 })
|
||||
expect(titled.items[1]?.title).toBeUndefined()
|
||||
|
||||
manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', sessionId: S1 } })
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a retained title beyond the subscription baseline before accepting its durable replay', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshList()
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-unflushed' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Unflushed', eventSeq: 4, updatedAt: 400 },
|
||||
})
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'subscribed-recovered' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
|
||||
expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(100)
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-durable' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Durable', eventSeq: 2, updatedAt: 200 },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'subscribed-current' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('host frame routing', () => {
|
||||
|
||||
@@ -266,6 +266,36 @@ describe('pending interactions', () => {
|
||||
session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq' as never, outcome: 'answered' })
|
||||
expect(session.getSnapshot().pending).toEqual([])
|
||||
})
|
||||
|
||||
it('mints waits whose respond() backfills the requested rpcId into the client-response envelope', async () => {
|
||||
const { api, session } = makeSession()
|
||||
session.handleMuxEnvelope('rq-answer' as never, { type: 'question/requested', sessionId: SID, questions: [] })
|
||||
const wait = session.getSnapshot().pending[0]!
|
||||
expect(wait).toMatchObject({ kind: 'question', key: 'q:rq-answer', sessionId: SID, payload: { questions: [] } })
|
||||
const receipt = await wait.respond({
|
||||
ok: true,
|
||||
value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } },
|
||||
})
|
||||
expect(receipt).toEqual({ accepted: true })
|
||||
expect(api.callsOf('respond')).toEqual([{
|
||||
type: 'client-response', rpcId: 'rq-answer',
|
||||
result: {
|
||||
ok: true,
|
||||
value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } },
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
it('settles the wait on the authoritative resolved frame: respond() then throws synchronously', async () => {
|
||||
const { api, session } = makeSession()
|
||||
session.handleMuxEnvelope('rq1' as never, { type: 'question/requested', sessionId: SID, questions: [] })
|
||||
const wait = session.getSnapshot().pending[0]!
|
||||
session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq1' as never, outcome: 'answered' })
|
||||
expect(session.getSnapshot().pending).toEqual([])
|
||||
expect(() => wait.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } }))
|
||||
.toThrow('already settled')
|
||||
expect(api.callsOf('respond')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('remaining branches', () => {
|
||||
@@ -370,7 +400,7 @@ describe('remaining branches', () => {
|
||||
session.handleMuxEnvelope('ra' as never, {
|
||||
type: 'approval/requested', sessionId: SID, approvalId: 'ap2' as never, toolName: 'rm', callId: 'c1' as never, reason: '危险',
|
||||
})
|
||||
expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', callId: 'c1', reason: '危险' })
|
||||
expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', payload: { callId: 'c1', reason: '危险' } })
|
||||
session.handleMuxEnvelope('rx' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never })
|
||||
session.handleMuxEnvelope('rx2' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never })
|
||||
session.handleMuxEnvelope('ry2' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'never-was' as never, outcome: 'cancelled' })
|
||||
@@ -583,6 +613,22 @@ describe('resync', () => {
|
||||
expect(cold.api.calls).toEqual([]) // never opened: no traffic
|
||||
})
|
||||
|
||||
it('re-mints a replayed requested frame as a fresh wait with the same key (old reference superseded)', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] })
|
||||
const before = session.getSnapshot().pending[0]!
|
||||
await session.resync()
|
||||
session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] })
|
||||
const after = session.getSnapshot().pending[0]!
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.key).toBe(before.key)
|
||||
// Superseded ≠ settled: an in-flight respond on the stale reference still reaches the host.
|
||||
await before.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
|
||||
expect(api.callsOf('respond')).toMatchObject([{ rpcId: 'rq-replay' }])
|
||||
})
|
||||
|
||||
it('drops a stale in-flight open superseded by resync (generation guard)', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
|
||||
@@ -41,16 +41,21 @@ async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: s
|
||||
}
|
||||
|
||||
describe('list store projection', () => {
|
||||
it('projects ids/byId with cwd-basename titles (id fallback) and parent links', async () => {
|
||||
it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => {
|
||||
const b = bench()
|
||||
b.svc.manager.handleMuxEnvelope({
|
||||
rpcId: 'title' as never,
|
||||
payload: { type: 'session/title', sessionId: sid('s1'), title: 'Durable title', eventSeq: 2, updatedAt: 3 },
|
||||
})
|
||||
await feedList(b, [
|
||||
{ id: 's1', cwd: '/home/u/proj-a/' },
|
||||
{ id: 's2', parentId: 's1', running: true },
|
||||
])
|
||||
const state = b.svc.list.getSnapshot()
|
||||
expect(state.ids).toEqual(['s1', 's2'])
|
||||
expect(state.byId[sid('s1')]).toMatchObject({ title: 'proj-a', cwd: '/home/u/proj-a/' })
|
||||
expect(state.byId[sid('s2')]).toMatchObject({ title: 's2', parentId: 's1', running: true })
|
||||
expect(state.byId[sid('s1')]).toMatchObject({ title: 'Durable title', displayTitle: 'Durable title', cwd: '/home/u/proj-a/' })
|
||||
expect(state.byId[sid('s2')]).toMatchObject({ displayTitle: 's2', parentId: 's1', running: true })
|
||||
expect(state.byId[sid('s2')]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reflects live increments (host stream via manager) into the store', async () => {
|
||||
@@ -273,12 +278,13 @@ describe('create', () => {
|
||||
})
|
||||
|
||||
describe('coverage tails (branch duals)', () => {
|
||||
it('titleOf falls back to the id for empty and separator-only cwd', async () => {
|
||||
it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 'no-base', cwd: '///' }, { id: 'empty-cwd', cwd: '' }])
|
||||
const { byId } = b.svc.list.getSnapshot()
|
||||
expect(byId[sid('no-base')]?.title).toBe('no-base')
|
||||
expect(byId[sid('empty-cwd')]?.title).toBe('empty-cwd')
|
||||
expect(byId[sid('no-base')]?.displayTitle).toBe('no-base')
|
||||
expect(byId[sid('empty-cwd')]?.displayTitle).toBe('empty-cwd')
|
||||
expect(byId[sid('no-base')]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('binding for an unknown session returns undefined and leaves the staged scope intact', async () => {
|
||||
|
||||
Reference in New Issue
Block a user