refactor(ui-plan): read plans through the events page

A plan is a logged tool-call argument: read one page at the current log
end and page backwards from its cut instead of opening a follow snapshot.
This commit is contained in:
imccyu
2026-10-25 18:14:19 +08:00
parent 2f2caf88b3
commit 985937265f
5 changed files with 82 additions and 76 deletions
@@ -11,7 +11,6 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
export function planFailureLine(t: TranslateNS<'plan'>, failure: RemoteFailure): string {
switch (failure.code) {
case 'plan/invalid-address': return t('preview.invalidAddress')
case 'plan/unavailable': return t('preview.historyUnavailable')
case 'plan/not-found': return t('preview.notFound')
// Transport failures, including plan/read-failed, retain the carrier's diagnostic.
default: return failure.message
@@ -12,7 +12,6 @@ export const zh = {
'preview.loading': '正在读取计划…',
'preview.failed': '无法读取计划',
'preview.invalidAddress': '计划地址无效',
'preview.historyUnavailable': '无法读取会话历史',
'preview.notFound': '未找到这份计划',
'preview.unavailable': '计划预览不可用',
'preview.expired': '临时计划预览已失效,请从仍在等待审批的卡片重新打开。',
@@ -36,7 +35,6 @@ export const en = {
'preview.loading': 'Loading plan…',
'preview.failed': 'Could not load plan',
'preview.invalidAddress': 'Invalid plan address',
'preview.historyUnavailable': 'Session history is unavailable',
'preview.notFound': 'This plan was not found',
'preview.unavailable': 'Plan preview is unavailable',
'preview.expired': 'This temporary plan preview has expired. Reopen it from the pending review card.',
@@ -57,7 +55,6 @@ export const ja = {
'preview.loading': '計画を読み込み中…',
'preview.failed': '計画を読み込めませんでした',
'preview.invalidAddress': '計画のアドレスが無効です',
'preview.historyUnavailable': 'セッション履歴を利用できません',
'preview.notFound': 'この計画は見つかりませんでした',
'preview.unavailable': '計画のプレビューを利用できません',
'preview.expired': 'この一時的な計画プレビューは期限切れです。保留中のレビューカードから再度開いてください。',
@@ -78,7 +75,6 @@ export const es = {
'preview.loading': 'Cargando plan…',
'preview.failed': 'No se pudo cargar el plan',
'preview.invalidAddress': 'Dirección del plan no válida',
'preview.historyUnavailable': 'El historial de sesiones no está disponible',
'preview.notFound': 'Este plan no fue encontrado.',
'preview.unavailable': 'La vista previa del plan no está disponible',
'preview.expired': 'Esta vista previa del plan temporal ha caducado. Vuelva a abrirlo desde la tarjeta de revisión pendiente.',
@@ -99,7 +95,6 @@ export const fr = {
'preview.loading': 'Plan de chargement…',
'preview.failed': 'Impossible de charger le plan',
'preview.invalidAddress': 'Adresse du forfait invalide',
'preview.historyUnavailable': "L'historique des sessions n'est pas disponible",
'preview.notFound': "Ce plan n'a pas été trouvé",
'preview.unavailable': "L'aperçu du plan n'est pas disponible",
'preview.expired': 'Cet aperçu du plan temporaire a expiré. Rouvrez-le à partir de la carte de révision en attente.',
@@ -120,7 +115,6 @@ export const de = {
'preview.loading': 'Ladeplan…',
'preview.failed': 'Plan konnte nicht geladen werden',
'preview.invalidAddress': 'Ungültige Planadresse',
'preview.historyUnavailable': 'Der Sitzungsverlauf ist nicht verfügbar',
'preview.notFound': 'Dieser Plan wurde nicht gefunden',
'preview.unavailable': 'Die Planvorschau ist nicht verfügbar',
'preview.expired': 'Diese temporäre Planvorschau ist abgelaufen. Öffnen Sie es erneut über die ausstehende Überprüfungskarte.',
@@ -141,7 +135,6 @@ export const ru = {
'preview.loading': 'План загрузки…',
'preview.failed': 'Не удалось загрузить план',
'preview.invalidAddress': 'Неверный адрес плана',
'preview.historyUnavailable': 'История сеансов недоступна',
'preview.notFound': 'Этот план не найден',
'preview.unavailable': 'Предварительный просмотр плана недоступен.',
'preview.expired': 'Срок действия этой временной предварительной версии плана истек. Откройте его снова из карточки ожидающего рассмотрения.',
@@ -162,7 +155,6 @@ export const pt = {
'preview.loading': 'Carregando plano…',
'preview.failed': 'Não foi possível carregar o plano',
'preview.invalidAddress': 'Endereço do plano inválido',
'preview.historyUnavailable': 'O histórico da sessão não está disponível',
'preview.notFound': 'Este plano não foi encontrado',
'preview.unavailable': 'A visualização do plano não está disponível',
'preview.expired': 'Esta visualização temporária do plano expirou. Reabra-o no cartão de revisão pendente.',
@@ -1,6 +1,5 @@
/** Read immutable plan arguments from a Session snapshot and earlier history pages. */
/** Read immutable plan arguments from a Session history page and earlier pages. */
import type { Context } from '@deepseek-ai/cordis'
import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client'
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
@@ -10,8 +9,6 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
interface RemoteErrorDetailsMap {
/** The resource URL does not identify a plan invocation. */
'plan/invalid-address': Record<string, never>
/** Session history ended before an opening snapshot. */
'plan/unavailable': Record<string, never>
/** The Session has no readable plan for this invocation. */
'plan/not-found': Record<string, never>
/** The Session history read failed. */
@@ -28,13 +25,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/**
* Bind plan reads to the generated Session Remote face.
* Opening a follow reads projections and may activate a prepared Session on the Host.
* Generated Remote streams can throw carrier failures; the provider reports failed
* reads as resource failure frames and preserves Remote error codes.
* A plan is a logged tool-call argument, so the provider reads one page at the current log
* end and pages backwards from its cut. It neither activates nor subscribes.
* Remote failures are reported as resource failure frames with their original codes.
* @param remote - Existing Session history API.
* @returns a provider whose reads stop after finding the exact invocation.
*/
export function planResourceProvider(remote: Pick<Context['remote']['session'], 'follow' | 'page'>): ResourceProvider<'plan'> {
export function planResourceProvider(remote: Pick<Context['remote']['session'], 'events' | 'page'>): ResourceProvider<'plan'> {
return {
protocol: 'plan',
async *open(address, { signal }): AsyncIterable<RemoteResult<SubmittedPlan>> {
@@ -47,14 +44,12 @@ export function planResourceProvider(remote: Pick<Context['remote']['session'],
}
const sessionAddress = target.session
try {
// The snapshot supplies the page API's fixed log cut. Breaking closes the follow stream.
let snapshot: Extract<SessionFollowFrame, { type: 'snapshot' }> | undefined
for await (const frame of remote.follow({ address: sessionAddress }, signal)) {
if (frame.type === 'snapshot') { snapshot = frame; break }
}
// One page fixes the cut the later pages page back from.
const first = await remote.events({ address: sessionAddress }, signal)
if (aborted()) return
if (snapshot === undefined) throw new RemoteError('plan/unavailable', 'Session history ended before the plan could be read.', {})
let page = { records: snapshot.records, hasMore: snapshot.hasMore }
if (!first.ok) { yield { ok: false, error: first.error }; return }
const throughSeq = first.value.cursor
let page = { records: first.value.records, hasMore: first.value.hasMore }
while (true) {
for (const entry of page.records) {
const plan = submittedPlan(entry.event)
@@ -65,7 +60,7 @@ export function planResourceProvider(remote: Pick<Context['remote']['session'],
}
const beforeSeq = page.records[0]?.event.seq
if (!page.hasMore || beforeSeq === undefined) break
const next = await remote.page({ address: sessionAddress, throughSeq: snapshot.cursor, beforeSeq }, signal)
const next = await remote.page({ address: sessionAddress, throughSeq, beforeSeq }, signal)
if (aborted()) return
if (!next.ok) { yield next; return }
page = next.value
@@ -333,7 +333,6 @@ describe('plan entry points and document', () => {
expect(screen.getByRole('status').textContent).toBe(dictionary['preview.unavailable'])
for (const [code, message] of [
['plan/invalid-address', dictionary['preview.invalidAddress']],
['plan/unavailable', dictionary['preview.historyUnavailable']],
['plan/not-found', dictionary['preview.notFound']],
['plan/read-failed', 'connection lost'],
['session/not-found', 'connection lost'],
@@ -1,20 +1,28 @@
import { describe, expect, it, vi } from 'vitest'
import type { SessionEventEntry, SessionEventsPage } from '@deepseek-ai/dsh-api-session-controller/types'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import { planResourceProvider } from '../src/client/plan-resource.ts'
const address = 'dsh-resource://plan/session/call'
const event = { type: 'tool/call', seq: 1, data: { callId: 'call', name: 'exit_plan_mode', arguments: '{"plan":"# Saved plan"}' } }
const entry = { type: 'event', event }
const event: SessionEventEntry['event'] = {
type: 'tool/call', seq: 1, time: 1,
data: { callId: 'call', name: 'exit_plan_mode', arguments: '{"plan":"# Saved plan"}' },
}
const entry: SessionEventEntry = { type: 'event', event }
const olderUserEntry: SessionEventEntry = {
type: 'event', event: { type: 'user/message', seq: 80, time: 1, data: {} },
}
function setup(records: unknown[], hasMore = false) {
const closed = vi.fn()
const follow = vi.fn(async function* () {
try { yield { type: 'snapshot', cursor: 100, records, hasMore } }
finally { closed() }
})
const page = vi.fn(async () => ({ ok: true, value: { records: [entry], hasMore: false } }))
const provider = planResourceProvider({ follow, page } as unknown as Parameters<typeof planResourceProvider>[0])
return { provider, follow, page, closed }
type SessionRemote = Parameters<typeof planResourceProvider>[0]
function setup(records: SessionEventsPage['records'], hasMore = false) {
const opening: SessionEventsPage = { cursor: 100, records, hasMore }
const events = vi.fn<SessionRemote['events']>(async () => ({ ok: true, value: opening }))
const page = vi.fn<SessionRemote['page']>(async () => ({
ok: true, value: { cursor: 100, records: [entry], hasMore: false },
}))
const provider = planResourceProvider({ events, page })
return { provider, events, page }
}
async function read(provider: ReturnType<typeof planResourceProvider>, url = address, signal = new AbortController().signal) {
@@ -24,82 +32,95 @@ async function read(provider: ReturnType<typeof planResourceProvider>, url = add
}
describe('plan history resource', () => {
it('restores the exact invocation and releases its snapshot stream', async () => {
it('restores the exact invocation from one page', async () => {
const b = setup([entry])
expect(await read(b.provider)).toEqual([{ ok: true, value: { callId: 'call', markdown: '# Saved plan', title: 'Saved plan' } }])
expect(b.closed).toHaveBeenCalledOnce()
expect(b.events).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' } }, expect.any(AbortSignal))
expect(b.page).not.toHaveBeenCalled()
})
it('pages older history with the fixed opening cursor without changing the chat window', async () => {
const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true)
const b = setup([olderUserEntry], true)
expect((await read(b.provider))[0]).toMatchObject({ ok: true })
expect(b.page).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' }, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal))
expect(b.closed).toHaveBeenCalledOnce()
expect(b.events).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' } }, expect.any(AbortSignal))
expect(b.page).toHaveBeenLastCalledWith({ address: { kind: 'session', sessionId: 'session' }, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal))
})
it.each(['one-shot', 'continuable', 'unknown'] as const)('restores a %s subagent plan with its complete parent address on every page', async (mode) => {
const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true)
const address = `dsh-resource://plan/subagent/parent/child/${mode}/call`
const b = setup([olderUserEntry], true)
const url = `dsh-resource://plan/subagent/parent/child/${mode}/call`
const session = { kind: 'subagent', parentSessionId: 'parent', childSessionId: 'child', mode }
expect(await read(b.provider, address)).toMatchObject([{ ok: true, value: { title: 'Saved plan' } }])
expect(b.follow).toHaveBeenCalledWith({ address: session }, expect.any(AbortSignal))
expect(b.page).toHaveBeenCalledWith({ address: session, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal))
expect(b.closed).toHaveBeenCalledOnce()
expect(await read(b.provider, url)).toMatchObject([{ ok: true, value: { title: 'Saved plan' } }])
expect(b.events).toHaveBeenCalledWith({ address: session }, expect.any(AbortSignal))
expect(b.page).toHaveBeenLastCalledWith({ address: session, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal))
})
it('reports missing plans and invalid saved addresses', async () => {
const b = setup([])
expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/not-found' } }])
expect(await read(b.provider, 'dsh-resource://plan/s/%')).toMatchObject([{ ok: false, error: { code: 'plan/invalid-address' } }])
expect(b.follow).toHaveBeenCalledOnce()
expect(b.events).toHaveBeenCalledOnce()
})
it('does not publish a response after cancellation', async () => {
const b = setup([entry])
const controller = new AbortController()
controller.abort()
expect(await read(b.provider, address, controller.signal)).toEqual([])
expect(b.follow).not.toHaveBeenCalled()
expect(b.events).not.toHaveBeenCalled()
})
it('reports stream failures and closes an empty stream', async () => {
it('reports page failures with their Remote code', async () => {
const b = setup([])
b.follow.mockImplementationOnce(async function* () { throw new Error('connection lost') })
expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/read-failed', message: 'connection lost' } }])
b.follow.mockImplementationOnce(async function* () {})
expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/unavailable' } }])
const error = new RemoteError('plan/read-failed', 'connection lost', {})
b.events.mockResolvedValueOnce({ ok: false, error })
expect(await read(b.provider)).toEqual([{ ok: false, error }])
})
it('preserves Remote failures from paging and stream reads', async () => {
const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true)
const error = new RemoteError('plan/unavailable', 'history unavailable', {})
b.page.mockResolvedValueOnce({ ok: false, error } as never)
it('preserves Remote failures from paging', async () => {
const b = setup([olderUserEntry], true)
const error = new RemoteError('plan/read-failed', 'history unavailable', {})
b.page.mockResolvedValueOnce({ ok: true, value: { cursor: 100, records: [olderUserEntry], hasMore: true } })
b.page.mockResolvedValueOnce({ ok: false, error })
expect(await read(b.provider)).toEqual([{ ok: false, error }])
b.follow.mockImplementationOnce(async function* () { throw error })
expect(await read(b.provider)).toEqual([{ ok: false, error }])
b.follow.mockImplementationOnce(async function* () { throw 'disconnected' })
})
it('reports a thrown page failure as plan/read-failed', async () => {
const b = setup([olderUserEntry], true)
b.page.mockResolvedValueOnce({ ok: true, value: { cursor: 100, records: [olderUserEntry], hasMore: true } })
b.page.mockRejectedValueOnce(new Error('connection lost'))
expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/read-failed', message: 'connection lost' } }])
})
it('stringifies a non-Error page failure', async () => {
const b = setup([olderUserEntry], true)
b.page.mockResolvedValueOnce({ ok: true, value: { cursor: 100, records: [olderUserEntry], hasMore: true } })
b.page.mockRejectedValueOnce('disconnected')
expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/read-failed', message: 'disconnected' } }])
})
it('waits for a snapshot and discards it when cancellation occurs during stream closure', async () => {
it('preserves a Remote failure thrown by page', async () => {
const b = setup([olderUserEntry], true)
const error = new RemoteError('plan/read-failed', 'history unavailable', {})
b.page.mockResolvedValueOnce({ ok: true, value: { cursor: 100, records: [olderUserEntry], hasMore: true } })
b.page.mockRejectedValueOnce(error)
expect(await read(b.provider)).toEqual([{ ok: false, error }])
})
it('discards an opening page that settles after cancellation', async () => {
const b = setup([entry])
const controller = new AbortController()
b.follow.mockImplementationOnce(async function* () {
try {
yield entry as never
yield { type: 'snapshot', cursor: 100, records: [entry], hasMore: false }
} finally { b.closed(); controller.abort() }
})
expect(await read(b.provider, address, controller.signal)).toEqual([])
expect(b.closed).toHaveBeenCalledOnce()
expect(b.page).not.toHaveBeenCalled()
const started = Promise.withResolvers<undefined>()
const pending = Promise.withResolvers<Awaited<ReturnType<typeof b.events>>>()
b.events.mockImplementationOnce(() => { started.resolve(undefined); return pending.promise })
const result = read(b.provider, address, controller.signal)
await started.promise
controller.abort()
pending.resolve({ ok: true, value: { cursor: 100, records: [entry], hasMore: false } })
expect(await result).toEqual([])
})
it.each(['resolve', 'reject'] as const)('discards a page that settles after cancellation: %s', async (settlement) => {
const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true)
it.each(['resolve', 'reject'] as const)('discards an older page that settles after cancellation: %s', async (settlement) => {
const b = setup([olderUserEntry], true)
const controller = new AbortController()
const started = Promise.withResolvers<undefined>()
const pending = Promise.withResolvers<Awaited<ReturnType<typeof b.page>>>()
b.page.mockResolvedValueOnce({ ok: true, value: { cursor: 100, records: [olderUserEntry], hasMore: true } })
b.page.mockImplementationOnce(() => { started.resolve(undefined); return pending.promise })
const result = read(b.provider, address, controller.signal)
await started.promise
controller.abort()
if (settlement === 'resolve') pending.resolve({ ok: true, value: { records: [entry], hasMore: false } })
if (settlement === 'resolve') pending.resolve({ ok: true, value: { cursor: 100, records: [entry], hasMore: false } })
else pending.reject(new Error('cancelled'))
expect(await result).toEqual([])
expect(b.closed).toHaveBeenCalledOnce()
})
})