From 985937265fb3118321b80c576ef32009ced8865b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:14:19 +0800 Subject: [PATCH] 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. --- .../client/ui-plan/src/client/failure-line.ts | 1 - packages/client/ui-plan/src/client/locales.ts | 8 -- .../ui-plan/src/client/plan-resource.ts | 27 ++-- .../tests/plan-preview.client.spec.tsx | 1 - .../tests/plan-resource.client.spec.ts | 121 ++++++++++-------- 5 files changed, 82 insertions(+), 76 deletions(-) diff --git a/packages/client/ui-plan/src/client/failure-line.ts b/packages/client/ui-plan/src/client/failure-line.ts index f0dd9f74a7..ef288ff1f4 100644 --- a/packages/client/ui-plan/src/client/failure-line.ts +++ b/packages/client/ui-plan/src/client/failure-line.ts @@ -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 diff --git a/packages/client/ui-plan/src/client/locales.ts b/packages/client/ui-plan/src/client/locales.ts index 5940519fa2..7dbf87ea84 100644 --- a/packages/client/ui-plan/src/client/locales.ts +++ b/packages/client/ui-plan/src/client/locales.ts @@ -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.', diff --git a/packages/client/ui-plan/src/client/plan-resource.ts b/packages/client/ui-plan/src/client/plan-resource.ts index 95ec5dff30..8a3a82a204 100644 --- a/packages/client/ui-plan/src/client/plan-resource.ts +++ b/packages/client/ui-plan/src/client/plan-resource.ts @@ -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 - /** Session history ended before an opening snapshot. */ - 'plan/unavailable': Record /** The Session has no readable plan for this invocation. */ 'plan/not-found': Record /** 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): ResourceProvider<'plan'> { +export function planResourceProvider(remote: Pick): ResourceProvider<'plan'> { return { protocol: 'plan', async *open(address, { signal }): AsyncIterable> { @@ -47,14 +44,12 @@ export function planResourceProvider(remote: Pick | 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 { 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'], diff --git a/packages/client/ui-plan/tests/plan-resource.client.spec.ts b/packages/client/ui-plan/tests/plan-resource.client.spec.ts index 57be9acd36..b6c1bae374 100644 --- a/packages/client/ui-plan/tests/plan-resource.client.spec.ts +++ b/packages/client/ui-plan/tests/plan-resource.client.spec.ts @@ -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[0]) - return { provider, follow, page, closed } +type SessionRemote = Parameters[0] + +function setup(records: SessionEventsPage['records'], hasMore = false) { + const opening: SessionEventsPage = { cursor: 100, records, hasMore } + const events = vi.fn(async () => ({ ok: true, value: opening })) + const page = vi.fn(async () => ({ + ok: true, value: { cursor: 100, records: [entry], hasMore: false }, + })) + const provider = planResourceProvider({ events, page }) + return { provider, events, page } } async function read(provider: ReturnType, url = address, signal = new AbortController().signal) { @@ -24,82 +32,95 @@ async function read(provider: ReturnType, 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() + const pending = Promise.withResolvers>>() + 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() const pending = Promise.withResolvers>>() + 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() }) })