feat(web): project plan mode through host API
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type {
|
||||
ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId,
|
||||
ClientResponse, HostFrame, IApiClient, MuxFrame, PlanModeState, RpcError, RpcReceipt, RpcRequest,
|
||||
RpcResponse, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
@@ -52,6 +53,10 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onPlanMode: (payload: unknown) => Promise<RpcResponse<PlanModeState | null>> =
|
||||
() => Promise.resolve(ok(null))
|
||||
onSetPlanMode: (payload: unknown) => Promise<RpcResponse<PlanModeState | null>> =
|
||||
() => Promise.resolve(ok(null))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
|
||||
@@ -68,6 +73,8 @@ export class FakeApiClient implements IApiClient {
|
||||
this.record('session.history', payload, this.onHistory(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
planMode: (payload: unknown) => this.record('session.planMode', payload, this.onPlanMode(payload)),
|
||||
setPlanMode: (payload: unknown) => this.record('session.setPlanMode', payload, this.onSetPlanMode(payload)),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
|
||||
@@ -83,6 +83,115 @@ describe('open', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('plan mode projection', () => {
|
||||
it('loads the optional capability and applies a host-confirmed pending selection', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onPlanMode = () => Promise.resolve(ok({ active: false }))
|
||||
await session.open()
|
||||
expect(session.getSnapshot().planMode).toEqual({ active: false })
|
||||
expect(api.callsOf('session.planMode')).toEqual([{ sessionId: SID }])
|
||||
|
||||
api.onSetPlanMode = () => Promise.resolve(ok({ active: false, pending: true }))
|
||||
const result = await session.setPlanMode(true)
|
||||
expect(result).toEqual({ ok: true, value: { active: false, pending: true } })
|
||||
expect(api.callsOf('session.setPlanMode')).toEqual([{ sessionId: SID, active: true }])
|
||||
expect(session.getSnapshot().planMode).toEqual({ active: false, pending: true })
|
||||
})
|
||||
|
||||
it('retains the prior state when a selection fails at the business or transport layer', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onPlanMode = () => Promise.resolve(ok({ active: true }))
|
||||
await session.open()
|
||||
api.onSetPlanMode = () => Promise.resolve(err({
|
||||
code: 'internal', message: 'selection failed', details: {},
|
||||
}))
|
||||
expect((await session.setPlanMode(false)).ok).toBe(false)
|
||||
expect(session.getSnapshot().planMode).toEqual({ active: true })
|
||||
|
||||
api.onSetPlanMode = () => Promise.reject(new Error('wire down'))
|
||||
expect((await session.setPlanMode(false)).ok).toBe(false)
|
||||
expect(session.getSnapshot().planMode).toEqual({ active: true })
|
||||
})
|
||||
|
||||
it('commits a live plan event, clears pending, and ignores malformed or unavailable projections', async () => {
|
||||
const available = makeSession()
|
||||
available.api.onPlanMode = () => Promise.resolve(ok({ active: false, pending: true }))
|
||||
await available.session.open()
|
||||
available.session.handleMuxEnvelope('rp1' as never, {
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: at(0, { type: 'plan/mode', data: { active: 'yes' } }),
|
||||
})
|
||||
expect(available.session.getSnapshot().planMode).toEqual({ active: false, pending: true })
|
||||
available.session.handleMuxEnvelope('rp2' as never, {
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: at(1, { type: 'plan/mode', data: { active: true } }),
|
||||
})
|
||||
expect(available.session.getSnapshot().planMode).toEqual({ active: true })
|
||||
|
||||
const unavailable = makeSession()
|
||||
await unavailable.session.open()
|
||||
unavailable.session.handleMuxEnvelope('rp3' as never, {
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: at(0, { type: 'plan/mode', data: { active: true } }),
|
||||
})
|
||||
expect(unavailable.session.getSnapshot().planMode).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a mux commit that overtakes the initial query or a selection response', async () => {
|
||||
const initial = makeSession()
|
||||
const initialQuery = deferred<Awaited<ReturnType<FakeApiClient['onPlanMode']>>>()
|
||||
initial.api.onPlanMode = () => initialQuery.promise
|
||||
const opening = initial.session.open()
|
||||
await vi.waitFor(() => {
|
||||
expect(initial.api.callsOf('session.planMode')).toHaveLength(1)
|
||||
})
|
||||
initial.session.handleMuxEnvelope('rp-overtake-open' as never, {
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: at(0, { type: 'plan/mode', data: { active: true } }),
|
||||
})
|
||||
expect(initial.session.getSnapshot().planMode).toBeNull()
|
||||
initialQuery.resolve(ok({ active: false }))
|
||||
await opening
|
||||
expect(initial.session.getSnapshot().planMode).toEqual({ active: true })
|
||||
|
||||
const selection = makeSession()
|
||||
selection.api.onPlanMode = () => Promise.resolve(ok({ active: false }))
|
||||
await selection.session.open()
|
||||
const selectionResponse = deferred<Awaited<ReturnType<FakeApiClient['onSetPlanMode']>>>()
|
||||
selection.api.onSetPlanMode = () => selectionResponse.promise
|
||||
const selecting = selection.session.setPlanMode(true)
|
||||
selection.session.handleMuxEnvelope('rp-overtake-set' as never, {
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: at(0, { type: 'plan/mode', data: { active: true } }),
|
||||
})
|
||||
selectionResponse.resolve(ok({ active: false, pending: true }))
|
||||
await selecting
|
||||
expect(selection.session.getSnapshot().planMode).toEqual({ active: true })
|
||||
})
|
||||
|
||||
it('keeps history usable when the independent capability query fails', async () => {
|
||||
const business = makeSession()
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
business.api.onPlanMode = () => Promise.resolve(err({
|
||||
code: 'internal', message: 'query failed', details: {},
|
||||
}))
|
||||
await business.session.open()
|
||||
expect(business.session.getSnapshot()).toMatchObject({ openState: 'open', planMode: null })
|
||||
|
||||
const transport = makeSession()
|
||||
transport.api.onPlanMode = () => Promise.reject(new Error('query wire down'))
|
||||
await transport.session.open()
|
||||
expect(transport.session.getSnapshot()).toMatchObject({ openState: 'open', planMode: null })
|
||||
expect(errorSpy).toHaveBeenCalledTimes(2)
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('live event path', () => {
|
||||
async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) {
|
||||
const { api, session } = makeSession()
|
||||
@@ -598,6 +707,22 @@ describe('resync', () => {
|
||||
expect(cold.api.calls).toEqual([]) // never opened: no traffic
|
||||
})
|
||||
|
||||
it('refreshes plan state and drops a superseded open query result', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const stale = deferred<Awaited<ReturnType<FakeApiClient['onPlanMode']>>>()
|
||||
api.onPlanMode = () => stale.promise
|
||||
const opening = session.open()
|
||||
await vi.waitFor(() => {
|
||||
expect(api.callsOf('session.planMode')).toHaveLength(1)
|
||||
})
|
||||
api.onPlanMode = () => Promise.resolve(ok({ active: true }))
|
||||
const resynced = session.resync()
|
||||
stale.resolve(ok({ active: false }))
|
||||
await Promise.all([opening, resynced])
|
||||
expect(api.callsOf('session.planMode')).toHaveLength(2)
|
||||
expect(session.getSnapshot().planMode).toEqual({ active: true })
|
||||
})
|
||||
|
||||
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'))
|
||||
|
||||
Reference in New Issue
Block a user