From 0289791d5da1d21ada0bbd59b5fcc45e62890ca1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 14:21:56 +0800 Subject: [PATCH 001/188] fix(web): restore focus after closing settings --- .../ui-settings/src/client/SettingsRoot.tsx | 3 +++ .../ui-settings/tests/settings-root.spec.tsx | 20 ++++++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 45055753ac..bac7ef1ec9 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -101,9 +101,11 @@ export function SettingsRoot(props: SettingsRootComponentProps) { const [open, setOpen] = useState(false) const [activeId, setActiveId] = useState(undefined) const [completedOnboarding, setCompletedOnboarding] = useState>(() => new Set()) + const triggerButton = useRef(null) const close = useCallback(() => { setOpen(false) setActiveId(undefined) + queueMicrotask(() => { triggerButton.current?.focus() }) }, []) const openSection = useCallback((id: string) => { setActiveId(id) @@ -145,6 +147,7 @@ export function SettingsRoot(props: SettingsRootComponentProps) { return ( <> + {open + ? ( +
    + {rows.map((record) => { + const overdue = Date.parse(record.scheduledAt) <= now + return ( +
  • + + + {record.prompt} + + {formatScheduleFrequency(record, t)} + + {formatScheduleLocalTime(record.scheduledAt)} + + + {formatScheduleRelative(record.scheduledAt, now, t)} + + +
  • + ) + })} +
+ ) + : null} + + ) +} diff --git a/packages/client/ui-schedule/src/client/index.ts b/packages/client/ui-schedule/src/client/index.ts new file mode 100644 index 0000000000..8ecb1f5bae --- /dev/null +++ b/packages/client/ui-schedule/src/client/index.ts @@ -0,0 +1,35 @@ +/** Browser half of the read-only Schedule catalog. */ + +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type {} from '@deepseek-ai/dsh-schedule/client' +import { ScheduleCatalogAction } from './ScheduleCatalogAction.tsx' +import { en, NS, zh, type ScheduleCatalogKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Read-only active Schedule catalog copy. */ + 'schedule.catalog': ScheduleCatalogKey + } +} + +/** Required services for locale registration and header-slot contribution. */ +export const inject = ['slots', 'locale'] + +/** Register the dictionaries and Session-header catalog action. */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-schedule: dictionaries') + ctx.slots.inject( + 'conversation.session.header.actions', + () => ctx.slots.register({ + name: 'conversation.session.header.actions', + id: 'schedule-catalog', + // Static Session identity precedes this entry; background jobs follow it. + order: 10, + locale: NS, + }, ScheduleCatalogAction), + ) +} diff --git a/packages/client/ui-schedule/src/client/locales.ts b/packages/client/ui-schedule/src/client/locales.ts new file mode 100644 index 0000000000..b6cd678036 --- /dev/null +++ b/packages/client/ui-schedule/src/client/locales.ts @@ -0,0 +1,51 @@ +/** `schedule.catalog` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'schedule.catalog' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'trigger.one': '{count} 个提醒', + 'trigger.other': '{count} 个提醒', + 'list.aria': '活动提醒', + 'status.scheduled': '等待中', + 'status.overdue': '已逾期', + 'frequency.once': '单次', + 'frequency.every': '{value}{unit}一次', + 'unit.day.one': '天', + 'unit.day.other': '天', + 'unit.hour.one': '小时', + 'unit.hour.other': '小时', + 'unit.minute.one': '分钟', + 'unit.minute.other': '分钟', + 'unit.second.one': '秒', + 'unit.second.other': '秒', + 'relative.now': '现在到期', + 'relative.future': '{value}{unit}后', + 'relative.overdue': '已逾期 {value}{unit}', +} as const + +/** English dictionary, key-identical to the Chinese source of truth. */ +export const en: Record = { + 'trigger.one': '{count} reminder', + 'trigger.other': '{count} reminders', + 'list.aria': 'Active reminders', + 'status.scheduled': 'Scheduled', + 'status.overdue': 'Overdue', + 'frequency.once': 'Once', + 'frequency.every': 'Every {value} {unit}', + 'unit.day.one': 'day', + 'unit.day.other': 'days', + 'unit.hour.one': 'hour', + 'unit.hour.other': 'hours', + 'unit.minute.one': 'minute', + 'unit.minute.other': 'minutes', + 'unit.second.one': 'second', + 'unit.second.other': 'seconds', + 'relative.now': 'Due now', + 'relative.future': 'in {value} {unit}', + 'relative.overdue': '{value} {unit} overdue', +} + +/** Key domain of the Schedule catalog namespace. */ +export type ScheduleCatalogKey = keyof typeof zh diff --git a/packages/client/ui-schedule/src/css-modules.d.ts b/packages/client/ui-schedule/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-schedule/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-schedule/src/index.ts b/packages/client/ui-schedule/src/index.ts new file mode 100644 index 0000000000..80b39b1bc9 --- /dev/null +++ b/packages/client/ui-schedule/src/index.ts @@ -0,0 +1,7 @@ +/** + * Read-only Schedule catalog plugin, node half. The empty apply keeps the + * optional browser feature addressable from the host-owned Loader overlay. + */ + +/** Host plugin body — Schedule catalog behavior exists only in the browser entry. */ +export function apply(): void {} diff --git a/packages/client/ui-schedule/src/invariant.ts b/packages/client/ui-schedule/src/invariant.ts new file mode 100644 index 0000000000..5df39278ac --- /dev/null +++ b/packages/client/ui-schedule/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion for the read-only Schedule catalog. */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-schedule' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-schedule-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: the package owns no mutable cross-plugin state. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts new file mode 100644 index 0000000000..d708b27d7b --- /dev/null +++ b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts @@ -0,0 +1,101 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '../src/client/index.ts' +import { apply as applyNode } from '../src/index.ts' +import * as ScheduleInvariant from '../src/invariant.ts' +import { en, NS, zh } from '../src/client/locales.ts' + +const Empty = () => null + +function headerEntryIds(ctx: Context): (string | undefined)[] { + return ctx.slots + .entries('conversation.session.header.actions') + .map(entry => entry.options.id) +} + +async function baseContext(): Promise { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() + return ctx +} + +function declareHeader(ctx: Context): () => void { + return ctx.slots.register({ + name: 'root', + children: { + 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, + }, + } as never, Empty) +} + +describe('ui-schedule browser half', () => { + it('declares only the services used by registration', () => { + expect(inject).toEqual(['slots', 'locale']) + }) + + it('waits for the header declaration, orders between static context and Jobs, and tears down', async () => { + const ctx = await baseContext() + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(headerEntryIds(ctx)).toEqual([]) + + const header = declareHeader(ctx) + ctx.slots.register({ + name: 'conversation.session.header.actions', id: 'agent-preset', order: -10, + }, Empty) + ctx.slots.register({ + name: 'conversation.session.header.actions', id: 'job-list', order: 20, + }, Empty) + expect(headerEntryIds(ctx)).toEqual(['agent-preset', 'schedule-catalog', 'job-list']) + + await fiber.dispose() + expect(headerEntryIds(ctx)).toEqual(['agent-preset', 'job-list']) + header() + await ctx.fiber.dispose() + }) + + it('registers both dictionaries and releases them with its fiber', async () => { + const ctx = await baseContext() + declareHeader(ctx) + ctx.locale.setLocale('zh') + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const translate = ctx.locale.bind(NS) + expect(translate('list.aria')).toBe(zh['list.aria']) + ctx.locale.setLocale('en') + expect(translate('list.aria')).toBe(en['list.aria']) + expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort()) + + await fiber.dispose() + expect(translate('list.aria')).not.toBe(en['list.aria']) + await ctx.fiber.dispose() + }) +}) + +describe('ui-schedule node and invariant halves', () => { + it('keeps the node half inert', () => { + expect(applyNode).not.toThrow() + }) + + it('reserves package ownership under its invariant companion name', async () => { + const ctx = new Context() + await ctx.plugin(InvariantRegistry, { enabled: true }) + const fiber = ctx.plugin(ScheduleInvariant) + await fiber.await() + expect(ScheduleInvariant.name).toBe('client-ui-schedule-invariant') + expect(ScheduleInvariant.inject).toEqual(['invariants']) + expect(() => { + Reflect.apply(ctx.emit.bind(ctx), undefined, ['unrelated/event']) + }).not.toThrow() + await fiber.dispose() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx new file mode 100644 index 0000000000..88e031cd06 --- /dev/null +++ b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx @@ -0,0 +1,232 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionSnapshot, UseProjection } from '@deepseek-ai/dsh-api-session-controller/client' +import type { ScheduleRecord } from '@deepseek-ai/dsh-schedule/client' +import { ScheduleId } from '@deepseek-ai/dsh-schedule' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { + formatScheduleFrequency, + formatScheduleLocalTime, + formatScheduleRelative, + orderScheduleRecords, + ScheduleCatalogAction, + type ScheduleCatalogActionProps, +} from '../src/client/ScheduleCatalogAction.tsx' +import { en, zh } from '../src/client/locales.ts' + +const SESSION = 'schedule-session' as SessionId +const START = Date.parse('2026-08-25T12:00:00.000Z') + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(START) +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +function record( + id: string, + kind: ScheduleRecord['kind'], + scheduledAt: number, + options: { prompt?: string; everySeconds?: number } = {}, +): ScheduleRecord { + const common = { + id: ScheduleId(id), + kind, + prompt: options.prompt ?? id, + scheduledAt: new Date(scheduledAt).toISOString(), + } + if (kind === 'after') return { ...common, kind, afterSeconds: 30 } + if (kind === 'every') return { ...common, kind, everySeconds: options.everySeconds ?? 300 } + return { ...common, kind } +} + +function sessionSnapshot(openState: SessionSnapshot['openState']): SessionSnapshot { + return { + sessionId: SESSION, + queue: [], + running: false, + subagent: null, + removed: false, + openState, + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: false, + lastAgentError: null, + promptAttempted: false, + awaitingFirstTurn: false, + } +} + +function props( + records: readonly ScheduleRecord[] | undefined, + openState: SessionSnapshot['openState'] = 'open', + dictionary: typeof zh | typeof en = en, +): ScheduleCatalogActionProps { + const snapshot = sessionSnapshot(openState) + const useSession = (select: (value: SessionSnapshot) => T): T => select(snapshot) + const useProjection = ((key: string, select?: (value: unknown) => unknown) => { + const value = key === 'schedule' ? records : undefined + return select === undefined ? value : select(value) + }) as UseProjection + return { + sessionId: SESSION, + useSession, + useProjection, + t: makeTranslate(dictionary), + } as unknown as ScheduleCatalogActionProps +} + +function prompts(): string[] { + return within(screen.getByRole('list', { name: en['list.aria'] })) + .getAllByRole('listitem') + .map(item => item.querySelector('[class*="prompt"]')?.textContent ?? '') +} + +describe('ScheduleCatalogAction visibility', () => { + it('renders only for a successfully opened Session with a non-empty projection', () => { + const active = [record('active', 'after', START + 60_000)] + const view = render() + expect(view.container.innerHTML).toBe('') + + view.rerender() + expect(view.container.innerHTML).toBe('') + for (const state of ['cold', 'loading', 'error'] as const) { + view.rerender() + expect(view.container.innerHTML).toBe('') + } + + view.rerender() + expect(screen.getByRole('button', { name: '1 reminder' })).toBeDefined() + }) + + it('closes and removes the trigger when the last live record disappears', () => { + const active = [record('active', 'after', START + 60_000)] + const view = render(<>) + const trigger = screen.getByRole('button', { name: '1 reminder' }) + fireEvent.click(trigger) + trigger.focus() + expect(screen.getByRole('list', { name: en['list.aria'] })).toBeDefined() + + view.rerender(<>) + expect(screen.queryByRole('button', { name: '1 reminder' })).toBeNull() + expect(document.activeElement).toBe(document.body) + expect(screen.getByRole('button', { name: 'Neighbor' })).not.toBe(document.activeElement) + }) +}) + +describe('ScheduleCatalogAction rows', () => { + it('shows only prompt and the three derived metadata fields, with overdue records first', () => { + const rawPrompt = ' Keep the complete long reminder prompt visible without truncation.' + const overdue = record('hidden-id', 'after', START - 60_000, { prompt: rawPrompt }) + const every = record('every-id', 'every', START + 300_000, { prompt: 'Check metrics', everySeconds: 300 }) + const at = record('at-id', 'at', START + 3_600_000, { prompt: 'Join meeting' }) + render() + fireEvent.click(screen.getByRole('button')) + + expect(prompts()).toEqual([rawPrompt, 'Check metrics', 'Join meeting']) + const rows = screen.getAllByRole('listitem') + expect(rows[0]?.getAttribute('data-overdue')).toBe('true') + expect(rows[1]?.getAttribute('data-overdue')).toBe('false') + expect(rows[0]?.querySelector('[data-schedule-status]')?.getAttribute('data-schedule-status')).toBe('overdue') + expect(rows[1]?.querySelector('[data-schedule-status]')?.getAttribute('data-schedule-status')).toBe('scheduled') + expect(rows[0]?.textContent).toContain('Overdue') + expect(rows[1]?.textContent).toContain('Scheduled') + expect(rows[0]?.textContent).toContain('Once') + expect(rows[0]?.textContent).toContain('1 minute overdue') + expect(rows[1]?.textContent).toContain('Every 5 minutes') + expect(rows[1]?.textContent).toContain('in 5 minutes') + expect(rows[2]?.textContent).toContain('Once') + expect(rows[2]?.textContent).toContain('in 1 hour') + expect(rows[2]?.textContent).toContain(formatScheduleLocalTime(at.scheduledAt)) + expect(document.querySelector('img')).toBeNull() + const text = screen.getByRole('list').textContent ?? '' + expect(text).not.toContain('hidden-id') + expect(text).not.toContain(overdue.scheduledAt) + expect(text).not.toMatch(/Delete|Retry|Details/) + expect(within(screen.getByRole('list')).queryAllByRole('button')).toHaveLength(0) + expect(rows.every(row => row.tabIndex === -1)).toBe(true) + }) + + it('renders exact recurring units without rounding and localizes both dictionaries', () => { + const tEn = makeTranslate(en) + const tZh = makeTranslate(zh) + const samples = [ + [86_400, 'Every 1 day', '1天一次'], + [172_800, 'Every 2 days', '2天一次'], + [3_600, 'Every 1 hour', '1小时一次'], + [7_200, 'Every 2 hours', '2小时一次'], + [300, 'Every 5 minutes', '5分钟一次'], + [301, 'Every 301 seconds', '301秒一次'], + ] as const + for (const [seconds, english, chinese] of samples) { + const item = record(String(seconds), 'every', START + 1_000, { everySeconds: seconds }) + expect(formatScheduleFrequency(item, tEn)).toBe(english) + expect(formatScheduleFrequency(item, tZh)).toBe(chinese) + } + expect(formatScheduleFrequency(record('once', 'at', START + 1_000), tZh)).toBe('单次') + expect(tZh('status.scheduled')).toBe('等待中') + expect(tZh('status.overdue')).toBe('已逾期') + }) + + it('derives relative seconds, minutes, hours, days, and the exact due boundary', () => { + const t = makeTranslate(en) + expect(formatScheduleRelative(new Date(START).toISOString(), START, t)).toBe('Due now') + expect(formatScheduleRelative(new Date(START + 500).toISOString(), START, t)).toBe('in 1 second') + expect(formatScheduleRelative(new Date(START + 61_000).toISOString(), START, t)).toBe('in 2 minutes') + expect(formatScheduleRelative(new Date(START - 3_600_000).toISOString(), START, t)).toBe('1 hour overdue') + expect(formatScheduleRelative(new Date(START - 172_800_000).toISOString(), START, t)).toBe('2 days overdue') + }) + + it('keeps equal targets stable and updates overdue status as the browser clock advances', () => { + const first = record('first', 'at', START + 500) + const second = record('second', 'at', START + 500) + expect(orderScheduleRecords([first, second], START).map(item => item.id)).toEqual(['first', 'second']) + expect(orderScheduleRecords([ + record('future', 'at', START + 1_000), + record('overdue', 'at', START - 1_000), + ], START).map(item => item.id)).toEqual(['overdue', 'future']) + + render() + fireEvent.click(screen.getByRole('button')) + expect(screen.getAllByRole('listitem').every(row => row.getAttribute('data-overdue') === 'false')).toBe(true) + act(() => { vi.advanceTimersByTime(1_000) }) + expect(screen.getAllByRole('listitem').every(row => row.getAttribute('data-overdue') === 'true')).toBe(true) + }) +}) + +describe('ScheduleCatalogAction dismissal', () => { + const active = [record('active', 'after', START + 60_000)] + + it('closes on Escape, restores trigger focus, and ignores unrelated or closed keys', () => { + render() + const trigger = screen.getByRole('button') + fireEvent.keyDown(trigger, { key: 'Escape' }) + fireEvent.click(trigger) + fireEvent.keyDown(trigger, { key: 'ArrowDown' }) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(trigger, { key: 'Escape' }) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(document.activeElement).toBe(trigger) + }) + + it('toggles from the trigger and dismisses only on an outside pointer press', () => { + render() + const trigger = screen.getByRole('button') + fireEvent.click(trigger) + fireEvent.pointerDown(screen.getByRole('list', { name: en['list.aria'] })) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + fireEvent.pointerDown(document.body) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(trigger) + fireEvent.click(trigger) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + }) +}) diff --git a/packages/client/ui-schedule/tsconfig.json b/packages/client/ui-schedule/tsconfig.json new file mode 100644 index 0000000000..4929733eb6 --- /dev/null +++ b/packages/client/ui-schedule/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../schedule/schedule" + }, + { + "path": "../locale" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/client/ui-schedule/tsdown.config.ts b/packages/client/ui-schedule/tsdown.config.ts new file mode 100644 index 0000000000..78b3175a0e --- /dev/null +++ b/packages/client/ui-schedule/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-schedule', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e038b24a0d..f7cf47756d 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1143,6 +1143,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [ 'client-ui-agent-preset AgentPresetLabel id \'agent-preset\'', 'client-ui-jobs JobListAction id \'job-list\'', + 'client-ui-schedule ScheduleCatalogAction id \'schedule-catalog\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 3d5d495cc0..39cacc6a2a 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1466,9 +1466,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'whole values per key with a usable row; empty when none.', }, { - signature: 'restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }', + signature: 'restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, initialization: ProjectionInitialization, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }', description: 'Cold read: fold every persisted unit over a stored log suffix, seeding each from its checkpoint row when usable — the one read recipe (cached state + forward tail replay + `view`) applied without a live `Session`. Call with the events returned by a persistence `readFrom(id, restoreFloor(checkpoint))` and that same floor as `baseSeq`; the floor\'s one-below anchor makes the supplied end honest, so a shrunk log is detected here. A row is usable iff its `ver` matches the live unit\'s `stateVersion`, it does not predate `baseSeq` (`seq >= baseSeq - 1`), and it does not claim events past the supplied end (`seq <= endSeq`); an unusable row is discarded and its key refolds from `init` — which is only sound over the full log, so a discarded row with `baseSeq > 0` throws (the caller re-reads from seq 0, e.g. after a crash-repair truncation shrank the log below a row\'s watermark).', - parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'events', description: 'the stored events with `seq >= baseSeq`, in seq order.' }, { name: 'baseSeq', description: 'the seq `events` starts at (its first event\'s seq when non-empty).' }], + parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'events', description: 'the stored events with `seq >= baseSeq`, in seq order.' }, { name: 'baseSeq', description: 'the seq `events` starts at (its first event\'s seq when non-empty).' }, { name: 'initialization', description: 'normalized facts from the stored header returned by the same read.' }], returns: 'the snapshot cut at the supplied log end (`asOfSeq` is the last supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the refreshed checkpoint rows at that cut, ready for a durable write-back.', }, ], @@ -4183,7 +4183,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProjectionDefinition', - declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}', + declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(initialization: ProjectionInitialization): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}', + }, + { + name: 'ProjectionInitialization', + declaration: 'export interface ProjectionInitialization {\n readonly seedLength: number;\n}', }, { name: 'ProjectionSnapshot', diff --git a/packages/schedule/README.i18n.yaml b/packages/schedule/README.i18n.yaml index 90aa318ed0..e43ce6a28f 100644 --- a/packages/schedule/README.i18n.yaml +++ b/packages/schedule/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/schedule/README.md -README.md: 2fac190cc19f40e5d5e87acacaddce970e4b8474 -README.zh.md: af4cd96dcb5f095a7ea83556e5f73eca532d0dee +README.md: 67d54465a5a32c7264f1624e697b068b0a421d8a +README.zh.md: cc662272a3ad488e19b1bcfd6657ced09b3975c0 diff --git a/packages/schedule/README.md b/packages/schedule/README.md index 2fac190cc1..67d54465a5 100644 --- a/packages/schedule/README.md +++ b/packages/schedule/README.md @@ -2,12 +2,12 @@ English | [中文](README.zh.md) -The Schedule family owns reminders whose durable state lives in the original Session log. A process-local owner waits only while that Session has a live root Agent; cold Sessions resume overdue work when they become live again and never imply an external notification channel. +The Schedule family owns reminders whose durable state lives in the original Session log. A process-local owner waits only while that Session has a live root Agent; cold Sessions resume overdue work when they become live again and never imply an external notification channel. An optional Session projection publishes the complete active-record set for read-only clients without changing that delivery boundary. | Package | Role | ctx key | |---|---|---| -| `schedule/` | Versioned Schedule events and fold, model-facing create/list/delete tools, and a live root-Agent timer owner | — | +| `schedule/` | Versioned Schedule events and fold, the active-record Session projection, model-facing create/list/delete tools, and a live root-Agent timer owner | — | -The package deliberately exposes no public Schedule service or mutable database. Tools and runtime append to the Session stream; due work enters the same conversation through the Agent's ordinary follow-up queue. +The package deliberately exposes no public Schedule service or mutable database. Tools and runtime append to the Session stream; due work enters the same conversation through the Agent's ordinary follow-up queue. The browser presentation is owned separately by [`dsh-client-ui-schedule`](../client/ui-schedule/README.md), whose catalog is current state rather than a delivery receipt. See [Session-local Schedule](../../docs/subsystems/schedule.md) for the durable record, transition, view, and delivery contracts. diff --git a/packages/schedule/README.zh.md b/packages/schedule/README.zh.md index af4cd96dcb..cc662272a3 100644 --- a/packages/schedule/README.zh.md +++ b/packages/schedule/README.zh.md @@ -2,12 +2,12 @@ [English](README.md) | 中文 -Schedule 家族负责管理提醒,其持久状态保存在原 Session 日志中。进程内 owner 只会在该 Session 拥有 live 根 Agent 时等待;cold Session 再次 live 后会恢复逾期工作,但这不意味着存在外部通知渠道。 +Schedule 家族负责管理提醒,其持久状态保存在原 Session 日志中。进程内 owner 只会在该 Session 拥有 live 根 Agent 时等待;cold Session 再次 live 后会恢复逾期工作,但这不意味着存在外部通知渠道。可选的 Session projection 会向只读客户端发布完整活动记录集合,而不改变该交付边界。 | 包 | 职责 | ctx 键 | |---|---|---| -| `schedule/` | 版本化 Schedule 事件与 fold、面向模型的创建/列出/删除工具,以及 live 根 Agent timer owner | 无 | +| `schedule/` | 版本化 Schedule 事件与 fold、活动记录 Session projection、面向模型的创建/列出/删除工具,以及 live 根 Agent timer owner | 无 | -本包有意不公开 Schedule service 或可变数据库。工具与 runtime 向 Session stream 追加事件;到期工作通过 Agent 的普通 follow-up 队列进入同一对话。 +本包有意不公开 Schedule service 或可变数据库。工具与 runtime 向 Session stream 追加事件;到期工作通过 Agent 的普通 follow-up 队列进入同一对话。浏览器呈现由 [`dsh-client-ui-schedule`](../client/ui-schedule/README.zh.md) 单独拥有;其目录表示当前状态,而非交付回执。 有关持久记录、转换、视图与交付约定,请参阅[仅限 Session 内的 Schedule](../../docs/subsystems/schedule.zh.md)。 diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index 8dc406becf..2a97d193d9 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/schedule/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/schedule/schedule/README.md -README.md: f56c689198b7e17a85f43a1ecb6e5f1527da6a91 -README.zh.md: 7180565370d83a7207c73aa9a2539219974dace0 +README.md: ed5917ec3d6cbf617fe18bcc6898dec6cc417a32 +README.zh.md: bda4e4d4861cebcc7c836a2bcf6e760c92789c88 diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index f56c689198..ed5917ec3d 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -10,13 +10,21 @@ Load this function plugin after `ctx.sessions`, `ctx.agents`, `ctx.tools`, `ctx. Time-context is not a Schedule dependency. A composition may mount `@deepseek-ai/dsh-time-context` so the model can interpret natural language in the browser's request-local zone, as the official Schedule Web overlay does. The model must still pass an explicit offset or `time_zone` to `schedule_create`; Schedule never imports or infers from model context. +Session projection is optional. When `ctx.sessionProjections` exists, the plugin registers the strict `schedule` unit and exposes the complete active `ScheduleRecord[]`; a headless composition without the registry keeps the same tools and runtime. The browser-safe record vocabulary is available from the type-only `@deepseek-ai/dsh-schedule/client` export. The shipped Web bundle resolves the `ui-schedule` client package through a disabled row, and the explicit Schedule overlay enables that row alongside the Host Schedule services. + Every operation that reads or decides from the Schedule fold first awaits `ctx.sessions.flush(session)`. A missing, rejected, or detached persistence path returns `persistence_uncertain`; it never turns an unconfirmed live suffix into a list or not-found answer. A successful create or actual delete also awaits a post-append barrier before confirming the mutation. ## Durable state The package owns the strict version-1 `schedule/change` create, delete, and dispatch union. Every create record contains a stable Session-local `ScheduleId`, the trimmed prompt, and a four-digit-year RFC 3339 UTC `scheduledAt`. An `after` record also stores `afterSeconds`; an `at` record stores no copy of its submitted offset, local calendar fields, or interpreting zone; an `every` record stores `everySeconds` and treats `scheduledAt` as the earliest creation-anchor-aligned occurrence not yet dispatched. Delete and one-shot dispatch carry only the id. Every dispatch adds `acceptedAt`, from which replay advances directly to the first anchor-aligned target after that decision time. -Replay rejects unknown versions, extra fields, reused ids, mismatched one-shot or Every dispatch shapes, and delete or dispatch transitions against inactive records. Normal Sessions fold the complete log. A fork folds only `session.events.slice(session.header.seedLength ?? 0)`, so it does not inherit its parent's reminders. The package's `./invariant` companion applies the same policy to existing logs and candidate events. +Replay rejects unknown versions, extra fields, reused ids, mismatched one-shot or Every dispatch shapes, and delete or dispatch transitions against inactive records. Normal Sessions fold the complete log. A fork folds only `session.events.slice(session.header.seedLength ?? 0)`, so it does not inherit its parent's reminders. The Schedule projection receives the same normalized `seedLength` when its state is initialized and ignores the inherited prefix while using the same strict transition function. The package's `./invariant` companion applies the same policy to existing logs and candidate events. + +## Client projection + +The optional `schedule` Session projection persists `{ seedLength, active, seenIds }` as strict plain JSON and publishes only the complete `active` array. Its checkpoint schema reuses the durable Schedule decoder, rejects duplicate or inconsistent ids, and fails the existing Session open or read path on corrupt durable events instead of publishing a partial catalog. Live lazy build, event-driven build, cached restore, history reads, and detached Subagent reads all receive `seedLength` from the same Session header that supplied their events. + +The projection carries durable records only. It does not persist or transmit `scheduled` versus `overdue`, localized text, relative time, browser-local time, sorting state, open state, or delivery receipts. [`dsh-client-ui-schedule`](../../client/ui-schedule/README.md) derives those presentation values from the full array and the viewing browser's clock. ## Absolute-time input @@ -40,7 +48,7 @@ The live owner derives the earliest target from the durable fold. It splits wait An overdue reminder first checkpoints persistence. If a turn or another maintenance task owns the Agent, `runMaintenance()` rejects the idle-phase claim; the record stays active and the owner retries after `whenIdle()`. A successful maintenance task refolds, samples one decision time, builds the appropriate fixed framing, synchronously queues `followup()`, and appends dispatch before releasing the phase. A one-shot appends its id. Each Every record in a batch appends its id plus the same `acceptedAt`; integer arithmetic selects that record's latest due creation-anchor-aligned occurrence and advances it directly to the first future target. Missed intervals are never enumerated or replayed, distinct overdue records each contribute one occurrence, and there is no shared recurrence gate. Waking input remains parked until release, after which the owner checkpoints dispatch. -The follow-up opens a normal later turn after the Agent becomes fully idle; it never steers or interrupts the current conversation. Its assistant output appears through the ordinary transcript, with no independent receipt or Schedule-specific browser UI. Dispatch means the follow-up was queued and recorded, not that the model succeeded or the user read the answer. +The follow-up opens a normal later turn after the Agent becomes fully idle; it never steers or interrupts the current conversation. Its assistant output appears through the ordinary transcript, with no independent receipt. The optional Web catalog shows only currently active records and never represents dispatch success. Dispatch means the follow-up was queued and recorded, not that the model succeeded or the user read the answer. Framing or synchronous follow-up failure writes no dispatch. An append failure faults that owner because the message may already be queued; a barrier rejection leaves dispatch pending for a later ordinary preflight. Agent or plugin disposal cancels timers, stops new work, and awaits in-flight preflights and idle waits without deleting durable records. @@ -115,3 +123,4 @@ The batch appends after existing history and preserves its reusable prefix. Its - **Latest-only catch-up** — an overdue Every record contributes only its latest due occurrence, so Schedule never replays a missed backlog. - **Narrow crash duplicate window** — a crash after synchronous follow-up admission but before the dispatch checkpoint can repeat the reminder; the package does not claim model completion, user acknowledgement, or exactly-once effects. - **Load-order boundary** — the plugin does not scan or adopt Agents that were already live when it loaded. +- **Catalog is read-only current state** — the optional Web surface has no history, mutation, retry, or acknowledgement semantics; terminal records disappear and delivery remains ordinary conversation output. diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index 7180565370..bda4e4d486 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -10,13 +10,21 @@ Time-context 不是 Schedule 的依赖。组合可以挂载 `@deepseek-ai/dsh-time-context`,使模型能够按浏览器的请求本地时区解释自然语言;官方 Schedule Web overlay 正是如此。模型仍必须向 `schedule_create` 传入显式偏移量或 `time_zone`;Schedule 绝不会从模型上下文中导入或推断该值。 +Session projection 是可选能力。`ctx.sessionProjections` 存在时,插件会注册严格的 `schedule` 单元并公开完整的活动 `ScheduleRecord[]`;不带注册表的 headless 组合仍保留相同工具与 runtime。浏览器安全的记录词汇由纯类型出口 `@deepseek-ai/dsh-schedule/client` 提供。shipped Web bundle 通过默认 disabled 的 row 解析 `ui-schedule` client 包,显式 Schedule overlay 再与 Host Schedule 服务一起启用该 row。 + 每项从 Schedule 折叠结果读取或作出判断的操作,都会先等待 `ctx.sessions.flush(session)`。持久化路径缺失、拒绝或已分离时,操作返回 `persistence_uncertain`;它绝不会把未经确认的 live 后缀当成列表或未找到结果。成功创建或实际删除后,还会等待追加后的持久化 barrier(屏障)再确认变更。 ## 持久状态 此包拥有严格的版本 1 `schedule/change` create、delete 与 dispatch 联合。每条 create 记录都包含稳定的会话本地 `ScheduleId`、已 trim 的提示词,以及使用四位年份的 RFC 3339 UTC `scheduledAt`。`after` 记录还会存储 `afterSeconds`;`at` 记录不会保留所提交的偏移量、本地日历字段或解释该值时所用的时区;`every` 记录存储 `everySeconds`,并把 `scheduledAt` 视为尚未 dispatch 的最早一个创建锚点对齐发生时点。delete 与一次性 dispatch 只携带 id。Every dispatch 还会添加 `acceptedAt`;回放会据此直接推进到该决策时点之后的第一个锚点对齐目标。 -回放会拒绝未知版本、额外字段、重复使用的 id、形状不匹配的一次性或 Every dispatch,以及针对非活动记录的 delete 或 dispatch 转换。普通会话折叠完整日志。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此不会继承父会话的提醒。此包的 `./invariant` 配套模块会对现有日志和候选事件应用相同策略。 +回放会拒绝未知版本、额外字段、重复使用的 id、形状不匹配的一次性或 Every dispatch,以及针对非活动记录的 delete 或 dispatch 转换。普通会话折叠完整日志。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此不会继承父会话的提醒。Schedule projection 初始化时接收同一个已规范化的 `seedLength`,忽略继承前缀,并复用同一严格 transition 函数。此包的 `./invariant` 配套模块会对现有日志和候选事件应用相同策略。 + +## 客户端 projection + +可选的 `schedule` Session projection 以严格纯 JSON 持久化 `{ seedLength, active, seenIds }`,只发布完整的 `active` 数组。其 checkpoint schema 复用持久 Schedule decoder,拒绝重复或内部不一致的 id;损坏的持久事件会使既有 Session 打开或读取路径失败,而不会发布部分目录。live 惰性构建、事件驱动构建、缓存恢复、history 读取与 detached Subagent 读取,都从提供对应事件的同一个 Session header 接收 `seedLength`。 + +projection 只携带持久记录,不持久化或传输 `scheduled`/`overdue`、本地化文案、相对时间、浏览器本地时间、排序状态、开合状态或交付回执。[`dsh-client-ui-schedule`](../../client/ui-schedule/README.zh.md) 从完整数组与查看方浏览器时钟派生这些呈现值。 ## 绝对时间输入 @@ -40,7 +48,7 @@ live owner 从持久折叠结果派生最早的目标。它会拆分超过 Node overdue 提醒首先为持久化建立检查点。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝对 idle phase 的认领;记录会保持活动,owner 会在 `whenIdle()` 后重试。获准执行的 maintenance task 会重新折叠、采样一个决策时点、构造相应的固定 framing、同步将 `followup()` 入队,并在释放 phase 前追加 dispatch。一次性提醒只追加 id。批次中的每条 Every 记录都会追加其 id 和相同的 `acceptedAt`;整数运算会选择该记录最新一个已到期且与创建锚点对齐的发生时点,并将记录直接推进到第一个未来目标。系统绝不会枚举或回放错过的间隔;每条不同的逾期记录各贡献一个发生时点,并且不存在共享的周期性准入门控。触发唤醒的 input 会保持 parked,直到 phase 释放;随后 owner 为 dispatch 建立检查点。 -Agent 完全 idle 后,follow-up 会开启一个普通的后续轮次;它绝不会中途引导或中断当前对话。assistant 输出通过普通 transcript(文本记录)显示,不存在独立回执或 Schedule 专属浏览器 UI。dispatch 表示 follow-up 已入队并被记录,不表示模型成功或用户已读取回答。 +Agent 完全 idle 后,follow-up 会开启一个普通的后续轮次;它绝不会中途引导或中断当前对话。assistant 输出通过普通 transcript(文本记录)显示,不存在独立回执。可选 Web 目录只显示当前活动记录,绝不表示 dispatch 成功。dispatch 表示 follow-up 已入队并被记录,不表示模型成功或用户已读取回答。 framing 构造或同步 follow-up 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态,因为消息可能已经入队;barrier 拒绝会把 dispatch 留给后续普通 preflight。agent 或插件执行资源释放时,会取消 timer、停止新工作,并等待进行中的 preflight 与 idle wait,且不会删除持久记录。 @@ -115,3 +123,4 @@ reminders_json: - **只追赶最新一次**:逾期 Every 记录只贡献其最新一个到期发生时点,因此 Schedule 绝不会回放因错过间隔而形成的积压。 - **存在狭窄的崩溃重复窗口**:同步 follow-up 获得准入后、dispatch 检查点完成前发生崩溃,可能使提醒重复;此包不承诺模型完成、用户确认或副作用恰好执行一次。 - **加载顺序边界**:插件不会扫描或接管加载时已经 live 的 Agent。 +- **目录只是只读当前状态**:可选 Web 界面没有历史、mutation、Retry 或 acknowledgement 语义;终结记录会消失,交付仍然是普通对话输出。 diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index 9cf315d544..e5a6fce780 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -22,12 +22,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./client": { + "types": "./lib/types/client.d.ts", + "default": "./lib/types/client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "MIT", @@ -38,9 +43,13 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "dependencies": { + "zod": "^4.4.3" + }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", @@ -52,6 +61,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/schedule/schedule/src/client.ts b/packages/schedule/schedule/src/client.ts new file mode 100644 index 0000000000..798a3cd14c --- /dev/null +++ b/packages/schedule/schedule/src/client.ts @@ -0,0 +1,2 @@ +/** Browser-safe Schedule vocabulary. @module @deepseek-ai/dsh-schedule/client */ +export type * from './types.ts' diff --git a/packages/schedule/schedule/src/domain.ts b/packages/schedule/schedule/src/domain.ts index 92e0db624b..b8e3c92e06 100644 --- a/packages/schedule/schedule/src/domain.ts +++ b/packages/schedule/schedule/src/domain.ts @@ -566,6 +566,57 @@ function dispatchedRecord(record: ScheduleRecord, change: DecodedDispatch): Sche : Object.freeze({ ...record, scheduledAt: occurrence.nextScheduledAt }) } +/** + * Apply one already-decoded Schedule change to a complete fold value. + * + * This is the single transition authority shared by full-log replay and the + * incremental Session projection. Inputs are never mutated; unchanged event + * filtering remains the caller's responsibility. + * @param folded - complete active records and used-id history before the change. + * @param change - one strictly decoded durable mutation. + * @returns the complete fold value after the mutation. + */ +export function applyScheduleChange( + folded: FoldedSchedules, + change: ScheduleChange, +): FoldedSchedules { + const active = new Map(folded.active.map(record => [record.id, record])) + const seen = new Set(folded.seenIds) + switch (change.operation) { + case 'create': + if (seen.has(change.schedule.id)) { + throw new ScheduleLogError(`schedule id ${JSON.stringify(change.schedule.id)} was reused`) + } + seen.add(change.schedule.id) + active.set(change.schedule.id, change.schedule) + break + case 'delete': + if (!active.delete(change.id)) { + throw new ScheduleLogError(`schedule delete targets inactive id ${JSON.stringify(change.id)}`) + } + break + case 'dispatch': { + const record = active.get(change.id) + if (record === undefined) { + throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(change.id)}`) + } + const next = dispatchedRecord(record, change) + if (next === undefined) active.delete(change.id) + else active.set(change.id, next) + break + } + /* v8 ignore next 3 -- decodeScheduleChange returns a closed operation union. */ + default: { + const unreachable: never = change + throw new ScheduleLogError(`unknown decoded schedule change ${String(unreachable)}`) + } + } + return Object.freeze({ + active: Object.freeze([...active.values()]), + seenIds: Object.freeze([...seen]), + }) +} + /** * Fold the package-owned stream after the durable fork seed boundary. * @param events - Complete ordered session log or candidate-extended log. @@ -579,45 +630,15 @@ export function foldScheduleEvents( if (!Number.isSafeInteger(seedLength) || seedLength < 0 || seedLength > events.length) { throw new ScheduleLogError('schedule seedLength must be within the supplied event log') } - const active = new Map() - const seen = new Set() + let folded: FoldedSchedules = Object.freeze({ + active: Object.freeze([]), + seenIds: Object.freeze([]), + }) for (const event of events.slice(seedLength)) { if (event.type !== 'schedule/change') continue - const change = decodeScheduleChange(event.data) - switch (change.operation) { - case 'create': - if (seen.has(change.schedule.id)) { - throw new ScheduleLogError(`schedule id ${JSON.stringify(change.schedule.id)} was reused`) - } - seen.add(change.schedule.id) - active.set(change.schedule.id, change.schedule) - break - case 'delete': - if (!active.delete(change.id)) { - throw new ScheduleLogError(`schedule delete targets inactive id ${JSON.stringify(change.id)}`) - } - break - case 'dispatch': { - const record = active.get(change.id) - if (record === undefined) { - throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(change.id)}`) - } - const next = dispatchedRecord(record, change) - if (next === undefined) active.delete(change.id) - else active.set(change.id, next) - break - } - /* v8 ignore next 3 -- decodeScheduleChange returns a closed operation union. */ - default: { - const unreachable: never = change - throw new ScheduleLogError(`unknown decoded schedule change ${String(unreachable)}`) - } - } + folded = applyScheduleChange(folded, decodeScheduleChange(event.data)) } - return Object.freeze({ - active: Object.freeze([...active.values()]), - seenIds: Object.freeze([...seen]), - }) + return folded } /** diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index 8a619e2e73..6dfbaeb4f6 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -6,6 +6,9 @@ import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-session-persistence' +// Type-only: resolves ctx.sessionProjections for the optional projection child. +import type {} from '@deepseek-ai/dsh-session-projection' +import { scheduleProjectionDefinition } from './projection.ts' import { ScheduleRuntime } from './runtime.ts' import { registerScheduleTools } from './tools.ts' @@ -38,6 +41,10 @@ type OwnerCleanup = () => void | Promise /** Install Schedule only for root agents published after this plugin loads. */ export function apply(ctx: Context): void { + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register(scheduleProjectionDefinition) + }) + const runtimes = new Map() let stopping = false diff --git a/packages/schedule/schedule/src/projection.ts b/packages/schedule/schedule/src/projection.ts new file mode 100644 index 0000000000..d7c77190fa --- /dev/null +++ b/packages/schedule/schedule/src/projection.ts @@ -0,0 +1,89 @@ +/** + * Strict Session projection of the Schedule domain's active reminder set. + * @module @deepseek-ai/dsh-schedule/projection + */ + +import { z } from 'zod' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import { applyScheduleChange, decodeScheduleChange } from './domain.ts' +import type { FoldedSchedules } from './domain.ts' +import type { ScheduleChange, ScheduleId, ScheduleRecord } from './types.ts' + +/** Persisted projection state: the immutable fork boundary plus the complete Schedule fold. */ +export interface ScheduleProjectionState extends FoldedSchedules { + readonly seedLength: number +} + +const scheduleId = z.unknown().transform((value, context): ScheduleId => { + try { + const change = decodeScheduleChange({ version: 1, operation: 'delete', id: value }) as Extract< + ScheduleChange, + { operation: 'delete' } + > + return change.id + } catch { + context.addIssue({ code: 'custom', message: 'invalid Schedule id' }) + return z.NEVER + } +}) + +const scheduleRecord = z.unknown().transform((value, context): ScheduleRecord => { + try { + const change = decodeScheduleChange({ version: 1, operation: 'create', schedule: value }) as Extract< + ScheduleChange, + { operation: 'create' } + > + return change.schedule + } catch { + context.addIssue({ code: 'custom', message: 'invalid Schedule record' }) + return z.NEVER + } +}) + +const scheduleRecords = z.array(scheduleRecord) as unknown as z.ZodType + +const scheduleProjectionStateSchema = z.object({ + seedLength: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + active: scheduleRecords, + seenIds: z.array(scheduleId), +}).strict().superRefine((state, context) => { + const seen = new Set(state.seenIds) + if (seen.size !== state.seenIds.length) { + context.addIssue({ code: 'custom', message: 'seen Schedule ids must be unique' }) + } + const active = new Set() + for (const record of state.active) { + if (!seen.has(record.id)) { + context.addIssue({ code: 'custom', message: 'every active Schedule id must have been seen' }) + } + if (active.has(record.id)) { + context.addIssue({ code: 'custom', message: 'active Schedule ids must be unique' }) + } + active.add(record.id) + } +}) as unknown as z.ZodType + +/** Projection definition sharing the Schedule domain's strict transition authority. */ +export const scheduleProjectionDefinition = { + key: 'schedule', + stateSchema: scheduleProjectionStateSchema, + init: ({ seedLength }) => ({ seedLength, active: [], seenIds: [] }), + apply: (state, event) => { + if (event.seq < state.seedLength || event.type !== 'schedule/change') return state + return { + seedLength: state.seedLength, + ...applyScheduleChange(state, decodeScheduleChange(event.data)), + } + }, + wire: { + viewSchema: scheduleRecords, + view: state => state.active, + }, + stateVersion: 1, +} satisfies ProjectionDefinition<'schedule', ScheduleProjectionState> + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + schedule: ScheduleProjectionState + } +} diff --git a/packages/schedule/schedule/src/types.ts b/packages/schedule/schedule/src/types.ts index 24240172cc..cbe3972d32 100644 --- a/packages/schedule/schedule/src/types.ts +++ b/packages/schedule/schedule/src/types.ts @@ -219,3 +219,10 @@ declare module '@deepseek-ai/dsh-session/types' { 'schedule/change': ScheduleChange } } + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** Complete active reminders owned by this Session's post-fork suffix. */ + schedule: readonly ScheduleRecord[] + } +} diff --git a/packages/schedule/schedule/tests/projection.spec.ts b/packages/schedule/schedule/tests/projection.spec.ts new file mode 100644 index 0000000000..253260a74a --- /dev/null +++ b/packages/schedule/schedule/tests/projection.spec.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import SessionStore from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { apply as applySchedule } from '../src/index.ts' +import { foldScheduleEvents, ScheduleId, ScheduleLogError } from '../src/domain.ts' +import { scheduleProjectionDefinition, type ScheduleProjectionState } from '../src/projection.ts' +import type { ScheduleRecord } from '../src/types.ts' + +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +function afterRecord(id: string, prompt = id): ScheduleRecord { + return { + id: ScheduleId(id), + kind: 'after', + prompt, + afterSeconds: 30, + scheduledAt: '2026-08-25T12:00:00.000Z', + } +} + +function atRecord(id: string): ScheduleRecord { + return { + id: ScheduleId(id), + kind: 'at', + prompt: id, + scheduledAt: '2026-08-25T13:00:00.000Z', + } +} + +function everyRecord(id: string): ScheduleRecord { + return { + id: ScheduleId(id), + kind: 'every', + prompt: id, + everySeconds: 300, + scheduledAt: '2026-08-25T14:00:00.000Z', + } +} + +function change(data: unknown, seq: number): SessionEvent { + return { type: 'schedule/change', seq, time: seq, data } as SessionEvent +} + +function created(record: ScheduleRecord, seq: number): SessionEvent { + return change({ version: 1, operation: 'create', schedule: record }, seq) +} + +describe('Schedule Session projection', () => { + it('shares strict transitions with full replay and excludes the inherited fork prefix', () => { + const events: SessionEvent[] = [ + created(afterRecord('parent'), 0), + created(atRecord('child-at'), 1), + created(everyRecord('child-every'), 2), + change({ + version: 1, + operation: 'dispatch', + id: 'child-every', + acceptedAt: '2026-08-25T14:02:00.000Z', + }, 3), + { type: 'turn/start', seq: 4, time: 4, data: { turn: 1 } }, + ] + let projected: ScheduleProjectionState = scheduleProjectionDefinition.init({ seedLength: 1 }) + for (const event of events) projected = scheduleProjectionDefinition.apply(projected, event) + + expect(projected).toEqual({ seedLength: 1, ...foldScheduleEvents(events, 1) }) + expect(scheduleProjectionDefinition.wire.view(projected)).toEqual(projected.active) + expect(projected.active.map(record => record.id)).toEqual(['child-at', 'child-every']) + }) + + it('restores checkpoints, folds a bounded tail, and fails loud on damaged durable data', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(scheduleProjectionDefinition) + + const first = created(afterRecord('one'), 0) + const second = created(atRecord('two'), 1) + const initial = ctx.sessionProjections.restore({}, [first, second], 0, { seedLength: 0 }) + expect(initial.snapshot.values.schedule?.map(record => record.id)).toEqual(['one', 'two']) + + const removed = change({ version: 1, operation: 'delete', id: 'one' }, 2) + const resumed = ctx.sessionProjections.restore( + initial.checkpoint, + [second, removed], + 1, + { seedLength: 0 }, + ) + expect(resumed.snapshot.values.schedule?.map(record => record.id)).toEqual(['two']) + expect(resumed.checkpoint.schedule).toMatchObject({ ver: 1, seq: 2 }) + + expect(() => ctx.sessionProjections.restore( + {}, + [change({ version: 1, operation: 'delete', id: 'missing' }, 0)], + 0, + { seedLength: 0 }, + )).toThrow(ScheduleLogError) + }) + + it('rejects malformed or internally inconsistent checkpoint states', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(scheduleProjectionDefinition) + const row = (val: unknown) => ({ schedule: { ver: 1, seq: 0, val } }) + + expect(ctx.sessionProjections.viewCheckpoint(row({ + seedLength: 0, + active: [{ ...afterRecord('bad-time'), scheduledAt: 'not-an-instant' }], + seenIds: ['bad-time'], + }))).toEqual({}) + expect(ctx.sessionProjections.viewCheckpoint(row({ + seedLength: 0, + active: [afterRecord('missing')], + seenIds: [], + }))).toEqual({}) + expect(ctx.sessionProjections.viewCheckpoint(row({ + seedLength: 0, + active: [afterRecord('duplicate'), afterRecord('duplicate')], + seenIds: ['duplicate', 'duplicate'], + }))).toEqual({}) + expect(ctx.sessionProjections.viewCheckpoint(row({ + seedLength: 0, + active: [], + seenIds: [' bad-id'], + }))).toEqual({}) + }) + + it('registers only while the Schedule plugin fiber is live', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const fiber = ctx.plugin({ apply: applySchedule }) + await fiber.await() + + const session = ctx.sessions.create() + session.append('schedule/change', { + version: 1, + operation: 'create', + schedule: afterRecord('live'), + }) + expect(ctx.sessionProjections.snapshot(session).values.schedule).toHaveLength(1) + + await fiber.dispose() + expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) + }) +}) diff --git a/packages/schedule/schedule/tsconfig.json b/packages/schedule/schedule/tsconfig.json index 3b7e107408..8e32dec9db 100644 --- a/packages/schedule/schedule/tsconfig.json +++ b/packages/schedule/schedule/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../../session/session-persistence-jsonl" }, + { + "path": "../../session/session-projection" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/session/session-projection-cache/src/index.ts b/packages/session/session-projection-cache/src/index.ts index f7bdb62a3a..ee05ff64ad 100644 --- a/packages/session/session-projection-cache/src/index.ts +++ b/packages/session/session-projection-cache/src/index.ts @@ -183,13 +183,17 @@ export class SessionProjectionCache extends Service { const related = record === undefined || identityMatches(record.identity, identityOf(tail.meta)) try { if (!related) throw new Error('unrelated log identity') - restored = this.ctx.sessionProjections.restore(cached, tail.events, floor) + restored = this.ctx.sessionProjections.restore(cached, tail.events, floor, { + seedLength: tail.meta.seedLength ?? 0, + }) } catch { // Recoverable failures are an unrelated record, a row outside the // supplied suffix or log end, and stateSchema rejection. The full read // removes every checkpoint seed and lets each unit refold from init. const whole = await persistence.readFrom(id, 0, signal) - restored = this.ctx.sessionProjections.restore({}, whole.events, 0) + restored = this.ctx.sessionProjections.restore({}, whole.events, 0, { + seedLength: whole.meta.seedLength ?? 0, + }) } await this.putSoft(id, identityOf(tail.meta), restored.checkpoint, 'cold-read write-back') return restored.snapshot diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index 89154ec108..f3f28fdf0a 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -52,12 +52,17 @@ const marksUnit = (stateVersion = 1) => ({ }) satisfies ProjectionDefinition<'cache-test/marks', MarksState> /** A persistence double serving readFrom over a fixed per-id stored log (headers stamp createdAt 0). */ -function fakePersistence(logs: Map) { +function fakePersistence(logs: Map, seedLength?: number) { const readFrom = vi.fn(async (id: SessionId, fromSeq: number) => { const events = logs.get(String(id)) if (events === undefined) throw new Error(`session "${id}" not found`) return { - meta: { version: 0, id, createdAt: 0 }, + meta: { + version: 0, + id, + createdAt: 0, + ...seedLength === undefined ? {} : { seedLength }, + }, events: events.filter(event => event.seq >= fromSeq), } }) @@ -73,6 +78,7 @@ interface HarnessOptions { config?: { writeEveryEvents: number; writeIntervalMs: number } stateVersion?: number logs?: Map + seedLength?: number } const contexts: Context[] = [] @@ -90,7 +96,7 @@ async function harness(options: HarnessOptions = {}) { await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) ctx.sessionProjections.register(marksUnit(options.stateVersion)) - const persistence = fakePersistence(logs) + const persistence = fakePersistence(logs, options.seedLength) ctx.provide('sessionPersistence', persistence as never) const fiber = await ctx.plugin(SessionProjectionCache, options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 }) return { ctx, pool, logs, fiber, persistence, cache: ctx.sessionProjectionCache } @@ -305,6 +311,19 @@ describe('SessionProjectionCache cold read', () => { expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('malformed'), 0, undefined) }) + it('passes the stored seed boundary through bounded and fallback full restores', async () => { + const pool = new MemoryMediaPool() + const logs = new Map([['seeded', storedLog([['real']])]]) + seedRow(pool, 'seeded', { ver: 1, seq: 1, val: { marks: 'not-an-array' } }) + const { ctx, cache } = await harness({ pool, logs, seedLength: 2 }) + const restore = vi.spyOn(ctx.sessionProjections, 'restore') + + await cache.coldSnapshot(SessionId('seeded')) + + expect(restore).toHaveBeenNthCalledWith(1, expect.any(Object), expect.any(Array), 1, { seedLength: 2 }) + expect(restore).toHaveBeenNthCalledWith(2, {}, expect.any(Array), 0, { seedLength: 2 }) + }) + it('write-back failure is contained: the snapshot is still served', async () => { const pool = new MemoryMediaPool() const logs = new Map([['soft', storedLog([['a']])]]) diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 7ce339474e..54ad5af88e 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-projection/README.md -README.md: 3b7ccecb7040b5340cd24da45d99bbfdc13fa15c -README.zh.md: bdf691761a6debf8f904924ab7198a5aed093b02 +README.md: 9a4687c1ac612a81966536e8590625e11889183f +README.zh.md: b79ea28a29e43bc474f372c4392636d24c0c9568 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 3b7ccecb70..9a4687c1ac 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -17,13 +17,15 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr - `SessionProjectionMap` — the merge-extensible client-view table shared by wire blocks and client hooks. Values are wire-JSON whole values; rendering belongs to the slot system, never this layer. - `SessionProjectionStateMap` — the merge-extensible host fold-state table. Every client-visible key appears in both tables; host-only keys appear only here. -- `ProjectionDefinition` — `{ key, stateSchema, init(), apply(state, event), wire?, stateVersion }`: a synchronous state-driven computation unit. `wire` supplies `viewSchema` and `view`; omitting it makes the unit host-only. +- `ProjectionInitialization` — immutable Session facts supplied at every fresh fold boundary; it currently contains normalized `seedLength` so a domain can exclude an inherited fork prefix without reading ambient Session state. +- `ProjectionDefinition` — `{ key, stateSchema, init(initialization), apply(state, event), wire?, stateVersion }`: a synchronous state-driven computation unit. `wire` supplies `viewSchema` and `view`; omitting it makes the unit host-only. ## Contract -- **The framework drives, the domain computes.** The registry subscribes to `session/event` once; every committed event passes every unit's `apply` eagerly. Domains hold no subscriptions. Cells (`{state, observedSeq}` per unit per session, WeakMap-keyed) build lazily — a unit registered after events flowed, or a read of a session predating the registration, folds `init` over the in-memory log on first touch. +- **The framework drives, the domain computes.** The registry subscribes to `session/event` once; every committed event passes every unit's `apply` eagerly. Domains hold no subscriptions. Cells (`{state, observedSeq}` per unit per session, WeakMap-keyed) build lazily — a unit registered after events flowed, or a read of a session predating the registration, calls `init({ seedLength })` and folds the in-memory log on first touch. - **Same-reference means no work.** `apply` MUST return the same state reference for events that do not concern the unit; the drive gates the change feed on `Object.is`, so non-matching events cost one call and nothing downstream. -- **Whole-value event rule (load-bearing).** A state-carrying log event MUST carry the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers). +- **Deterministic fold, complete wire value.** A unit synchronously validates and folds the Session events its domain owns; those durable events may be complete values or domain transitions. When a `wire` view exists, it always publishes the complete current value rather than a client-side delta. +- **Initialization follows the event source.** Live lazy and event-driven builds normalize `session.header.seedLength ?? 0`. Detached restore callers pass the same normalized value from the header returned with the persisted event read. The initialization object is immutable input to `init`; a unit must not fetch Session or process state behind the registry. - **Synchronous unit discipline.** `init`/`apply`/`wire.view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally async view returns a Promise, which fails `wire.viewSchema.parse`. - **State is validated plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows and validates `val` with `stateSchema` before use; bump `stateVersion` whenever the state fields or fold semantics change. Every unit's state is checkpointed — client-visible and host-only alike. - **No wire vocabulary here.** The registry exposes only the change feed and the snapshot read face; carriers (api-proxy) mint their own frames (`session/projection`) and blocks from them. @@ -45,6 +47,6 @@ None; projections never assemble or send provider requests. - **Every tail page carries every client-visible key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large. - **The unit table is process-wide, so key presence is not a per-session capability signal** — a key registered by ANY agent preset appears in every session's snapshot, including sessions whose own composition mounts nothing that produces it. A client must read the VALUE (`plan.active`, an empty todo list) rather than treat an absent key as absence of the feature; a unit whose empty value is indistinguishable from a real one belongs on the host plane instead, which is why `dsh-token-meter` sits there. -- **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change. +- **Eager drive touches every unit per event** — cheap by construction (deterministic synchronous folds and the same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change. - **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead. - **Synchronous unit discipline is only partially mechanical** — `wire.viewSchema.parse` rejects a Promise-returning view, but an `apply` that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists. diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index bdf691761a..b79ea28a29 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -17,13 +17,15 @@ - `SessionProjectionMap`——协议块与客户端钩子共享的 merge-extensible client view 表。值是协议层 JSON 全量值;渲染归 slot 体系管,永远不归本层。 - `SessionProjectionStateMap`——merge-extensible host 折叠状态表。每个 client-visible key 同时出现在两个表中;host-only key 只出现在这里。 -- `ProjectionDefinition`——`{ key, stateSchema, init(), apply(state, event), wire?, stateVersion }`:同步的状态驱动计算单元。`wire` 提供 `viewSchema` 与 `view`;省略它即为 host-only 单元。 +- `ProjectionInitialization`——每个新折叠边界都会收到的不可变 Session 事实;当前包含规范化后的 `seedLength`,使领域无需读取环境 Session 状态即可排除 fork 继承前缀。 +- `ProjectionDefinition`——`{ key, stateSchema, init(initialization), apply(state, event), wire?, stateVersion }`:同步的状态驱动计算单元。`wire` 提供 `viewSchema` 与 `view`;省略它即为 host-only 单元。 ## 约定 -- **框架负责驱动,领域负责计算。** 注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个单元的 `apply`。领域不持有任何订阅。cell(每会话每单元一份 `{state, observedSeq}`,以 WeakMap 为键)惰性构建——在事件流过之后才注册的单元,或读取一个早于该注册的会话,都在首次触达时从 `init` 出发在内存日志上折叠。 +- **框架负责驱动,领域负责计算。** 注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个单元的 `apply`。领域不持有任何订阅。cell(每会话每单元一份 `{state, observedSeq}`,以 WeakMap 为键)惰性构建——在事件流过之后才注册的单元,或读取一个早于该注册的会话,都会在首次触达时调用 `init({ seedLength })` 并折叠内存日志。 - **同引用即无工作。** 对与单元无关的事件,`apply` 必须返回同一个状态引用;驱动以 `Object.is` 把守变更流,因此不匹配的事件只花一次调用,不产生任何下游工作。 -- **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。 +- **确定性 fold,完整 wire 值。** 单元同步校验并折叠其领域拥有的 Session 事件;这些持久事件既可以是完整值,也可以是领域 transition。存在 `wire` 视图时,它始终发布完整当前值,而不是让客户端处理 delta。 +- **初始化跟随事件来源。** live 惰性构建与事件驱动构建会规范化 `session.header.seedLength ?? 0`。detached restore 调用方从与持久事件同一次读取返回的 header 传入同样的规范值。初始化对象是 `init` 的不可变输入;单元不得绕过注册表读取 Session 或进程状态。 - **单元的同步纪律。**`init`/`apply`/`wire.view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 - **状态是经校验的纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存存储 `(sessionId, key, ver, seq, val)` 行,并在使用前以 `stateSchema` 校验 `val`;状态字段或折叠语义一旦变化就递增 `stateVersion`。每个单元的状态都会被检查点化——client-visible 与 host-only 一视同仁。 - **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。 @@ -45,6 +47,6 @@ - **每个尾页携带每个 client-visible key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。 - **单元表是进程级的,因此 key 是否存在不能当作逐会话的能力信号**——只要**任何**一个 agent preset 注册了某个 key,它就出现在每个会话的快照里,包括自身组装完全不产出该值的会话。客户端必须读**值**(`plan.active`、空的 todo 列表),不能把 key 缺席当作功能缺席;如果某个单元的空值与真实值无法区分,它就该待在宿主平面——`dsh-token-meter` 正因如此留在那里。 -- **主动驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,约定不变。 +- **主动驱动(eager drive)逐事件触达每个单元**——按构造开销很低(确定性同步 fold、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,约定不变。 - **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。 - **单元同步纪律只有部分可机械把关**——`wire.viewSchema.parse` 能拒绝返回 Promise 的 view,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套项记载了为何不存在运行时检查。 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 89d356df02..44d8dbd1c3 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -10,9 +10,9 @@ * (capability-seam three-way split). Design authority: the session-projection * RFC (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md). * - * Whole-value event rule (load-bearing): a state-carrying log event MUST - * carry the complete post-change state, never a bare delta — it keeps every - * unit's transition trivially cheap and every served value self-describing. + * Fold rule (load-bearing): a unit synchronously and deterministically + * validates and folds the Session events its domain owns. The client-facing + * wire value, when present, is always the complete current value. * * @module @deepseek-ai/dsh-session-projection */ @@ -31,6 +31,12 @@ import type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts export type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts' +/** Minimal immutable Session fact supplied when a projection state is initialized. */ +export interface ProjectionInitialization { + /** Number of inherited leading events that belong to a fork's source Session. */ + readonly seedLength: number +} + /** * One domain's state-driven computation unit: a pure synchronous fold plus * declarations and an optional client view — never an opaque getter. The framework drives @@ -49,9 +55,10 @@ export interface ProjectionDefinition< stateSchema: ZodType /** * State for the empty log. + * @param initialization - immutable Session facts needed to establish the fold boundary. * @returns the initial state. */ - init(): NoInfer + init(initialization: ProjectionInitialization): NoInfer /** * Pure transition: previous state + one committed event → next state. A * unit uninterested in an event MUST return the same state reference — an @@ -129,7 +136,7 @@ export type ProjectionCheckpoint = Record interface ErasedDefinition { key: string stateSchema: { parse(value: unknown): unknown } - init(): unknown + init(initialization: ProjectionInitialization): unknown apply(state: unknown, event: SessionEvent): unknown wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined stateVersion: number @@ -230,7 +237,7 @@ export class SessionProjectionRegistry extends Service { const erased: ErasedDefinition = { key: definition.key, stateSchema: definition.stateSchema, - init: () => definition.init(), + init: initialization => definition.init(initialization), apply: (state, event) => definition.apply(state as S, event), wire: wire === undefined ? undefined @@ -413,6 +420,7 @@ export class SessionProjectionRegistry extends Service { * @param checkpoint - persisted rows for one session (possibly stale or empty). * @param events - the stored events with `seq >= baseSeq`, in seq order. * @param baseSeq - the seq `events` starts at (its first event's seq when non-empty). + * @param initialization - normalized facts from the stored header returned by the same read. * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last * supplied event's seq, `baseSeq - 1` for an empty tail) plus the * refreshed checkpoint rows at that cut, ready for a durable write-back. @@ -421,6 +429,7 @@ export class SessionProjectionRegistry extends Service { checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, + initialization: ProjectionInitialization, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } { const endSeq = events.at(-1)?.seq ?? baseSeq - 1 @@ -439,7 +448,7 @@ export class SessionProjectionRegistry extends Service { + 'its checkpoint row is missing, version-mismatched, or beyond the supplied log end; re-read from seq 0', ) } - let state = usable ? def.stateSchema.parse(row.val) : def.init() + let state = usable ? def.stateSchema.parse(row.val) : def.init(initialization) const from = usable ? row.seq : baseSeq - 1 for (const event of events) { if (event.seq > from) state = def.apply(state, event) @@ -454,8 +463,12 @@ export class SessionProjectionRegistry extends Service { } /** Fold one unit from init over `events`, producing a cell watermarked at the last folded event. */ - private buildCell(def: ErasedDefinition, events: readonly SessionEvent[]): UnitCell { - let state = def.init() + private buildCell( + def: ErasedDefinition, + events: readonly SessionEvent[], + initialization: ProjectionInitialization, + ): UnitCell { + let state = def.init(initialization) for (const event of events) state = def.apply(state, event) return { state, observedSeq: (events.at(-1)?.seq ?? -1) } } @@ -464,7 +477,9 @@ export class SessionProjectionRegistry extends Service { private cellFor(registration: Registration, session: Session): UnitCell { let cell = registration.cells.get(session) if (cell === undefined) { - cell = this.buildCell(registration.def, session.events) + cell = this.buildCell(registration.def, session.events, { + seedLength: session.header.seedLength ?? 0, + }) registration.cells.set(session, cell) } return cell @@ -477,7 +492,9 @@ export class SessionProjectionRegistry extends Service { if (cell === undefined) { // Late build mid-stream: fold history before this event (seq = log // index, so the prefix slice is exact), then take the normal gate. - cell = this.buildCell(registration.def, session.events.slice(0, event.seq)) + cell = this.buildCell(registration.def, session.events.slice(0, event.seq), { + seedLength: session.header.seedLength ?? 0, + }) registration.cells.set(session, cell) } const next = registration.def.apply(cell.state, event) diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index 3ee2491d4f..59bbcae8d5 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -19,6 +19,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { 'test/marks': MarksState 'test/count': number + 'test/seed': number } interface SessionProjectionMap { @@ -33,6 +34,7 @@ declare module '@deepseek-ai/dsh-session/types' { } type MarksState = { marks: string[] } | null +const INITIALIZATION = { seedLength: 0 } as const /** Whole-value unit: latest test/mark event wins; unrelated events return the same reference. */ const marksUnit = (): Omit, 'wire'> & { wire: NonNullable['wire']> } => ({ @@ -56,6 +58,15 @@ const countUnit = (): ProjectionDefinition<'test/count', number> => ({ stateVersion: 1, }) +/** Host-only sentinel proving which Session seed boundary initialized a cell. */ +const seedUnit = (): ProjectionDefinition<'test/seed', number> => ({ + key: 'test/seed', + stateSchema: z.number().int().nonnegative(), + init: initialization => initialization.seedLength, + apply: state => state, + stateVersion: 1, +}) + async function harness(): Promise<{ ctx: Context; session: Session }> { const ctx = new Context() await ctx.plugin(SessionStore) @@ -95,6 +106,30 @@ describe('SessionProjectionRegistry drive', () => { expect(snapshot.values['test/marks']).toEqual({ marks: [] }) }) + it('passes the normalized fork boundary to lazy, event-driven, and restore initialization', async () => { + const { ctx } = await harness() + const parentMark: SessionEvent = { + type: 'test/mark', seq: 0, time: 0, data: { marks: ['parent'] }, + } + + const lazy = ctx.sessions.create(undefined, { + seed: [parentMark], + meta: { seedLength: 1 }, + }) + ctx.sessionProjections.register(seedUnit()) + expect(ctx.sessionProjections.stateOf(lazy, 'test/seed')).toBe(1) + + const driven = ctx.sessions.create(undefined, { + seed: [parentMark], + meta: { seedLength: 1 }, + }) + mark(driven, ['child']) + expect(ctx.sessionProjections.stateOf(driven, 'test/seed')).toBe(1) + + const restored = ctx.sessionProjections.restore({}, [], 0, { seedLength: 7 }) + expect(restored.checkpoint['test/seed']).toEqual({ ver: 1, seq: -1, val: 7 }) + }) + it('notifies onChanged with the validated view and the causing seq, and skips same-reference applies', async () => { const { ctx, session } = await harness() ctx.sessionProjections.register(marksUnit()) @@ -280,7 +315,7 @@ describe('SessionProjectionRegistry drive', () => { expect(() => ctx.sessionProjections.restore({ 'test/marks': { ver: 1, seq: 2, val: { marks: ['old'] } }, 'test/count': { ver: 99, seq: 2, val: 3 }, - }, tail, 3)).toThrow(/re-read from seq 0/) + }, tail, 3, INITIALIZATION)).toThrow(/re-read from seq 0/) // The full-log re-read (baseSeq 0) refolds the mismatched key from init. const full: SessionEvent[] = [ { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }, @@ -291,7 +326,7 @@ describe('SessionProjectionRegistry drive', () => { const { snapshot, checkpoint } = ctx.sessionProjections.restore({ 'test/marks': { ver: 1, seq: 2, val: { marks: ['old', '2'] } }, 'test/count': { ver: 99, seq: 2, val: 3 }, - }, full, 0) + }, full, 0, INITIALIZATION) expect(snapshot.asOfSeq).toBe(4) expect(snapshot.values['test/marks']).toEqual({ marks: ['new'] }) expect('test/count' in snapshot.values).toBe(false) @@ -312,7 +347,7 @@ describe('SessionProjectionRegistry drive', () => { { type: 'turn/start', seq: 3, time: 3, data: { turn: 2 } }, { type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } }, ] - const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, tail, 3) + const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, tail, 3, INITIALIZATION) expect(snapshot.asOfSeq).toBe(4) // marks already covers the tail (watermark 4): nothing re-applied. expect(snapshot.values['test/marks']).toEqual({ marks: ['done'] }) @@ -324,7 +359,7 @@ describe('SessionProjectionRegistry drive', () => { const { snapshot: current, checkpoint: currentCheckpoint } = ctx.sessionProjections.restore({ 'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } }, 'test/count': { ver: 1, seq: 4, val: 5 }, - }, [], 5) + }, [], 5, INITIALIZATION) expect(current.asOfSeq).toBe(4) expect('test/count' in current.values).toBe(false) expect(currentCheckpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 }) @@ -355,7 +390,7 @@ describe('SessionProjectionRegistry drive', () => { 'test/marks': { marks: ['stored'] }, }) - const restored = ctx.sessionProjections.restore(rows, [], 5) + const restored = ctx.sessionProjections.restore(rows, [], 5, INITIALIZATION) expect(restored.snapshot.values).toEqual({ 'test/marks': { marks: ['stored'] }, }) @@ -370,7 +405,7 @@ describe('SessionProjectionRegistry drive', () => { } expect(ctx.sessionProjections.viewCheckpoint(drifted)).toEqual({}) - expect(() => ctx.sessionProjections.restore(drifted, [], 3)).toThrow() + expect(() => ctx.sessionProjections.restore(drifted, [], 3, INITIALIZATION)).toThrow() }) it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => { @@ -383,18 +418,18 @@ describe('SessionProjectionRegistry drive', () => { expect(floor).toBe(9) // …an intact log serves the anchor event and the checkpoint stands as-is. const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, reason: { kind: 'completed' } } } - const anchored = ctx.sessionProjections.restore(rows, [anchor], 9) + const anchored = ctx.sessionProjections.restore(rows, [anchor], 9, INITIALIZATION) expect(anchored.snapshot.values).toEqual({}) expect(anchored.checkpoint['test/count']).toEqual({ ver: 1, seq: 9, val: 10 }) // …while a log crash-repaired down to fewer events returns an empty tail: // the row overreaches the proven end and a tail read cannot fix this key. - expect(() => ctx.sessionProjections.restore(rows, [], 9)).toThrow(/re-read from seq 0/) + expect(() => ctx.sessionProjections.restore(rows, [], 9, INITIALIZATION)).toThrow(/re-read from seq 0/) // The full re-read discards the overreaching row and refolds from init. const events: SessionEvent[] = [ { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }, { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, ] - const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, events, 0) + const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, events, 0, INITIALIZATION) expect(snapshot.asOfSeq).toBe(1) expect(snapshot.values).toEqual({}) expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 1, val: 2 }) diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 95c3be8520..b9a016968c 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -395,7 +395,9 @@ async function resolveColdIdentity( } let identity: SubagentIdentityProjection | null | undefined try { - identity = projections.restore({}, inspected.events, 0).snapshot.values.subagent + identity = projections.restore({}, inspected.events, 0, { + seedLength: inspected.meta.seedLength ?? 0, + }).snapshot.values.subagent } catch { // The restore folds EVERY registered unit over this child's log, so any // unit's fold or schema can reject damaged payloads — deterministic data diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index e9a5633189..2f86c0a36a 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -459,11 +459,13 @@ describe('SubagentRuntime.listChildren', () => { values: { subagent: { mode: 'continuable', label: 'ancestor label', seq: 2 } }, }) const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') + const restore = vi.spyOn(ctx.sessionProjections, 'restore') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ kind: 'child', id: forkChild, label: 'own label', mode: 'continuable', activity: 'inactive', hasChildren: false, }]) expect(inspect).toHaveBeenCalledTimes(1) + expect(restore).toHaveBeenCalledWith({}, expect.any(Array), 0, { seedLength: seed.length }) }) it.each([ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 823b10216f..ed9d80a83d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1415,6 +1415,9 @@ importers: '@deepseek-ai/dsh-client-ui-renderer': specifier: workspace:^ version: link:../../client/ui-renderer + '@deepseek-ai/dsh-client-ui-schedule': + specifier: workspace:^ + version: link:../../client/ui-schedule '@deepseek-ai/dsh-client-ui-session': specifier: workspace:^ version: link:../../client/ui-session @@ -2809,6 +2812,57 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-schedule: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-schedule': + specifier: workspace:^ + version: link:../../schedule/schedule + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-session: devDependencies: '@deepseek-ai/cordis': @@ -6602,6 +6656,10 @@ importers: version: link:../sandbox-local packages/schedule/schedule: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -6636,6 +6694,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4acfd5b694..1b447e58fd 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -579,6 +579,7 @@ export const LINK_MAP: Readonly> = { WorkflowRunInfo: 'workflow.md', WorkflowStartRequest: 'workflow.md', ProjectionDefinition: 'session-projection.md', + ProjectionInitialization: 'session-projection.md', SessionProjectionMap: 'session-projection.md', SessionProjectionStateMap: 'session-projection.md', ProjectionChangeListener: 'session-projection.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 81a3e124a1..f8f4269867 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1871,6 +1871,11 @@ "symbol": "PresetSpec", "source": "packages/interaction/permission-presets/src/index.ts" }, + { + "doc": "docs/subsystems/session-projection.md", + "symbol": "ProjectionInitialization", + "source": "packages/session/session-projection/src/index.ts" + }, { "doc": "docs/subsystems/session-projection.md", "symbol": "ProjectionDefinition", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 086db6eb60..f8a76ffdfd 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -83,6 +83,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-message-feedback': { kind: 'none', reason: 'Browser-side controls over the message-feedback sidecar; ratings and notes never enter the Session log, model context, or telemetry.' }, 'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' }, 'packages/client/ui-jobs': { kind: 'none', reason: 'Browser-side read-only projection of ctx.jobs records; dsh-tool-jobs owns the model-facing behavior.' }, + 'packages/client/ui-schedule': { kind: 'none', reason: 'Browser-side read-only projection of active Schedule records; dsh-schedule owns the model-facing tools and delivery.' }, 'packages/client/ui-workflow-run': { kind: 'none', reason: 'Browser-side UI plugin layer; renders durable workflow records without changing model context.' }, 'packages/client/ui-input-trigger': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-reference': { kind: 'indirect', reason: 'Browser-side reference selection delegates file guidance and session snapshot preparation to Host-owned providers.' }, diff --git a/snapshots/web/schedule-catalog/catalog.expected.md b/snapshots/web/schedule-catalog/catalog.expected.md new file mode 100644 index 0000000000..36c8268784 --- /dev/null +++ b/snapshots/web/schedule-catalog/catalog.expected.md @@ -0,0 +1,4 @@ +- list "Active reminders": + - listitem: Overdue Review overdue deployment Once Aug 25, 2099, 7:59 PM 1 minute overdue + - listitem: Scheduled Join release review with the release owners, verify the rollout checklist, capture each unresolved dependency, confirm the customer-facing message, compare the staged configuration with the approved release notes, inspect the deployment dashboard for every region, confirm that database migrations completed without warnings, review the rollback steps with the incident commander, verify that support has the final customer timeline, record every owner and deadline, check the public status wording against the internal decision, review the accessibility and localization sign-offs, confirm the monitoring thresholds and alert routes, read back the final launch sequence, document every unresolved question in plain language, keep all technical qualifiers and exception cases visible, include the exact handoff conditions for each downstream team, retain the complete audit context for the final decision, and preserve every final word without truncation. Once Aug 25, 2099, 8:05 PM in 6 minutes + - listitem: Scheduled Check exact cadence Every 301 seconds Aug 25, 2099, 8:05 PM in 6 minutes diff --git a/snapshots/web/schedule-catalog/session.jsonl b/snapshots/web/schedule-catalog/session.jsonl new file mode 100644 index 0000000000..540fddeb49 --- /dev/null +++ b/snapshots/web/schedule-catalog/session.jsonl @@ -0,0 +1,11 @@ +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787644800000,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"turn/start","data":{"turn":1}} +{"type":"user/message","data":{"role":"user","content":[{"type":"text","text":"Show the active reminders."}],"source":{"kind":"user"},"id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Active schedule catalog","messageSeqs":[1],"source":{"kind":"user"}}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"The active reminders are available in the session header."}],"source":{"kind":"model","provider":"fixture","model":"fixture"},"id":"{{message:2}}"},"usage":{"inputTokens":4,"outputTokens":10}},"surfaceOp":"append"} +{"type":"schedule/change","data":{"version":1,"operation":"create","schedule":{"id":"catalog-after","kind":"after","prompt":"Review overdue deployment","afterSeconds":60,"scheduledAt":"2099-08-25T11:59:00.000Z"}}} +{"type":"schedule/change","data":{"version":1,"operation":"create","schedule":{"id":"catalog-at","kind":"at","prompt":"Join release review with the release owners, verify the rollout checklist, capture each unresolved dependency, confirm the customer-facing message, compare the staged configuration with the approved release notes, inspect the deployment dashboard for every region, confirm that database migrations completed without warnings, review the rollback steps with the incident commander, verify that support has the final customer timeline, record every owner and deadline, check the public status wording against the internal decision, review the accessibility and localization sign-offs, confirm the monitoring thresholds and alert routes, read back the final launch sequence, document every unresolved question in plain language, keep all technical qualifiers and exception cases visible, include the exact handoff conditions for each downstream team, retain the complete audit context for the final decision, and preserve every final word without truncation.","scheduledAt":"2099-08-25T12:05:01.000Z"}}} +{"type":"schedule/change","data":{"version":1,"operation":"create","schedule":{"id":"catalog-every","kind":"every","prompt":"Check exact cadence","everySeconds":301,"scheduledAt":"2099-08-25T12:05:01.000Z"}}} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/schedule-catalog/snapshot.yml b/snapshots/web/schedule-catalog/snapshot.yml new file mode 100644 index 0000000000..7c8d2b6e17 --- /dev/null +++ b/snapshots/web/schedule-catalog/snapshot.yml @@ -0,0 +1,8 @@ +version: 1 +scenario: schedule-catalog +profile: web +composition: web-schedule +recording: authored +header: + class: web-schedule + pin: true diff --git a/snapshots/web/schedule-catalog/system-prompt.expected.md b/snapshots/web/schedule-catalog/system-prompt.expected.md new file mode 100644 index 0000000000..004b2dc501 --- /dev/null +++ b/snapshots/web/schedule-catalog/system-prompt.expected.md @@ -0,0 +1,37 @@ +You are an AI agent powered by DeepSeek Harness. + +The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. + +You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. + +You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. + +Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it. + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session. + +Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one keeps the modification-time-ordered head. + +Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. + +Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + +Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + +When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn. diff --git a/snapshots/web/schedule-catalog/tool-schemas.expected.json b/snapshots/web/schedule-catalog/tool-schemas.expected.json new file mode 100644 index 0000000000..cca88853f1 --- /dev/null +++ b/snapshots/web/schedule-catalog/tool-schemas.expected.json @@ -0,0 +1,761 @@ +{ + "initial": [ + { + "name": "ask_user_question", + "description": "Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer.", + "parameters": { + "type": "object", + "properties": { + "questions": { + "type": "array", + "description": "Questions to ask the user before continuing.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "string", + "description": "Stable id for this question; echoed in the answer." + }, + "question": { + "type": "string", + "description": "The specific question to ask the user." + }, + "header": { + "type": "string", + "description": "Optional short heading for the question, such as \"Confirm\" or \"Choose Mode\"." + }, + "options": { + "type": "array", + "description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "label": { + "type": "string", + "description": "Short user-facing option label." + }, + "description": { + "type": "string", + "description": "One sentence explaining the tradeoff or impact." + } + }, + "required": [ + "label" + ] + } + }, + "multi_select": { + "type": "boolean", + "description": "Whether the user may select more than one option. Defaults to false." + } + }, + "required": [ + "id", + "question" + ] + } + } + }, + "required": [ + "questions" + ] + } + }, + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "exit_plan_mode", + "description": "Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.", + "parameters": { + "type": "object", + "properties": { + "plan": { + "type": "string", + "description": "The complete plan, as markdown, starting with a # heading that names it." + } + }, + "required": [ + "plan" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "glob", + "description": "Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 100 paths come back in modification-time order; a larger result returns the first 100 paths in modification-time order, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth." + }, + "path": { + "type": "string", + "description": "Directory to search in. Defaults to the session workspace; a relative path resolves against it." + } + }, + "required": [ + "pattern" + ] + } + }, + { + "name": "grep", + "description": "Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns the first 250 matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Regular expression to search for (ripgrep syntax)." + }, + "path": { + "type": "string", + "description": "File or directory to search. Defaults to the session workspace; a relative path resolves against it." + }, + "include": { + "type": "string", + "description": "One glob filter for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). Not a list; negation is not supported." + } + }, + "required": [ + "pattern" + ] + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "job_kill", + "description": "Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the job." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "job_list", + "description": "List your background jobs (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "job_output", + "description": "Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` starts a new turn on the same conversation, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "read_image", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. Harness validates and downscales large supported images before the next model request, so use this tool directly instead of installing image libraries or creating thumbnails merely to inspect an image. Independent files may be read concurrently in small batches. Requires the current model to accept image input.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the image file, resolved by the filesystem backend." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "schedule_create", + "description": "Create one reminder in the current session. Supply a non-empty prompt and exactly one selector: a positive safe-integer after_seconds delay, at as a strict offset date-time or local date/time object, or safe-integer every_seconds of at least 300. Fixed-rate reminders stay creation-aligned, skip missed occurrences, and batch one latest occurrence per overdue rule. Delivery is session-local: the reminder runs on time only while this session is live and otherwise becomes overdue until the session is resumed.", + "parameters": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "Reminder content to present when the target becomes due." + }, + "after_seconds": { + "type": "number", + "description": "Positive safe-integer delay in seconds." + }, + "every_seconds": { + "type": "number", + "description": "Fixed-rate safe-integer interval in seconds, at least 300." + }, + "at": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "time": { + "type": "string" + }, + "time_zone": { + "type": "string" + } + }, + "required": [ + "date", + "time", + "time_zone" + ] + } + ], + "description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone." + } + }, + "required": [ + "prompt" + ] + } + }, + { + "name": "schedule_delete", + "description": "Delete one active reminder in the current session by the exact id returned by schedule_create or schedule_list. Unknown or already-finished ids return deleted false.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Exact session-local schedule id." + } + }, + "required": [ + "id" + ] + } + }, + { + "name": "schedule_list", + "description": "List every active reminder in the current session in creation order, including its exact id, UTC target, scheduled or overdue state, and session-local delivery mode.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "web_search", + "description": "Search the web for current information. Provide 1–4 queries in the required queries array. Returns an optional summary answer and a list of source URLs.", + "parameters": { + "type": "object", + "properties": { + "queries": { + "type": "array", + "description": "Required search queries; accepts 1–4 items and merges their results.", + "items": { + "type": "string" + } + } + }, + "required": [ + "queries" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 8a8bc37baa..d017d8def1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -245,6 +245,9 @@ "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], "@deepseek-ai/dsh-client-ui-settings-plugins": ["./packages/client/ui-settings-plugins/src"], "@deepseek-ai/dsh-client-ui-jobs": ["./packages/client/ui-jobs/src"], + "@deepseek-ai/dsh-client-ui-schedule": ["./packages/client/ui-schedule/src"], + "@deepseek-ai/dsh-client-ui-schedule/client": ["./packages/client/ui-schedule/src/client/index.ts"], + "@deepseek-ai/dsh-client-ui-schedule/invariant": ["./packages/client/ui-schedule/src/invariant.ts"], "@deepseek-ai/dsh-client-ui-plan": ["./packages/client/ui-plan/src"], "@deepseek-ai/dsh-client-ui-user-questions": ["./packages/client/ui-user-questions/src"], "@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"], @@ -256,6 +259,7 @@ "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": ["./packages/client/ui-settings-plugin-inventory/src"], "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"], "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"], + "@deepseek-ai/dsh-schedule/client": ["./packages/schedule/schedule/src/client.ts"], // sdk/ folders are role-named without their npm-side sdk/jsonrpc prefixes, // so the generic wildcard cannot map these three package names. "@deepseek-ai/dsh-sdk-client": ["./packages/sdk/client/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 291b813300..6b3b88e30a 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -77,6 +77,7 @@ { "path": "./packages/client/ui-reference" }, { "path": "./packages/client/ui-subagent" }, { "path": "./packages/client/ui-jobs" }, + { "path": "./packages/client/ui-schedule" }, { "path": "./packages/client/ui-directory-picker-browse" }, { "path": "./packages/client/ui-directory-picker-native" }, { "path": "./packages/client/ui-goal" }, From 5fe7dc333f03c2f7c73dd0303a0edb3160a52c7e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 25 Aug 2026 22:17:55 +0800 Subject: [PATCH 022/188] fix(session): reconcile cached projection hints --- ...nd-projection-owned-client-state.i18n.yaml | 4 +- ...tions-and-projection-owned-client-state.md | 4 +- ...ns-and-projection-owned-client-state.zh.md | 4 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/session-projection.i18n.yaml | 4 +- docs/subsystems/session-projection.md | 11 +-- docs/subsystems/session-projection.zh.md | 11 +-- .../src/client/sessions/manager.ts | 14 ++- .../src/client/sessions/projection-store.ts | 70 +++++++++----- .../src/client/sessions/session.ts | 9 +- .../tests/manager.client.spec.ts | 13 ++- .../tests/projection-store.client.spec.ts | 75 ++++++++++++--- .../src/client/ScheduleCatalogAction.tsx | 95 ++++++++++--------- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../session-projection-cache/README.i18n.yaml | 4 +- .../session-projection-cache/README.md | 2 +- .../session-projection-cache/README.zh.md | 2 +- .../session-projection-cache/src/index.ts | 24 ++--- 20 files changed, 216 insertions(+), 140 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index 6660f864d6..0b38f31d9f 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md -2026-08-25-session-observations-and-projection-owned-client-state.md: e47f2fc75ecbca51d01af077f6c6ab98f4e275f9 -2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 527a4eb6b6765cba95d6067f2be60bff8f31a559 +2026-08-25-session-observations-and-projection-owned-client-state.md: 0fa69dde28eadc860d426ea511f4aaf1356c7afa +2026-08-25-session-observations-and-projection-owned-client-state.zh.md: ab601d7e6839eba6370564a25f92aa5cef99ca40 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index e47f2fc75e..0fa69dde28 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -108,11 +108,11 @@ These distinctions prevent one overloaded `undefined` from representing cache mi | Follow opening baseline | Complete for the Host composition | Exact opening cursor | Capability absent | | Projection frame | One whole key | Event sequence carried by the frame | Not applicable | -The Client stores one row per key with its sequence number. A newer hint, baseline, or frame replaces a row; an equal or older input is ignored. Reconnect can therefore replace the event window without rolling back a projection frame that was already accepted at a later sequence. +The Client stores one row per key with its provenance and sequence number. A list hint fills or advances only a tentative row. A complete opening baseline replaces or clears tentative rows even when a cache hint claims a higher sequence, while preserving an authoritative frame newer than the opening cut. Frames use higher-sequence-wins and promote an equal-sequence hint to authoritative state. A replacement control baseline first discards rows beyond its durable cut, then installs its complete values. The list view reads the same per-Session store as the opened Session. Hints can populate title, preset, and other list presentation before follow completes; the opening baseline then converges that state without creating a second summary-only authority. -The per-Session Client projection store accepts list hints, the follow baseline, and later whole-value frames under one higher-sequence-wins rule. It never folds Session events. A baseline or frame may advance a hinted value, while an older cut cannot overwrite a newer row. +The per-Session Client projection store never folds Session events; it only reconciles finished hints, complete baselines, and whole-value frames under those source-aware rules. Data that is not derived from one Session remains outside projections. `llm.models` owns the Host-generation model catalog, and `agentPreset.list` owns the configurable preset roster. A selector combines the relevant catalog with the Session's `modelSelection` or `agentPreset` projection only when both inputs are ready. During refresh it may retain the last complete catalog; before the first complete pair it reports loading instead of rendering a guessed name or availability verdict. diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index 527a4eb6b6..ab601d7e68 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -108,11 +108,11 @@ Projection 的三种交付状态含义不同: | Follow opening baseline | 对当前 Host composition 完整 | 精确 opening cursor | Capability 不存在 | | Projection frame | 单个完整 key | Frame 携带的 event sequence | 不适用 | -Client 为每个 key 保存带 sequence number 的一行。更新的 hint、baseline 或 frame 会替换 row;相同或更旧的输入被忽略。因此 reconnect 可以替换 event window,而不会回退已经在更晚 sequence 接受的 projection frame。 +Client 为每个 key 保存带来源与 sequence number 的一行。List hint 只能填充或推进暂定 row。完整 opening baseline 即使面对声称更高 sequence 的 cache hint,也会替换或清除暂定 row,同时保留晚于 opening cut 的权威 frame。Frame 继续使用 higher-sequence-wins,并会把相同 sequence 的 hint 提升为权威状态。Replacement control baseline 会先丢弃超出其 durable cut 的 row,再安装完整值。 List view 与已打开 Session 读取同一个 per-Session store。Hints 可以在 follow 完成前填充 title、preset 和其他 list presentation;opening baseline 随后收敛这份状态,而不会建立第二套 summary-only authority。 -每个 Session 的 Client projection store 按一条 higher-sequence-wins 规则接收 list hints、follow baseline 和后续 whole-value frame。它从不折叠 Session event。Baseline 或 frame 可以推进 hinted value,较旧切面不能覆盖较新的 row。 +每个 Session 的 Client projection store 从不折叠 Session event;它只按上述来源感知规则协调成品 hint、完整 baseline 与 whole-value frame。 不由单个 Session 派生的数据不进入 projection。`llm.models` 拥有当前 Host generation 的 model catalog,`agentPreset.list` 拥有可配置 preset roster。Selector 只在相应 catalog 与 Session 的 `modelSelection` 或 `agentPreset` projection 均就绪后组合两者。刷新时可以保留上一份完整 catalog;第一次获得完整输入前显示 loading,而不是展示猜测的名称或可用性结论。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 8613048b0b..0b17881cd7 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 8543498400d5f8f2e0230db5898296f9942c5640 -config-catalog.zh.md: a162650a53a7e42f61a695120673d804b3737f68 +config-catalog.md: 9ac708ebf60e12c9c061a0d809ca5ac7eebfd8a4 +config-catalog.zh.md: f32e55abc884ee8b76ea4fdf47bd7601649d9af5 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8543498400..9ac708ebf6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1839,7 +1839,7 @@ export interface Config { } ``` -Source: [`packages/session/session-projection-cache/src/index.ts:46`](../packages/session/session-projection-cache/src/index.ts) +Source: [`packages/session/session-projection-cache/src/index.ts:47`](../packages/session/session-projection-cache/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index a162650a53..f32e55abc8 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1841,7 +1841,7 @@ export interface Config { } ``` -来源:[`packages/session/session-projection-cache/src/index.ts:46`](../packages/session/session-projection-cache/src/index.ts) +来源:[`packages/session/session-projection-cache/src/index.ts:47`](../packages/session/session-projection-cache/src/index.ts) diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 361004f650..367a2ce8cd 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md -session-projection.md: 2e64dca6b22b39771f6ab5ccc594e894a9b3785f -session-projection.zh.md: 356668e24e12e95b2b8abb92c4878a15f9ecec07 +session-projection.md: 289e69e4f03020beed835ff6c7012a4b6934dfe3 +session-projection.zh.md: 14b0acd8a7ee3ef33e85ec122fb29625ba352399 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 2e64dca6b2..289e69e4f0 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -118,12 +118,11 @@ The persisted projection cache service. Opens the `session_projcache` domain at ```ts cordis-catalog /** * The zero-I/O listing read: whole values viewed straight from the stored - * rows (version-matching keys only), each cut carried with its watermark - * so a client value store can seed under its higher-seq-wins rule — as - * stale as the last durable checkpoint but never wrong, and never from an - * unrelated log (the caller's header is the identity witness). Fresher - * paths (the history tail baseline, {@link coldSnapshot}) supersede these - * values whenever a session is actually opened. + * rows (version-matching keys only), each cut carried with its watermark so + * a client value store can prewarm tentative rows. The caller's header keeps + * unrelated lifecycles out, but a row may lag the log or overreach a + * crash-repaired truncation; the exact history or {@link coldSnapshot} + * baseline replaces or clears hints whenever a session is opened. * @param meta - the listed session's header (identity witness; no log read). * @param keys - optional projection keys required by the caller's audience. * @returns the cut (`asOfSeq` = lowest served-row watermark), or diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 356668e24e..14b0acd8a7 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -118,12 +118,11 @@ The persisted projection cache service. Opens the `session_projcache` domain at ```ts cordis-catalog /** * The zero-I/O listing read: whole values viewed straight from the stored - * rows (version-matching keys only), each cut carried with its watermark - * so a client value store can seed under its higher-seq-wins rule — as - * stale as the last durable checkpoint but never wrong, and never from an - * unrelated log (the caller's header is the identity witness). Fresher - * paths (the history tail baseline, {@link coldSnapshot}) supersede these - * values whenever a session is actually opened. + * rows (version-matching keys only), each cut carried with its watermark so + * a client value store can prewarm tentative rows. The caller's header keeps + * unrelated lifecycles out, but a row may lag the log or overreach a + * crash-repaired truncation; the exact history or {@link coldSnapshot} + * baseline replaces or clears hints whenever a session is opened. * @param meta - the listed session's header (identity witness; no log read). * @param keys - optional projection keys required by the caller's audience. * @returns the cut (`asOfSeq` = lowest served-row watermark), or diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 1a76102eaa..7bb1df6c80 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -491,18 +491,16 @@ export class SessionManager { session.handleBlank(s.blank) session.handleRunning(s.running) } - // Seed each row's projection baseline into the per-session value - // store (cold titles surface without opening the session). Per-key - // apply, not seed(): the list block is a partial baseline — the - // cold cache serves only version-matching keys — so an absent key - // must not clear; higher-seq-wins still keeps a stale list block - // from overwriting a newer push frame or tail baseline. + // Prewarm each row's projection hints (cold titles surface without + // opening the session). The list block is partial, so an absent key + // must not clear; hints never replace an authoritative frame or + // successful opening baseline, even if the cache claims a higher cut. for (const s of result.value.items) { const block = s.projections if (block === undefined) continue const store = this.projectionStore(s.sessionId) const values = block.values as Record - for (const key of Object.keys(values)) store.apply(key, values[key], block.asOfSeq) + for (const key of Object.keys(values)) store.prewarm(key, values[key], block.asOfSeq) } } else { this.listState = 'error' @@ -725,7 +723,7 @@ export class SessionManager { if (projections !== undefined) { const store = this.projectionStore(summary.sessionId) for (const [key, value] of Object.entries(projections.values)) { - store.apply(key, value, projections.asOfSeq) + store.prewarm(key, value, projections.asOfSeq) } } if (summary.origin === 'subagent' && summary.parentSessionId !== undefined) { diff --git a/packages/api/session-controller/src/client/sessions/projection-store.ts b/packages/api/session-controller/src/client/sessions/projection-store.ts index fd0ad1b5d2..e48f24679b 100644 --- a/packages/api/session-controller/src/client/sessions/projection-store.ts +++ b/packages/api/session-controller/src/client/sessions/projection-store.ts @@ -2,11 +2,13 @@ * Generic per-session projection value store (push model; see the * session-projection subsystem page, docs/subsystems/session-projection.md): * the host is the only computation site; the client holds finished - * whole values per key — `key → { value, seq }` — seeded by a follow opening - * baseline and updated by Session Controller `projection` frames, - * under the single rule **higher seq wins**. No client-side domain folding - * exists: a domain ships projection support with zero client code. Per-key - * bare observable faces feed `useProjection` (ui-renderer binds them). + * whole values per key — `key → { value, seq, provenance }`. Session-list and + * session-added blocks are tentative prewarm hints; a successful follow + * opening installs the complete authoritative baseline, and Session Controller + * `projection` frames advance authoritative rows by sequence. No client-side + * domain folding exists: a domain ships projection support with zero client + * code. Per-key bare observable faces feed `useProjection` (ui-renderer binds + * them). */ import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' @@ -51,10 +53,11 @@ export interface ProjectionsBaseline { values: Readonly> } -/** One key's row: the latest finished value and the seq it is consistent with. */ +/** One key's row: the latest finished value, its cut, and its trust level. */ interface Row { value: unknown seq: number + provenance: 'prewarm' | 'authoritative' } /** Per-key notification channel: the bare face plus its batching notifier. */ @@ -64,14 +67,15 @@ interface Channel { } /** - * One session's projection values. Framework semantics, uniform across every - * key: a baseline seeds rows at its cut, a push frame updates one row, and in - * both paths a lower-or-equal seq loses — a replayed frame cannot regress a - * value, a stale baseline cannot overwrite a newer frame. A key the store has - * never seen reads `undefined` (capability absent). Faces are identity-stable - * per key (create-on-demand, cached) so the React side binds each exactly - * once; the store-level channel (`subscribeAny`) serves coarse consumers (the - * manager's list projection reads the `title` key). + * One session's projection values. A list hint can fill or advance only a + * tentative row. A complete baseline replaces or clears every tentative row, + * regardless of its claimed sequence, while preserving authoritative frames + * newer than the baseline cut. Frames use higher-sequence-wins after promoting + * an equal-sequence hint to authoritative state. A key the store has never seen + * reads `undefined` (capability absent). Faces are identity-stable per key + * (create-on-demand, cached) so the React side binds each exactly once; the + * store-level channel (`subscribeAny`) serves coarse consumers (the manager's + * list projection reads the `title` key). */ export class ProjectionValueStore { private readonly rows = new Map() @@ -124,6 +128,22 @@ export class ProjectionValueStore { return this.anyNotifier.subscribe(listener) } + /** + * Prewarm one tentative value from a partial Session list or session-added + * block. Hints compete only with other hints; once an authoritative value is + * known, no later list refresh may replace it. + * @param key - projection key. + * @param value - whole cached value. + * @param seq - the cache row's claimed watermark. + */ + prewarm(key: string, value: unknown, seq: number): void { + const row = this.rows.get(key) + if (row?.provenance === 'authoritative') return + if (row !== undefined && seq <= row.seq) return + this.rows.set(key, { value, seq, provenance: 'prewarm' }) + this.changed(key) + } + /** * Apply one finished value from the Session control stream. * @param key - projection key. @@ -132,27 +152,31 @@ export class ProjectionValueStore { */ apply(key: string, value: unknown, seq: number): void { const row = this.rows.get(key) - if (row !== undefined && seq <= row.seq) return // higher seq wins; replays and stale frames drop - this.rows.set(key, { value, seq }) + if (row !== undefined && (seq < row.seq || (seq === row.seq && row.provenance === 'authoritative'))) return + this.rows.set(key, { value, seq, provenance: 'authoritative' }) this.changed(key) } /** - * Seed from a history tail page's projections block: every carried key - * lands under the same seq rule as frames; a key the block omits is - * capability-absent as of the cut — its row clears unless a newer frame - * already superseded the cut (a stale baseline can neither overwrite nor - * clear newer values). + * Seed from a complete history or control projections block. The baseline + * replaces every tentative hint, including one whose cache watermark is + * higher, and clears omitted hints. Only an authoritative frame newer than + * the cut survives. * @param baseline - the response's projections block. */ seed(baseline: ProjectionsBaseline): void { // Erased walk: the framework crosses the open key space; per-key typing // is re-established at the consumer (useProjection's map lookup). const values = baseline.values as Record - for (const key of Object.keys(values)) this.apply(key, values[key], baseline.asOfSeq) + for (const key of Object.keys(values)) { + const row = this.rows.get(key) + if (row?.provenance === 'authoritative' && row.seq > baseline.asOfSeq) continue + this.rows.set(key, { value: values[key], seq: baseline.asOfSeq, provenance: 'authoritative' }) + this.changed(key) + } for (const [key, row] of this.rows) { if (Object.hasOwn(values, key)) continue - if (row.seq > baseline.asOfSeq) continue + if (row.provenance === 'authoritative' && row.seq > baseline.asOfSeq) continue this.rows.delete(key) this.changed(key) } diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 182d8514d9..a98c25ea02 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -107,9 +107,10 @@ export class Session implements SessionFace { /** * Per-session projection value store (push model; see the session-projection * subsystem page, docs/subsystems/session-projection.md): finished whole - * values computed on the Host, seeded by the tail page's - * projections block and updated by Session Controller control frames under the - * one higher-seq-wins rule. Keys are read via `projections.faceOf(key)` + * values computed on the Host. Partial list blocks prewarm tentative rows; + * the tail page installs the complete authoritative baseline, and Session + * Controller frames advance authoritative rows by sequence. Keys are read + * via `projections.faceOf(key)` * (the useProjection resolution face); the conversation snapshot never * carries projection values, and no client-side domain folding exists. * Manager-owned when constructed through SessionManager (frames route and @@ -574,7 +575,7 @@ export class Session implements SessionFace { } } - /** Replace the complete contiguous window and apply page-owned projection metadata. */ + /** Replace the complete contiguous window and install its authoritative projection baseline. */ private installWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void { this.baseSeq = entries[0]?.event.seq ?? 0 this.hasMore = hasMore diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index d271845e78..73abe33b9f 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -151,25 +151,30 @@ describe('list lifecycle', () => { expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined() }) - it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => { + it('prewarms cold titles from list and session-added hints without replacing authoritative values', async () => { const api = new FakeApiClient() const manager = new SessionManager(api, fakeRemote(api)) - // A push frame landed before the list (S2's title is newer than the block's cut). + // A push frame landed before the list. Even a later cache watermark stays + // tentative and cannot replace this authoritative value. manager.handleControlFrame({ type: 'projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9, }) api.onList = () => Promise.resolve(ok({ items: [ { ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } }, - { ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } }, + { ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 12, values: { title: 'List stale' } } }, ] as never[], })) await manager.refreshList() const items = manager.getListSnapshot().items // Cold row: title surfaces straight from the list block — no open, no history. expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached') - // The stale list block (seq 5) cannot overwrite the newer push frame (seq 9). expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed') + manager.handleSessionAdded({ + ...summary(S2, { updatedAt: 300 }), + projections: { asOfSeq: 15, values: { title: 'Added stale' } }, + }) + expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)?.title).toBe('Pushed') }) it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => { diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index d72be91338..c0231c6786 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -1,18 +1,17 @@ /** * Projection value store (push model; session-projection subsystem page: - * docs/subsystems/session-projection.md): the single - * higher-seq-wins rule on both paths (a stale baseline cannot overwrite a - * newer push frame; a replayed frame cannot regress), capability absence as - * undefined, generation truncation, and the Session/manager wiring (tail-page - * seeding, control-stream projection routing pre- and post-instantiation, the - * list rows' title projection). + * docs/subsystems/session-projection.md): tentative list prewarm versus + * authoritative baselines and frames, capability absence as undefined, + * generation truncation, and the Session/manager wiring (tail-page seeding, + * control-stream projection routing pre- and post-instantiation, the list + * rows' title projection). */ import { describe, expect, it } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts' import { Session } from '../src/client/sessions/session.ts' import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' import { entries, plainTurn } from './event-script.client.ts' // Test-domain keys merged into the projection map (the Service Definition package's @@ -42,18 +41,31 @@ describe('Session projection value semantics', () => { expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] }) }) - it('a stale baseline can neither overwrite nor clear a newer frame; a fresh one reseeds and clears', () => { + it('prewarms only tentative rows and promotes an equal-seq authoritative frame', () => { const store = new ProjectionValueStore() - store.apply('test/marks', { marks: ['frame-20'] }, 20) - // Stale cut: carried key loses to the newer frame; omitted key survives. + store.prewarm('test/marks', { marks: ['hint-5'] }, 5) + store.prewarm('test/marks', { marks: ['stale-hint'] }, 3) + expect(store.get('test/marks')).toEqual({ marks: ['hint-5'] }) + store.prewarm('test/marks', { marks: ['hint-9'] }, 9) + store.apply('test/marks', { marks: ['frame-9'] }, 9) + store.prewarm('test/marks', { marks: ['later-hint'] }, 20) + expect(store.get('test/marks')).toEqual({ marks: ['frame-9'] }) + }) + + it('a complete baseline replaces hints but preserves newer authoritative frames', () => { + const store = new ProjectionValueStore() + store.prewarm('test/marks', { marks: ['hint-20'] }, 20) + store.prewarm('hint-only', 'stale', 20) + store.apply('frame-only', 'frame-20', 20) store.seed({ asOfSeq: 10, values: { 'test/marks': { marks: ['baseline-10'] } } }) + expect(store.get('test/marks')).toEqual({ marks: ['baseline-10'] }) + expect(store.get('hint-only')).toBeUndefined() + expect(store.get('frame-only')).toBe('frame-20') + store.apply('test/marks', { marks: ['frame-20'] }, 20) + store.seed({ asOfSeq: 15, values: { 'test/marks': { marks: ['baseline-15'] } } }) expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] }) - store.seed({ asOfSeq: 15, values: {} }) - expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] }) - // Fresh cut: carried key reseeds… store.seed({ asOfSeq: 30, values: { 'test/marks': { marks: ['baseline-30'] } } }) expect(store.get('test/marks')).toEqual({ marks: ['baseline-30'] }) - // …and an omitting fresh cut clears (capability absent as of the cut). store.seed({ asOfSeq: 40, values: {} }) expect(store.get('test/marks')).toBeUndefined() }) @@ -104,7 +116,7 @@ describe('Session tail-page seeding', () => { it('retains a prewarmed projection when opening the Session fails', async () => { const api = new FakeApiClient() const projections = new ProjectionValueStore() - projections.apply('test/marks', { marks: ['cached'] }, 5) + projections.prewarm('test/marks', { marks: ['cached'] }, 5) const session = new Session(SID, api, fakeRemote(api), { projections }) api.onHistory = () => Promise.resolve(err({ code: 'session-not-found', @@ -129,6 +141,39 @@ describe('Session tail-page seeding', () => { expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] }) }) + it('replaces a higher-seq prewarm hint after a successful opening', async () => { + const api = new FakeApiClient() + const projections = new ProjectionValueStore() + projections.prewarm('test/marks', { marks: ['stale-list'] }, 9) + const session = new Session(SID, api, fakeRemote(api), { projections }) + api.onHistory = () => Promise.resolve(ok({ + records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, + projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['authoritative'] } } }, + } as never)) + + await session.open() + + expect(session.getSnapshot().openState).toBe('open') + expect(session.projections.get('test/marks')).toEqual({ marks: ['authoritative'] }) + }) + + it('preserves an authoritative frame that lands while opening waits for its older baseline', async () => { + const api = new FakeApiClient() + const history = deferred>>() + api.onHistory = () => history.promise + const session = new Session(SID, api, fakeRemote(api)) + + const opening = session.open() + session.projections.apply('test/marks', { marks: ['live-3'] }, 3) + history.resolve(ok({ + records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, + projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['baseline-2'] } } }, + } as never)) + await opening + + expect(session.projections.get('test/marks')).toEqual({ marks: ['live-3'] }) + }) + it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => { const api = new FakeApiClient() const session = new Session(SID, api, fakeRemote(api)) diff --git a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx index 8d1e7a4675..4ef243b99e 100644 --- a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx +++ b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx @@ -138,54 +138,59 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule setOpen(false) triggerRef.current?.focus() } + const toggleCatalog = (): void => { + setNow(Date.now()) + setOpen(current => !current) + } + const trigger = ( + + ) + const catalog = open + ? ( +
    + {rows.map((record) => { + const overdue = Date.parse(record.scheduledAt) <= now + return ( +
  • + + + {record.prompt} + + {formatScheduleFrequency(record, t)} + + {formatScheduleLocalTime(record.scheduledAt)} + + + {formatScheduleRelative(record.scheduledAt, now, t)} + + +
  • + ) + })} +
+ ) + : null return (
- - {open - ? ( -
    - {rows.map((record) => { - const overdue = Date.parse(record.scheduledAt) <= now - return ( -
  • - - - {record.prompt} - - {formatScheduleFrequency(record, t)} - - {formatScheduleLocalTime(record.scheduledAt)} - - - {formatScheduleRelative(record.scheduledAt, now, t)} - - -
  • - ) - })} -
- ) - : null} + {trigger} + {catalog}
) } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 0d615e8c25..37d2c3468d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1400,7 +1400,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'cachedSnapshot( meta: SessionHeader, keys?: readonly Extract[], ): ProjectionSnapshot | undefined', - description: 'The zero-I/O listing read: whole values viewed straight from the stored rows (version-matching keys only), each cut carried with its watermark so a client value store can seed under its higher-seq-wins rule — as stale as the last durable checkpoint but never wrong, and never from an unrelated log (the caller\'s header is the identity witness). Fresher paths (the history tail baseline, coldSnapshot) supersede these values whenever a session is actually opened.', + description: 'The zero-I/O listing read: whole values viewed straight from the stored rows (version-matching keys only), each cut carried with its watermark so a client value store can prewarm tentative rows. The caller\'s header keeps unrelated lifecycles out, but a row may lag the log or overreach a crash-repaired truncation; the exact history or coldSnapshot baseline replaces or clears hints whenever a session is opened.', parameters: [{ name: 'meta', description: 'the listed session\'s header (identity witness; no log read).' }, { name: 'keys', description: 'optional projection keys required by the caller\'s audience.' }], returns: 'the cut (`asOfSeq` = lowest served-row watermark), or `undefined` when no usable row exists for this lifecycle.', }, diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index 609f146f24..ce6d1644e2 100644 --- a/packages/session/session-projection-cache/README.i18n.yaml +++ b/packages/session/session-projection-cache/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-projection-cache/README.md -README.md: 33908578a5127f2b6bb78ed7467833aaaa2cf085 -README.zh.md: 0ca410f91562360d85faadf4cf64cb61ac467482 +README.md: bddff27bf89c31049c72ed2e8027f452cd6fbae2 +README.zh.md: 3695fff30cb3c7741f448f0bb46660fd6a73a331 diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index 33908578a5..bddff27bf8 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -28,7 +28,7 @@ Both `Config` fields are required (no defaults): flush cadence is a deployment c ## Listing read (`cachedSnapshot(meta)`) -The zero-I/O rung: client values viewed straight from the identity-matching stored record (version- and state-schema-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. Host-only rows are never returned. `undefined` when no usable client row exists (unknown id, unrelated lifecycle, or no usable rows); the api-proxy list carrier turns that into an absent column. +The zero-I/O rung: client values viewed straight from the identity-matching stored record (version- and state-schema-matching keys only), returned as a `{asOfSeq, values}` cut. `asOfSeq` is the lowest served-row watermark, and the list carrier uses the block only to prewarm tentative rows. Newer hints may replace older hints, but no hint replaces an authoritative opening baseline or control frame; a successful exact opening replaces or clears hints regardless of their claimed sequence. The record may lag the log or overreach a crash-repaired truncation, which the exact cold/open path validates and refolds. Host-only rows are never returned. `undefined` means no usable client row exists (unknown id, unrelated lifecycle, or no usable rows); the api-proxy list carrier turns that into an absent column. ## Cold read (`coldSnapshot(id, signal?)`) diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index 0ca410f915..3695fff30c 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -28,7 +28,7 @@ ## 列表读(`cachedSnapshot(meta)`) -零 I/O 一档:从身份匹配的存储记录直接 view 客户端值(仅版本与 state schema 均匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。host-only 行永不返回。无可用客户端行(未知 id、无关生命周期、无可用行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。 +零 I/O 一档:从身份匹配的存储记录直接 view 客户端值(仅版本与 state schema 均匹配的 key),以 `{asOfSeq, values}` 切面返回。`asOfSeq` 取所服务行的最低水位,列表载体只用该块预热暂定 row。较新的 hint 可以替换较旧的 hint,但任何 hint 都不能替换权威 opening baseline 或 control frame;成功的精确打开会忽略 hint 声称的 sequence,直接替换或清除它。存储记录可能落后于日志,也可能越过崩溃修复后的截断点;精确 cold/open 路径会校验并重新折叠。host-only 行永不返回。`undefined` 表示无可用客户端行(未知 id、无关生命周期或无可用行);api-proxy 列表载体将其转为列缺席。 ## 冷读(`coldSnapshot(id, signal?)`) diff --git a/packages/session/session-projection-cache/src/index.ts b/packages/session/session-projection-cache/src/index.ts index f1f50bbba0..7bbcd20cf4 100644 --- a/packages/session/session-projection-cache/src/index.ts +++ b/packages/session/session-projection-cache/src/index.ts @@ -3,10 +3,11 @@ * checkpoints of every client-visible or explicitly persisted projection unit's state, one record per * session on the domain data form (`session_projcache` domain — the shipped * json backend lands it beside `workspace.json`). The cache is a fold - * shortcut, never an authority: a row is possibly stale (its `seq` - * says how stale) but never wrong, so every write path is fail-soft (a lost - * write costs a longer tail replay on the next cold read) and a - * `ver` mismatch discards the row instead of migrating it. Design + * shortcut, never an authority: an identity-matching row may lag the log or + * overreach a crash-repaired truncation, so exact reads validate and refold it. + * Every write path is fail-soft (a lost write costs a longer tail replay on + * the next cold read), and a `ver` mismatch discards the row instead of + * migrating it. Design * authority: the session-projection RFC * (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md). * @module @deepseek-ai/dsh-session-projection-cache @@ -110,12 +111,11 @@ export class SessionProjectionCache extends Service { /** * The zero-I/O listing read: whole values viewed straight from the stored - * rows (version-matching keys only), each cut carried with its watermark - * so a client value store can seed under its higher-seq-wins rule — as - * stale as the last durable checkpoint but never wrong, and never from an - * unrelated log (the caller's header is the identity witness). Fresher - * paths (the history tail baseline, {@link coldSnapshot}) supersede these - * values whenever a session is actually opened. + * rows (version-matching keys only), each cut carried with its watermark so + * a client value store can prewarm tentative rows. The caller's header keeps + * unrelated lifecycles out, but a row may lag the log or overreach a + * crash-repaired truncation; the exact history or {@link coldSnapshot} + * baseline replaces or clears hints whenever a session is opened. * @param meta - the listed session's header (identity witness; no log read). * @param keys - optional projection keys required by the caller's audience. * @returns the cut (`asOfSeq` = lowest served-row watermark), or @@ -131,8 +131,8 @@ export class SessionProjectionCache extends Service { const servedKeys = Object.keys(values) if (servedKeys.length === 0) return undefined // The block carries ONE cut: the lowest served watermark is the seq every - // value is at least current as of (under-claiming is safe under - // higher-seq-wins; over-claiming would let a stale value outrank pushes). + // value is at least current as of. Under-claiming is safe; over-claiming + // would misorder this hint against other tentative observations. const asOfSeq = Math.min(...servedKeys.map(key => (record.rows[key] as { seq: number }).seq)) return { asOfSeq, values } } From cdba045dfcb146bb6c8f1dbed1113d550451cbba Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 25 Aug 2026 23:18:46 +0800 Subject: [PATCH 023/188] fix(session): trust authoritative projection frames --- ...s-and-projection-owned-client-state.i18n.yaml | 4 ++-- ...rvations-and-projection-owned-client-state.md | 4 ++-- ...tions-and-projection-owned-client-state.zh.md | 4 ++-- docs/subsystems/session-projection.i18n.yaml | 4 ++-- docs/subsystems/session-projection.md | 9 +++++---- docs/subsystems/session-projection.zh.md | 9 +++++---- .../src/client/sessions/projection-store.ts | 14 +++++++------- .../tests/projection-store.client.spec.ts | 6 ++++-- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../session-projection-cache/README.i18n.yaml | 4 ++-- .../session/session-projection-cache/README.md | 6 +++--- .../session-projection-cache/README.zh.md | 6 +++--- .../session/session-projection-cache/src/spec.ts | 16 ++++++++++------ packages/session/session-projection/src/index.ts | 9 +++++---- 14 files changed, 53 insertions(+), 44 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index 0b38f31d9f..53fdc566f2 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md -2026-08-25-session-observations-and-projection-owned-client-state.md: 0fa69dde28eadc860d426ea511f4aaf1356c7afa -2026-08-25-session-observations-and-projection-owned-client-state.zh.md: ab601d7e6839eba6370564a25f92aa5cef99ca40 +2026-08-25-session-observations-and-projection-owned-client-state.md: f1bb2d42d7f5d8e00c1a13a2d5297f7342bdd4c3 +2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 15e55f17b7fa0e38a3c298e89beabf2605b60ddc diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index 0fa69dde28..f1bb2d42d7 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -94,7 +94,7 @@ A Client-visible fact belongs to `SessionProjectionMap` when its value is determ The three projection delivery states have different meanings: -- A Session-list hint is optional, partial, and possibly stale. A missing key means unknown, so a list consumer must not invent an empty value or deployment default. +- A Session-list hint is optional, partial, and unvalidated against the current log extent. It may be stale or claim a cut removed by crash repair. A missing key means unknown, so a list consumer must not invent an empty value or deployment default. - A follow opening baseline is the complete set of client-visible projection capabilities registered at its cursor. A missing key there means the capability is absent for that Host composition. - An explicit `null` is a domain-computed no-value result. It is distinct from a missing list hint and survives JSON transport. @@ -108,7 +108,7 @@ These distinctions prevent one overloaded `undefined` from representing cache mi | Follow opening baseline | Complete for the Host composition | Exact opening cursor | Capability absent | | Projection frame | One whole key | Event sequence carried by the frame | Not applicable | -The Client stores one row per key with its provenance and sequence number. A list hint fills or advances only a tentative row. A complete opening baseline replaces or clears tentative rows even when a cache hint claims a higher sequence, while preserving an authoritative frame newer than the opening cut. Frames use higher-sequence-wins and promote an equal-sequence hint to authoritative state. A replacement control baseline first discards rows beyond its durable cut, then installs its complete values. +The Client stores one row per key with its provenance and sequence number. A list hint fills or advances only a tentative row. The first authoritative frame replaces a tentative hint regardless of its claimed sequence; later authoritative frames use higher-sequence-wins. A complete opening baseline replaces or clears tentative rows while preserving an authoritative frame newer than the opening cut. A replacement control baseline first discards rows beyond its durable cut, then installs its complete values. The list view reads the same per-Session store as the opened Session. Hints can populate title, preset, and other list presentation before follow completes; the opening baseline then converges that state without creating a second summary-only authority. diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index ab601d7e68..15e55f17b7 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -94,7 +94,7 @@ Registry 拥有 fold state;各领域拥有自己的 `init`、`apply`、`view` Projection 的三种交付状态含义不同: -- Session-list hint 是可选、部分且可能陈旧的数据。key 缺失表示未知,因此 list 消费方不得自行补成空值或部署默认值。 +- Session-list hint 是可选、部分且未经当前日志范围校验的数据。它可能陈旧,也可能声称一个已被崩溃修复移除的 cut。key 缺失表示未知,因此 list 消费方不得自行补成空值或部署默认值。 - Follow opening baseline 是其 cursor 上所有已注册 Client 可见 projection capability 的完整集合。此处缺少 key 表示当前 Host composition 不具备该 capability。 - 显式 `null` 是领域计算出的无值结果。它不同于 list hint 缺失,并且能够完整通过 JSON transport。 @@ -108,7 +108,7 @@ Projection 的三种交付状态含义不同: | Follow opening baseline | 对当前 Host composition 完整 | 精确 opening cursor | Capability 不存在 | | Projection frame | 单个完整 key | Frame 携带的 event sequence | 不适用 | -Client 为每个 key 保存带来源与 sequence number 的一行。List hint 只能填充或推进暂定 row。完整 opening baseline 即使面对声称更高 sequence 的 cache hint,也会替换或清除暂定 row,同时保留晚于 opening cut 的权威 frame。Frame 继续使用 higher-sequence-wins,并会把相同 sequence 的 hint 提升为权威状态。Replacement control baseline 会先丢弃超出其 durable cut 的 row,再安装完整值。 +Client 为每个 key 保存带来源与 sequence number 的一行。List hint 只能填充或推进暂定 row。首个权威 frame 无论暂定 hint 声称的 sequence 多高都会替换它;后续权威 frame 之间才使用 higher-sequence-wins。完整 opening baseline 会替换或清除暂定 row,同时保留晚于 opening cut 的权威 frame。Replacement control baseline 会先丢弃超出其 durable cut 的 row,再安装完整值。 List view 与已打开 Session 读取同一个 per-Session store。Hints 可以在 follow 完成前填充 title、preset 和其他 list presentation;opening baseline 随后收敛这份状态,而不会建立第二套 summary-only authority。 diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 367a2ce8cd..1fd53e148c 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md -session-projection.md: 289e69e4f03020beed835ff6c7012a4b6934dfe3 -session-projection.zh.md: 14b0acd8a7ee3ef33e85ec122fb29625ba352399 +session-projection.md: f8cf86c17079b4fbfb528fede1815feb0b4f6918 +session-projection.zh.md: 0e61158b0ab37fe8423d2f7c38e9edddbf2cd478 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 289e69e4f0..f8cf86c170 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -272,10 +272,11 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined /** * View a checkpoint's rows without any log read: for every registered * client-visible unit whose row's `ver` matches, serve the schema-validated - * `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key - * absent (a cold or listing consumer treats it as not-yet-available and a - * fuller read path refolds it). The zero-I/O rung of the read ladder — - * values are as stale as their rows, never wrong. + * `view` of the schema-validated stored state; mismatched, malformed, or + * absent rows leave their key absent. The current log extent is unknown, so + * returned values are tentative hints: a row may trail the log or overreach + * a crash-repaired truncation. Exact restore validates the cut before using + * a row as authoritative state. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @param keys - optional wire keys to view. * @returns whole values per key with a usable row; empty when none. diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 14b0acd8a7..0e61158b0a 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -272,10 +272,11 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined /** * View a checkpoint's rows without any log read: for every registered * client-visible unit whose row's `ver` matches, serve the schema-validated - * `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key - * absent (a cold or listing consumer treats it as not-yet-available and a - * fuller read path refolds it). The zero-I/O rung of the read ladder — - * values are as stale as their rows, never wrong. + * `view` of the schema-validated stored state; mismatched, malformed, or + * absent rows leave their key absent. The current log extent is unknown, so + * returned values are tentative hints: a row may trail the log or overreach + * a crash-repaired truncation. Exact restore validates the cut before using + * a row as authoritative state. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @param keys - optional wire keys to view. * @returns whole values per key with a usable row; empty when none. diff --git a/packages/api/session-controller/src/client/sessions/projection-store.ts b/packages/api/session-controller/src/client/sessions/projection-store.ts index e48f24679b..52baf936c8 100644 --- a/packages/api/session-controller/src/client/sessions/projection-store.ts +++ b/packages/api/session-controller/src/client/sessions/projection-store.ts @@ -70,12 +70,12 @@ interface Channel { * One session's projection values. A list hint can fill or advance only a * tentative row. A complete baseline replaces or clears every tentative row, * regardless of its claimed sequence, while preserving authoritative frames - * newer than the baseline cut. Frames use higher-sequence-wins after promoting - * an equal-sequence hint to authoritative state. A key the store has never seen - * reads `undefined` (capability absent). Faces are identity-stable per key - * (create-on-demand, cached) so the React side binds each exactly once; the - * store-level channel (`subscribeAny`) serves coarse consumers (the manager's - * list projection reads the `title` key). + * newer than the baseline cut. The first authoritative frame replaces any + * tentative hint; later authoritative frames use higher-sequence-wins. A key + * the store has never seen reads `undefined` (capability absent). Faces are + * identity-stable per key (create-on-demand, cached) so the React side binds + * each exactly once; the store-level channel (`subscribeAny`) serves coarse + * consumers (the manager's list projection reads the `title` key). */ export class ProjectionValueStore { private readonly rows = new Map() @@ -152,7 +152,7 @@ export class ProjectionValueStore { */ apply(key: string, value: unknown, seq: number): void { const row = this.rows.get(key) - if (row !== undefined && (seq < row.seq || (seq === row.seq && row.provenance === 'authoritative'))) return + if (row?.provenance === 'authoritative' && seq <= row.seq) return this.rows.set(key, { value, seq, provenance: 'authoritative' }) this.changed(key) } diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index c0231c6786..9cdc26dd3c 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -157,11 +157,13 @@ describe('Session tail-page seeding', () => { expect(session.projections.get('test/marks')).toEqual({ marks: ['authoritative'] }) }) - it('preserves an authoritative frame that lands while opening waits for its older baseline', async () => { + it('preserves an authoritative frame below a higher hint while opening waits for its older baseline', async () => { const api = new FakeApiClient() const history = deferred>>() api.onHistory = () => history.promise - const session = new Session(SID, api, fakeRemote(api)) + const projections = new ProjectionValueStore() + projections.prewarm('test/marks', { marks: ['hint-9'] }, 9) + const session = new Session(SID, api, fakeRemote(api), { projections }) const opening = session.open() session.projections.apply('test/marks', { marks: ['live-3'] }, 3) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 37d2c3468d..e83e6d7553 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1479,7 +1479,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract[], ): Partial', - description: 'View a checkpoint\'s rows without any log read: for every registered client-visible unit whose row\'s `ver` matches, serve the schema-validated `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key absent (a cold or listing consumer treats it as not-yet-available and a fuller read path refolds it). The zero-I/O rung of the read ladder — values are as stale as their rows, never wrong.', + description: 'View a checkpoint\'s rows without any log read: for every registered client-visible unit whose row\'s `ver` matches, serve the schema-validated `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key absent. The current log extent is unknown, so returned values are tentative hints: a row may trail the log or overreach a crash-repaired truncation. Exact restore validates the cut before using a row as authoritative state.', parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'keys', description: 'optional wire keys to view.' }], returns: 'whole values per key with a usable row; empty when none.', }, diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index ce6d1644e2..39abe56528 100644 --- a/packages/session/session-projection-cache/README.i18n.yaml +++ b/packages/session/session-projection-cache/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-projection-cache/README.md -README.md: bddff27bf89c31049c72ed2e8027f452cd6fbae2 -README.zh.md: 3695fff30cb3c7741f448f0bb46660fd6a73a331 +README.md: 898af3feb69fcf709cdbb3d089aebabc40c278d4 +README.zh.md: 0663f6b95fa2168a9561cbda24498fefa1dfe55f diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index bddff27bf8..898af3feb6 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -4,14 +4,14 @@ English | [中文](README.zh.md) The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section). -A stored row `(key → {ver, seq, val})` is a fold shortcut, never an authority: possibly stale (`seq` says exactly how stale) but never wrong. Consequences the implementation commits to: +A stored row `(key → {ver, seq, val})` is a disposable fold shortcut, never an authority. The zero-I/O listing path can expose it only as a tentative hint: the row may lag the log, or crash repair may truncate the log below its claimed `seq`. An exact cold or opening read validates the current log extent and refolds instead of accepting a row that no longer fits. Consequences the implementation commits to: -- **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write or cold read self-heals. A crash between writes costs a longer tail replay, never a wrong value. +- **Every background write is fail-soft.** A failed durable write logs a warning and retains the previous row; the next write or exact cold read self-heals. A crash between writes normally costs a longer tail replay, while crash repair can turn the retained row into a tentative overreach until the exact path validates it. - **A `ver` mismatch against the live unit's `stateVersion` discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log. - **A row must pass the live unit's `stateSchema`.** A malformed row is omitted from the zero-I/O view and rejected by restore so the cold-read ladder refolds it from the log. - **Whole-record writes.** Each write replaces the session's full checkpoint (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud. - **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live or stored header is the witness) before accepting a row, so a deleted-then-recreated id or a persistence store swapped under a surviving cache discards the unrelated record instead of seeding phantom values. -- **The log leads, the cache follows.** A live checkpoint flushes the session's buffered events durably BEFORE the cache row lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it. +- **The log leads each checkpoint write.** A live checkpoint flushes the session's buffered events durably BEFORE the cache row lands, so the cache cannot lead the log when the write commits. Later crash repair may truncate the log below an existing row; exact reads detect that overreach before returning authoritative state. ## Write policy diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index 3695fff30c..0663f6b95f 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -4,14 +4,14 @@ 持久投影缓存(`ctx.sessionProjectionCache`):把每个投影单元的状态保存为检查点,基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 JSON 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)(persisted projection cache 一节)。 -一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺: +一条存储行 `(key → {ver, seq, val})` 是可丢弃的折叠捷径,绝不是权威。零 I/O 列表路径只能把它公开为暂定 hint:该行可能落后于日志,崩溃修复也可能把日志截断到其声称的 `seq` 之前。精确冷读或打开会校验当前日志范围;若该行不再匹配,就从日志重新折叠,而不会把它当作权威值。实现据此承诺: -- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部回放,绝不是错误的值。 +- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保留先前行;下一次写入或精确冷读会自愈。两次写之间崩溃通常只增加尾部回放,崩溃修复则可能让保留行暂时越界,直到精确路径完成校验。 - **`ver` 与当前运行单元的 `stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。 - **存储行必须通过当前单元的 `stateSchema`。** 畸形行从零 I/O view 中省略,并被 restore 拒绝,使冷读阶梯从日志重新折叠。 - **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 约定的单元状态会显式失败并报错。 - **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。 -- **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部回放),绝不领先于它。 +- **每次检查点写入都由日志领先。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此写入提交时缓存不可能领先日志。后续崩溃修复可能把日志截断到现有缓存行之前;精确读取会在返回权威状态前识别这种越界。 ## 写策略 diff --git a/packages/session/session-projection-cache/src/spec.ts b/packages/session/session-projection-cache/src/spec.ts index 26de5abe73..889f99fcf2 100644 --- a/packages/session/session-projection-cache/src/spec.ts +++ b/packages/session/session-projection-cache/src/spec.ts @@ -17,9 +17,12 @@ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' * One persisted checkpoint row (the RFC's `(sessionId, key, ver, seq, val)` * minus the two record keys). `val` is the unit's internal state — plain * JSON by the unit contract; `z.json()` enforces that at the durable - * boundary. A row is never wrong, only possibly stale: `seq` says exactly - * how stale, and a `ver` mismatch against the live unit's `stateVersion` - * discards it at read time (never a migration). + * boundary. Without reading the current log extent, `seq` identifies only the + * row's original fold cut. A zero-I/O consumer treats the row as a tentative + * hint because it may trail the log or overreach a crash-repaired truncation; + * exact restore validates the cut before using it as authoritative state. A + * `ver` mismatch against the live unit's `stateVersion` discards the row at + * read time (never a migration). */ export const checkpointRow = z.object({ ver: z.number().int().nonnegative(), @@ -59,9 +62,10 @@ export const checkpointRecord = z.object({ export type CheckpointRecord = z.infer /** - * The session-projcache domain spec. Version bumps discard the whole medium - * (cache semantics: a stale or unreadable cache costs a longer tail replay, - * never a wrong value). + * The session-projcache domain spec. Version bumps discard the whole medium. + * Zero-I/O callers may use matching rows only as tentative hints; exact reads + * validate the current log extent and refold before returning authoritative + * state. */ export const projectionCacheDomainSpec = defineDomain({ name: 'session_projcache', diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 24678d86ea..10a28f2432 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -428,10 +428,11 @@ export class SessionProjectionRegistry extends Service { /** * View a checkpoint's rows without any log read: for every registered * client-visible unit whose row's `ver` matches, serve the schema-validated - * `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key - * absent (a cold or listing consumer treats it as not-yet-available and a - * fuller read path refolds it). The zero-I/O rung of the read ladder — - * values are as stale as their rows, never wrong. + * `view` of the schema-validated stored state; mismatched, malformed, or + * absent rows leave their key absent. The current log extent is unknown, so + * returned values are tentative hints: a row may trail the log or overreach + * a crash-repaired truncation. Exact restore validates the cut before using + * a row as authoritative state. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @param keys - optional wire keys to view. * @returns whole values per key with a usable row; empty when none. From 5521b98143d595f4e1488ca2b1f6ead5c8d1541e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 10:52:27 +0800 Subject: [PATCH 024/188] fix(session-projection): isolate host state from wire snapshots --- ...ubagent-list-identity-projection.i18n.yaml | 4 +- ...08-06-subagent-list-identity-projection.md | 28 ++++++------ ...06-subagent-list-identity-projection.zh.md | 44 +++++++++---------- ...rojection-state-and-client-views.i18n.yaml | 4 +- ...ssion-projection-state-and-client-views.md | 4 +- ...on-projection-state-and-client-views.zh.md | 4 +- .../time-context/tests/time-context.spec.ts | 4 +- .../sandbox-policy/tests/policy.spec.ts | 4 +- .../tests/cache.spec.ts | 27 ++++++++++++ .../session-projection/README.i18n.yaml | 4 +- packages/session/session-projection/README.md | 4 +- .../session/session-projection/README.zh.md | 6 +-- .../subagent/subagent/src/list-children.ts | 2 +- .../subagent/subagent/src/projection-types.ts | 2 + packages/subagent/subagent/src/projection.ts | 1 + .../tests/continuation-inheritance.spec.ts | 2 +- .../subagent/tests/list-children.spec.ts | 8 +++- 17 files changed, 94 insertions(+), 58 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml index 7f3103c5c7..3380cae988 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md -2026-08-06-subagent-list-identity-projection.md: a4fd153f3c052b684146f1c488e3c8f12f8f7cb0 -2026-08-06-subagent-list-identity-projection.zh.md: 9680a96310c203b6bb540f7faf1eb5b89ce13d17 +2026-08-06-subagent-list-identity-projection.md: e53d918afe3415213edb0156a5f2125c73d2ae35 +2026-08-06-subagent-list-identity-projection.zh.md: 80f7583430eb5e7b7909e32e46a347224bc102ec diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md index a4fd153f3c..e53d918afe 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md @@ -21,18 +21,18 @@ There are three families of escape from the per-child scan: promote mode/label i Key points: - **The subagent list does not depend on session-query**: enumeration is completed by a subagent-owned live-preferred merge, and mode/label is retrieved through `ctx.sessionProjections`; deployments without a query backend list as usual. -- **Value retrieval is a three-rung compute-and-discard ladder**: a live child reads `sessionProjections.stateOf(session, 'subagent')` (the registry's existing watermark cache, zero log reads); a cold child first reads the optional `sessionProjectionCache.cachedSnapshot(header)`, using the value directly when a non-null `subagent` identity passing the seq gate (`seq >= seedLength ?? 0`) is among its values; otherwise it pays one full `persistence.inspect` read plus a fold through the registered `subagent` unit; beyond that, absent is absent — no cache of its own, no write-back, no index. -- **The `subagent` projection unit is the sole authority over the fold rules**: the live `stateOf` read, the cold unit fold, and GUI history's detached fold all run the one registered unit; no second copy of descriptor-interpretation logic exists. +- **Value retrieval is a three-rung compute-and-discard ladder**: a live child reads `sessionProjections.snapshot(session, ['subagent'])` (the registry's existing watermark cache, zero log reads); a cold child first reads the optional `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`, using the non-null identity directly when it passes the seq gate (`seq >= seedLength ?? 0`); otherwise it pays one full Session observation plus a fold through the registered `subagent` unit; beyond that, absent is absent — no cache of its own, no write-back, no index. +- **The `subagent` projection unit is the sole authority over the fold rules**: live and cold snapshots both run the one registered unit; no second copy of descriptor-interpretation logic exists. - **The header, the descriptor (v2), session-persistence, session-projection(-cache), and session-query(-sqlite) are all untouched**; pre-existing data acquires exact values through one `inspect` computation the first time it is listed — no degraded unknown state, no migration. Relationship to existing notes: - This note supersedes two designs on the list read path in [durable-subagent-catalog](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md): enumeration through `sessionQuery.traceSession`, and per-child descriptor-event reads (the `listEvents`-plus-exact-`readEvent` double read with in-place diagnostic classification). The diagnostic row semantics is retained, with classification now derived by the list from projection-value absence and activity; the descriptor event remains the sole durable authority for mode/label and the fold input, and the resume authorization and Activation contracts are untouched. This is partial supersession; the two notes stay cross-linked. -- The [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) is the authority for the registry contract, split by the later [state-and-client-views note](2026-08-19-session-projection-state-and-client-views.md); this note only adds one registration to it — the `subagent` identity unit, host-only state — and consumes it through the live `stateOf` read and a cold fold of the same unit (GUI history's cold read is the same detached-restore shape). The fold rules are registered with the registry exactly once; every consuming surface computes through the one registered unit, and no second copy of the fold logic exists. +- The [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) is the authority for the registry contract, split by the later [state-and-client-views note](2026-08-19-session-projection-state-and-client-views.md); this note adds the client-visible `subagent` identity unit and consumes it through live and cold snapshots. The fold rules are registered with the registry exactly once; every consuming surface computes through the one registered unit, and no second copy of the fold logic exists. ### `subagent` projection unit -It hangs beside the existing `subagentTiming` ([projection.ts](../../../../packages/subagent/subagent/src/projection.ts), [projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)), under key `subagent` — host-only state, while `subagentTiming` keeps the client wire view: +It hangs beside the existing `subagentTiming` ([projection.ts](../../../../packages/subagent/subagent/src/projection.ts), [projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)), under key `subagent`. Both units provide client wire views; the identity view is the validated state itself: ```ts ignore-check export type SubagentIdentityProjection = @@ -51,7 +51,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { - The projection is pure identity, and **the projection system has no failure channel**: a unit never throws; a corrupt payload or an unrecognized version folds exactly like a log with no descriptor at all — the result is a **serializable null sentinel**: the map entry is `SubagentIdentityProjection | null`, non-optional, never undefined or an absent key. The reason: the unit's state is persisted as plain JSON, where an undefined field is dropped by stringify and a dropped key is indistinguishable from an absent value on the read side — a stale identity would survive in place; `null` passes JSON losslessly, and consumers replace the old identity with the sentinel. The judging discipline: consuming surfaces treat null and undefined (which only a JSON boundary dropping the key can produce) alike as no value. How "computed to nothing" is presented is the consumer's own business (see the `listChildren` four-state mapping below). - Label strength is decided by the descriptor schema: a continuable's label is mandatory at parse, a one-shot's was always optional; the mode/label discriminant matches the child row's strong contract below exactly (the row carries no `seq` — it is the projection's internal own-suffix proof). -- The identity carries `seq`: the seq of the `subagent/descriptor` event it was folded from, mandatory on both arms and absent on the null sentinel — `seq >= header.seedLength ?? 0` proves the identity was folded from the child's own suffix rather than a fork seed's replayed ancestor descriptor. The unit is host-only — no client wire view, so its state never enters client snapshots or `onChanged` frames — and it is checkpointed like every unit (the `persist` opt-in is gone); its `stateVersion` is 3: adding `seq` bumped it to 2, and the state/client-views split changing the fold state to the direct `SubagentIdentityProjection | null` sentinel bumped it to 3. Existing checkpoint rows are invalidated by version mismatch per the registry contract, falling to the authoritative refold. +- The identity carries `seq`: the seq of the `subagent/descriptor` event it was folded from, mandatory on both arms and absent on the null sentinel — `seq >= header.seedLength ?? 0` proves the identity was folded from the child's own suffix rather than a fork seed's replayed ancestor descriptor. The unit exposes that validated state directly as its client wire view and is checkpointed like every unit (the `persist` opt-in is gone); its `stateVersion` is 3: adding `seq` bumped it to 2, and the state/client-views split changing the fold state to the direct `SubagentIdentityProjection | null` sentinel bumped it to 3. Existing checkpoint rows are invalidated by version mismatch per the registry contract, falling to the authoritative refold. - Fold rule: `subagent/descriptor` is last-wins, under the same descriptor-reset discipline as `subagentTiming` — ancestor descriptors in the fork prefix are overridden by the session's own descriptor. A corrupt or unrecognized-version payload is last-wins all the same: it resets to the null sentinel rather than keeping the prior identity, so a fork of a healthy ancestor does not inherit an identity its own descriptor cannot stand up. ### Enumeration: subagent-owned live-preferred merge @@ -71,8 +71,8 @@ For each enumerated child, mode/label retrieval walks a three-rung ladder — co | Rung | Read | Cost | | --- | --- | --- | -| 1: live child | `ctx.sessionProjections.stateOf(session, 'subagent')` | Zero log reads — the registry's existing watermark cache, synchronous retrieval | -| 2: cold child, cache hit | The optional `sessionProjectionCache.cachedSnapshot(header)`, used directly only when a non-null `subagent` identity satisfies `identity.seq >= header.seedLength ?? 0` — an own descriptor is immutable once appended, and the seq gate proves the value was folded from the child's own suffix, regardless of the row's watermark | Zero log reads | +| 1: live child | `ctx.sessionProjections.snapshot(session, ['subagent'])` | Zero log reads — the registry's existing watermark cache, synchronous retrieval | +| 2: cold child, cache hit | The optional `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`, used directly only when the non-null identity satisfies `identity.seq >= header.seedLength ?? 0` — an own descriptor is immutable once appended, and the seq gate proves the value was folded from the child's own suffix, regardless of the row's watermark | Zero log reads | | 3: cold child, fallback | One full `persistence.inspect(id)` read + a fold through the registered `subagent` unit | One full read computed per listing | - Error contract: `sessionProjections` is a required injection — `SubagentRuntime` declares it in its inject set, so a deployment without the registry never activates the service (or the loop), and `listChildren` is unreachable rather than served degraded rows ([mandatory-seam note](2026-08-19-session-projection-mandatory-seam.md)); the loud runtime check and `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` are deleted with it. The session store keeps the explicit posture: an absent `ctx.get('sessions')` (a strict global read, never the caller-scope-bound property proxy) fails with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. apiproxy's dedicated `PROJECTIONS_UNAVAILABLE` wire face is deleted along with the code; `SESSION_STORE_UNAVAILABLE` goes through the generic internal fallback — apiproxy's composition injects `sessions` itself, so that error is unreachable in its deployment, and a dedicated mapping would violate the need principle. `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is deleted along with the session-query dependency. @@ -122,7 +122,7 @@ For each enumerated child, the ladder's result maps to a row through four states - `unsupported` is no longer produced: the type and the wire enum retain the member under "data structures stay as they are", and this note records it as no longer produced. - Descriptor-less settled debris moves from the old implementation's omit into the `corrupt` diagnostic — damaged, dead child sessions in the corpus are visible rather than silently vanishing, which is exactly the original motivation for keeping diagnostics. -- Only the `subagent` unit is folded by the list — live via `stateOf`, cold via the unit's own fold — and that fold never throws: a corrupt or unrecognized-version payload folds to the null sentinel, which the four-state mapping turns into that child's `corrupt` row (a deterministic data fault, aligned with the old implementation's `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` mapping semantics); no other registered unit is folded by listing, so there is no per-child containment for foreign folds. Live and cold are treated alike, isolation is per-child, and siblings and the listing itself are unaffected. It is orthogonal to "value absent + running → omit": the creation window means "no data yet", a fold to nothing means "the data is bad" — a poisoned running child also gets a `corrupt` row rather than an omit. +- The list selects only the `subagent` wire unit, whose fold never throws: a corrupt or unrecognized-version payload folds to the null sentinel, which the four-state mapping turns into that child's `corrupt` row (a deterministic data fault, aligned with the old implementation's `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` mapping semantics). Live and cold are treated alike, isolation is per-child, and siblings and the listing itself are unaffected. It is orthogonal to "value absent + running → omit": the creation window means "no data yet", a fold to nothing means "the data is bad" — a poisoned running child also gets a `corrupt` row rather than an omit. Known boundary deviations (deliberately accepted, recorded with this note): @@ -131,7 +131,7 @@ Known boundary deviations (deliberately accepted, recorded with this note): - A live/persisted header conflict: the old implementation made it per-child corrupt; enumeration now prefers live with no consistency check, the conflict goes unnoticed, and the live record forms the row. - A source-read failure on damaged storage (e.g. a bad surface rejected by the cold full read): the old implementation mapped it to per-child `corrupt`; it is now uniformly an `unavailable` row (the read side cannot tell the causes apart). - An unknown parent: the old implementation threw not-found through session-query ('parent session … was not found'); the subagent-owned merge now yields an empty subset for a nonexistent parent, enumeration returns an empty list, and later operations on the wire land as child-level subagent-not-found — a silent change of semantics and wording, recorded as explicitly accepted. -- Rung 2's later-event window: a cache row lands right after the first own descriptor, the log then appends a second own descriptor (or a malformed payload setting the null sentinel), and the process crashes before the next checkpoint — from then on a cold listing's rung 2, admitted by the seq≥seedLength gate, keeps serving the row's old identity (the first own descriptor's value), diverging from the authoritative refold (last-wins, the second), and a rung-2 hit triggers no refold, so nothing notices. Three boundaries: ① the precondition is a second own descriptor on the same child, violating the establishing provider's append-exactly-once contract — corruption-class data, same family and source as the multi-descriptor deviation; ② it takes both "corruption + a crash missing every checkpoint (the two mandatory points, turn/end and disposal, and the count/interval throttle points all unmet)" at once; ③ a healthy child (exactly one own descriptor) is unaffected — what the seq gate admits is precisely the only true identity. Self-healing: any live run of that child (the turn/end mandatory checkpoint) or any moment that triggers cache.write overwrites the whole row with a fresh fold (whole-record replace), and rung 2 serves correctly from then on; the authoritative paths (the rung-3 refold, the live `stateOf` read, the resume fold) are correct from the start, and the divergence exists only in listing reads while the child stays cold and the row is never rewritten. The mechanical fixes were not taken: gate reconciliation would need the log-end seq, unavailable to a zero-read cold path; a cache row carrying the revision is an opaque token, incomparable and a cross-domain schema change — filed as accepted under the "the cache is never authoritative" doctrine. +- Rung 2's later-event window: a cache row lands right after the first own descriptor, the log then appends a second own descriptor (or a malformed payload setting the null sentinel), and the process crashes before the next checkpoint — from then on a cold listing's rung 2, admitted by the seq≥seedLength gate, keeps serving the row's old identity (the first own descriptor's value), diverging from the authoritative refold (last-wins, the second), and a rung-2 hit triggers no refold, so nothing notices. Three boundaries: ① the precondition is a second own descriptor on the same child, violating the establishing provider's append-exactly-once contract — corruption-class data, same family and source as the multi-descriptor deviation; ② it takes both "corruption + a crash missing every checkpoint (the two mandatory points, turn/end and disposal, and the count/interval throttle points all unmet)" at once; ③ a healthy child (exactly one own descriptor) is unaffected — what the seq gate admits is precisely the only true identity. Self-healing: any live run of that child (the turn/end mandatory checkpoint) or any moment that triggers cache.write overwrites the whole row with a fresh fold (whole-record replace), and rung 2 serves correctly from then on; the authoritative paths (the rung-3 refold, the live snapshot, the resume fold) are correct from the start, and the divergence exists only in listing reads while the child stays cold and the row is never rewritten. The mechanical fixes were not taken: gate reconciliation would need the log-end seq, unavailable to a zero-read cold path; a cache row carrying the revision is an opaque token, incomparable and a cross-domain schema change — filed as accepted under the "the cache is never authoritative" doctrine. Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entirely as it was, zero changes** (the `list_agents` description and output schema are untouched; the plugin's load requirement changes — `sessionQuery` dropped from inject, `sessionProjections` added as a required injection). The only behavioral changes are in apiproxy: on the route segment, the `hasSubagentDescriptor()` scan is deleted and `hasSubagentOwner` looks only at `header.origin` — pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and the pre-release stance accepts this; and `subagents.history` is aligned with `session.history`'s source — a live child served from in-memory events and the registry's watermark snapshot, a cold child from `inspectServable` reading persistence directly with a detached fold, no query service involved, the SESSION_QUERY_* error arms retired with it, and the wire shape unchanged (the `history` JSDoc wording becomes the live in-memory snapshot / cold persisted log dual arm). @@ -139,7 +139,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir | Area | Files | Change | | --- | --- | --- | -| subagent | projection.ts, projection-types.ts, index.ts | New host-only `subagent` unit and its registration | +| subagent | projection.ts, projection-types.ts, index.ts | New client-visible `subagent` unit and its registration | | subagent | list-children.ts and its types | Rewritten as subagent-owned enumeration plus the projection-ladder four-state mapping; the session-query dependency, per-child event reads, and in-place classification machinery deleted; error code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` deleted, and `sessionProjections` becomes a required injection (no projection error code remains); new optional dependency dsh-session-projection-cache (pure read acceleration, skipped when absent) | | host/apiproxy | api-proxy.ts | `hasSubagentDescriptor` deleted; the owner check looks only at `header.origin`; `subagents.history` shares `session.history`'s source — live from in-memory events and the registry's watermark snapshot, cold from `inspectServable` reading persistence directly with a detached fold, no query service, the SESSION_QUERY_* error arms and the dedicated `PROJECTIONS_UNAVAILABLE` wire face retired with it | | tool | tool-subagent-control/list-agents.ts | Load requirement narrowed (`sessionQuery` dropped from inject); model-visible schema, description, and rendering unchanged | @@ -150,7 +150,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir **mode/label into SessionHeader.** The strongest zero-read guarantee — rows form from the header alone. But a header shape change propagates into both persistence backends and the header compatibility check; SQLite rejects pre-existing data outright, and JSONL pre-existing data can only degrade to unknown or be backfilled. Read-time computation's answer for pre-existing data is "one `inspect` computation on first listing", touching no durable format. -**The projection-cache ladder (`cachedSnapshot ?? coldSnapshot` plus fail-soft write-back).** The mechanism works — session-projection-cache's checkpoint ladder is designed for cold reads in the first place. But checkpoint write-back is a whole list-driven body of derived-data persistence and invalidation orchestration (floor/identity/putSoft); what was rejected is that orchestration as the primary mechanism. The settled three-rung ladder later reuses this cache opportunistically, read-only, as its second rung — no write-back, no orchestration, skipped when absent. +**The projection-cache ladder (`cachedSnapshot ?? cold fold` plus fail-soft write-back).** The mechanism works — session-projection-cache's checkpoint ladder is designed for cold reads in the first place. But checkpoint write-back is a whole list-driven body of derived-data persistence and invalidation orchestration (floor/identity/putSoft); what was rejected is that orchestration as the primary mechanism. The settled three-rung ladder later reuses this cache opportunistically, read-only, as its second rung — no write-back, no orchestration, skipped when absent. **A bounded-read primitive on persistence to rescue pre-existing data.** Opens a new persistence primitive for a one-time problem; superseded by the read-time `inspect` full read — the full read the first time pre-existing data is listed is itself the value retrieval. @@ -174,7 +174,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir - Listing a live child reads zero log throughout; with the cache unmounted or missed, a cold child pays one full `inspect` read per listing, at a cost proportional to its transcript size and repeated with listing frequency — compute-and-discard is the settled stance: no cache of its own is built, nothing is written back, and short-term repeated full reads of the same id can hit the preparation-phase LRU, though listing does not depend on it. - The subagent list no longer requires a query backend: both pure-live and persistence-less deployments can list; `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is gone, loading the `list_agents` plugin no longer requires `sessionQuery`, and `sessionProjections` becomes a required injection of `SubagentRuntime` — a deployment without the projection registry never activates the service (the mandatory seam). -- Identity interpretation exists only in the single unit registered with the registry: the list's three-rung ladder and GUI history's cold read all use the unit's own reads (live `stateOf`, the cache's `cachedSnapshot`, the cold unit fold), and no hand-written bypass fold exists; if some future consuming surface bypasses the unit with a hand-written fold, values will drift across read faces — a discipline this design requires be maintained, not a mechanical guarantee. +- Identity interpretation exists only in the single unit registered with the registry: the list's three-rung ladder and GUI history's cold read use its live, cached, or observed wire snapshots, and no hand-written bypass fold exists; if some future consuming surface bypasses the unit with a hand-written fold, values will drift across read faces — a discipline this design requires be maintained, not a mechanical guarantee. - Per-child isolation is back: a single child's cold-read failure loses only that row and healthy siblings are unaffected; a persistence listing failure still fails the whole enumeration. - The diagnostic and enumeration semantics leaves six boundary deviations (a stillborn fork surfacing under its ancestor's identity, multiple descriptors resolving to the last, header conflicts going unnoticed, damaged-source read failures shifting from `corrupt` to `unavailable`, an unknown parent yielding an empty list instead of not-found, and rung 2's later-event window); the full semantics is in the known-boundary-deviations list; the first four are display or classification deviations on debris-grade data, the unknown-parent one is a silent query-semantics change, and the rung-2 window is a self-healing cache-serving divergence under the double condition of corruption plus a crash; resume authorization is unaffected throughout, all explicitly accepted. - Pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and pre-release carries no compatibility promise. @@ -182,8 +182,8 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir ## Related - [Durable subagent catalog and list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) — partially superseded by this note: the descriptor remains the durable authority for mode/label and the fold input, while the list's enumeration and value retrieval move to the subagent-owned merge plus the projection ladder. -- [Session projections and command lifecycle logging](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) — the authority for the registry contract; this note adds the `subagent` identity unit to it and consumes it through the live `stateOf` read and the cold unit fold. -- [Session projection state and client views](2026-08-19-session-projection-state-and-client-views.md) — the state/client split; the `subagent` identity unit is host-only state in the state table, and `subagentTiming` keeps the client wire view. +- [Session projections and command lifecycle logging](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) — the authority for the registry contract; this note adds the `subagent` identity unit and consumes its live and cold wire snapshots. +- [Session projection state and client views](2026-08-19-session-projection-state-and-client-views.md) — the state/client split; both `subagent` and `subagentTiming` provide client wire views. - [Session projections as a required seam](2026-08-19-session-projection-mandatory-seam.md) — `sessionProjections` becomes a required injection; the list's error contract follows it (registry absence is an activation-time failure, and the projection error code is deleted). - [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md) — the origin of `SessionHeader.origin` (#1569), the first half of taking identity determination off the log; its history cold read (inspect prefix plus registry fold) is the same-shape precedent for this note's value ladder. - [Reusable Session preparation before publication](2026-08-05-session-preparation.md) — the `inspect()` cold read and LRU reuse; the cold child's full-read cost model builds on it. diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md index 9680a96310..80f7583430 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md @@ -10,7 +10,7 @@ Status: implemented 同一根因还有第二个症状:host 侧的 `hasSubagentDescriptor()` 在每次 Agent(智能体)绑定 RPC 的属主判定上扫描目标会话的 own suffix,即便 `SessionHeader.origin` 已经回答了同一个问题的绝大部分。 -根因在于 [durable-subagent-catalog 决策](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)把描述符事件(`subagent/descriptor`)定为目录的唯一持久权威,却没有为描述符读取配任何缓存层,并把逐 child 双读明确接受为「无索引的正确性基线」。[web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md)(#1569)已把「是不是 subagent」放进了 header(`SessionHeader.origin`),身份判定不再读日志;mode 与 label 仍然要扫。 +根因在于 [durable-subagent-catalog 决策](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md)把描述符事件(`subagent/descriptor`)定为目录的唯一持久权威,却没有为描述符读取配任何缓存层,并把逐 child 双读明确接受为「无索引的正确性基线」。[web subagent conversations](../feature/2026-07-27-web-subagent-conversations.zh.md)(#1569)已把「是不是 subagent」放进了 header(`SessionHeader.origin`),身份判定不再读日志;mode 与 label 仍然要扫。 ## 决策 @@ -21,18 +21,18 @@ mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠 要点: - **subagent 列表不依赖 session-query**:枚举由 subagent 自管的 live-preferred 合并完成,mode/label 经 `ctx.sessionProjections` 取值;没有 query backend 的部署照常列表。 -- **取值三级「算完即止」阶梯**:live child 读 `sessionProjections.stateOf(session, 'subagent')`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 且过 seq 门(`seq >= seedLength ?? 0`)的 `subagent` 身份即直接用;否则一次 `persistence.inspect` 整读加经注册的 `subagent` unit 折叠;再没有就没有——不自建缓存、无回写、无索引。 -- **`subagent` projection unit 是折叠规则唯一权威**:live `stateOf` 读取、cold unit 折叠、GUI history 的 detached 折叠全部运行同一份已注册 unit,不存在第二份描述符解释逻辑。 +- **取值三级「算完即止」阶梯**:live child 读 `sessionProjections.snapshot(session, ['subagent'])`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`,非 null 身份通过 seq 门(`seq >= seedLength ?? 0`)即直接使用;否则执行一次完整 Session 观察,再经注册的 `subagent` unit 折叠;再没有就没有——不自建缓存、无回写、无索引。 +- **`subagent` projection unit 是折叠规则唯一权威**:live 与 cold 快照都运行同一份已注册 unit,不存在第二份描述符解释逻辑。 - **header、描述符(v2)、session-persistence、session-projection(-cache)、session-query(-sqlite) 全部零改动**;存量数据第一次被列表时一次 `inspect` 现算获得精确值,无 unknown 降级态、无迁移。 与既有记录的关系: -- 本记录取代 [durable-subagent-catalog](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) 中列表读路径的两项设计:经 `sessionQuery.traceSession` 枚举,与逐 child 读取描述符事件(`listEvents` 加精确 `readEvent` 双读、就地诊断分类)。diagnostic 行语义保留,分类改由列表按投影值缺席与 activity 派生;描述符事件仍是 mode/label 的唯一持久权威与折叠输入,恢复鉴权与激活约定不动。属部分取代,两记录保持交叉链接。 -- [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) 是 registry 约定的权威,其后由 [state-and-client-views 记录](2026-08-19-session-projection-state-and-client-views.md)拆分为 host 状态与客户端视图;本记录只为其新增 `subagent` 身份 unit 一个注册项(host-only 状态),并经 live `stateOf` 读取与同一 unit 的 cold 折叠消费它——GUI history 的冷读仍是同款 detached-restore 形状。折叠规则只在 registry 注册一份;任何消费面都经这一份已注册 unit 计算,不存在第二份折叠逻辑。 +- 本记录取代 [durable-subagent-catalog](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md) 中列表读路径的两项设计:经 `sessionQuery.traceSession` 枚举,与逐 child 读取描述符事件(`listEvents` 加精确 `readEvent` 双读、就地诊断分类)。diagnostic 行语义保留,分类改由列表按投影值缺席与 activity 派生;描述符事件仍是 mode/label 的唯一持久权威与折叠输入,恢复鉴权与激活约定不动。属部分取代,两记录保持交叉链接。 +- [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md) 是 registry 约定的权威,其后由 [state-and-client-views 记录](2026-08-19-session-projection-state-and-client-views.zh.md)拆分为 host 状态与客户端视图;本记录新增客户端可见的 `subagent` 身份 unit,并经 live 与 cold 快照消费它。折叠规则只在 registry 注册一份;任何消费面都经这一份已注册 unit 计算,不存在第二份折叠逻辑。 ### `subagent` projection unit -挂在现有 `subagentTiming` 旁([projection.ts](../../../../packages/subagent/subagent/src/projection.ts)、[projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)),key 为 `subagent`——host-only 状态,`subagentTiming` 保留客户端 wire view: +挂在现有 `subagentTiming` 旁([projection.ts](../../../../packages/subagent/subagent/src/projection.ts)、[projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)),key 为 `subagent`。两个 unit 都提供客户端 wire view;身份 view 就是校验后的状态本身: ```ts ignore-check export type SubagentIdentityProjection = @@ -51,7 +51,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { - 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样,折叠结果是**可序列化的 null 哨兵**——map 条目为 `SubagentIdentityProjection | null`,非可选、非 undefined/缺 key。理由:unit 的状态以纯 JSON checkpoint 持久化,undefined 字段被 stringify 丢弃,读侧无从区分被丢弃的 key 与缺值——旧身份将原样存活;null 完好过 JSON,消费方以哨兵替换旧身份。判定纪律:消费面把 null 与 undefined(仅 JSON 边界丢 key 可产生)一律视为无值。「算出来没有」如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。 - label 强度由描述符 schema 决定:continuable 的 label 解析强制必有,one-shot 的本就可选;mode/label 判别与下文 child 行的强约定完全一致(行不携带 `seq`——它是投影内部的 own-suffix 证明)。 -- 身份携带 `seq`:折出该身份的 `subagent/descriptor` 事件 seq,两臂必有、null 哨兵无——`seq >= header.seedLength ?? 0` 证明身份折叠自 child 自身后缀,而非 fork 种子回放的祖先描述符。unit 为 host-only——无客户端 wire view,其状态不进客户端 snapshot 与 `onChanged` 帧——且与其他 unit 一律检查点化(`persist` 选项已删除);`stateVersion` 现为 3:增 `seq` 升至 2,state/client 视图拆分把折叠状态改为直接的 `SubagentIdentityProjection | null` 哨兵后再升至 3。既存 checkpoint 行按 registry 约定版本失配失效、落权威重折。 +- 身份携带 `seq`:折出该身份的 `subagent/descriptor` 事件 seq,两臂必有、null 哨兵无——`seq >= header.seedLength ?? 0` 证明身份折叠自 child 自身后缀,而非 fork 种子回放的祖先描述符。unit 将这份校验后的状态直接暴露为客户端 wire view,并与其他 unit 一律检查点化(`persist` 选项已删除);`stateVersion` 现为 3:增 `seq` 升至 2,state/client 视图拆分把折叠状态改为直接的 `SubagentIdentityProjection | null` 哨兵后再升至 3。既存 checkpoint 行按 registry 约定版本失配失效、落权威重折。 - 折叠规则:`subagent/descriptor` last-wins,与 `subagentTiming` 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。损坏或版本不认识的载荷同样 last-wins:重置为 null 哨兵而非保留先前身份,健康祖先的 fork 不会继承自身描述符立不住的身份。 ### 枚举:subagent 自管 live-preferred 合并 @@ -71,16 +71,16 @@ declare module '@deepseek-ai/dsh-session-projection/types' { | 级 | 读法 | 成本 | | --- | --- | --- | -| 1:live child | `ctx.sessionProjections.stateOf(session, 'subagent')` | 零日志读——注册表既有水位缓存,同步取值 | -| 2:cold child,cache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 的 `subagent` 身份且 `identity.seq >= header.seedLength ?? 0` 才直接用——own descriptor 一经追加不可变,seq 门证明该值折叠自 child 自身后缀,无视行水位 | 零日志读 | +| 1:live child | `ctx.sessionProjections.snapshot(session, ['subagent'])` | 零日志读——注册表既有水位缓存,同步取值 | +| 2:cold child,cache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`,非 null 身份满足 `identity.seq >= header.seedLength ?? 0` 才直接使用——own descriptor 一经追加不可变,seq 门证明该值折叠自 child 自身后缀,无视行水位 | 零日志读 | | 3:cold child,兜底 | `persistence.inspect(id)` 整读 + 经注册的 `subagent` unit 折叠 | 每次列表一次整读现算 | -- 错误约定:`sessionProjections` 是必需注入——`SubagentRuntime` 在 inject 集里声明它,没有 registry 的部署根本无法激活服务(与 loop),`listChildren` 不可达,而不是供出降级行([mandatory-seam 记录](2026-08-19-session-projection-mandatory-seam.md));响亮运行时检查与 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 随之删除。会话存储保留显式姿态:`ctx.get('sessions')`(严格全局读取,不走调用方作用域的属性代理)缺席以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 失败。apiproxy 为 `PROJECTIONS_UNAVAILABLE` 设的专门 wire 脸随码删除;`SESSION_STORE_UNAVAILABLE` 走通用 internal 兜底——apiproxy 组合自身就 inject `sessions`,该错误在其部署不可达,专门映射违反 need 原则。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。 +- 错误约定:`sessionProjections` 是必需注入——`SubagentRuntime` 在 inject 集里声明它,没有 registry 的部署根本无法激活服务(与 loop),`listChildren` 不可达,而不是供出降级行([mandatory-seam 记录](2026-08-19-session-projection-mandatory-seam.zh.md));响亮运行时检查与 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 随之删除。会话存储保留显式姿态:`ctx.get('sessions')`(严格全局读取,不走调用方作用域的属性代理)缺席以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 失败。apiproxy 为 `PROJECTIONS_UNAVAILABLE` 设的专门 wire 脸随码删除;`SESSION_STORE_UNAVAILABLE` 走通用 internal 兜底——apiproxy 组合自身就 inject `sessions`,该错误在其部署不可达,专门映射违反 need 原则。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。 - cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的必需注入相对)。第二级任何抛错(包括缓存内任一 unit 行中毒使 `viewCheckpoint` 引爆)静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 切面早于描述符的行,`subagent` key 天然缺席,自动落底,无特判;行里的 null 哨兵同样不作数——一律落第三级,由权威重折裁决。创建窗口内的 count/interval checkpoint 可能把 fork 种子回放的祖先身份落进行——祖先 seq 落在 seed 区间,被 seq 门拒绝,同样落第三级裁决。 - per-child 隔离:单 child 的 cold 整读失败只使该行成为 `unavailable` diagnostic,下次列表自然重试,不影响 sibling(见四态映射)。 - 冷路径的生命周期见证:preparation 的结果必须仍指向枚举时的那个生命周期——见证字段集与旧 SOURCE_CONFLICT 检查同款七字段(version、id、createdAt、cwd、parentSession、seedLength、delegationDepth);同 id 删除后重新发布的会话对旧 parent 的目录降级为 `corrupt` 行,不外漏新 owner 的 child。 - 冷读并发以常数 4 有界——它约束的是本地介质的一次只读扫描而非部署行为;出现联网 persistence backend 时提升为验证过的 `Config` 字段。 -- 冷读成本如实记录:cache 未挂载或未命中时,cold child 每次列表才付一次整读,成本与其 transcript 大小成正比;定案「算完即止」,不自建缓存。整读经 `inspect()` 走 [Session 准备阶段](2026-08-05-session-preparation.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。 +- 冷读成本如实记录:cache 未挂载或未命中时,cold child 每次列表才付一次整读,成本与其 transcript 大小成正比;定案「算完即止」,不自建缓存。整读经 `inspect()` 走 [Session 准备阶段](2026-08-05-session-preparation.zh.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。 - 取消:每次 persistence 读前后检查调用方 signal,abort 之后才结算的读拒绝归一化为稳定错误码 `CANCELLED`。 ### 权威模型 @@ -122,7 +122,7 @@ export type SubagentListEntry = - `unsupported` 不再被产出:类型与 wire 枚举按「数据结构保持现状」留存该成员,本记录留档其为不再产出。 - descriptor-less 定局残骸从旧实现的 omit 归入 `corrupt` diagnostic——库里的坏、死子会话可见,不静默消失,这正是保留 diagnostic 的原始动机。 -- 列表只折叠 `subagent` 一个 unit——live 经 `stateOf`,cold 经该 unit 自身的折叠——而该折叠永不抛错:描述符损坏或版本不认识折为 null 哨兵,由四态映射收纳为该 child 的 `corrupt` 行(确定性数据故障,对齐旧实现 `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` 的映射语义);列表不再折叠任何其他注册 unit,也就不存在对外来 fold 的逐 child 收纳。live 与 cold 同待遇,逐 child 隔离,sibling 与列表本身不受影响。它与「无值 + running → omit」正交:创建窗口是「尚无数据」,折为无值是「数据坏了」——running 的中毒 child 也出 `corrupt` 行而非 omit。 +- 列表只选择 `subagent` wire unit,且该折叠永不抛错:描述符损坏或版本不认识折为 null 哨兵,由四态映射收纳为该 child 的 `corrupt` 行(确定性数据故障,对齐旧实现 `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` 的映射语义)。live 与 cold 同待遇,逐 child 隔离,sibling 与列表本身不受影响。它与「无值 + running → omit」正交:创建窗口是「尚无数据」,折为无值是「数据坏了」——running 的中毒 child 也出 `corrupt` 行而非 omit。 已知边界偏差(有意接受,随本记录留档): @@ -131,7 +131,7 @@ export type SubagentListEntry = - live/persisted header 冲突,旧实现是 per-child corrupt;现枚举 live 优先、不做一致性校验,冲突不再被察觉,以 live 记录成行。 - 损坏存储的源读失败(如坏 surface 被冷读整读拒收),旧实现映射 per-child `corrupt`,现统一成 `unavailable` 行(读侧无从区分成因)。 - 未知 parent,旧实现经 session-query 抛 not-found(「parent session … was not found」);现自管合并对不存在的 parent 得到空子集,枚举返回空列表,wire 上后续操作落到 child 级 subagent-not-found——语义与文案的静默变化,显式接受。 -- rung 2 的更晚事件窗口:cache 行恰在首个自有描述符之后落盘,日志随后追加第二个自有描述符(或 malformed 载荷置 null 哨兵),且进程在下一次 checkpoint 前崩溃——此后冷列表的 rung 2 凭 seq≥seedLength 门持续供出行内旧身份(第一个自有描述符的值),与权威重折(last-wins 第二个)分歧,且 rung 2 命中期间不触发重折、无从察觉。边界三条:①前提是同一 child 出现第二个自有描述符,违反建档提供方「恰追加一次」约定,属损坏类数据,与多描述符偏差同族同源;②需「损坏 + 崩溃错过 checkpoint(turn/end 与 disposal 两个 mandatory 点及 count/interval 节流点全部未及)」双条件同时成立;③健康 child(恰一自有描述符)不受影响——seq 门放行的正是唯一真身份。自愈条件:该 child 任一次 live 运行(turn/end mandatory checkpoint)或任何触发 cache.write 的时点,都会以新 fold 整行覆写(whole-record replace),rung 2 随即供正;权威路径(rung 3 重折、live `stateOf` 读取、resume 折叠)自始正确,分歧只存在于持续冷、行未再更新期间的列表读。机制修法不采:gate 对账需知日志末端 seq,冷路径零读不可得;cache 行携 revision 是 opaque token,无法比较且跨域改 schema——按「cache 永不为权威」总纲归档为接受项。 +- rung 2 的更晚事件窗口:cache 行恰在首个自有描述符之后落盘,日志随后追加第二个自有描述符(或 malformed 载荷置 null 哨兵),且进程在下一次 checkpoint 前崩溃——此后冷列表的 rung 2 凭 seq≥seedLength 门持续供出行内旧身份(第一个自有描述符的值),与权威重折(last-wins 第二个)分歧,且 rung 2 命中期间不触发重折、无从察觉。边界三条:①前提是同一 child 出现第二个自有描述符,违反建档提供方「恰追加一次」约定,属损坏类数据,与多描述符偏差同族同源;②需「损坏 + 崩溃错过 checkpoint(turn/end 与 disposal 两个 mandatory 点及 count/interval 节流点全部未及)」双条件同时成立;③健康 child(恰一自有描述符)不受影响——seq 门放行的正是唯一真身份。自愈条件:该 child 任一次 live 运行(turn/end mandatory checkpoint)或任何触发 cache.write 的时点,都会以新 fold 整行覆写(whole-record replace),rung 2 随即供正;权威路径(rung 3 重折、live snapshot、resume 折叠)自始正确,分歧只存在于持续冷、行未再更新期间的列表读。机制修法不采:gate 对账需知日志末端 seq,冷路径零读不可得;cache 行携 revision 是 opaque token,无法比较且跨域改 schema——按「cache 永不为权威」总纲归档为接受项。 消费面:wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件的加载要求变化——inject 去掉 `sessionQuery`、新增必需注入 `sessionProjections`)。行为上动的只有 apiproxy:路由段的 `hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受;`subagents.history` 与 `session.history` 同源对齐——live child 用内存事件与注册表水位快照,cold child 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役,wire 形状不变(`history` 的 JSDoc 措辞改为 live 内存快照/cold 持久日志双臂)。 @@ -139,7 +139,7 @@ export type SubagentListEntry = | 区域 | 文件 | 改动 | | --- | --- | --- | -| subagent | projection.ts、projection-types.ts、index.ts | 新 host-only `subagent` unit 与注册 | +| subagent | projection.ts、projection-types.ts、index.ts | 新客户端可见 `subagent` unit 与注册 | | subagent | list-children.ts 及类型 | 重写为自管枚举 + 投影阶梯四态映射;删 session-query 依赖、逐 child 事件读取与就地分类机器;错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 删除,`sessionProjections` 转为必需注入(不再存在投影错误码);新增可选依赖 dsh-session-projection-cache(纯加速读取,缺席跳过) | | host/apiproxy | api-proxy.ts | 删 `hasSubagentDescriptor`,属主判定只看 `header.origin`;`subagents.history` 与 `session.history` 同源——live 用内存事件与注册表水位快照,cold 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂与 `PROJECTIONS_UNAVAILABLE` 专门 wire 脸随之退役 | | tool | tool-subagent-control/list-agents.ts | 加载要求收窄(inject 去 `sessionQuery`);model-visible schema、描述与渲染零改动 | @@ -150,7 +150,7 @@ export type SubagentListEntry = **mode/label 进 SessionHeader。** 零读保证最强——列表只看 header 就能成行。但 header 形状变更传导两个 persistence backend 与 header 兼容检查;SQLite 存量直接拒收,JSONL 存量只能 unknown 降级或 backfill。读时现算对存量的答案是「第一次列表一次 `inspect` 现算」,不碰持久格式。 -**projection-cache 阶梯(`cachedSnapshot ?? coldSnapshot` 加 fail-soft 写回)。** 机制成立——session-projection-cache 的 checkpoint 阶梯本就为冷读设计。但 checkpoint 写回是一套由列表驱动的派生数据持久化与失效编排(floor/identity/putSoft);被否的是这套编排作为主机制。定稿的第三级阶梯后来以只读方式机会性复用该缓存作第二级——无写回、无编排、缺席即跳过。 +**projection-cache 阶梯(`cachedSnapshot ?? cold fold` 加 fail-soft 写回)。** 机制成立——session-projection-cache 的 checkpoint 阶梯本就为冷读设计。但 checkpoint 写回是一套由列表驱动的派生数据持久化与失效编排(floor/identity/putSoft);被否的是这套编排作为主机制。定稿的第三级阶梯后来以只读方式机会性复用该缓存作第二级——无写回、无编排、缺席即跳过。 **给 persistence 加有界读原语抢救存量。** 为一次性问题新开 persistence 原语;被读时 `inspect` 整读取代——存量第一次被列表时的整读就是取值本身。 @@ -174,16 +174,16 @@ export type SubagentListEntry = - live child 的列表全程零日志读;cold child 在 cache 未挂载或未命中时每次列表一次 `inspect` 整读,成本与其 transcript 大小成正比、随列表频率重复——定案「算完即止」,不自建缓存、不回写,同 id 短期重复整读可命中准备阶段 LRU 但列表不依赖它。 - subagent 列表不再要求 query backend:纯 live 与无 persistence 的部署都能列表;`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 消失,`list_agents` 插件加载不再要求 `sessionQuery`,而 `sessionProjections` 转为 `SubagentRuntime` 的必需注入——没有投影 registry 的部署根本不会激活服务(mandatory seam)。 -- 身份解释只存在于 registry 注册的一份 unit:列表三级阶梯与 GUI history 冷读走的都是该 unit 自身的读法(live `stateOf`、cache 的 `cachedSnapshot`、cold unit 折叠),不存在手写旁路折叠;若未来某消费面绕开该 unit 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。 +- 身份解释只存在于 registry 注册的一份 unit:列表三级阶梯与 GUI history 冷读使用其 live、cached 或 observed wire 快照,不存在手写旁路折叠;若未来某消费面绕开该 unit 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。 - per-child 隔离回归:单 child 冷读失败只损失该行,healthy sibling 不受影响;persistence 列表失败仍使整次枚举失败。 - 诊断与枚举语义留下六处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`、未知 parent 由 not-found 改为空列表、rung 2 更晚事件窗口),完整语义见已知边界偏差清单;前四处为残骸级数据的展示或分类偏差,未知 parent 一处是查询语义的静默变化,rung 2 窗口一处是损坏加崩溃双条件下可自愈的缓存供值分歧;恢复鉴权均不受影响,显式接受。 - pre-#1569 的无 `origin` 存量不再被认作 subagent 属主;其本就不进目录,pre-release 无兼容承诺。 ## 相关 -- [durable-subagent-catalog 与 list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)——被本记录部分取代:描述符仍是 mode/label 的持久权威与折叠输入,列表的枚举与取值改为自管合并加投影阶梯。 -- [session projections 与命令生命周期日志](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md)——registry 约定的权威;本记录为其新增 `subagent` 身份 unit,并经 live `stateOf` 读取与 cold unit 折叠消费它。 -- [session projection 状态与客户端视图](2026-08-19-session-projection-state-and-client-views.md)——state/client 拆分;`subagent` 身份 unit 是状态表中的 host-only 状态,`subagentTiming` 保留客户端 wire view。 -- [session projections 作为必需接缝](2026-08-19-session-projection-mandatory-seam.md)——`sessionProjections` 转为必需注入;列表的错误约定随其变化(registry 缺席是激活期失败,投影错误码删除)。 -- [web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md)——`SessionHeader.origin` 的出处(#1569),身份判定去日志化的前半步;其 history 冷读(inspect 前缀加 registry 折叠)是本记录取值阶梯的同款先例。 -- [发布前可复用的 Session 准备阶段](2026-08-05-session-preparation.md)——`inspect()` 冷读与 LRU 复用;cold child 整读的成本模型建立其上。 +- [durable-subagent-catalog 与 list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md)——被本记录部分取代:描述符仍是 mode/label 的持久权威与折叠输入,列表的枚举与取值改为自管合并加投影阶梯。 +- [session projections 与命令生命周期日志](../../proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)——registry 约定的权威;本记录为其新增 `subagent` 身份 unit,并消费其 live 与 cold wire 快照。 +- [session projection 状态与客户端视图](2026-08-19-session-projection-state-and-client-views.zh.md)——state/client 拆分;`subagent` 与 `subagentTiming` 都提供客户端 wire view。 +- [session projections 作为必需接缝](2026-08-19-session-projection-mandatory-seam.zh.md)——`sessionProjections` 转为必需注入;列表的错误约定随其变化(registry 缺席是激活期失败,投影错误码删除)。 +- [web subagent conversations](../feature/2026-07-27-web-subagent-conversations.zh.md)——`SessionHeader.origin` 的出处(#1569),身份判定去日志化的前半步;其 history 冷读(inspect 前缀加 registry 折叠)是本记录取值阶梯的同款先例。 +- [发布前可复用的 Session 准备阶段](2026-08-05-session-preparation.zh.md)——`inspect()` 冷读与 LRU 复用;cold child 整读的成本模型建立其上。 diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml index 2597ed75df..6461682609 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md -2026-08-19-session-projection-state-and-client-views.md: 0b1810b27594e7f18af5afca67ba39b0cb0f3cc3 -2026-08-19-session-projection-state-and-client-views.zh.md: 3b1ed72bf240ebf43924826d42226ff301ad8fca +2026-08-19-session-projection-state-and-client-views.md: e8b4c3c052ba3f172515a4d131b2f58f75d902d8 +2026-08-19-session-projection-state-and-client-views.zh.md: fb2488aeb6be00f981444a26baab2b5d9c8115ae diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md index 0b1810b275..e8b4c3c052 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md @@ -12,11 +12,11 @@ The projection registry persisted each unit's internal fold state without a runt `SessionProjectionStateMap` is the merge-extensible table for host fold states. Every `ProjectionDefinition` key belongs to this table and supplies a `stateSchema`; cached rows are validated before they seed a fold. `SessionProjectionMap` retains its existing meaning and name as the sole table of client-visible whole values, preserving existing client data structures such as `title: string | null`. -A unit whose key also appears in `SessionProjectionMap` supplies `wire.viewSchema` and `wire.view`. Every unit's state is checkpointed — client-visible and host-only alike; the `persist` opt-in is gone, so no unit can silently skip the durable cache. Carrier reads use `wireOnly` so internal states do not enter API payloads. Host code reads one current state through `stateOf(session, key)`; the returned reference is borrowed and must not be mutated. +A unit whose key also appears in `SessionProjectionMap` supplies `wire.viewSchema` and `wire.view`. Every unit's state is checkpointed — client-visible and host-only alike; the `persist` opt-in is gone, so no unit can silently skip the durable cache. Snapshot APIs enumerate only definitions with `wire`, so omitting an audience flag cannot place internal state in an API payload. Host code reads one current state through `stateOf(session, key)`; the returned reference is borrowed and must not be mutated. ## Consequences -Projection state and client values are independently typed and validated without introducing a second client DTO vocabulary. A unit may expose a compact or compatibility-preserving client value while retaining richer host state. Malformed cached state cannot seed `viewCheckpoint`; restore rejects malformed state and the cache's existing full-read fallback rebuilds it from the log. Host consumers can replace private log scans with the same incremental fold used by carriers. +Projection state and client values are independently typed and validated without introducing a second client DTO vocabulary. A unit may expose a compact or compatibility-preserving client value while retaining richer host state. Malformed cached state cannot seed `viewCheckpoint`; restore rejects malformed state and the cache's existing full-read fallback rebuilds it from the log. Host consumers can replace private log scans with the same incremental fold used by carriers, while carrier snapshots cannot enumerate host-only state. The original [session-projection proposal](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) now records this split. The earlier [subagent identity projection](2026-08-06-subagent-list-identity-projection.md) and [projected token usage](2026-07-29-projected-token-usage-and-request-context.md) decisions remain current; their domain folds move to the state table without changing their user-facing values. diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md index 3b1ed72bf2..fb2488aeb6 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md @@ -12,11 +12,11 @@ `SessionProjectionStateMap` 是 host 折叠状态的 merge-extensible 类型表。每个 `ProjectionDefinition` key 都属于此表并提供 `stateSchema`;缓存行只有通过校验后才能为折叠提供初始状态。`SessionProjectionMap` 保留原有名称和语义,继续作为唯一的客户端可见全量值类型表,因此 `title: string | null` 等既有客户端数据结构保持不变。 -如果一个单元的 key 也存在于 `SessionProjectionMap`,该单元就提供 `wire.viewSchema` 与 `wire.view`。每个单元的状态都会写入检查点——client-visible 与 host-only 一视同仁;`persist` 选择项已移除,任何单元都不能悄悄跳过持久化缓存。快照 API 只返回 `SessionProjectionMap`,因此内部状态不会进入 API 载荷。host 代码通过 `stateOf(session, key)` 读取一份当前状态;返回的是借用引用,不得修改。 +如果一个单元的 key 也存在于 `SessionProjectionMap`,该单元就提供 `wire.viewSchema` 与 `wire.view`。每个单元的状态都会写入检查点——client-visible 与 host-only 一视同仁;`persist` 选择项已移除,任何单元都不能悄悄跳过持久化缓存。快照 API 只枚举带 `wire` 的定义,因此漏传受众标记也无法让内部状态进入 API 载荷。host 代码通过 `stateOf(session, key)` 读取一份当前状态;返回的是借用引用,不得修改。 ## 结果 -投影状态和客户端值分别获得类型与校验,同时不引入第二套客户端 DTO 词汇。单元可以保留更丰富的 host 状态,并暴露紧凑或兼容既有结构的客户端值。畸形缓存状态不能为 `viewCheckpoint` 提供数据;恢复会拒绝畸形状态,并由缓存既有的全量读取回退从日志重建。host 消费方可以用同一套增量折叠替换私有日志扫描。 +投影状态和客户端值分别获得类型与校验,同时不引入第二套客户端 DTO 词汇。单元可以保留更丰富的 host 状态,并暴露紧凑或兼容既有结构的客户端值。畸形缓存状态不能为 `viewCheckpoint` 提供数据;恢复会拒绝畸形状态,并由缓存既有的全量读取回退从日志重建。host 消费方可以用同一套增量折叠替换私有日志扫描,而载体快照无法枚举 host-only 状态。 原始 [session-projection 提案](../../proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)已记录这次拆分。既有的 [subagent 身份投影](2026-08-06-subagent-list-identity-projection.zh.md)与[投影化 token 用量](2026-07-29-projected-token-usage-and-request-context.zh.md)决策仍然有效;其中的领域折叠迁入状态表,不改变面向用户的值。 diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 49d4c94335..f1c0f70d12 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -420,7 +420,7 @@ describe('time-context projection fold edges', () => { const session = Session.create(SessionId('same-turn')) session.append('turn/start', { turn: 1 }) session.append('turn/start', { turn: 1 }) - expect(ctx.sessionProjections.snapshot(session).values.timeContext).toMatchObject({ + expect(ctx.sessionProjections.stateOf(session, 'timeContext')).toMatchObject({ currentTurn: 1, }) }) @@ -429,7 +429,7 @@ describe('time-context projection fold edges', () => { const { ctx } = await mount() const session = Session.create(SessionId('end-without-start')) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - expect(ctx.sessionProjections.snapshot(session).values.timeContext).toMatchObject({ + expect(ctx.sessionProjections.stateOf(session, 'timeContext')).toMatchObject({ currentTurn: null, }) }) diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 86c9fa3905..52172cb09b 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -222,10 +222,10 @@ describe('the sandbox/mode session kit', () => { it('the sandboxMode projection folds to the last switch, or null without one', async () => { const ctx = await mounted() const session = Session.create(SessionId('sess-fold')) - expect(ctx.sessionProjections.snapshot(session).values.sandboxMode).toBeNull() + expect(ctx.sessionProjections.stateOf(session, 'sandboxMode')).toBeNull() setSandboxMode(session, 'workspace-write') setSandboxMode(session, 'read-only') - expect(ctx.sessionProjections.snapshot(session).values.sandboxMode).toBe('read-only') + expect(ctx.sessionProjections.stateOf(session, 'sandboxMode')).toBe('read-only') }) it('setSandboxMode appends exactly one sandbox/mode event per switch', () => { diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index 9889bbbd7a..8f664e7275 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -36,6 +36,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { 'cache-test/marks': MarksState 'cache-test/marks2': Map 'cache-test/count': number + 'cache-test/secret': string } interface SessionProjectionMap { 'cache-test/marks': { marks: string[] } @@ -65,6 +66,14 @@ const marksUnit = (stateVersion = 1) => ({ stateVersion, }) satisfies ProjectionDefinition<'cache-test/marks', MarksState> +const secretUnit = { + key: 'cache-test/secret', + stateSchema: z.string(), + init: () => '', + apply: state => state, + stateVersion: 1, +} satisfies ProjectionDefinition<'cache-test/secret', string> + /** One session's record document on the per-record medium. */ const recordPath = (root: string, id: Session['id']): string => join(root, projectionCacheDomainSpec.name, 'sessions', `${String(id)}.json`) @@ -271,6 +280,24 @@ describe('SessionProjectionCache write policy', () => { }) describe('SessionProjectionCache listing read', () => { + it('keeps host-only checkpoint state out of cached wire snapshots', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) + roots.push(root) + await seedRecord(root, 'host-state', { + 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['wire'] } }, + 'cache-test/secret': { ver: 1, seq: 4, val: 'private prompt text' }, + }) + const { ctx, cache } = await harness({ root }) + ctx.sessionProjections.register(secretUnit) + const header = headerOf(SessionId('host-state')) + + expect(cache.cachedSnapshot(header)).toEqual({ + asOfSeq: 4, + values: { 'cache-test/marks': { marks: ['wire'] } }, + }) + expect(JSON.stringify(cache.cachedSnapshot(header))).not.toContain('private prompt text') + }) + it('serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) roots.push(root) diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 050cbc6bb1..9bb0f4a26c 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-projection/README.md -README.md: cec9f60f8a01788e6d51189934834e6a473db43e -README.zh.md: d2a42736b2dbdaddb60fdb821453d98d7c2f8e54 +README.md: 039209de357bb3849f5b8f046aafe2b1b5a6a9ff +README.zh.md: 0401471561ccf4b672210c0472ba2398391a8e88 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index cec9f60f8a..039209de35 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -11,7 +11,7 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr - `ctx.sessionProjections.register(definition): () => void` Register one domain's unit. Duplicate keys and invalid `stateVersion` throw; the registration is an effect on the calling fiber, so an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots — clients read that as capability absence. - `ctx.sessionProjections.onChanged(listener): () => void` Subscribe to the change feed: one call per client-visible unit whose state reference changed, per committed event, carrying the schema-validated view and the causing seq. Effect-tied like `register`. - `ctx.sessionProjections.stateOf(session, key)` Read one registered unit's current host state without computing unrelated views. The returned value is a live read-only reference; callers must not mutate it. -- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` One consistent synchronous cut over every registered client-visible unit — `{ asOfSeq, values }` with `asOfSeq` = the seq of the last event every value reflects (`-1` for an empty log). Host-only state is available only through `stateOf`. +- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` One consistent synchronous cut over every registered client-visible unit — `{ asOfSeq, values }` with `asOfSeq` = the seq of the last event every value reflects (`-1` for an empty log). Host-only state is structurally absent and is available only through keyed state reads. ### Key Types @@ -26,7 +26,7 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr - **Whole-value event rule (load-bearing).** A state-carrying log event MUST carry the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers). - **Synchronous unit discipline.** `init`/`apply`/`wire.view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally async view returns a Promise, which fails `wire.viewSchema.parse`. - **State is validated plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows and validates `val` with `stateSchema` before use; bump `stateVersion` whenever the state fields or fold semantics change. Every unit's state is checkpointed — client-visible and host-only alike. -- **No wire vocabulary here.** The registry exposes only the change feed and the snapshot read face; carriers (api-proxy) mint their own frames (`session/projection`) and blocks from them. +- **Wire snapshots and host state are separate faces.** `snapshot()`, `cachedSnapshot()`, and `viewCheckpoint()` iterate only definitions with `wire`; host code reads other state through `stateOf()`. A carrier cannot accidentally serialize every internal state by omitting a filter flag. - **Required for units and host reads.** Plugins that contribute or read projection units inject `sessionProjections`, so incomplete compositions fail during activation. The lower-level api-proxy factory remains tolerant for isolated tests and diagnostics and omits projection blocks and frames when the registry is absent. ## Role diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index d2a42736b2..0401471561 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -10,8 +10,8 @@ - `ctx.sessionProjections.register(definition): () => void` 注册一个领域的单元。key 重复或 `stateVersion` 非法都会 throw;注册是挂在调用方 fiber 上的 effect,领域插件卸载后其 key(连同缓存的 cell)从后续驱动与快照中消失——客户端将其读作能力缺失。 - `ctx.sessionProjections.onChanged(listener): () => void` 订阅变更流:每个已提交事件、每个状态引用发生变化的客户端可见单元各回调一次,携带经 schema 校验的 view 与致因 seq。与 `register` 一样绑定 effect。 -- `ctx.sessionProjections.stateOf(session, key)` 读取一个已注册单元的当前 host 状态,不计算无关 view。返回值是活的只读引用;调用方不得修改。 -- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` 对全部已注册客户端可见单元做一次一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` = 所有值共同反映到的最后一个事件的 seq(空日志为 `-1`)。host-only 状态只能通过 `stateOf` 读取。 +- `ctx.sessionProjections.stateOf(session, key)` 读取一个已注册单元的当前 host 状态,不计算无关 view。返回的是在线只读引用;调用方不得修改。 +- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` 对全部已注册客户端可见单元做一次一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` = 所有值共同反映到的最后一个事件的 seq(空日志为 `-1`)。host-only 状态在结构上缺席,只能通过按 key 的状态读取面取得。 ### 关键类型 @@ -26,7 +26,7 @@ - **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。 - **单元的同步纪律。**`init`/`apply`/`wire.view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 - **状态是经校验的纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存存储 `(sessionId, key, ver, seq, val)` 行,并在使用前以 `stateSchema` 校验 `val`;状态字段或折叠语义一旦变化就递增 `stateVersion`。每个单元的状态都会被检查点化——client-visible 与 host-only 一视同仁。 -- **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。 +- **wire 快照与 host 状态是分离的读取面。** `snapshot()`、`cachedSnapshot()` 与 `viewCheckpoint()` 只遍历带 `wire` 的定义;host 代码通过 `stateOf()` 读取其他状态。载体无法因漏传过滤标记而意外序列化全部内部状态。 - **单元与 host 读取方必需。** 贡献或读取投影单元的插件注入 `sessionProjections`,因此不完整的组合会在激活时失败。较低层的 api-proxy factory 仍对隔离测试和诊断保持容错,注册表缺席时省略投影块与帧。 ## 职责 diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index ffefa624b8..afa6701367 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -119,7 +119,7 @@ interface PositionedCandidate { * live-preferred merge of `ctx.sessions` and optional session persistence, * serving each identity from the `subagent` projection unit: the registry's * watermark snapshot for a live child; for a cold one, a durable - * projection-cache row when it serves an own-suffix identity (the seq gate), + * projection-cache read when it serves an own-suffix identity (the seq gate), * else one bounded-concurrency shared Session observation. * @see SubagentRuntime.listChildren for the public cancellation and failure contract. * @param ctx - context carrying the session store, the projection registry, diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts index b07ec8e8a2..6eebb78a52 100644 --- a/packages/subagent/subagent/src/projection-types.ts +++ b/packages/subagent/subagent/src/projection-types.ts @@ -60,6 +60,8 @@ declare module '@deepseek-ai/dsh-session-projection/types' { subagent: SubagentIdentityProjection | null } interface SessionProjectionMap { + /** Durable mode and label for a descriptor-backed subagent session. */ + subagent: SubagentIdentityProjection | null /** Active-turn duration for a descriptor-backed subagent session. */ subagentTiming: SubagentTimingProjection } diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts index ab510541a6..49ee4af239 100644 --- a/packages/subagent/subagent/src/projection.ts +++ b/packages/subagent/subagent/src/projection.ts @@ -166,4 +166,5 @@ export const subagentIdentityProjectionDefinition = { const identity = descriptorIdentity(event) return identity === undefined ? null : identity }, + wire: { viewSchema: identitySchema, view: state => state }, } satisfies ProjectionDefinition<'subagent', SubagentIdentityProjection | null> diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index dda18898f9..8c975b57fc 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -78,7 +78,7 @@ function policyEvents(events: readonly SessionEvent[]) { } function foldedSandboxMode(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown { - return ctx.sessionProjections.snapshot(Session.create(id, events)).values.sandboxMode + return ctx.sessionProjections.stateOf(Session.create(id, events), 'sandboxMode') } function foldedApprovalPolicy(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown { diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 4d6ed421b2..31109f1396 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -67,7 +67,13 @@ async function setup( await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const loop = ctx.get('agentLoop') + const parent = loop === undefined + ? (() => { + const session = ctx.sessions.create(SessionId('parent')) + return { id: session.id, session } as ReturnType + })() + : loop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent } } From cf06f10229129d2f32c71f3462d8ecb920d55ccb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 11:32:19 +0800 Subject: [PATCH 025/188] test(session-controller): cover cold host-only projection cache --- packages/api/session-controller/package.json | 3 + .../tests/session-projections.host.spec.ts | 72 +++++++++++++++++++ pnpm-lock.yaml | 9 +++ 3 files changed, 84 insertions(+) diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index dabfd8fd6a..357090e0b6 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -127,6 +127,9 @@ "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index fd30f66158..43391af21f 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -8,6 +8,9 @@ */ import { describe, expect, it, vi } from 'vitest' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' @@ -19,6 +22,10 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import SessionProjectionCache, { projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache' +import Storage from '@deepseek-ai/dsh-storage' +import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' +import * as StorageJson from '@deepseek-ai/dsh-storage-json' import { SessionControlController } from '@deepseek-ai/dsh-api-session-controller/src/control.ts' import type { SessionControlFrame, SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types' import { createSessionTestRemote, type TestSessionRemote } from './test-remote.ts' @@ -27,6 +34,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { 'test/last-user': LastUserState 'test/internal-count': number + 'test/private-prompt': string | null } interface SessionProjectionMap { 'test/last-user': { text: string } | null @@ -91,6 +99,16 @@ const internalCountUnit = () => ({ stateVersion: 1, }) satisfies ProjectionDefinition<'test/internal-count', number> +const privatePromptUnit = () => ({ + key: 'test/private-prompt', + stateSchema: z.string().nullable(), + init: () => null, + apply: (state, event) => (event.type === 'user/message' + ? (event.data.content[0] as { text?: string }).text ?? '' + : state), + stateVersion: 1, +}) satisfies ProjectionDefinition<'test/private-prompt', string | null> + async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session }> { const ctx = new Context() await ctx.plugin(SessionStore) @@ -420,6 +438,60 @@ describe('session.list projections column', () => { }) }) + it('keeps persisted host-only state out of a cold session.list response', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-api-projcache-')) + const ctx = new Context() + try { + await ctx.plugin(Storage) + await ctx.plugin(StorageJson, { root }) + await ctx.plugin(StorageDomain, { backend: 'json' }) + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(privatePromptUnit()) + await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + const gateway = remote(ctx) + await new Promise(resolve => setTimeout(resolve, 0)) + + const id = SessionId('session-cold-host-state') + const secret = 'private prompt text from the cache' + let session: Session | undefined + const owner = await ctx.plugin(Object.assign((sessionCtx: Context) => { + session = sessionCtx.sessions.create(id, { meta: { createdAt: 5, cwd: '/workspace' } }) + }, { inject: ['sessions'] })) + if (session === undefined) throw new Error('session was not created') + session.append('turn/start', { turn: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: secret }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + await ctx.sessionProjectionCache.write(session) + const stored = await readFile( + join(root, projectionCacheDomainSpec.name, 'sessions', `${id}.json`), + 'utf8', + ) + expect(stored).toContain(secret) + + const header = session.header + await owner.dispose() + expect(ctx.sessions.get(id)).toBeUndefined() + ctx.provide('sessionPersistence', { + list: async () => [header], + locate: () => undefined, + } as never) + + const response = await gateway.list(request({})) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === id) + expect(row?.projections?.values.sessionListMetadata).toMatchObject({ blank: false }) + expect('test/private-prompt' in (row?.projections?.values ?? {})).toBe(false) + expect(JSON.stringify(row)).not.toContain(secret) + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + } + }) + it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => { const { ctx } = await harness(true) const coldId = SessionId('session-cold-uncached') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fef9eb248..368df9b58d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -798,6 +798,15 @@ importers: '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session/session-title + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../../storage/storage + '@deepseek-ai/dsh-storage-domain': + specifier: workspace:^ + version: link:../../storage/storage-domain + '@deepseek-ai/dsh-storage-json': + specifier: workspace:^ + version: link:../../storage/storage-json '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent From 3d05fdfbfbd062d61b713147d418be6080572ac2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 11:32:38 +0800 Subject: [PATCH 026/188] refactor(session): keep instruction and skill scans on event log --- .../context/agent-instructions/package.json | 6 +- .../context/agent-instructions/src/index.ts | 7 - .../context/agent-instructions/src/render.ts | 2 +- .../context/agent-instructions/src/state.ts | 62 +------ .../tests/agent-instructions.spec.ts | 173 +++++------------- packages/skill/tool-skill/package.json | 9 +- packages/skill/tool-skill/src/index.ts | 63 ++----- .../skill/tool-skill/tests/tool-skill.spec.ts | 4 - pnpm-lock.yaml | 9 - 9 files changed, 77 insertions(+), 258 deletions(-) diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index e1d555cb0a..255ee9e720 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -39,12 +39,10 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", - "zod": "^4.4.3" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index 25b35a4169..ab68bce9d0 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -15,13 +15,11 @@ import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-session-projection' import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts' import { findProjectRoot, loadBaselineInstructionSet } from './files.ts' import { applyInstructionVersionUpdates, baselineInstructionState, - createWorkspaceInstructionsProjection, name, reconcileInstructionContext, workspaceContextMessage, @@ -79,12 +77,7 @@ function filePathFromExecution(exec: ToolExecution): string | undefined { return filePath.length > 0 ? filePath : undefined } -/** Required services (the projection registry drives the instruction fold). */ -export const inject = ['sessionProjections'] - export function apply(ctx: Context, config: Config): void { - ctx.sessionProjections.register(createWorkspaceInstructionsProjection()) - const resolved: ResolvedConfig = resolveConfig(config) const instructionVersions: InstructionVersionCache = new WeakMap() const baselinePreparations = new WeakMap - -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Newest-first workspace-instruction change history by scope. */ - workspaceInstructions: WorkspaceInstructionsState - } -} - -/** - * Create the workspace-instruction projection. - * @returns Workspace-instruction projection definition. - */ -export function createWorkspaceInstructionsProjection(): ProjectionDefinition<'workspaceInstructions', WorkspaceInstructionsState> { - return { - key: 'workspaceInstructions', - stateSchema: workspaceInstructionsStateSchema, - init: () => ({}), - apply: (state, event) => { - if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) return state - const changes = workspaceInstructionChanges(event.data.source) - if (changes.length === 0) return state - let next = state - for (const change of changes) { - const record = { change, seq: event.seq } - const history = next[change.scope] - next = { ...next, [change.scope]: history === undefined ? [record] : [record, ...history] } - } - return next - }, - stateVersion: 2, - } -} - function visibleInstructionChanges( agent: Agent, authorityMessages: readonly UserMessage[], ): Map { const visibleSeqs = new Set(agent.session.surface.nodes) const visible = new Map() - const folded = agent.ctx.get('sessionProjections')?.stateOf(agent.session, 'workspaceInstructions') - if (folded === undefined) throw new Error('workspaceInstructions projection is not registered') - for (const [scope, history] of Object.entries(folded)) { - // History is newest-first; the latest visible record restores the previous - // scan-visible semantics when a surface replacement shadows the newest - // change but an older one stays visible. - const latestVisible = history.find(record => visibleSeqs.has(record.seq)) - if (latestVisible !== undefined) visible.set(scope, latestVisible.change) + for (const [seq, event] of agent.session.events.entries()) { + if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue + const changes = workspaceInstructionChanges(event.data.source) + for (const change of changes) { + if (visibleSeqs.has(seq)) visible.set(change.scope, change) + } } for (const message of authorityMessages) { if (!isWorkspaceContextSource(message.source)) continue diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 86052dc379..11d385106e 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -37,7 +37,6 @@ import { import { applyInstructionVersionUpdates, baselineInstructionState, - createWorkspaceInstructionsProjection, reconcileInstructionContext, type InstructionVersionCache, } from '../src/state.ts' @@ -169,16 +168,9 @@ class BlockingReadFileSystem extends RecordingFileSystem { } } -async function pluginWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { - if (ctx.get('sessionProjections') === undefined) { - await ctx.plugin(SessionProjectionRegistry) - } - return ctx.plugin(workspaceContext, config) -} - async function mountWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { await ctx.plugin(LocalFileSystem, { cwd: '/' }) - return pluginWorkspaceContext(ctx, config) + return ctx.plugin(workspaceContext, config) } async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { @@ -186,17 +178,14 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace await ctx.plugin(ToolRuntime) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) - return pluginWorkspaceContext(ctx, config) + return ctx.plugin(workspaceContext, config) } function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { const id = SessionId('s1') const session = Session.create(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd }) - const ctx = new Context() - new SessionProjectionRegistry(ctx) - ctx.sessionProjections.register(createWorkspaceInstructionsProjection()) return { - ctx, + ctx: new Context(), id: SessionId('a1'), options: {}, session, @@ -205,7 +194,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { send: () => {}, followup: () => {}, steer: () => {}, - inject: () => { throw new Error('workspace-context must append directly to the open step') }, + inject: () => { throw new Error('agent-instructions must append directly to the open step') }, cancel() {}, runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), @@ -634,10 +623,10 @@ describe('workspace context instruction discovery', () => { it('labels the default DSH home as ~/.dsh when HOME points at the configured default', async () => { const root = await tempRepo() const home = await tempRepo() - vi.stubEnv('DSH_HOME', '') try { await write(join(home, '.dsh/AGENTS.md'), 'global default rule') + // A set DSH_HOME would override the homedir default and relabel the home. vi.stubEnv('DSH_HOME', '') vi.resetModules() vi.doMock('node:os', () => ({ homedir: () => home })) @@ -997,27 +986,26 @@ describe('workspace context request injection', () => { it('requires an explicit maxBytes configuration', async () => { const ctx = new Context() - await expect(pluginWorkspaceContext(ctx, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/) + await expect(ctx.plugin(workspaceContext, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/) }) it('mounts without requiring a filesystem provider', async () => { const ctx = new Context() try { - await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) } finally { await ctx.fiber.dispose() } }) it('does not declare fs as a static inject dependency', () => { - expect(workspaceContext.inject).toEqual(['sessionProjections']) - expect(workspaceContext.inject).not.toContain('fs') + expect('inject' in workspaceContext).toBe(false) }) it('does not inject baseline context when no filesystem provider is present', async () => { const ctx = new Context() try { - await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const agent = stubAgent('/virtual/repo') await composeBaselinePrefix(ctx, agent) @@ -1124,7 +1112,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const original = stubAgent(root) await composeBaselinePrefix(ctx, original) @@ -1368,7 +1356,7 @@ describe('workspace context request injection', () => { expect(inserted?.source).toMatchObject({ kind: 'agent-instructions', baseline: true }) await fiber.dispose() - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const resumed = stubAgent(root, [...original.session.events]) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) @@ -1414,7 +1402,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'new repo rule') await fiber.dispose() - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const resumed = stubAgent(root, [...original.session.events]) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const staleClaim = resumed.inbox.claim('next-step', 1) @@ -1467,7 +1455,7 @@ describe('workspace context request injection', () => { await originalCtx.fiber.dispose() if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) - await pluginWorkspaceContext(resumedCtx, { dshHome: home, maxBytes }) + await resumedCtx.plugin(workspaceContext, { dshHome: home, maxBytes }) const resumed = stubAgent(root, [...original.session.events]) agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) @@ -1577,7 +1565,7 @@ describe('workspace context request injection', () => { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(10_000) }, + { messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve(downstream), ) @@ -1662,7 +1650,7 @@ describe('workspace context request injection', () => { // Hot remount over the live session: the durable baseline remains // visible, so the fresh mount does not append a duplicate. await fiber.dispose() - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) await composeBaselinePrefix(ctx, agent) expect(baselineEvents(agent)).toHaveLength(1) @@ -1692,7 +1680,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) - const fiber = await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const fiber = await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) const baseline = baselineEvents(agent)[0] @@ -1707,7 +1695,7 @@ describe('workspace context request injection', () => { }) await fiber.dispose() - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) await composeBaselinePrefix(ctx, agent) expect(baselineEvents(agent)).toHaveLength(2) @@ -1994,7 +1982,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'ctx.fs rule' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2017,7 +2005,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'provider-only rule' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2059,7 +2047,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'far too large' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) @@ -2084,7 +2072,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(instructionPath, { type: 'file', content: 'far too large' }) fs.omitSizes.add(instructionPath) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) @@ -2107,7 +2095,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as BlockingReadFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'blocked' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const controller = new AbortController() const reason = new Error('cancel prefix') const pending = agentEvents(ctx, stubAgent(root)).waterfall( @@ -2141,7 +2129,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(home, 'AGENTS.md'), { type: 'file', content: 'ctx global rule' }) fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'ctx claude rule' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2167,7 +2155,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2190,7 +2178,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2213,7 +2201,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.throwOnStat.add(join(root, 'AGENTS.md')) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2235,7 +2223,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.throwOnStat.add(join(root, 'AGENTS.md')) fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'claude sibling rule' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2260,7 +2248,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.throwOnStat.add(join(root, '.git')) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2310,7 +2298,7 @@ describe('workspace context request injection', () => { await write(join(cwd, 'AGENTS.md'), 'child schema default rule') const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) - await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const agent = stubAgent(cwd) await composeBaselinePrefix(ctx, agent) @@ -2331,7 +2319,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.local.md'), 'local rule') const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) - await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2527,7 +2515,8 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root }) @@ -2603,7 +2592,7 @@ describe('dynamic nested workspace context injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const controller = new AbortController() const reason = new Error('cancel dynamic reconciliation') controller.abort(reason) @@ -2866,7 +2855,7 @@ describe('dynamic nested workspace context injection', () => { fs.omitSizes.add(instructionPath) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -2903,7 +2892,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-1') }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await ctx.tools.execute({ @@ -2948,7 +2937,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(instructionPath, { type: 'file', content: 'shared path, separate sessions' }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const firstAgent = stubAgent(root) const secondAgent = stubAgent(root) @@ -3484,7 +3473,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'provider package rule' }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -3626,72 +3615,6 @@ describe('dynamic nested workspace context injection', () => { } }) - it('still removes a deleted instruction whose latest update is shadowed while an older one stays visible', async () => { - const root = await tempRepo() - const home = await tempRepo() - try { - await mkdir(join(root, '.git'), { recursive: true }) - await write(join(root, 'pkg/AGENTS.md'), 'first nested rule') - await write(join(root, 'pkg/deep/file.txt'), 'hello') - const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) - - const first = await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('read-before-update'), - name: 'read', - arguments: { file_path: join('pkg', 'deep', 'file.txt') }, - agent, - }) - const firstSeq = (await appendAdditionalContexts(ctx, agent))! - - await write(join(root, 'pkg/AGENTS.md'), 'second nested rule') - const second = await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('read-after-update'), - name: 'read', - arguments: { file_path: join('pkg', 'deep', 'file.txt') }, - agent, - }) - const secondSeq = (await appendAdditionalContexts(ctx, agent))! - expect(agent.session.surface.nodes).toContain(firstSeq) - expect(agent.session.surface.nodes).toContain(secondSeq) - - // A surface replacement shadows only the latest update; the older one - // stays visible, so the scope still has visible state to lose. - agent.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'compacted summary' }], - source: { kind: 'plugin', plugin: 'compact' }, - }), { - surfaceOp: { op: 'replace', start: secondSeq, end: secondSeq }, - sourceEventSeqs: [secondSeq], - }) - - await rm(join(root, 'pkg/AGENTS.md')) - await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('read-after-delete'), - name: 'read', - arguments: { file_path: join('pkg', 'deep', 'file.txt') }, - agent, - }) - await appendAdditionalContexts(ctx, agent) - - expect(first.additionalContexts).toBeUndefined() - expect(second.additionalContexts).toBeUndefined() - const removal = agent.session.events.find(event => event.type === 'user/message' - && event.data.source.kind === 'agent-instructions' - && event.data.source.changes.some(change => change.action === 'remove')) - expect(removal?.type === 'user/message' ? removal.data.source : undefined).toMatchObject({ - changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], - }) - } finally { - await rm(root, { recursive: true, force: true }) - await rm(home, { recursive: true, force: true }) - } - }) - it('re-arms an unchanged baseline after compaction removes it from the surface', async () => { const root = await tempRepo() const home = await tempRepo() @@ -3929,7 +3852,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(join(root, 'pkg/deep/file.txt'), { type: 'file', content: 'hello' }) fs.throwOnRead.add(nested) await ctx.plugin(ToolFs) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const result = await ctx.tools.execute({ @@ -4070,7 +3993,7 @@ describe('dynamic nested workspace context injection', () => { ? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer policy block' }] } : downstream }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const blocked = await ctx.tools.execute({ @@ -4139,7 +4062,7 @@ describe('dynamic nested workspace context injection', () => { ? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer composite block' }] } : downstream }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const blocked = await ctx.tools.execute({ @@ -4163,7 +4086,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' }) @@ -4238,7 +4161,7 @@ describe('dynamic nested workspace context injection', () => { agent.session.append('step/start', { turn: 1, step: 1 }) agent.session.append('step/end', { turn: 1, step: 1 }) agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, @@ -4261,7 +4184,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem const agent = stubAgent('/') const plainResult = { callId: CallId('plain'), content: [], isError: false as const, value: null } @@ -4310,7 +4233,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem const root = resolve('/') const agent = stubAgent(root) @@ -4376,7 +4299,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(instructionPath, { type: 'file', content: 'x'.repeat(1000) }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 20 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 20 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -4498,7 +4421,7 @@ describe('workspace context inbox synchronization', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'tiny-budget rule' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 1 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 1 }) const agent = stubAgent(root) ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, @@ -4574,7 +4497,7 @@ describe('workspace context inbox synchronization', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'restored A' }) fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'restored B' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = stubToolExecution({ signal: testToolSignal, @@ -4614,7 +4537,7 @@ describe('workspace context inbox synchronization', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'scope A' }) fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'scope B' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = stubToolExecution({ signal: testToolSignal, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 36ef65599f..798c09ca05 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -37,12 +37,10 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", - "zod": "^4.4.3" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -53,7 +51,6 @@ "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 72af2f7006..222e8a0ace 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -7,12 +7,10 @@ import { createHash } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { z as zod } from 'zod' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-projection' import { escapeText, isModelInvocable, @@ -24,7 +22,7 @@ import { } from '@deepseek-ai/dsh-skill' export const name = 'tool-skill' -export const inject = ['agents', 'tools', 'skills', 'sessionProjections'] +export const inject = ['agents', 'tools', 'skills'] const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500 /** @@ -70,30 +68,12 @@ export const Config: z = z.object({ catalogDescriptionMaxLength: z.number().default(DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH), }) -/** - * The skill-catalog projection's state schema — the one definition of the - * state shape; the type is inferred from it (state equals the public shape). - */ -const skillCatalogStateSchema = zod.array(zod.object({ - digest: zod.string().min(1), - seq: zod.number().int().nonnegative(), -})).nullable() - -type SkillCatalogState = zod.infer - /** * Register the model-facing skill loader and its visibility-matched * durable session catalog. The catalog is emitted only when the calling agent * resolves this plugin's exact tool registration; a restriction or scoped * same-name shadow therefore removes both the schema and its call guidance. */ -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Newest-first published skill-catalog messages (digest + seq); null before the first publication. */ - skillCatalog: SkillCatalogState - } -} - export function apply(ctx: Context, config: Config = {}): void { const catalogDescriptionMaxLength = config.catalogDescriptionMaxLength ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH assertPositiveInteger('catalogDescriptionMaxLength', catalogDescriptionMaxLength, 3) @@ -223,20 +203,6 @@ export function apply(ctx: Context, config: Config = {}): void { return { ...decision, messages: [...decision.messages, ...injections] } }) - ctx.sessionProjections.register({ - key: 'skillCatalog', - stateVersion: 2, - stateSchema: skillCatalogStateSchema, - init: () => null, - apply: (state, event) => { - if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') return state - const entries = readCatalogEntries(event.data.source) - if (entries === undefined) return state - const record = { digest: digestCatalogEntries(entries), seq: event.seq } - return state === null ? [record] : [record, ...state] - }, - }) - // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. // @@ -260,7 +226,7 @@ export function apply(ctx: Context, config: Config = {}): void { const skills = snapshot.skills.filter(isModelInvocable) const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength) const digest = digestCatalogEntries(entries) - const history = catalogHistory(ctx, agent) + const history = catalogHistory(agent) const existing = catalogMessage(decision.messages) if (history.visibleDigest === digest) { return existing === undefined @@ -392,17 +358,22 @@ function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | un return readable } -function catalogHistory(ctx: Context, agent: Agent): { visibleDigest?: string; published: boolean } { +function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } { const visible = new Set(agent.session.surface.nodes) - const state = ctx.sessionProjections.stateOf(agent.session, 'skillCatalog') ?? null - if (state === null) return { published: false } - // History is newest-first; the latest visible record restores the previous - // scan-visible semantics when a surface replacement shadows the newest - // catalog message but an older one stays visible. - const latestVisible = state.find(record => visible.has(record.seq)) - return latestVisible !== undefined - ? { visibleDigest: latestVisible.digest, published: true } - : { published: true } + const events = agent.session.events + let published = false + for (let index = events.length - 1; index >= 0; index -= 1) { + // The loop bounds prove the read-only event view contains this index. + // oxlint-disable-next-line typescript/no-non-null-assertion + const event = events[index]! + if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue + const entries = readCatalogEntries(event.data.source) + if (entries === undefined) continue + const digest = digestCatalogEntries(entries) + published = true + if (visible.has(event.seq)) return { visibleDigest: digest, published } + } + return { published } } function catalogMessage( diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 4b63ba8207..d8dd9df2f4 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -9,7 +9,6 @@ import { Session, SessionId, type SessionEvent, type UserMessage } from '@deepse import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { agentEvents, Inbox, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SkillRegistry from '@deepseek-ai/dsh-skill' import * as SkillFileSystem from '@deepseek-ai/dsh-skill-filesystem' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' @@ -31,7 +30,6 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) const home = await tempDir('tool-schema') await ctx.plugin(SkillRegistry) await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) @@ -754,7 +751,6 @@ describe('dsh-tool-skill', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SkillRegistry) await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 368df9b58d..18a33e8793 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3879,9 +3879,6 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -8039,9 +8036,6 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -8061,9 +8055,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-session-projection': - specifier: workspace:^ - version: link:../../session/session-projection '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill From c7abeb23bfed380d2663145c9225d363dedb52ca Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 11:59:28 +0800 Subject: [PATCH 027/188] refactor(session): keep approval outside projection migration --- docs/subsystems/approval.i18n.yaml | 4 +- docs/subsystems/approval.md | 24 +++--- docs/subsystems/approval.zh.md | 26 +++--- .../interaction/user-approval/package.json | 10 +-- .../user-approval/tests/approval.spec.ts | 84 ++----------------- .../interaction/user-approval/tsconfig.json | 3 - .../tests/continuation-inheritance.spec.ts | 2 +- pnpm-lock.yaml | 9 -- 8 files changed, 39 insertions(+), 123 deletions(-) diff --git a/docs/subsystems/approval.i18n.yaml b/docs/subsystems/approval.i18n.yaml index 08495f66b2..e70b9747e5 100644 --- a/docs/subsystems/approval.i18n.yaml +++ b/docs/subsystems/approval.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/approval.md -approval.md: ee8ab1cfca1fba6868b5f4931bab3dd9118a7a9b -approval.zh.md: f84d6a1bcd44e565a5cce85fbeb2a1194a2acade +approval.md: d9f1169b52e427cd37e7bc54fa37da59d48aecce +approval.zh.md: abc2361db3d84517c7e7497cfb39b4548160c259 diff --git a/docs/subsystems/approval.md b/docs/subsystems/approval.md index ee8ab1cfca..d9f1169b52 100644 --- a/docs/subsystems/approval.md +++ b/docs/subsystems/approval.md @@ -30,7 +30,7 @@ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' ## Per-session policy -`ApprovalPolicy` determines what happens before interactive answerers run. `ask` delegates to the composed answerer chain, whose no-answer default is `unavailable`; `never` deterministically returns `rejected` without dispatching any answerer. The effective value is the last `approval/policy` event in the session log, falling back to the service config. `setApprovalPolicy(session, policy)` is the single write path, so replay reconstructs the override. +`ApprovalPolicy` determines what happens before interactive answerers run. `ask` delegates to the composed answerer chain, whose no-answer default is `unavailable`; `never` deterministically returns `rejected` without dispatching any answerer. The effective value is the last `approval/policy` event in the session log, falling back to the service config. Consumers read it with `ctx.approval.effectivePolicy(session)`; `setApprovalPolicy(session, policy)` is the single write path, so replay reconstructs the override. ```ts type-equiv /** @@ -57,7 +57,7 @@ Both policies contribute their complete current meaning to the cache-safe runtim * Readonly same-process permission question. `callId` links to an already * presented tool call, so arguments are not duplicated here. */ -interface ApprovalRequest { +interface ApprovalRequest extends ApprovalRequestEvent { /** * The agent on whose behalf the question is asked. Routes the question (a * UI answerer only answers for agents it owns) and receives the audit @@ -93,7 +93,7 @@ The audit events are log-only and do not enter the model transcript. Model-visib ## Cordis API -Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). @@ -132,7 +132,7 @@ setPolicy(agent: Agent, policy: ApprovalPolicy): void async request(req: ApprovalRequest): Promise /** - * Read the projected session override without applying the configured default. + * Read the session override without applying the configured default. * @param session - session whose log supplies the override. * @returns the last logged policy, or `undefined` without one. */ @@ -141,7 +141,7 @@ overrideOf(session: Session): ApprovalPolicy | undefined Types: [Agent](core.md) · [Session](session.md) -Source: [`packages/interaction/user-approval/src/index.ts:190`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts`](../../packages/interaction/user-approval/src/index.ts) @@ -151,20 +151,20 @@ Source: [`packages/interaction/user-approval/src/index.ts:190`](../../packages/i #### `approval/request` — waterfall -Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. +Ask composed answerers for one decision. Return an outcome to claim the request or call `next()` to delegate. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog /** * Ask composed answerers for one decision. Return an outcome to claim the - * request or call `next()`; failure yields the fail-closed default. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param req - the pending decision (agent, tool identity, reason, signal). + * request or call `next()` to delegate. Scope-filtered dispatch + * (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param req - pending approval request. * @mode waterfall */ -'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise +'approval/request'( this: Scoped, req: ApprovalRequestEvent, next: () => Promise, ): Promise ``` -Types: [Scoped](scope.md) +Types: [Agent](core.md) · [Scoped](scope.md) -Source: [`packages/interaction/user-approval/src/index.ts:32`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/types.ts`](../../packages/interaction/user-approval/src/types.ts) diff --git a/docs/subsystems/approval.zh.md b/docs/subsystems/approval.zh.md index f84d6a1bcd..abc2361db3 100644 --- a/docs/subsystems/approval.zh.md +++ b/docs/subsystems/approval.zh.md @@ -30,7 +30,7 @@ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' ## 按会话策略 -`ApprovalPolicy` 决定在交互式应答者运行之前发生什么。`ask` 委托给组合的应答者链,链的无应答默认值为 `unavailable`;`never` 确定性地返回 `rejected`,不分发任何应答者。生效值为会话日志中最后一条 `approval/policy` 事件,回退到服务配置。`setApprovalPolicy(session, policy)` 是唯一的写入路径,因此回放能重建覆盖值。 +`ApprovalPolicy` 决定在交互式应答者运行之前发生什么。`ask` 委托给组合的应答者链,链的无应答默认值为 `unavailable`;`never` 确定性地返回 `rejected`,不分发任何应答者。生效值为会话日志中最后一条 `approval/policy` 事件,回退到服务配置。消费方通过 `ctx.approval.effectivePolicy(session)` 读取;`setApprovalPolicy(session, policy)` 是唯一的写入路径,因此回放能重建覆盖值。 ```ts type-equiv /** @@ -57,7 +57,7 @@ type ApprovalPolicy = 'ask' | 'never' * Readonly same-process permission question. `callId` links to an already * presented tool call, so arguments are not duplicated here. */ -interface ApprovalRequest { +interface ApprovalRequest extends ApprovalRequestEvent { /** * The agent on whose behalf the question is asked. Routes the question (a * UI answerer only answers for agents it owns) and receives the audit @@ -93,7 +93,7 @@ interface ApprovalRequest { ## Cordis API -Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). @@ -132,16 +132,16 @@ setPolicy(agent: Agent, policy: ApprovalPolicy): void async request(req: ApprovalRequest): Promise /** - * Read the projected session override without applying the configured default. + * Read the session override without applying the configured default. * @param session - session whose log supplies the override. * @returns the last logged policy, or `undefined` without one. */ overrideOf(session: Session): ApprovalPolicy | undefined ``` -Types: [Agent](core.md) · [Session](session.md) +Types: [Agent](core.zh.md) · [Session](session.zh.md) -Source: [`packages/interaction/user-approval/src/index.ts:190`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts`](../../packages/interaction/user-approval/src/index.ts) @@ -151,20 +151,20 @@ Source: [`packages/interaction/user-approval/src/index.ts:190`](../../packages/i #### `approval/request` — waterfall -Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. +Ask composed answerers for one decision. Return an outcome to claim the request or call `next()` to delegate. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog /** * Ask composed answerers for one decision. Return an outcome to claim the - * request or call `next()`; failure yields the fail-closed default. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param req - the pending decision (agent, tool identity, reason, signal). + * request or call `next()` to delegate. Scope-filtered dispatch + * (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param req - pending approval request. * @mode waterfall */ -'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise +'approval/request'( this: Scoped, req: ApprovalRequestEvent, next: () => Promise, ): Promise ``` -Types: [Scoped](scope.md) +Types: [Agent](core.zh.md) · [Scoped](scope.zh.md) -Source: [`packages/interaction/user-approval/src/index.ts:32`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/types.ts`](../../packages/interaction/user-approval/src/types.ts) diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index d94977e09e..5ec59a32d6 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -44,12 +44,10 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", - "zod": "^4.4.3" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -59,8 +57,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/interaction/user-approval/tests/approval.spec.ts b/packages/interaction/user-approval/tests/approval.spec.ts index 906551f229..3271ecc7a5 100644 --- a/packages/interaction/user-approval/tests/approval.spec.ts +++ b/packages/interaction/user-approval/tests/approval.spec.ts @@ -6,14 +6,8 @@ import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ApprovalService, { ApprovalOutcome, ApprovalRequest, ApprovalRequestId, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' - -function registerTurnBoundary(ctx: Context): void { - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) -} +import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' /** * A minimal Agent stand-in — the service only reaches `agent.session.append` @@ -21,10 +15,7 @@ function registerTurnBoundary(ctx: Context): void { * turn-enclosure precondition); pass `seed` to stage idle/closed logs. * Returns the recorded audit appends alongside the fake. */ -function fakeAgent(seed: Array<{ type: string; data?: Record; seq?: number }> = [ - { type: 'turn/start', seq: 0, data: { turn: 1 } }, - { type: 'user/message', seq: 1, data: {} }, -]): { agent: Agent; appended: Array<{ type: string; data: Record }> } { +function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { type: 'user/message' }]): { agent: Agent; appended: Array<{ type: string; data: Record }> } { const appended: Array<{ type: string; data: Record }> = [] const agent = { session: { @@ -40,8 +31,6 @@ function fakeAgent(seed: Array<{ type: string; data?: Record; s async function mounted(): Promise { const ctx = new Context() - await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) return ctx } @@ -61,10 +50,7 @@ describe('ApprovalService.request', () => { it('throws between turns — a closed turn does not satisfy the enclosure precondition', async () => { const ctx = await mounted() - const { agent, appended } = fakeAgent([ - { type: 'turn/start', seq: 0, data: { turn: 1 } }, - { type: 'turn/end', seq: 1, data: { turn: 1, reason: { kind: 'completed' } } }, - ]) + const { agent, appended } = fakeAgent([{ type: 'turn/start' }, { type: 'turn/end' }]) await expect(ctx.approval.request(requestOf(agent))).rejects.toThrow(/outside an open turn/) expect(appended).toHaveLength(0) @@ -130,8 +116,6 @@ describe('ApprovalService.request', () => { it('contains an approval/asked observer throw after append and still completes the pair', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) const session = ctx.sessions.create(SessionId('asked-observer-throw')) session.append('turn/start', { turn: 1 }) @@ -155,8 +139,6 @@ describe('ApprovalService.request', () => { it('contains an approval/decided observer throw after append and still resolves', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) const session = ctx.sessions.create(SessionId('decided-observer-throw')) session.append('turn/start', { turn: 1 }) @@ -182,7 +164,7 @@ describe('ApprovalService.request', () => { const failure = new Error('append failed before log growth') const agent = { session: { - events: [{ type: 'turn/start', seq: 0, data: { turn: 1 } }], + events: [{ type: 'turn/start' }], append: () => { throw failure }, }, } as unknown as Agent @@ -382,45 +364,15 @@ describe('approval policy (the approval/policy fold)', () => { return { agent, session } } - it('folds to the last event, or undefined without one', async () => { - const ctx = await mounted() + it('folds to the last event, or undefined without one', () => { const { session } = sessionAgent('sess-fold') - expect(ctx.approval.overrideOf(session)).toBeUndefined() + expect(effectiveApprovalPolicy(session.events)).toBeUndefined() setApprovalPolicy(session, 'never') setApprovalPolicy(session, 'ask') - expect(ctx.approval.overrideOf(session)).toBe('ask') + expect(effectiveApprovalPolicy(session.events)).toBe('ask') expect(session.events.at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } }) }) - it('folds the pending audit pair without double-counting duplicates', async () => { - const ctx = await mounted() - const { session } = sessionAgent('sess-pending') - const asked = session.append('approval/asked', { - id: ApprovalRequestId('pending-1'), - callId: CallId('call-1'), - toolName: 'echo', - }) - session.append('approval/asked', { - id: ApprovalRequestId('pending-1'), - callId: CallId('call-1'), - toolName: 'echo', - }) - const pending = ctx.sessionProjections.snapshot(session).values.approvalPending ?? {} - expect(pending).toEqual({ - 'pending-1': { callId: 'call-1', seq: asked.seq }, - }) - }) - - it('ignores a decided event without a pending asked record', async () => { - const ctx = await mounted() - const { session } = sessionAgent('sess-unknown-decide') - session.append('approval/decided', { - id: ApprovalRequestId('never-asked'), - outcome: 'rejected', - }) - expect(ctx.sessionProjections.snapshot(session).values.approvalPending).toEqual({}) - }) - it('rejects a policy outside the closed vocabulary before appending', () => { const append = vi.fn() const session = { append } as unknown as Session @@ -434,8 +386,6 @@ describe('approval policy (the approval/policy fold)', () => { // Direct construction bypasses the plugin schema (the SystemPrompt-test // precedent for covering a defaulted Config field's type-narrowing ??). const ctx = new Context() - new SessionProjectionRegistry(ctx) - registerTurnBoundary(ctx) const service = new ApprovalService(ctx, {}) const { agent } = sessionAgent('sess-bare-config') ctx.on('approval/request', () => Promise.resolve('allowed-once')) @@ -444,8 +394,6 @@ describe('approval policy (the approval/policy fold)', () => { it('contains an answerer that throws SYNCHRONOUSLY as unavailable', async () => { const ctx = new Context() - await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) const { agent } = sessionAgent('sess-syncthrow') ctx.on('approval/request', () => { throw new Error('sync bug') }) @@ -454,8 +402,6 @@ describe('approval policy (the approval/policy fold)', () => { it('a never config rejects deterministically without consulting any answerer', async () => { const ctx = new Context() - await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) await ctx.plugin(ApprovalService, { policy: 'never' }) const consulted = vi.fn() ctx.on('approval/request', (_req, next) => { consulted(); return next() }) @@ -470,8 +416,6 @@ describe('approval policy (the approval/policy fold)', () => { it('the gate decides FIRST even against an answerer registered before the service (prepend)', async () => { const ctx = new Context() ctx.on('approval/request', () => Promise.resolve('allowed-once')) - await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) await ctx.plugin(ApprovalService, { policy: 'never' }) const { agent } = sessionAgent('sess-gate-2') await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected') @@ -482,8 +426,6 @@ describe('approval policy (the approval/policy fold)', () => { // service could register — which is exactly why the 'never' decision lives inside request() // instead. This eager grant would bypass a listener-based gate and therefore must never run. const ctx = new Context() - await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) await ctx.plugin(ApprovalService, { policy: 'never' }) const consulted = vi.fn() ctx.on('approval/request', () => { consulted(); return Promise.resolve('allowed-once') }, { prepend: true }) @@ -495,8 +437,6 @@ describe('approval policy (the approval/policy fold)', () => { it('a session override outranks the configured default, in both directions', async () => { const ctx = new Context() - await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) await ctx.plugin(ApprovalService, { policy: 'never' }) ctx.on('approval/request', () => Promise.resolve('allowed-once')) const { agent, session } = sessionAgent('sess-gate-3') @@ -510,8 +450,6 @@ describe('approval policy (the approval/policy fold)', () => { it('queues a live policy switch for the next model step', async () => { const ctx = new Context() - await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-policy-notice') const inject = vi.fn() @@ -520,7 +458,7 @@ describe('approval policy (the approval/policy fold)', () => { ctx.approval.setPolicy(liveAgent, 'never') ctx.approval.setPolicy(liveAgent, 'never') - expect(ctx.approval.overrideOf(session)).toBe('never') + expect(effectiveApprovalPolicy(session.events)).toBe('never') expect(inject).toHaveBeenCalledOnce() expect(inject.mock.calls[0]?.[0]).toMatchObject({ content: [{ @@ -534,8 +472,6 @@ describe('approval policy (the approval/policy fold)', () => { it('contributes the complete current ask or never policy as cache-safe context', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) - await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) const askAgent = sessionAgent('sess-sect-ask').agent const { agent: neverAgent, session } = sessionAgent('sess-sect-never') @@ -551,8 +487,6 @@ describe('approval policy (the approval/policy fold)', () => { it('reflects the latest durable switch in cache-safe context and stays byte-stable while unchanged', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) - await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-context-switch') const contextFor = async () => @@ -569,8 +503,6 @@ describe('approval policy (the approval/policy fold)', () => { it('disposes the runtime-context contribution with the service', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) - await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) const fiber = await ctx.plugin(ApprovalService) const { agent } = sessionAgent('sess-hmr-service-live') const contextFor = async () => diff --git a/packages/interaction/user-approval/tsconfig.json b/packages/interaction/user-approval/tsconfig.json index 7adfb948c7..2c8d26e1f6 100644 --- a/packages/interaction/user-approval/tsconfig.json +++ b/packages/interaction/user-approval/tsconfig.json @@ -37,9 +37,6 @@ }, { "path": "../../runtime-diagnostics/invariants" - }, - { - "path": "../../session/session-projection" } ] } diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index 8c975b57fc..97c7737ad7 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -82,7 +82,7 @@ function foldedSandboxMode(ctx: Context, id: SessionId, events: readonly Session } function foldedApprovalPolicy(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown { - return ctx.sessionProjections.snapshot(Session.create(id, events)).values.approvalPolicy + return ctx.approval.overrideOf(Session.create(id, events)) } describe('continuable policy inheritance', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bca6baead..96fb5a7d31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6110,9 +6110,6 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -6120,9 +6117,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -6138,9 +6132,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-session-projection': - specifier: workspace:^ - version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt From 3759ea5dfe998cb07d6c7b8c2ff883015ce2b576 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 12:00:02 +0800 Subject: [PATCH 028/188] fix(agent-team): keep projection through runtime disposal --- packages/experimental/agent-team/src/index.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/experimental/agent-team/src/index.ts b/packages/experimental/agent-team/src/index.ts index 690247ec45..de0487e93b 100644 --- a/packages/experimental/agent-team/src/index.ts +++ b/packages/experimental/agent-team/src/index.ts @@ -34,7 +34,6 @@ export type * from './types.ts' export type { TeamMembership } from './roster.ts' export { TeamId, TeamMessageId, TeamTaskId } from './types.ts' export { TeamError } from './error.ts' -export { foldTeam } from './fold.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -114,10 +113,16 @@ export class TeamService extends TypertRemoteService { const membership = this.roster.tryMembership(agent) if (membership !== undefined) this.activity.notify(membership.id) }) - ctx.effect(function* (this: TeamService) { - yield ctx.sessionProjections.register(teamProjectionDefinition) - yield () => this.disposeRuntime() - }.bind(this), 'agentTeams.runtimeLifecycle()') + ctx.effect(() => { + const disposeProjection = ctx.root.sessionProjections.register(teamProjectionDefinition) + return async () => { + try { + await this.disposeRuntime() + } finally { + disposeProjection() + } + } + }, 'agentTeams.runtimeLifecycle()') for (const agent of ctx.agents.list()) this.scheduleRecovery(agent) } From 212df86cf896ab650146d14ce4f6306aa8d78c7d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 13:23:01 +0800 Subject: [PATCH 029/188] refactor(session): migrate simple folds to projections --- docs/config-catalog.md | 64 ++-- docs/event-producer-consumer.md | 4 +- docs/module-graph.md | 360 +++++++++--------- docs/persistence-catalog.md | 16 +- .../context/agent-instructions/package.json | 1 + .../context/agent-instructions/src/index.ts | 27 +- .../tests/agent-instructions.spec.ts | 97 ++--- .../context/agent-instructions/tsconfig.json | 3 + packages/context/time-context/src/index.ts | 25 +- .../time-context/tests/time-context.spec.ts | 16 +- packages/core/agent-loop/src/index.ts | 6 +- packages/core/agent/src/types.ts | 2 - .../experimental/agent-team/src/journal.ts | 7 +- .../experimental/agent-team/src/projection.ts | 40 +- .../agent-team/tests/persistence.spec.ts | 9 +- .../tests/projection-events.spec.ts | 33 +- .../agent-team/tests/team.spec.ts | 9 +- packages/goal/goal/src/index.ts | 135 ++++--- packages/goal/goal/src/types.ts | 12 +- packages/goal/goal/tests/projection.spec.ts | 67 ++-- packages/plan/plan-mode/src/index.ts | 6 +- packages/plan/plan-mode/src/types.ts | 4 +- packages/preset/agent-presets/src/index.ts | 12 +- .../agent-presets/tests/authoring.spec.ts | 17 +- .../preset/agent-presets/tests/mount.spec.ts | 1 + .../preset/agent-presets/tests/remote.spec.ts | 2 + .../agent-presets/tests/settings.spec.ts | 1 - .../agent-presets/tests/shipped-root.spec.ts | 2 + .../agent-presets/tests/user-root.spec.ts | 2 + packages/session/session-title/src/index.ts | 36 +- packages/session/session-title/src/types.ts | 4 +- .../session-title/tests/projection.spec.ts | 16 +- packages/subagent/tool-subagent/package.json | 4 +- packages/subagent/tool-subagent/src/index.ts | 16 +- .../subagent/tool-subagent/src/invariant.ts | 6 +- .../src/model-selection-state.ts | 31 +- .../subagent/tool-subagent/tests/harness.ts | 2 + .../tool-subagent/tests/list-models.spec.ts | 8 + .../tests/model-selection-settings.spec.ts | 14 +- .../tests/model-selection.spec.ts | 3 + .../tool-subagent/tests/tool-subagent.spec.ts | 70 ++-- packages/subagent/tool-subagent/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 43 files changed, 677 insertions(+), 519 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7483499490..a9ffef7fe6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -53,6 +53,8 @@ Source: [`packages/core/agent-default-model/src/index.ts:41`](../packages/core/a ## `@deepseek-ai/dsh-agent-instructions` +Requires: `sessionProjections` + ```ts config-catalog /** User-facing workspace instruction loader configuration. */ export interface Config { @@ -83,7 +85,7 @@ Source: [`packages/context/agent-instructions/src/config.ts:18`](../packages/con ## `@deepseek-ai/dsh-agent-loop` -Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` +Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -109,13 +111,13 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) -Source: [`packages/core/agent-loop/src/index.ts:255`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:310`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-presets` -Requires: `loader` +Requires: `loader` · `sessionProjections` ```ts config-catalog /** Plugin config: which preset is the default, and where presets live. */ @@ -248,7 +250,7 @@ export interface GoalConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`JobsConfig`](#deepseek-aidsh-jobs-local) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillFileSystem`](../packages/skill/skill-filesystem/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/shell/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`toolJobs`](../packages/jobs/tool-jobs/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`workspaceContext`](../packages/context/agent-instructions/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:93`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:94`](../packages/examples/agent-spine-demo/src/index.ts) @@ -576,7 +578,7 @@ Source: [`packages/e2b/e2b/src/index.ts:43`](../packages/e2b/e2b/src/index.ts) ## `@deepseek-ai/dsh-experimental-agent-team` -Requires: `agents` · `sessions` · `sessionPersistence` · `subagents` +Requires: `agents` · `sessions` · `sessionPersistence` · `sessionProjections` · `subagents` ```ts config-catalog /** Team-service deployment limits. */ @@ -677,7 +679,7 @@ Source: [`packages/fs/fs-sandbox/src/index.ts:45`](../packages/fs/fs-sandbox/src ## `@deepseek-ai/dsh-goal` -Requires: `agents` +Requires: `agents` · `sessionProjections` ```ts config-catalog /** Deployment defaults for goal creation. */ @@ -687,7 +689,7 @@ export interface Config { } ``` -Source: [`packages/goal/goal/src/index.ts:116`](../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:171`](../packages/goal/goal/src/index.ts) @@ -709,7 +711,7 @@ Source: [`packages/bundle/headless/src/index.ts:32`](../packages/bundle/headless ## `@deepseek-ai/dsh-hooks-claude-code` -Requires: `shell` +Requires: `shell` · `sessionProjections` ```ts config-catalog /** Plugin config: where the CC hook config lives + substitution roots. */ @@ -741,13 +743,13 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude-code/src/index.ts:45`](../packages/hooks/hooks-claude-code/src/index.ts) +Source: [`packages/hooks/hooks-claude-code/src/index.ts:46`](../packages/hooks/hooks-claude-code/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` -Requires: `shell` +Requires: `shell` · `sessionProjections` ```ts config-catalog /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ @@ -768,7 +770,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:45`](../packages/hooks/hooks-codex/src/index.ts) @@ -1313,14 +1315,14 @@ Source: [`packages/test-support/llm-replay/src/index.ts:914`](../packages/test-s ## `@deepseek-ai/dsh-llm-retry` -Requires: `agents` +Requires: `agents` · `sessionProjections` ```ts config-catalog /** This policy executor has no config; providers own `retryPolicy`. */ export type Config = Readonly> ``` -Source: [`packages/llm/llm-retry/src/index.ts:24`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:25`](../packages/llm/llm-retry/src/index.ts) @@ -1457,7 +1459,7 @@ Source: [`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedb ## `@deepseek-ai/dsh-permission-presets` -Requires: `shell` · `approval` · `sessions` +Requires: `shell` · `approval` · `sessions` · `sessionProjections` ```ts config-catalog /** The {@link PermissionPresetService} config: preset table and composition default. */ @@ -1490,7 +1492,7 @@ export interface PresetSpec { Depends on: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md) -Source: [`packages/interaction/permission-presets/src/index.ts:156`](../packages/interaction/permission-presets/src/index.ts) +Source: [`packages/interaction/permission-presets/src/index.ts:112`](../packages/interaction/permission-presets/src/index.ts) @@ -1520,7 +1522,7 @@ Source: [`packages/preset/persona/src/index.ts:34`](../packages/preset/persona/s ## `@deepseek-ai/dsh-plan-mode` -Requires: `tools` · `systemPrompt` +Requires: `tools` · `systemPrompt` · `sessionProjections` ```ts config-catalog /** Deployment-owned plan guidance. */ @@ -1530,7 +1532,7 @@ export interface PlanModeConfig { } ``` -Source: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:65`](../packages/plan/plan-mode/src/index.ts) @@ -1673,6 +1675,8 @@ Source: [`packages/sandbox/sandbox-local/src/index.ts:44`](../packages/sandbox/s ## `@deepseek-ai/dsh-sandbox-policy` +Requires: `sessionProjections` + ```ts config-catalog /** * Plugin config: the deployment's sandbox default. All optional — `Config` @@ -1694,7 +1698,7 @@ export interface Config { Depends on: [`SandboxMode`](subsystems/sandbox.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:67`](../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts:68`](../packages/sandbox/sandbox-policy/src/index.ts) @@ -1958,7 +1962,7 @@ Source: [`packages/session/session-telemetry-otel/src/index.ts:91`](../packages/ ## `@deepseek-ai/dsh-session-title` -Requires: `sessions` +Requires: `sessions` · `sessionProjections` ```ts config-catalog /** Required deterministic fallback and accepted-title limits. */ @@ -1972,7 +1976,7 @@ export interface Config { } ``` -Source: [`packages/session/session-title/src/index.ts:79`](../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:53`](../packages/session/session-title/src/index.ts) @@ -2479,7 +2483,7 @@ Source: [`packages/core/system-prompt/src/index.ts:237`](../packages/core/system ## `@deepseek-ai/dsh-terminal-bash` -Requires: `terminals` · `sandboxPolicy` · `subprocess` +Requires: `terminals` · `sandboxPolicy` · `sessionProjections` · `subprocess` ```ts config-catalog /** Public plugin configuration. */ @@ -2529,7 +2533,7 @@ Source: [`packages/terminal/terminal-bash/src/config.ts:10`](../packages/termina ## `@deepseek-ai/dsh-time-context` -Requires: `agents` +Requires: `agents` · `sessionProjections` ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -2541,13 +2545,13 @@ export interface Config { } ``` -Source: [`packages/context/time-context/src/index.ts:27`](../packages/context/time-context/src/index.ts) +Source: [`packages/context/time-context/src/index.ts:48`](../packages/context/time-context/src/index.ts) ## `@deepseek-ai/dsh-tmux-context` -Requires: `agents` +Requires: `agents` · `sessionProjections` ```ts config-catalog /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ @@ -2557,12 +2561,14 @@ export interface Config { } ``` -Source: [`packages/context/tmux-context/src/index.ts:34`](../packages/context/tmux-context/src/index.ts) +Source: [`packages/context/tmux-context/src/index.ts:36`](../packages/context/tmux-context/src/index.ts) ## `@deepseek-ai/dsh-token-meter` +Requires: `sessionProjections` + ```ts config-catalog /** Token-meter plugin configuration; the fixed estimator has no settings. */ export type TokenMeterConfig = Record @@ -2669,7 +2675,7 @@ Source: [`packages/fs/tool-fs-search/src/index.ts:73`](../packages/fs/tool-fs-se ## `@deepseek-ai/dsh-tool-goal` -Requires: `agents` · `goals` · `tools` · `systemPrompt` +Requires: `agents` · `goals` · `tools` · `systemPrompt` · `sessionProjections` ```ts config-catalog /** Model policy and hard lower bounds for goal-state updates. */ @@ -2799,7 +2805,7 @@ Source: [`packages/workflow/tool-ralph/src/index.ts:22`](../packages/workflow/to ## `@deepseek-ai/dsh-tool-session-query` -Requires: `tools` · `systemPrompt` · `sessionQuery` +Requires: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` ```ts config-catalog /** Deployment-owned search count and timeout bounds. */ @@ -2851,7 +2857,7 @@ Source: [`packages/fs/tool-str-replace-editor/src/index.ts:505`](../packages/fs/ ## `@deepseek-ai/dsh-tool-subagent` -Requires: `tools` · `subagents` · `systemPrompt` +Requires: `tools` · `subagents` · `systemPrompt` · `sessionProjections` ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ @@ -2918,7 +2924,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:45`](../packages/subagent/tool-subagent/src/index.ts) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ff4a530f60..a3c8ae5ae4 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:92`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -77,7 +77,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | diff --git a/docs/module-graph.md b/docs/module-graph.md index f2c8a499fc..0ff087dd2a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -461,12 +461,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_agent --> pkg_invariants - pkg_agent --> pkg_llm - pkg_agent --> pkg_scope - pkg_agent --> pkg_session - pkg_agent --> pkg_system_prompt - pkg_agent --> pkg_typert_protocol pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill pkg_web_fetch_http --> pkg_invariants @@ -506,43 +500,19 @@ flowchart TD pkg_session_projection --> pkg_session pkg_session_snapshot --> pkg_invariants pkg_session_snapshot --> pkg_session - pkg_llm_retry --> pkg_agent - pkg_llm_retry --> pkg_brand - pkg_llm_retry --> pkg_invariants - pkg_llm_retry --> pkg_llm - pkg_llm_retry --> pkg_session - pkg_llm_retry --> pkg_timeout - pkg_agent_default_model --> pkg_agent - pkg_agent_default_model --> pkg_invariants - pkg_agent_default_model --> pkg_llm - pkg_agent_default_model --> pkg_settings - pkg_goal --> pkg_agent - pkg_goal --> pkg_brand - pkg_goal --> pkg_invariants - pkg_goal --> pkg_llm - pkg_goal --> pkg_scope - pkg_goal --> pkg_session - pkg_goal --> pkg_session_projection - pkg_goal --> pkg_typert_protocol + pkg_agent --> pkg_invariants + pkg_agent --> pkg_llm + pkg_agent --> pkg_scope + pkg_agent --> pkg_session + pkg_agent --> pkg_session_projection + pkg_agent --> pkg_system_prompt + pkg_agent --> pkg_typert_protocol pkg_fs --> pkg_brand pkg_fs --> pkg_invariants pkg_fs --> pkg_llm pkg_fs --> pkg_sandbox - pkg_web_search_deepseek --> pkg_agent - pkg_web_search_deepseek --> pkg_credentials - pkg_web_search_deepseek --> pkg_invariants - pkg_web_search_deepseek --> pkg_launch_environment - pkg_web_search_deepseek --> pkg_session - pkg_web_search_deepseek --> pkg_settings - pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_file_reference --> pkg_agent - pkg_file_reference --> pkg_invariants - pkg_file_reference --> pkg_typert_protocol - pkg_time_context --> pkg_agent - pkg_time_context --> pkg_invariants - pkg_time_context --> pkg_session pkg_message_feedback --> pkg_brand pkg_message_feedback --> pkg_invariants pkg_message_feedback --> pkg_llm @@ -550,38 +520,10 @@ flowchart TD pkg_message_feedback --> pkg_session_persistence pkg_message_feedback --> pkg_storage_domain pkg_message_feedback --> pkg_typert_protocol - pkg_commands --> pkg_agent - pkg_commands --> pkg_attachment - pkg_commands --> pkg_brand - pkg_commands --> pkg_invariants - pkg_commands --> pkg_llm - pkg_commands --> pkg_scope - pkg_commands --> pkg_session - pkg_commands --> pkg_typert_protocol - pkg_user_approval --> pkg_agent - pkg_user_approval --> pkg_brand - pkg_user_approval --> pkg_invariants - pkg_user_approval --> pkg_llm - pkg_user_approval --> pkg_scope - pkg_user_approval --> pkg_session - pkg_user_approval --> pkg_system_prompt - pkg_user_questions --> pkg_agent - pkg_user_questions --> pkg_invariants - pkg_user_questions --> pkg_llm - pkg_user_questions --> pkg_scope - pkg_jobs --> pkg_agent - pkg_jobs --> pkg_brand - pkg_jobs --> pkg_invariants - pkg_jobs --> pkg_session pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_sandbox_local --> pkg_session - pkg_sandbox_policy --> pkg_agent - pkg_sandbox_policy --> pkg_invariants - pkg_sandbox_policy --> pkg_sandbox - pkg_sandbox_policy --> pkg_session - pkg_sandbox_policy --> pkg_system_prompt pkg_session_persistence_jsonl --> pkg_invariants pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence @@ -597,30 +539,10 @@ flowchart TD pkg_session_stats --> pkg_llm pkg_session_stats --> pkg_session pkg_session_stats --> pkg_session_projection - pkg_session_telemetry --> pkg_agent - pkg_session_telemetry --> pkg_invariants - pkg_session_telemetry --> pkg_session - pkg_session_title --> pkg_brand - pkg_session_title --> pkg_invariants - pkg_session_title --> pkg_llm - pkg_session_title --> pkg_session - pkg_session_title --> pkg_session_projection pkg_shell --> pkg_invariants pkg_shell --> pkg_sandbox pkg_shell --> pkg_settings pkg_shell --> pkg_subprocess - pkg_terminal --> pkg_agent - pkg_terminal --> pkg_brand - pkg_terminal --> pkg_invariants - pkg_loader_smoke --> pkg_agent - pkg_loader_smoke --> pkg_invariants - pkg_loader_smoke --> pkg_llm - pkg_loader_smoke --> pkg_session - pkg_workflow --> pkg_agent - pkg_workflow --> pkg_brand - pkg_workflow --> pkg_invariants - pkg_workflow --> pkg_llm - pkg_workflow --> pkg_session pkg_workspace --> pkg_brand pkg_workspace --> pkg_invariants pkg_workspace --> pkg_session @@ -649,6 +571,125 @@ flowchart TD pkg_llm_pi_ai --> pkg_llm pkg_llm_pi_ai --> pkg_settings pkg_llm_pi_ai --> pkg_timeout + pkg_llm_retry --> pkg_agent + pkg_llm_retry --> pkg_brand + pkg_llm_retry --> pkg_invariants + pkg_llm_retry --> pkg_llm + pkg_llm_retry --> pkg_session + pkg_llm_retry --> pkg_session_projection + pkg_llm_retry --> pkg_timeout + pkg_agent_default_model --> pkg_agent + pkg_agent_default_model --> pkg_invariants + pkg_agent_default_model --> pkg_llm + pkg_agent_default_model --> pkg_settings + pkg_goal --> pkg_agent + pkg_goal --> pkg_brand + pkg_goal --> pkg_invariants + pkg_goal --> pkg_llm + pkg_goal --> pkg_scope + pkg_goal --> pkg_session + pkg_goal --> pkg_session_projection + pkg_goal --> pkg_typert_protocol + pkg_fs_local --> pkg_fs + pkg_fs_local --> pkg_invariants + pkg_fs_observation_policy --> pkg_fs + pkg_fs_observation_policy --> pkg_invariants + pkg_skill_filesystem --> pkg_fs + pkg_skill_filesystem --> pkg_home_paths + pkg_skill_filesystem --> pkg_invariants + pkg_skill_filesystem --> pkg_skill + pkg_web_search_deepseek --> pkg_agent + pkg_web_search_deepseek --> pkg_credentials + pkg_web_search_deepseek --> pkg_invariants + pkg_web_search_deepseek --> pkg_launch_environment + pkg_web_search_deepseek --> pkg_session + pkg_web_search_deepseek --> pkg_settings + pkg_web_search_deepseek --> pkg_web + pkg_hook_protocol --> pkg_invariants + pkg_hook_protocol --> pkg_session + pkg_hook_protocol --> pkg_shell + pkg_file_reference --> pkg_agent + pkg_file_reference --> pkg_invariants + pkg_file_reference --> pkg_typert_protocol + pkg_time_context --> pkg_agent + pkg_time_context --> pkg_invariants + pkg_time_context --> pkg_session + pkg_time_context --> pkg_session_projection + pkg_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session + pkg_tmux_context --> pkg_session_projection + pkg_tmux_context --> pkg_shell + pkg_fs_e2b --> pkg_e2b + pkg_fs_e2b --> pkg_fs + pkg_fs_e2b --> pkg_invariants + pkg_commands --> pkg_agent + pkg_commands --> pkg_attachment + pkg_commands --> pkg_brand + pkg_commands --> pkg_invariants + pkg_commands --> pkg_llm + pkg_commands --> pkg_scope + pkg_commands --> pkg_session + pkg_commands --> pkg_typert_protocol + pkg_user_approval --> pkg_agent + pkg_user_approval --> pkg_brand + pkg_user_approval --> pkg_invariants + pkg_user_approval --> pkg_llm + pkg_user_approval --> pkg_scope + pkg_user_approval --> pkg_session + pkg_user_approval --> pkg_system_prompt + pkg_user_questions --> pkg_agent + pkg_user_questions --> pkg_invariants + pkg_user_questions --> pkg_llm + pkg_user_questions --> pkg_scope + pkg_jobs --> pkg_agent + pkg_jobs --> pkg_brand + pkg_jobs --> pkg_invariants + pkg_jobs --> pkg_session + pkg_lsp_stdio --> pkg_brand + pkg_lsp_stdio --> pkg_fs + pkg_lsp_stdio --> pkg_invariants + pkg_lsp_stdio --> pkg_llm + pkg_lsp_stdio --> pkg_lsp + pkg_lsp_stdio --> pkg_subprocess + pkg_lsp_stdio --> pkg_timeout + pkg_sandbox_policy --> pkg_agent + pkg_sandbox_policy --> pkg_invariants + pkg_sandbox_policy --> pkg_sandbox + pkg_sandbox_policy --> pkg_session + pkg_sandbox_policy --> pkg_session_projection + pkg_sandbox_policy --> pkg_system_prompt + pkg_session_telemetry --> pkg_agent + pkg_session_telemetry --> pkg_invariants + pkg_session_telemetry --> pkg_session + pkg_session_title --> pkg_agent + pkg_session_title --> pkg_brand + pkg_session_title --> pkg_invariants + pkg_session_title --> pkg_llm + pkg_session_title --> pkg_session + pkg_session_title --> pkg_session_projection + pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_settings + pkg_bash_local --> pkg_shell + pkg_bash_local --> pkg_subprocess + pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_settings + pkg_pwsh_local --> pkg_shell + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout + pkg_terminal --> pkg_agent + pkg_terminal --> pkg_brand + pkg_terminal --> pkg_invariants + pkg_loader_smoke --> pkg_agent + pkg_loader_smoke --> pkg_invariants + pkg_loader_smoke --> pkg_llm + pkg_loader_smoke --> pkg_session + pkg_workflow --> pkg_agent + pkg_workflow --> pkg_brand + pkg_workflow --> pkg_invariants + pkg_workflow --> pkg_llm + pkg_workflow --> pkg_session pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_invariants @@ -666,17 +707,11 @@ flowchart TD pkg_goal_round_driver --> pkg_invariants pkg_goal_round_driver --> pkg_llm pkg_goal_round_driver --> pkg_session - pkg_fs_local --> pkg_fs - pkg_fs_local --> pkg_invariants - pkg_fs_observation_policy --> pkg_fs - pkg_fs_observation_policy --> pkg_invariants - pkg_skill_filesystem --> pkg_fs - pkg_skill_filesystem --> pkg_home_paths - pkg_skill_filesystem --> pkg_invariants - pkg_skill_filesystem --> pkg_skill - pkg_hook_protocol --> pkg_invariants - pkg_hook_protocol --> pkg_session - pkg_hook_protocol --> pkg_shell + pkg_fs_sandbox --> pkg_fs + pkg_fs_sandbox --> pkg_fs_local + pkg_fs_sandbox --> pkg_invariants + pkg_fs_sandbox --> pkg_sandbox + pkg_fs_sandbox --> pkg_sandbox_policy pkg_headless --> pkg_agent pkg_headless --> pkg_agent_default_model pkg_headless --> pkg_invariants @@ -687,13 +722,6 @@ flowchart TD pkg_compaction --> pkg_invariants pkg_compaction --> pkg_llm pkg_compaction --> pkg_session - pkg_tmux_context --> pkg_agent - pkg_tmux_context --> pkg_invariants - pkg_tmux_context --> pkg_session - pkg_tmux_context --> pkg_shell - pkg_fs_e2b --> pkg_e2b - pkg_fs_e2b --> pkg_fs - pkg_fs_e2b --> pkg_invariants pkg_command_feedback --> pkg_anonymous_user_id pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants @@ -713,33 +741,27 @@ flowchart TD pkg_jobs_local --> pkg_jobs pkg_jobs_local --> pkg_scope pkg_jobs_local --> pkg_timeout - pkg_lsp_stdio --> pkg_brand - pkg_lsp_stdio --> pkg_fs - pkg_lsp_stdio --> pkg_invariants - pkg_lsp_stdio --> pkg_llm - pkg_lsp_stdio --> pkg_lsp - pkg_lsp_stdio --> pkg_subprocess - pkg_lsp_stdio --> pkg_timeout pkg_session_title_llm --> pkg_invariants pkg_session_title_llm --> pkg_llm pkg_session_title_llm --> pkg_session pkg_session_title_llm --> pkg_session_title pkg_session_title_llm --> pkg_timeout - pkg_bash_local --> pkg_invariants - pkg_bash_local --> pkg_settings - pkg_bash_local --> pkg_shell - pkg_bash_local --> pkg_subprocess - pkg_bash_local --> pkg_timeout - pkg_pwsh_local --> pkg_invariants - pkg_pwsh_local --> pkg_settings - pkg_pwsh_local --> pkg_shell - pkg_pwsh_local --> pkg_subprocess - pkg_pwsh_local --> pkg_timeout + pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_invariants + pkg_bash_sandbox --> pkg_sandbox + pkg_bash_sandbox --> pkg_sandbox_policy + pkg_bash_sandbox --> pkg_shell + pkg_pwsh_sandbox --> pkg_invariants + pkg_pwsh_sandbox --> pkg_pwsh_local + pkg_pwsh_sandbox --> pkg_sandbox + pkg_pwsh_sandbox --> pkg_sandbox_policy + pkg_pwsh_sandbox --> pkg_shell pkg_terminal_bash --> pkg_agent pkg_terminal_bash --> pkg_invariants pkg_terminal_bash --> pkg_sandbox pkg_terminal_bash --> pkg_sandbox_policy pkg_terminal_bash --> pkg_session + pkg_terminal_bash --> pkg_session_projection pkg_terminal_bash --> pkg_subprocess pkg_terminal_bash --> pkg_terminal pkg_token_meter --> pkg_compaction @@ -754,6 +776,7 @@ flowchart TD pkg_agent_loop --> pkg_scope pkg_agent_loop --> pkg_session pkg_agent_loop --> pkg_session_persistence + pkg_agent_loop --> pkg_session_projection pkg_agent_loop --> pkg_settings pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools @@ -764,13 +787,9 @@ flowchart TD pkg_tool_goal --> pkg_invariants pkg_tool_goal --> pkg_llm pkg_tool_goal --> pkg_session + pkg_tool_goal --> pkg_session_projection pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools - pkg_fs_sandbox --> pkg_fs - pkg_fs_sandbox --> pkg_fs_local - pkg_fs_sandbox --> pkg_invariants - pkg_fs_sandbox --> pkg_sandbox - pkg_fs_sandbox --> pkg_sandbox_policy pkg_tool_fs --> pkg_attachment pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants @@ -831,6 +850,7 @@ flowchart TD pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_session_persistence + pkg_hooks_codex --> pkg_session_projection pkg_hooks_codex --> pkg_tools pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compaction @@ -841,6 +861,7 @@ flowchart TD pkg_agent_instructions --> pkg_invariants pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session + pkg_agent_instructions --> pkg_session_projection pkg_agent_instructions --> pkg_tools pkg_file_reference_local --> pkg_agent pkg_file_reference_local --> pkg_file_reference @@ -926,16 +947,6 @@ flowchart TD pkg_session_title_first_prompt_llm --> pkg_session pkg_session_title_first_prompt_llm --> pkg_session_title pkg_session_title_first_prompt_llm --> pkg_session_title_llm - pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_invariants - pkg_bash_sandbox --> pkg_sandbox - pkg_bash_sandbox --> pkg_sandbox_policy - pkg_bash_sandbox --> pkg_shell - pkg_pwsh_sandbox --> pkg_invariants - pkg_pwsh_sandbox --> pkg_pwsh_local - pkg_pwsh_sandbox --> pkg_sandbox - pkg_pwsh_sandbox --> pkg_sandbox_policy - pkg_pwsh_sandbox --> pkg_shell pkg_shell_env --> pkg_home_paths pkg_shell_env --> pkg_invariants pkg_shell_env --> pkg_session_persistence @@ -1071,9 +1082,11 @@ flowchart TD pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence pkg_session_query_sqlite --> pkg_session_query + pkg_tool_session_query --> pkg_agent pkg_tool_session_query --> pkg_invariants pkg_tool_session_query --> pkg_llm pkg_tool_session_query --> pkg_session + pkg_tool_session_query --> pkg_session_projection pkg_tool_session_query --> pkg_session_query pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout @@ -1166,6 +1179,7 @@ flowchart TD pkg_tool_subagent --> pkg_llm pkg_tool_subagent --> pkg_scope pkg_tool_subagent --> pkg_session + pkg_tool_subagent --> pkg_session_projection pkg_tool_subagent --> pkg_settings pkg_tool_subagent --> pkg_subagent pkg_tool_subagent --> pkg_system_prompt @@ -1186,6 +1200,7 @@ flowchart TD pkg_hooks_claude_code --> pkg_llm pkg_hooks_claude_code --> pkg_session pkg_hooks_claude_code --> pkg_session_persistence + pkg_hooks_claude_code --> pkg_session_projection pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools pkg_api_gateway --> pkg_brand @@ -1199,6 +1214,7 @@ flowchart TD pkg_experimental_agent_team --> pkg_llm pkg_experimental_agent_team --> pkg_session pkg_experimental_agent_team --> pkg_session_persistence + pkg_experimental_agent_team --> pkg_session_projection pkg_experimental_agent_team --> pkg_subagent pkg_experimental_agent_team --> pkg_typert_protocol pkg_host_frontend_static --> pkg_client_connection @@ -1738,7 +1754,6 @@ flowchart TD | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | @@ -1752,58 +1767,61 @@ flowchart TD | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | -| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | +| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | -| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | -| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | -| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | -| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | +| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | +| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | +| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | +| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | +| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | +| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | -| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | -| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | -| [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | +| [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | +| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | +| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | -| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | @@ -1812,9 +1830,9 @@ flowchart TD | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | -| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | +| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | @@ -1829,8 +1847,6 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | -| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1850,7 +1866,7 @@ flowchart TD | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | -| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | @@ -1861,12 +1877,12 @@ flowchart TD | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | -| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | +| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 593fb07302..7956999d8d 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -104,7 +104,7 @@ Sources: [`packages/core/session/src/types.ts:328`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:38`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:57`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -513,13 +513,13 @@ Source: [`packages/api/session-controller/src/types.ts:40`](../packages/api/sess /** * Records the selected preset as durable, log-only user intent. The knob * events follow in the same turn and control execution; this event stays - * out of the model transcript and lets {@link effectivePermissionPreset} + * out of the model transcript and lets the permission projection unit * preserve a selection when bundles match. */ 'permission/preset': { preset: string } ``` -Source: [`packages/interaction/permission-presets/src/index.ts:50`](../packages/interaction/permission-presets/src/index.ts) +Source: [`packages/interaction/permission-presets/src/index.ts:46`](../packages/interaction/permission-presets/src/index.ts) ### `plan/*` @@ -531,12 +531,12 @@ Source: [`packages/interaction/permission-presets/src/index.ts:50`](../packages/ /** * Whether plan mode is in force from this point on: log-only, non-surface, * whole-value replace. The last `plan/mode` wins; a log with none folds to - * inactive through {@link foldPlanMode}. + * inactive through the projection unit's fold. */ 'plan/mode': { active: boolean } ``` -Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:48`](../packages/plan/plan-mode/src/index.ts) ### `request/*` @@ -584,7 +584,7 @@ Source: [`packages/core/session/src/types.ts:291`](../packages/core/session/src/ * The session's sandbox mode was switched — log-only (like `approval/*`; * NOT a surface event, carries no `surfaceOp`): durable and replayable, * never in the model transcript. The LAST such event is the session's - * override ({@link effectiveSandboxMode}). `source: 'delegation'` marks + * override (folded by the sandboxMode projection unit). `source: 'delegation'` marks * an override seeded into a child; an absent source is a runtime switch. */ 'sandbox/mode': { @@ -662,7 +662,7 @@ Source: [`packages/core/session/src/types.ts:324`](../packages/core/session/src/ Types: [SessionTitleEventData](subsystems/session-title.md) -Source: [`packages/session/session-title/src/index.ts:100`](../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:74`](../packages/session/session-title/src/index.ts) @@ -752,7 +752,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent 'subagent/model-selection-enabled': Record ``` -Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:13`](../packages/subagent/tool-subagent/src/model-selection-state.ts) +Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:14`](../packages/subagent/tool-subagent/src/model-selection-state.ts) ### `team/*` diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 255ee9e720..1f1202afce 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index ab68bce9d0..ace82c24b2 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -14,6 +14,7 @@ import { isDeepStrictEqual } from 'node:util' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-projection' import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts' import { findProjectRoot, loadBaselineInstructionSet } from './files.ts' @@ -29,6 +30,8 @@ import { import type { AgentInstructionChange } from './render.ts' export { Config, name } +/** Services required by workspace instruction projection. */ +export const inject = ['sessionProjections'] export { discoverBaselineInstructionFiles, loadBaselineInstructions, @@ -99,7 +102,6 @@ export function apply(ctx: Context, config: Config): void { const projectionTails = new WeakMap>() // Execution ancestry and the enclosing durable step are the two commit // boundaries before an asynchronous projection may mutate the agent inbox. - const openSteps = new WeakMap() const stepTouches = new WeakMap() const compose = async ( @@ -280,15 +282,13 @@ export function apply(ctx: Context, config: Config): void { } const stepIsOpen = (session: Session): boolean => { - const known = openSteps.get(session) - if (known !== undefined) return known - let open = false - for (const event of session.events) { - if (event.type === 'step/start') open = true - else if (event.type === 'step/end' || event.type === 'turn/end') open = false + const boundary = ctx.sessionProjections.stateOf(session, 'turnBoundary') + if (boundary === undefined) { + throw new Error('agent-instructions requires the turnBoundary session projection') } - openSteps.set(session, open) - return open + return boundary.openTurnStartSeq !== null + && boundary.lastStepBoundary?.kind === 'start' + && boundary.lastStepBoundary.seq > boundary.openTurnStartSeq } const projectTouch = (touch: ProjectionTouch): void => { @@ -303,16 +303,7 @@ export function apply(ctx: Context, config: Config): void { } ctx.on('session/event', (session, event) => { - if (event.type === 'step/start') { - openSteps.set(session, true) - return - } - if (event.type === 'turn/end') { - openSteps.set(session, false) - return - } if (event.type !== 'step/end') return - openSteps.set(session, false) const pending = stepTouches.get(session) if (pending === undefined) return stepTouches.delete(session) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 11d385106e..c73280f87f 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -9,7 +9,7 @@ import LlmRuntime, { createUserMessage, CallId, type Message, type StreamChunk } import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import AgentLoop, { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsDirEntry, @@ -168,9 +168,15 @@ class BlockingReadFileSystem extends RecordingFileSystem { } } +async function mountWorkspaceContextPlugin(ctx: Context, config: workspaceContext.Config): Promise>> { + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + return ctx.plugin(workspaceContext, config) +} + async function mountWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { await ctx.plugin(LocalFileSystem, { cwd: '/' }) - return ctx.plugin(workspaceContext, config) + return mountWorkspaceContextPlugin(ctx, config) } async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { @@ -178,7 +184,7 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace await ctx.plugin(ToolRuntime) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) - return ctx.plugin(workspaceContext, config) + return mountWorkspaceContextPlugin(ctx, config) } function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { @@ -986,26 +992,26 @@ describe('workspace context request injection', () => { it('requires an explicit maxBytes configuration', async () => { const ctx = new Context() - await expect(ctx.plugin(workspaceContext, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/) + await expect(mountWorkspaceContextPlugin(ctx, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/) }) it('mounts without requiring a filesystem provider', async () => { const ctx = new Context() try { - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 }) } finally { await ctx.fiber.dispose() } }) - it('does not declare fs as a static inject dependency', () => { - expect('inject' in workspaceContext).toBe(false) + it('requires projections without making the optional filesystem a static dependency', () => { + expect(workspaceContext.inject).toEqual(['sessionProjections']) }) it('does not inject baseline context when no filesystem provider is present', async () => { const ctx = new Context() try { - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 }) const agent = stubAgent('/virtual/repo') await composeBaselinePrefix(ctx, agent) @@ -1112,7 +1118,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const original = stubAgent(root) await composeBaselinePrefix(ctx, original) @@ -1356,7 +1362,7 @@ describe('workspace context request injection', () => { expect(inserted?.source).toMatchObject({ kind: 'agent-instructions', baseline: true }) await fiber.dispose() - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const resumed = stubAgent(root, [...original.session.events]) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) @@ -1402,7 +1408,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'new repo rule') await fiber.dispose() - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const resumed = stubAgent(root, [...original.session.events]) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const staleClaim = resumed.inbox.claim('next-step', 1) @@ -1455,7 +1461,7 @@ describe('workspace context request injection', () => { await originalCtx.fiber.dispose() if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) - await resumedCtx.plugin(workspaceContext, { dshHome: home, maxBytes }) + await mountWorkspaceContextPlugin(resumedCtx, { dshHome: home, maxBytes }) const resumed = stubAgent(root, [...original.session.events]) agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) @@ -1650,7 +1656,7 @@ describe('workspace context request injection', () => { // Hot remount over the live session: the durable baseline remains // visible, so the fresh mount does not append a duplicate. await fiber.dispose() - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) await composeBaselinePrefix(ctx, agent) expect(baselineEvents(agent)).toHaveLength(1) @@ -1680,7 +1686,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) - const fiber = await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const fiber = await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) const baseline = baselineEvents(agent)[0] @@ -1695,7 +1701,7 @@ describe('workspace context request injection', () => { }) await fiber.dispose() - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) await composeBaselinePrefix(ctx, agent) expect(baselineEvents(agent)).toHaveLength(2) @@ -1982,7 +1988,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'ctx.fs rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2005,7 +2011,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'provider-only rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2047,7 +2053,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'far too large' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) @@ -2072,7 +2078,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(instructionPath, { type: 'file', content: 'far too large' }) fs.omitSizes.add(instructionPath) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) @@ -2095,7 +2101,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as BlockingReadFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'blocked' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const controller = new AbortController() const reason = new Error('cancel prefix') const pending = agentEvents(ctx, stubAgent(root)).waterfall( @@ -2129,7 +2135,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(home, 'AGENTS.md'), { type: 'file', content: 'ctx global rule' }) fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'ctx claude rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2155,7 +2161,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2178,7 +2184,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2201,7 +2207,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.throwOnStat.add(join(root, 'AGENTS.md')) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2223,7 +2229,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.throwOnStat.add(join(root, 'AGENTS.md')) fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'claude sibling rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2248,7 +2254,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.throwOnStat.add(join(root, '.git')) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2298,7 +2304,7 @@ describe('workspace context request injection', () => { await write(join(cwd, 'AGENTS.md'), 'child schema default rule') const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 }) const agent = stubAgent(cwd) await composeBaselinePrefix(ctx, agent) @@ -2319,7 +2325,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.local.md'), 'local rule') const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2515,8 +2521,7 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) - await ctx.plugin(SessionProjectionRegistry) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root }) @@ -2592,7 +2597,7 @@ describe('dynamic nested workspace context injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const controller = new AbortController() const reason = new Error('cancel dynamic reconciliation') controller.abort(reason) @@ -2855,7 +2860,7 @@ describe('dynamic nested workspace context injection', () => { fs.omitSizes.add(instructionPath) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -2892,7 +2897,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-1') }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await ctx.tools.execute({ @@ -2937,7 +2942,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(instructionPath, { type: 'file', content: 'shared path, separate sessions' }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const firstAgent = stubAgent(root) const secondAgent = stubAgent(root) @@ -3473,7 +3478,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'provider package rule' }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -3852,7 +3857,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(join(root, 'pkg/deep/file.txt'), { type: 'file', content: 'hello' }) fs.throwOnRead.add(nested) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const result = await ctx.tools.execute({ @@ -3993,7 +3998,7 @@ describe('dynamic nested workspace context injection', () => { ? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer policy block' }] } : downstream }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const blocked = await ctx.tools.execute({ @@ -4062,7 +4067,7 @@ describe('dynamic nested workspace context injection', () => { ? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer composite block' }] } : downstream }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const blocked = await ctx.tools.execute({ @@ -4086,7 +4091,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' }) @@ -4161,7 +4166,7 @@ describe('dynamic nested workspace context injection', () => { agent.session.append('step/start', { turn: 1, step: 1 }) agent.session.append('step/end', { turn: 1, step: 1 }) agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, @@ -4184,7 +4189,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem const agent = stubAgent('/') const plainResult = { callId: CallId('plain'), content: [], isError: false as const, value: null } @@ -4233,7 +4238,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem const root = resolve('/') const agent = stubAgent(root) @@ -4299,7 +4304,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(instructionPath, { type: 'file', content: 'x'.repeat(1000) }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 20 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 20 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -4421,7 +4426,7 @@ describe('workspace context inbox synchronization', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'tiny-budget rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 1 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 1 }) const agent = stubAgent(root) ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, @@ -4497,7 +4502,7 @@ describe('workspace context inbox synchronization', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'restored A' }) fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'restored B' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = stubToolExecution({ signal: testToolSignal, @@ -4537,7 +4542,7 @@ describe('workspace context inbox synchronization', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'scope A' }) fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'scope B' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = stubToolExecution({ signal: testToolSignal, diff --git a/packages/context/agent-instructions/tsconfig.json b/packages/context/agent-instructions/tsconfig.json index ecdccf49b5..b22904487a 100644 --- a/packages/context/agent-instructions/tsconfig.json +++ b/packages/context/agent-instructions/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../session/session-projection" + }, { "path": "../../core/tools" }, diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index c3b84d285f..c551b83f58 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -34,10 +34,8 @@ const timeContextStateSchema = zod.object({ lastMessageTime: zod.number().nullable(), /** Time of this plugin's latest durable injection, or null. */ lastInjectionTime: zod.number().nullable(), - /** Turn of the latest injection, or null (a same-turn injection answers precedingStepContextTime). */ - lastInjectionTurn: zod.number().nullable(), - /** The open turn at the latest fold (null between turns); the injection's turn is captured at append. */ - currentTurn: zod.number().nullable(), + /** Latest injection time in the open turn, or null before that turn receives one. */ + lastTurnInjectionTime: zod.number().nullable(), }) /** Folded time-context readings. */ @@ -151,15 +149,12 @@ export function apply(ctx: Context, config: Config): void { ctx.sessionProjections.register({ key: 'timeContext', - stateVersion: 1, + stateVersion: 2, stateSchema: timeContextStateSchema, - init: () => ({ lastMessageTime: null, lastInjectionTime: null, lastInjectionTurn: null, currentTurn: null }), + init: () => ({ lastMessageTime: null, lastInjectionTime: null, lastTurnInjectionTime: null }), apply: (state, event) => { - if (event.type === 'turn/start') { - return state.currentTurn === event.data.turn ? state : { ...state, currentTurn: event.data.turn } - } - if (event.type === 'turn/end') { - return state.currentTurn === null ? state : { ...state, currentTurn: null } + if (event.type === 'turn/start' || event.type === 'turn/end') { + return state.lastTurnInjectionTime === null ? state : { ...state, lastTurnInjectionTime: null } } if (event.type === 'user/message') { const injected = event.data.source.kind === 'plugin' && event.data.source.plugin === name @@ -170,7 +165,7 @@ export function apply(ctx: Context, config: Config): void { return { ...withMessage, lastInjectionTime: event.time, - lastInjectionTurn: state.currentTurn, + lastTurnInjectionTime: event.time, } } if (event.type === 'assistant/message' || event.type === 'tool/result') { @@ -196,12 +191,10 @@ export function apply(ctx: Context, config: Config): void { } /* v8 ignore next 2 -- time-context registers its own unit in apply, so the key is always present */ if (state === undefined) return decision - /* v8 ignore next 6 -- an injection always records a time, so the lastInjectionTime nullish fallback is unreachable */ + /* v8 ignore next 6 -- every later step follows a recorded injection in the same turn */ const previous = step === 1 ? state.lastMessageTime ?? undefined - : state.lastInjectionTurn === turn - ? state.lastInjectionTime ?? undefined - : undefined + : state.lastTurnInjectionTime ?? undefined const messages = requestMessages(agent, turn, decision.messages) const browser = deriveBrowserTimeZoneContext(messages) const selectedTimeZone = browser.kind === 'resolved' ? browser.timeZone : fallbackTimeZone diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index f1c0f70d12..886e6a020d 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -415,22 +415,28 @@ describe('configuration and lifecycle', () => { }) describe('time-context projection fold edges', () => { - it('keeps the same turn when a repeated turn/start lands', async () => { + it('clears the open-turn injection time at the next turn start', async () => { const { ctx } = await mount() const session = Session.create(SessionId('same-turn')) session.append('turn/start', { turn: 1 }) - session.append('turn/start', { turn: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'reading' }], + source: { kind: 'plugin', plugin: 'time-context' }, + }), { surfaceOp: 'append' }) + expect(typeof ctx.sessionProjections.stateOf(session, 'timeContext')?.lastTurnInjectionTime).toBe('number') + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('turn/start', { turn: 2 }) expect(ctx.sessionProjections.stateOf(session, 'timeContext')).toMatchObject({ - currentTurn: 1, + lastTurnInjectionTime: null, }) }) - it('stays null when turn/end arrives without a turn/start', async () => { + it('keeps the open-turn injection time null when turn/end arrives first', async () => { const { ctx } = await mount() const session = Session.create(SessionId('end-without-start')) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(ctx.sessionProjections.stateOf(session, 'timeContext')).toMatchObject({ - currentTurn: null, + lastTurnInjectionTime: null, }) }) }) diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 04c1d7897a..0f0f4858c7 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -41,7 +41,6 @@ const INACTIVE_STATES: ReadonlySet = new Set([ ]) const turnBoundaryProjectionSchema: zod.ZodType = zod.object({ - openTurn: zod.number().int().nonnegative().nullable(), openTurnStartSeq: zod.number().int().nonnegative().nullable(), lastStepStartSeq: zod.number().int().nonnegative().nullable(), lastStepBoundary: zod.object({ @@ -54,10 +53,9 @@ const turnBoundaryProjectionSchema: zod.ZodType = zod.ob /** Host projection of agent turn and step boundaries. */ export const turnBoundaryProjectionDefinition = { key: 'turnBoundary', - stateVersion: 1, + stateVersion: 2, stateSchema: turnBoundaryProjectionSchema, init: () => ({ - openTurn: null, openTurnStartSeq: null, lastStepStartSeq: null, lastStepBoundary: null, @@ -68,14 +66,12 @@ export const turnBoundaryProjectionDefinition = { case 'turn/start': return { ...state, - openTurn: event.data.turn, openTurnStartSeq: event.seq, lastTurn: event.data.turn, } case 'turn/end': return { ...state, - openTurn: null, openTurnStartSeq: null, } case 'step/start': diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index cc3cf477b8..79461caad6 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -37,8 +37,6 @@ export type InboxTarget = 'next-turn' | 'next-step' * corrupt state — and never treat it as an error. */ export interface TurnBoundaryProjection { - /** Open turn number, or null between turns. */ - readonly openTurn: number | null /** Seq of the open turn's `turn/start`, or null between turns. */ readonly openTurnStartSeq: number | null /** Seq of the latest `step/start` event, or null before the first step. */ diff --git a/packages/experimental/agent-team/src/journal.ts b/packages/experimental/agent-team/src/journal.ts index 391966bcbc..773866e814 100644 --- a/packages/experimental/agent-team/src/journal.ts +++ b/packages/experimental/agent-team/src/journal.ts @@ -3,9 +3,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { Context } from '@deepseek-ai/cordis' import type { SessionEventMap, SessionId } from '@deepseek-ai/dsh-session' -import { emptyTeamState } from './projection.ts' import type { TeamEventType, TeamState } from './projection.ts' -import { TeamId } from './types.ts' type AppendTeamEvent = (type: T, data: SessionEventMap[T]) => void type MutableTeamEventType = 'team/member' | 'team/task' | 'team/message/queued' | 'team/message/delivered' @@ -31,9 +29,8 @@ export class TeamJournal { state(root: Agent): TeamState { const projection = this.ctx.sessionProjections.stateOf(root.session, 'team') if (projection === undefined) throw new Error('Agent Teams projection is not registered') - const selected = projection.teams.find(team => team.id === TeamId(root.id)) - if (selected?.failure !== undefined) throw new Error(selected.failure) - return selected ?? emptyTeamState(root.id) + if (projection.failure !== undefined) throw new Error(projection.failure) + return projection } /** diff --git a/packages/experimental/agent-team/src/projection.ts b/packages/experimental/agent-team/src/projection.ts index 1ab43a0b53..ad3d0c8357 100644 --- a/packages/experimental/agent-team/src/projection.ts +++ b/packages/experimental/agent-team/src/projection.ts @@ -139,7 +139,7 @@ export interface TeamState { * @param rootId - root Session identity. * @returns mutable empty Team state. */ -export function emptyTeamState(rootId: SessionId): TeamProjectionEntry { +export function emptyTeamState(rootId: SessionId): TeamProjectionState { return { id: toTeamId(rootId), members: [], @@ -150,16 +150,11 @@ export function emptyTeamState(rootId: SessionId): TeamProjectionEntry { } } -/** One checkpoint-safe Team state retained by its durable identity. */ -export interface TeamProjectionEntry extends TeamState { +/** Checkpoint-safe state for the Team owned by the projected Session. */ +export interface TeamProjectionState extends TeamState { failure?: string } -/** Plain-JSON Team states grouped by identity for fork isolation. */ -export interface TeamProjectionState { - readonly teams: TeamProjectionEntry[] -} - declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { team: TeamProjectionState @@ -174,10 +169,6 @@ const teamProjectionEntrySchema = z.object({ delivered: z.array(teamMessageIdSchema), nextTaskNumber: positiveSafeInteger, failure: z.string().optional(), -}).strict() as z.ZodType - -const teamProjectionStateSchema = z.object({ - teams: z.array(teamProjectionEntrySchema), }).strict() as z.ZodType /** Whether one event belongs to the Team domain. */ @@ -230,22 +221,17 @@ function parseCurrentTeamEvent(event: TeamSessionEvent): TeamSessionEvent { function applyProjectionEvent(state: TeamProjectionState, event: SessionEvent): void { if (!isTeamEvent(event)) return - const selector = parsePersisted(event.type, teamEventSelectorSchema, event.data) - const current = selector.version === 1 ? parseCurrentTeamEvent(event) : undefined - let team = state.teams.find(candidate => candidate.id === selector.teamId) - if (team === undefined) { - team = emptyTeamState(SessionId(selector.teamId)) - state.teams.push(team) - } - if (team.failure !== undefined) return + if (state.failure !== undefined) return try { - if (current === undefined) { + const selector = parsePersisted(event.type, teamEventSelectorSchema, event.data) + if (selector.teamId !== state.id) return + if (selector.version !== 1) { throw new Error(`unsupported Agent Teams event version ${String(selector.version)}`) } - applyCurrentTeamEvent(team, current) + applyCurrentTeamEvent(state, parseCurrentTeamEvent(event)) } catch (error: unknown) { /* v8 ignore next -- the owned Team transition throws Error instances. */ - team.failure = error instanceof Error ? error.message : String(error) + state.failure = error instanceof Error ? error.message : String(error) } } @@ -318,12 +304,12 @@ function applyCurrentTeamEvent(state: TeamState, event: TeamSessionEvent): void } } -/** Host-only Team projection grouped by durable Team identity. */ +/** Host-only Team projection selected by the projected Session identity. */ export const teamProjectionDefinition = { key: 'team', - stateVersion: 1, - stateSchema: teamProjectionStateSchema, - init: (): TeamProjectionState => ({ teams: [] }), + stateVersion: 2, + stateSchema: teamProjectionEntrySchema, + init: header => emptyTeamState(header.id), apply: (state, event) => { applyProjectionEvent(state, event) return state diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts index 06295aadab..88e72e5506 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -16,7 +16,7 @@ import SubagentService, { seedDescriptorTurn, snapshotSubagentDescriptor } from import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import TeamService, { TeamId, TeamMessageId } from '../src/index.ts' -import { emptyTeamState, teamProjectionDefinition } from '../src/projection.ts' +import { teamProjectionDefinition } from '../src/projection.ts' import type { TeamMemberSnapshot, TeamMessageSnapshot, TeamTaskSnapshot } from '../src/index.ts' import { TestSessionQuery } from './test-session-query.ts' @@ -31,11 +31,10 @@ function durable(agent: Agent): { tasks: TeamTaskSnapshot[] pendingMessages: TeamMessageSnapshot[] } { - let projected = teamProjectionDefinition.init() + let projected = teamProjectionDefinition.init(agent.session.header) for (const event of agent.session.events) projected = teamProjectionDefinition.apply(projected, event) - const selected = projected.teams.find(team => team.id === TeamId(agent.id)) - if (selected?.failure !== undefined) throw new Error(selected.failure) - const state = selected ?? emptyTeamState(agent.id) + if (projected.failure !== undefined) throw new Error(projected.failure) + const state = projected return { members: state.members, tasks: state.tasks, diff --git a/packages/experimental/agent-team/tests/projection-events.spec.ts b/packages/experimental/agent-team/tests/projection-events.spec.ts index b5d050a830..a572fc74b1 100644 --- a/packages/experimental/agent-team/tests/projection-events.spec.ts +++ b/packages/experimental/agent-team/tests/projection-events.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionEventMap, SessionEventType } from '@deepseek-ai/dsh-session' -import { emptyTeamState, teamProjectionDefinition } from '../src/projection.ts' +import { teamProjectionDefinition } from '../src/projection.ts' import type { TeamProjectionState, TeamState } from '../src/projection.ts' import { TeamId, TeamMessageId, TeamTaskId } from '../src/types.ts' import type { TeamMemberSnapshot, TeamMessageSnapshot, TeamTaskSnapshot } from '../src/types.ts' @@ -14,20 +14,19 @@ function event(type: T, data: SessionEventMap[T], se return { type, data, seq, time: seq } as SessionEvent } -function project(events: readonly SessionEvent[]): TeamProjectionState { - let state = teamProjectionDefinition.init() +function project(rootId: SessionId, events: readonly SessionEvent[]): TeamProjectionState { + let state = teamProjectionDefinition.init({ version: 0, id: rootId, createdAt: 0 }) for (const event of events) state = teamProjectionDefinition.apply(state, event) return state } -function teamState(rootId: SessionId, projected: TeamProjectionState): TeamState { - const selected = projected.teams.find(team => team.id === TeamId(rootId)) - if (selected?.failure !== undefined) throw new Error(selected.failure) - return selected ?? emptyTeamState(rootId) +function teamState(projected: TeamProjectionState): TeamState { + if (projected.failure !== undefined) throw new Error(projected.failure) + return projected } function projectTeam(rootId: SessionId, events: readonly SessionEvent[]): TeamState { - return teamState(rootId, project(events)) + return teamState(project(rootId, events)) } /** Queued-minus-delivered mail retained by the projection. */ @@ -91,10 +90,9 @@ describe('Agent Teams projection events', () => { event('team/task', { version: 1, teamId: TEAM, task: task({ id: TeamTaskId('task-7') }) }, 3), event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, 4), ] - const projected = project(records) - const state = teamState(ROOT, projected) + const projected = project(ROOT, records) + const state = teamState(projected) - expect(projected.teams.map(team => team.id)).toEqual([TeamId('ancestor'), TEAM]) expect(state).toMatchObject({ id: TEAM }) expect(state.members).toHaveLength(1) expect(state.tasks).toHaveLength(1) @@ -305,7 +303,7 @@ describe('Agent Teams projection events', () => { teamId: TEAM, task: task(), }, 0) - const state = project([invalid]).teams[0]! + const state = project(ROOT, [invalid]) expect(state.failure).toMatch(/unsupported Agent Teams event version 2/) expect(isEmptyState(state)).toBe(true) }) @@ -316,12 +314,12 @@ describe('Agent Teams projection events', () => { teamId: TeamId('ancestor'), task: task(), }, 0) - const projected = project([inherited]) - expect(projected.teams[0]?.failure).toMatch(/unsupported Agent Teams event version 2/) - expect(isEmptyState(teamState(ROOT, projected))).toBe(true) + const projected = project(ROOT, [inherited]) + expect(projected.failure).toBeUndefined() + expect(isEmptyState(teamState(projected))).toBe(true) }) - it('still validates complete current-version records inherited from another Team', () => { + it('ignores malformed current-version records inherited from another Team', () => { const inherited = { ...event('team/task', { version: 1, @@ -334,7 +332,6 @@ describe('Agent Teams projection events', () => { task: { ...task(), subject: 42 }, }, } as unknown as SessionEvent - expect(() => projectTeam(ROOT, [inherited])) - .toThrow(/persisted Agent Teams team\/task payload is invalid/) + expect(isEmptyState(projectTeam(ROOT, [inherited]))).toBe(true) }) }) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 49f0ca3321..c6c7ebfb31 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -16,7 +16,7 @@ import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import TeamService, { TeamError, TeamId, TeamMessageId, TeamTaskId } from '../src/index.ts' import { TeamRuntimeLifecycle } from '../src/lifecycle.ts' -import { emptyTeamState, teamProjectionDefinition } from '../src/projection.ts' +import { teamProjectionDefinition } from '../src/projection.ts' import type { TeamMemberSnapshot, TeamMessageSnapshot, TeamTaskSnapshot } from '../src/index.ts' import { TestSessionQuery } from './test-session-query.ts' @@ -34,11 +34,10 @@ function durable(agent: Agent): { tasks: TeamTaskSnapshot[] pendingMessages: TeamMessageSnapshot[] } { - let projected = teamProjectionDefinition.init() + let projected = teamProjectionDefinition.init(agent.session.header) for (const event of agent.session.events) projected = teamProjectionDefinition.apply(projected, event) - const selected = projected.teams.find(team => team.id === TeamId(agent.id)) - if (selected?.failure !== undefined) throw new Error(selected.failure) - const state = selected ?? emptyTeamState(agent.id) + if (projected.failure !== undefined) throw new Error(projected.failure) + const state = projected return { members: state.members, tasks: state.tasks, diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 7a02532e54..c95ce32943 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -14,10 +14,12 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import { - decodeGoalChange, + applyGoalEvent, goalChangeRef, } from './fold.ts' +import type { GoalFoldState } from './fold.ts' import { GOAL_CHANGE_VERSION, GoalError, @@ -31,6 +33,7 @@ import type { GoalBlockReason, GoalPhase, GoalProjection, + GoalProjectionState, GoalRef, GoalSnapshot, GoalView, @@ -76,47 +79,94 @@ const goalProjectionSchema: ZodType = zod.union([ zod.null(), ]) as ZodType +const goalProjectionStateSchema: ZodType = zod.object({ + current: goalProjectionSchema, + seenGoalIds: zod.array(zod.string().min(1)).refine( + ids => new Set(ids).size === ids.length, + { message: 'seen goal ids must be unique' }, + ), + failure: zod.string().min(1).nullable(), +}).strict().superRefine((state, context) => { + if (state.current === null) return + if (!state.seenGoalIds.includes(state.current.goal.id)) { + context.addIssue({ code: 'custom', message: 'current goal id must be retained among seen goal ids' }) + } + if (state.current.updatedAt < state.current.createdAt) { + context.addIssue({ code: 'custom', message: 'current goal update cannot precede its creation' }) + } + if (state.current.roundsStarted > state.current.goal.maxGoalRounds) { + context.addIssue({ code: 'custom', message: 'current goal rounds cannot exceed its configured limit' }) + } +}) as unknown as ZodType + +/** Build strict fold state from one checkpoint-safe projection state. */ +function goalFoldState(state: GoalProjectionState): GoalFoldState { + return { + goal: state.current?.goal, + roundsStarted: state.current?.roundsStarted ?? 0, + createdAt: state.current?.createdAt, + updatedAt: state.current?.updatedAt, + lastRef: undefined, + seenGoalIds: new Set(state.seenGoalIds), + } +} + +/** Convert strict fold state into checkpoint-safe projection state. */ +function goalProjectionState(state: GoalFoldState): GoalProjectionState { + let current: GoalProjection | null = null + if (state.goal !== undefined) { + const { createdAt, updatedAt } = state + if (createdAt === undefined || updatedAt === undefined) { + throw new Error('current goal fold lacks timestamps') + } + current = { + goal: state.goal, + roundsStarted: state.roundsStarted, + createdAt, + updatedAt, + } + } + return { + current, + seenGoalIds: [...state.seenGoalIds], + failure: null, + } +} + /** - * Light fold of the `goal` projection unit. Unlike the strict - * replay fold (fold.ts: transition validation, fail-loud on malformed - * changes, Set-typed state), this transition is projection-grade: the state - * is plain JSON (persisted-cache precondition), any non-goal or malformed - * event returns the same reference (the registry's Object.is gate — the - * title/todos posture), and correctness of the written change is the write - * side's job (GoalService validated it before appending; the package - * invariant rejects a violating stream fail-loud where it is installed). + * Fold durable goal events through the strict replay rules without throwing + * from the projection registry's event drive. The first invalid owned event + * is retained in `failure`; host goal access rejects that state while the + * client view remains at the last valid goal. * @param state - the projection covering all prior events. * @param event - the next committed session event. * @returns the next projection (same reference when the event is unrelated). */ -export function applyGoalProjection(state: GoalProjection | null, event: SessionEvent): GoalProjection | null { - if (event.type === 'user/message' && state !== null) { - const source = event.data.source - if (source.kind !== 'goal' - || source.goalId !== state.goal.id - || source.revision !== state.goal.revision) return state - return { ...state, roundsStarted: source.round } +export function applyGoalProjection(state: GoalProjectionState, event: SessionEvent): GoalProjectionState { + if (state.failure !== null) return state + if (event.type !== 'goal/change' + && (event.type !== 'user/message' || event.data.source.kind !== 'goal')) return state + const folded = goalFoldState(state) + try { + applyGoalEvent(folded, event) + return goalProjectionState(folded) + } catch (error: unknown) { + /* v8 ignore next -- the strict goal fold throws Error instances. */ + const message = error instanceof Error ? error.message : String(error) + return { ...state, failure: `goal replay failed at session event ${event.seq}: ${message}` } } - if (event.type === 'goal/change') { - let change: GoalChangeMeta | undefined - try { - change = decodeGoalChange(event.data) - } catch (_invalidPersistedGoalChange) { - return state - } - if (change === undefined) return state - return change.operation === 'clear' - ? null - : { - goal: change.goal, - roundsStarted: change.roundsStarted, - createdAt: change.createdAt, - updatedAt: change.updatedAt, - } - } - return state } +/** Strict host goal state with the existing cropped client value. */ +export const goalProjectionDefinition = { + key: 'goal', + stateSchema: goalProjectionStateSchema, + init: (): GoalProjectionState => ({ current: null, seenGoalIds: [], failure: null }), + apply: applyGoalProjection, + wire: { viewSchema: goalProjectionSchema, view: state => state.current }, + stateVersion: 6, +} satisfies ProjectionDefinition<'goal', GoalProjectionState> + /** Deployment defaults for goal creation. */ export interface Config { /** Total rounds used when a create request omits its own cap. */ @@ -201,14 +251,7 @@ export class GoalService extends TypertRemoteService { ctx.on('agent/session-start', ({ agent }) => { this.runtimeState(agent.session).activation = 'disarmed' }) - ctx.sessionProjections.register<'goal', GoalProjection | null>({ - key: 'goal', - stateSchema: goalProjectionSchema, - init: () => null, - apply: applyGoalProjection, - wire: { viewSchema: goalProjectionSchema, view: state => state }, - stateVersion: 5, - }) + ctx.sessionProjections.register(goalProjectionDefinition) ctx.on('session/event', (session, event) => { if (event.type !== 'goal/change') return const runtime = this.runtimeState(session) @@ -424,7 +467,11 @@ export class GoalService extends TypertRemoteService { /** Read the current durable projection maintained by the registry. */ private state(session: Session): GoalProjection | null { - return this.ctx.sessionProjections.stateOf(session, 'goal') as GoalProjection | null + const state = this.ctx.sessionProjections.stateOf(session, 'goal') + /* v8 ignore next -- GoalService registers its required projection in the constructor. */ + if (state === undefined) throw new Error('goal projection is not registered') + if (state.failure !== null) throw new Error(state.failure) + return state.current } /** Return the process-local activation state, initially disarmed. */ @@ -536,7 +583,7 @@ export class GoalService extends TypertRemoteService { runtime.pendingActivation = { seq: agent.session.seq, activation } try { const event = agent.session.append('goal/change', change) - if (runtime.pendingActivation?.seq === event.seq) runtime.activation = activation + if (runtime.pendingActivation.seq === event.seq) runtime.activation = activation } finally { runtime.pendingActivation = undefined } diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts index 0ef17dd1a6..bb7d8b9e1c 100644 --- a/packages/goal/goal/src/types.ts +++ b/packages/goal/goal/src/types.ts @@ -99,9 +99,19 @@ export interface GoalProjection { readonly updatedAt: number } +/** Strict checkpoint state used to derive the current goal client value. */ +export interface GoalProjectionState { + /** Latest valid current goal, or null before creation and after clear. */ + readonly current: GoalProjection | null + /** Goal identities already created in this Session, retained to reject reuse. */ + readonly seenGoalIds: GoalId[] + /** First strict replay failure, or null while the durable stream is valid. */ + readonly failure: string | null +} + declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { - goal: GoalProjection | null + goal: GoalProjectionState } interface SessionProjectionMap { /** diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index a6b8ab1725..d9ab29008c 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -3,9 +3,9 @@ * serves the current whole goal on the history tail page with a consistent * asOfSeq; before the first create the value is null; a clear tombstone * returns it to null; a composition without the goal service has no `goal` - * key; unmounting drops it (HMR safety). Malformed goal-shaped events are - * ignored fail-soft (same-reference return) — strict replay validation - * belongs to the write side and foldGoal, never the projection drive. + * key; unmounting drops it (HMR safety). The host state retains strict replay + * failures without throwing from the registry drive, and GoalService rejects + * access after such a failure. */ import { describe, expect, it, vi } from 'vitest' @@ -17,8 +17,8 @@ import type { UserMessage } from '@deepseek-ai/dsh-session' import SessionStore from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import GoalService, { GoalId, applyGoalProjection, foldGoal } from '@deepseek-ai/dsh-goal' -import type { GoalProjection, GoalRef } from '@deepseek-ai/dsh-goal' +import GoalService, { GoalId, applyGoalProjection, foldGoal, goalProjectionDefinition } from '@deepseek-ai/dsh-goal' +import type { GoalProjection, GoalProjectionState, GoalRef } from '@deepseek-ai/dsh-goal' interface Bench { ctx: Context @@ -139,23 +139,34 @@ describe('goal projection unit', () => { expect(foldGoal(bench.session.events).goal).toBeUndefined() }) - it('ignores non-goal and malformed goal-shaped events fail-soft (same reference)', () => { - // The package invariant rejects a violating stream loudly wherever it is - // installed — the unit itself must never throw on the projection drive - // (a throwing apply would tear down every registered unit's drive), so - // its transition is exercised directly as the pure function it is. + it('retains strict replay failures without throwing from the projection drive', () => { const plainUser = createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }) const user = { type: 'user/message', seq: 0, time: 1, data: plainUser } as never - const state: GoalProjection = { + const current: GoalProjection = { goal: { id: GoalId('g1'), revision: 1, objective: 'x', phase: 'active', maxGoalRounds: 4 }, roundsStarted: 0, createdAt: 1, updatedAt: 1, } - const empty = null + const state: GoalProjectionState = { + current, + seenGoalIds: [current.goal.id], + failure: null, + } + expect(goalProjectionDefinition.stateSchema.parse(state)).toEqual(state) + expect(goalProjectionDefinition.stateSchema.safeParse({ ...state, seenGoalIds: [] }).success).toBe(false) + expect(goalProjectionDefinition.stateSchema.safeParse({ + ...state, + current: { ...current, createdAt: 2, updatedAt: 1 }, + }).success).toBe(false) + expect(goalProjectionDefinition.stateSchema.safeParse({ + ...state, + current: { ...current, roundsStarted: current.goal.maxGoalRounds + 1 }, + }).success).toBe(false) + const empty = goalProjectionDefinition.init() expect(applyGoalProjection(empty, user)).toBe(empty) const admittedRound = { type: 'user/message', seq: 1, time: 2, @@ -164,21 +175,22 @@ describe('goal projection unit', () => { source: { kind: 'goal', goalId: 'g1', revision: 1, round: 1 } as never, }), } as never - expect(applyGoalProjection(state, admittedRound)).toEqual({ ...state, roundsStarted: 1 }) + expect(applyGoalProjection(state, admittedRound)).toEqual({ + ...state, + current: { ...current, roundsStarted: 1 }, + }) const queuedUser = { type: 'agent/inbox/spliced', seq: 1, time: 2, data: { target: 'next-step', start: 0, inserted: [plainUser] }, } as never - const current = state - expect(applyGoalProjection(current, queuedUser)).toBe(current) + expect(applyGoalProjection(state, queuedUser)).toBe(state) const malformed = { type: 'goal/change', seq: 1, time: 2, data: { kind: 'goal/change', version: 1, operation: 'create' }, } as never - // Same-reference return: the registry's Object.is gate sees no change. - expect(applyGoalProjection(current, malformed)).toBe(current) - expect(applyGoalProjection(empty, malformed)).toBe(empty) + expect(applyGoalProjection(state, malformed).failure).toMatch(/goal snapshot change must have exactly/) + expect(applyGoalProjection(empty, malformed).failure).toMatch(/goal snapshot change must have exactly/) const queuedRound = { type: 'agent/inbox/spliced', seq: 3, time: 4, @@ -187,16 +199,27 @@ describe('goal projection unit', () => { source: { kind: 'goal', goalId: 'g1', revision: 1, round: 1 } as never, })] }, } as never - expect(applyGoalProjection(current, queuedRound)).toBe(current) + expect(applyGoalProjection(state, queuedRound)).toBe(state) // A non-message event (the registry drives EVERY committed event through // apply): early same-reference return. const turnStart = { type: 'turn/start', seq: 3, time: 4, data: { turn: 1 } } as never - expect(applyGoalProjection(current, turnStart)).toBe(current) + expect(applyGoalProjection(state, turnStart)).toBe(state) - // A goal/change event whose payload carries a foreign kind is ignored. + // A declared goal/change record with a foreign payload kind is an owned-stream failure. const foreignKind = { type: 'goal/change', seq: 4, time: 5, data: { kind: 'not-a-goal-change' } } as never - expect(applyGoalProjection(current, foreignKind)).toBe(current) + expect(applyGoalProjection(state, foreignKind).failure).toMatch(/invalid kind/) + }) + + it('fails host goal access when the projection retained a replay failure', async () => { + const bench = await harness(true) + const failure = 'goal replay failed at session event 0: invalid restored goal stream' + const state = bench.ctx.sessionProjections.stateOf(bench.session, 'goal') + expect(state).toBeDefined() + Object.assign(state!, { failure }) + + expect(() => bench.ctx.goals.get(bench.agent)).toThrow(failure) + expect(bench.tailValues().goal).toBeNull() }) it('has no goal key when the goal service is not composed', async () => { diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 0684ea1b7d..64a820a43b 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -32,7 +32,7 @@ import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import { defineTool } from '@deepseek-ai/dsh-tools' import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' -import type {} from '@deepseek-ai/dsh-commands' +import type { CommandId } from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { PlanProjection, PlanUnitState } from './types.ts' @@ -116,7 +116,7 @@ export function resolveConfig(config: PlanModeConfig): PlanModeConfig { const planUnitStateSchema = zod.object({ active: zod.boolean(), wanted: zod.boolean().nullable(), - running: zod.object({ commandId: zod.string(), wanted: zod.boolean() }).nullable(), + running: zod.object({ commandId: zod.string() as unknown as ZodType, wanted: zod.boolean() }).nullable(), activeAtLastHeader: zod.boolean().nullable(), }) @@ -367,7 +367,7 @@ export class PlanModeController extends Service { } private hasOpenTurn(session: Session): boolean { - return (this.ctx.sessionProjections.stateOf(session, 'turnBoundary')?.openTurn ?? null) !== null + return (this.ctx.sessionProjections.stateOf(session, 'turnBoundary')?.openTurnStartSeq ?? null) !== null } private loggedActiveAtLastHeader(session: Session): boolean | undefined { diff --git a/packages/plan/plan-mode/src/types.ts b/packages/plan/plan-mode/src/types.ts index ffb19aee1e..3325c6b0c4 100644 --- a/packages/plan/plan-mode/src/types.ts +++ b/packages/plan/plan-mode/src/types.ts @@ -8,6 +8,8 @@ * @module @deepseek-ai/dsh-plan-mode/types */ +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' + /** * The plan projection's wire value. `active` is the logged state in force * (the last `plan/mode`, inactive before the first); `pending` is true while @@ -28,7 +30,7 @@ export interface PlanUnitState { /** The selection's target mode; null when no selection is outstanding. */ wanted: boolean | null /** The latest plan command awaiting its paired settlement. */ - running: { commandId: string; wanted: boolean } | null + running: { commandId: CommandId; wanted: boolean } | null /** Active state recorded by the latest `request/header`, or null. */ activeAtLastHeader: boolean | null } diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 6be67f11b4..3ed3f34d5f 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -153,7 +153,7 @@ declare module '@deepseek-ai/cordis' { * and a preset deleted underneath a picker disappears from the next read. */ export class AgentPresets extends TypertRemoteService { - static inject = ['loader'] + static inject = ['loader', 'sessionProjections'] /** Runtime schema for the preset roster. */ static Config = z.object({ @@ -229,9 +229,7 @@ export class AgentPresets extends TypertRemoteService { }, 'agentPresets.settings()') }) - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register(agentPresetProjectionDefinition) - }) + ctx.sessionProjections.register(agentPresetProjectionDefinition) // Advisory, not fatal: a synchronous `agent/created` listener that throws // VETOES publication, and this service must not, because composing an agent @@ -692,7 +690,11 @@ export class AgentPresets extends TypertRemoteService { // conversation may have started, since this call was queued. A turn is one // model-loop execution; standalone plugin events never open one, so a // session that has only run commands is still blank. - if (agent.session.events.some(event => event.type === 'turn/start')) { + const boundary = this.selfCtx.sessionProjections.stateOf(agent.session, 'turnBoundary') + if (boundary === undefined) { + throw new Error('agent-presets: select requires the turnBoundary session projection') + } + if (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0) { throw new PresetLockedError(agent.id, agentPreset) } const preset = await this.recompose(agent.ctx, agentPreset) diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts index 2166229cf6..b966c7b12a 100644 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -14,9 +14,10 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { beforeEach, describe, expect, it } from 'vitest' import AgentPresets, { - COMPOSITION_FILE, copyComposition, METADATA_FILE, + COMPOSITION_FILE, copyComposition, METADATA_FILE, type Config, } from '@deepseek-ai/dsh-agent-presets' const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') @@ -25,6 +26,12 @@ const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\ let ctx: Context let userRoot: string +/** Mount the required projection seam before the roster service. */ +async function mountAgentPresets(context: Context, config: Config): Promise { + await context.plugin(SessionProjectionRegistry) + await context.plugin(AgentPresets, config) +} + /** Hand-craft a preset directory (tests cannot author text through the service). */ async function seedPreset( root: string, id: string, options: { composition?: string; metadata?: string; extras?: Record } = {}, @@ -46,7 +53,7 @@ beforeEach(async () => { ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include - await ctx.plugin(AgentPresets, { + await mountAgentPresets(ctx, { default: 'standard', roots: [ { path: join(FIXTURES, 'system'), trust: 'system' as const }, @@ -199,7 +206,7 @@ describe('a deployment with more than one user root', () => { layered.baseUrl = pathToFileURL(FIXTURES).href + '/' await layered.plugin(Loader) layered.loader.builtins.include = Include - await layered.plugin(AgentPresets, { + await mountAgentPresets(layered, { default: 'standard', roots: [ { path: userRoot, trust: 'user' as const }, @@ -224,7 +231,7 @@ describe('a deployment with no writable root', () => { readOnly.baseUrl = pathToFileURL(FIXTURES).href + '/' await readOnly.plugin(Loader) readOnly.loader.builtins.include = Include - await readOnly.plugin(AgentPresets, { + await mountAgentPresets(readOnly, { default: 'standard', roots: [{ path: join(FIXTURES, 'system'), trust: 'system' as const }], includeShippedRoot: false, @@ -244,7 +251,7 @@ describe('a user root that does not exist yet', () => { fresh.baseUrl = pathToFileURL(FIXTURES).href + '/' await fresh.plugin(Loader) fresh.loader.builtins.include = Include - await fresh.plugin(AgentPresets, { + await mountAgentPresets(fresh, { default: 'standard', roots: [ { path: join(FIXTURES, 'system'), trust: 'system' as const }, diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 100c724ec9..2aea1b72a6 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -381,6 +381,7 @@ describe('a roster with nothing in it', () => { it('says so instead of naming an empty list of candidates', async () => { const bare = new Context() await bare.plugin(Loader) + await bare.plugin(SessionProjectionRegistry) await bare.plugin(AgentPresets, { default: 'standard', roots: [], includeShippedRoot: false, includeUserRoot: false }) await expect(bare.agentPresets.resolve()) diff --git a/packages/preset/agent-presets/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts index 3a8380cb02..406ef3f528 100644 --- a/packages/preset/agent-presets/tests/remote.spec.ts +++ b/packages/preset/agent-presets/tests/remote.spec.ts @@ -13,6 +13,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' @@ -72,6 +73,7 @@ async function harness( await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentPresets, roster) return ctx diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index 703c1857f2..9dc9d78d3c 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -48,7 +48,6 @@ async function harness( await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) const settingsFiber = ctx.plugin(FileSettingsProvider, { path: settingsFile, watch: false }) await settingsFiber diff --git a/packages/preset/agent-presets/tests/shipped-root.spec.ts b/packages/preset/agent-presets/tests/shipped-root.spec.ts index b7981eb2d6..edb53bd5e2 100644 --- a/packages/preset/agent-presets/tests/shipped-root.spec.ts +++ b/packages/preset/agent-presets/tests/shipped-root.spec.ts @@ -16,6 +16,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include, { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as yaml from 'js-yaml' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import AgentPresets, { SHIPPED_PRESET_ROOT, type Config } from '@deepseek-ai/dsh-agent-presets' @@ -41,6 +42,7 @@ async function roster(config: Partial = {}): Promise { ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentPresets, { default: 'standard', roots: [], diff --git a/packages/preset/agent-presets/tests/user-root.spec.ts b/packages/preset/agent-presets/tests/user-root.spec.ts index c749db8a75..6da05b2ca7 100644 --- a/packages/preset/agent-presets/tests/user-root.spec.ts +++ b/packages/preset/agent-presets/tests/user-root.spec.ts @@ -18,6 +18,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import AgentPresets, { COMPOSITION_FILE, type Config } from '@deepseek-ai/dsh-agent-presets' @@ -47,6 +48,7 @@ async function roster(config: Partial = {}): Promise { ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentPresets, { default: 'standard', roots: [{ path: SYSTEM_ROOT, trust: 'system' as const }], diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index f7d6a553cb..a72f8baa6b 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -210,7 +210,30 @@ export function titleSnapshotFromState(state: TitleUnitState): SessionTitleSnaps }) } -const EMPTY_TITLE_INPUT: TitleInputState = { first: null, last: null, count: 0 } +const EMPTY_TITLE_INPUT: TitleInputState = { first: null, count: 0, lastSeq: null } + +const sessionTitleUserMessageSchema: ZodType = zod.object({ + seq: zod.number().int().nonnegative(), + text: zod.string(), +}).strict() + +const titleInputStateSchema: ZodType = zod.object({ + first: sessionTitleUserMessageSchema.nullable(), + count: zod.number().int().nonnegative(), + lastSeq: zod.number().int().nonnegative().nullable(), +}).strict().superRefine((state, context) => { + const empty = state.first === null && state.lastSeq === null && state.count === 0 + const populated = state.first !== null + && state.lastSeq !== null + && state.count > 0 + && state.first.seq <= state.lastSeq + if (!empty && !populated) { + context.addIssue({ + code: 'custom', + message: 'title input state must pair its count with first and last message seqs', + }) + } +}) /** * Collect eligible human text messages from a session log, in seq order. @@ -333,16 +356,16 @@ export class SessionTitleService extends Service { ctx.sessionProjections.register<'titleInput', TitleInputState>({ key: 'titleInput', - stateVersion: 2, - stateSchema: zod.custom(), + stateVersion: 3, + stateSchema: titleInputStateSchema, init: () => EMPTY_TITLE_INPUT, apply: (state, event) => { const message = sessionTitleUserMessageOf(event) if (message === undefined) return state return { first: state.first ?? message, - last: message, count: state.count + 1, + lastSeq: message.seq, } }, }) @@ -429,8 +452,7 @@ export class SessionTitleService extends Service { } const registration = this.registration const input = this.titleInputOf(session) - const latest = input.last - if (registration === undefined || registration.closing || latest === null) { + if (registration === undefined || registration.closing || input.lastSeq === null) { // Explicit refresh is the unpin even without a provider: a standing // user title must not short-circuit ensureFallback into a no-op, so // re-derive and append the fallback over it when one is derivable. @@ -450,7 +472,7 @@ export class SessionTitleService extends Service { const work = this.activate({ registration, revision, - throughSeq: latest.seq, + throughSeq: input.lastSeq, }, state, signal) const config = session.requestHeader()?.config const route = config === undefined ? undefined : { provider: config.provider, model: config.model } diff --git a/packages/session/session-title/src/types.ts b/packages/session/session-title/src/types.ts index 6f4797d262..e6c21947d5 100644 --- a/packages/session/session-title/src/types.ts +++ b/packages/session/session-title/src/types.ts @@ -71,8 +71,8 @@ export interface TitleInputState { readonly first: SessionTitleUserMessage | null /** Total eligible messages folded so far. */ readonly count: number - /** Newest eligible message, or null before any. */ - readonly last: SessionTitleUserMessage | null + /** Seq of the newest eligible message, or null before any. */ + readonly lastSeq: number | null } declare module '@deepseek-ai/dsh-session-projection/types' { diff --git a/packages/session/session-title/tests/projection.spec.ts b/packages/session/session-title/tests/projection.spec.ts index 74aec3bb70..2847ca6e88 100644 --- a/packages/session/session-title/tests/projection.spec.ts +++ b/packages/session/session-title/tests/projection.spec.ts @@ -76,7 +76,21 @@ describe('title projection unit', () => { const state = ctx.sessionProjections.stateOf(session, 'titleInput') expect(state?.count).toBe(5_000) expect(state?.first?.text).toBe('message 0') - expect(state?.last?.text).toBe('message 4999') + expect(state?.lastSeq).toBe(session.seq - 1) expect(ctx.sessionProjections.checkpoint(session).titleInput).toBeDefined() }) + + it('rejects a version-matching checkpoint with inconsistent title input counters', async () => { + const { ctx, session } = await harness(true) + const checkpoint = ctx.sessionProjections.checkpoint(session) + const row = checkpoint.titleInput + expect(row).toBeDefined() + const malformed = { + ...checkpoint, + titleInput: { ...row!, val: { first: null, count: 1, lastSeq: null } }, + } + + expect(() => ctx.sessionProjections.restore(malformed, [], 0, session.header)) + .toThrow(/title input state must pair its count with first and last message seqs/) + }) }) diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 302cb98251..b5ed64a696 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -41,6 +41,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", @@ -50,7 +51,8 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index f4737cbb3b..3825048229 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -33,13 +33,10 @@ import { import type { DelegationModelRequest } from './model-selection.ts' import { registerListSubagentModels } from './list-models.ts' import type {} from './model-selection-settings.ts' -import { - hasSubagentModelSelection, - recordSubagentModelSelection, -} from './model-selection-state.ts' +import { subagentModelSelectionProjectionDefinition } from './model-selection-state.ts' export const name = 'tool-subagent' -export const inject = ['tools', 'subagents', 'systemPrompt'] +export const inject = ['tools', 'subagents', 'systemPrompt', 'sessionProjections'] /** Prompt order after bounded delegation policy and before child reporting. */ const SUBAGENT_SECTION_ORDER = FIRST_PARTY_SECTION_ORDER.TOOL_SUBAGENT @@ -324,6 +321,7 @@ export function apply(ctx: Context, config: Config): void { const toolName = config.toolName ?? 'subagent' const modelSelectionCapable = config.enableModelSelection === true || config.modelSelectionSettings === true + ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) const assertSubagentProviderConfiguration = (subagentProvider: SubagentProvider): void => { if (typeof config.maxDepth === 'number' && !subagentProvider.capabilities.depthLimit) { @@ -616,19 +614,21 @@ export function apply(ctx: Context, config: Config): void { } const selectForAgent = (agent: NonNullable): boolean => { - let enabled = hasSubagentModelSelection(agent.session) + const recorded = ctx.sessionProjections.stateOf(agent.session, 'subagentModelSelectionEnabled') === true + let enabled = recorded if (!enabled) { const parentId = agent.session.header.origin === 'subagent' ? agent.session.header.parentSession : undefined if (parentId !== undefined) { const parent = ctx.get('agents')?.get(parentId) - enabled = parent !== undefined && hasSubagentModelSelection(parent.session) + enabled = parent !== undefined + && ctx.sessionProjections.stateOf(parent.session, 'subagentModelSelectionEnabled') === true } else if (agent.session.firstLiveSeq === 0) { enabled = settings.currentEnabled() } } - if (enabled) recordSubagentModelSelection(agent.session) + if (enabled && !recorded) agent.session.append('subagent/model-selection-enabled', {}) return enabled } diff --git a/packages/subagent/tool-subagent/src/invariant.ts b/packages/subagent/tool-subagent/src/invariant.ts index 84bd209caa..fc7dd4705a 100644 --- a/packages/subagent/tool-subagent/src/invariant.ts +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -6,7 +6,7 @@ /* jscpd:ignore-start */ import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import { hasSubagentModelSelection } from './model-selection-state.ts' +import type {} from './model-selection-state.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent' @@ -18,7 +18,7 @@ export const inject = ['invariants'] /** Assert that a durable opt-in is represented by both model-facing definitions. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { ctx.on('agent/pre-step', async ({ agent }, next) => { - if (hasSubagentModelSelection(agent.session)) { + if (ctx.sessionProjections.stateOf(agent.session, 'subagentModelSelectionEnabled') === true) { const schemas = ctx.tools.schemas(agent) const selectable = schemas.some((schema) => { const properties = (schema.parameters as { properties?: Record }).properties @@ -32,7 +32,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant } return next() }, { global: true }) -}, { inject: ['tools'] }) +}, { inject: ['tools', 'sessionProjections'] }) /** * Register this package's invariant companion. diff --git a/packages/subagent/tool-subagent/src/model-selection-state.ts b/packages/subagent/tool-subagent/src/model-selection-state.ts index 35345ac115..d8445c5b67 100644 --- a/packages/subagent/tool-subagent/src/model-selection-state.ts +++ b/packages/subagent/tool-subagent/src/model-selection-state.ts @@ -1,6 +1,7 @@ /** Durable per-session state for the user-controlled model-selection opt-in. */ -import type { Session } from '@deepseek-ai/dsh-session' +import { z as zod } from 'zod' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { @@ -14,20 +15,18 @@ declare module '@deepseek-ai/dsh-session/types' { } } -/** - * Whether a session log records the enabled model-selection definition. - * @param session - session whose durable decision is read. - * @returns whether model-selectable delegation is enabled for the session. - */ -export function hasSubagentModelSelection(session: Session): boolean { - return session.events.some(event => event.type === 'subagent/model-selection-enabled') +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Whether the session's delegation tool exposes child LLM route selection. */ + subagentModelSelectionEnabled: boolean + } } -/** - * Append the enabled decision once, before its definition can reach a model request. - * @param session - session receiving the enabled decision. - */ -export function recordSubagentModelSelection(session: Session): void { - if (hasSubagentModelSelection(session)) return - session.append('subagent/model-selection-enabled', {}) -} +/** Host-only projection of the durable model-selection decision. */ +export const subagentModelSelectionProjectionDefinition = { + key: 'subagentModelSelectionEnabled', + stateVersion: 1, + stateSchema: zod.boolean(), + init: () => false, + apply: (enabled, event) => enabled || event.type === 'subagent/model-selection-enabled', +} satisfies ProjectionDefinition<'subagentModelSelectionEnabled', boolean> diff --git a/packages/subagent/tool-subagent/tests/harness.ts b/packages/subagent/tool-subagent/tests/harness.ts index 36ac602c89..a4f3af88c7 100644 --- a/packages/subagent/tool-subagent/tests/harness.ts +++ b/packages/subagent/tool-subagent/tests/harness.ts @@ -5,6 +5,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' @@ -24,6 +25,7 @@ export async function setup(toolConfig: tool.Config, mockConfig: Partial { it('is omitted unless its delegation-tool instance owns discovery', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) @@ -90,6 +94,7 @@ describe('list_subagent_models', () => { it('stays registered without the optional LLM service and rejects discovery calls', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -110,6 +115,8 @@ describe('list_subagent_models', () => { it('lists registered providers and follows live registration changes', async () => { const { ctx, fiber } = await setupListTool() + const session = Session.create(SessionId('projection-lifecycle')) + expect(ctx.sessionProjections.stateOf(session, 'subagentModelSelectionEnabled')).toBe(false) const empty = await call(ctx, {}) expect(empty.isError).toBe(false) expect(text(empty)).toBe('(no LLM providers)') @@ -125,6 +132,7 @@ describe('list_subagent_models', () => { await fiber.dispose() expect(ctx.tools.get('list_subagent_models')).toBeUndefined() + expect(ctx.sessionProjections.stateOf(session, 'subagentModelSelectionEnabled')).toBeUndefined() }) it('lists one provider\'s advertised models without treating the catalog as a whitelist', async () => { diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 4f95db088f..1cb7c0bb00 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -10,6 +10,7 @@ import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' import InvariantRegistry from '@deepseek-ai/dsh-invariants' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import * as tool from '../src/index.ts' @@ -17,7 +18,7 @@ import * as ToolInvariant from '../src/invariant.ts' import SubagentModelSelectionConfig, { SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, } from '../src/model-selection-settings.ts' -import { hasSubagentModelSelection } from '../src/model-selection-state.ts' +import type {} from '../src/model-selection-state.ts' /** Writable in-memory settings provider for the package integration. */ class MemorySettings extends SettingsProvider { @@ -53,6 +54,7 @@ async function boot(): Promise { await ctx.plugin(MemorySettings) await ctx.plugin(SubagentModelSelectionConfig) await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) @@ -102,11 +104,11 @@ describe('SubagentModelSelectionConfig', () => { const ctx = await boot() const disabled = await createAgent(ctx, 'disabled') expect(selectable(ctx, disabled)).toBe(false) - expect(hasSubagentModelSelection(disabled.session)).toBe(false) + expect(ctx.sessionProjections.stateOf(disabled.session, 'subagentModelSelectionEnabled')).toBe(false) await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) const enabled = await createAgent(ctx, 'enabled') - expect(hasSubagentModelSelection(enabled.session)).toBe(true) + expect(ctx.sessionProjections.stateOf(enabled.session, 'subagentModelSelectionEnabled')).toBe(true) expect(selectable(ctx, enabled)).toBe(true) expect(selectable(ctx, disabled)).toBe(false) @@ -165,7 +167,7 @@ describe('SubagentModelSelectionConfig', () => { meta: { parentSession: parent.id, origin: 'subagent' }, }) expect(selectable(ctx, child)).toBe(true) - expect(hasSubagentModelSelection(child.session)).toBe(true) + expect(ctx.sessionProjections.stateOf(child.session, 'subagentModelSelectionEnabled')).toBe(true) const enabledSeed = Session.create(SessionId('enabled-seed')) enabledSeed.append('subagent/model-selection-enabled', {}) @@ -176,13 +178,14 @@ describe('SubagentModelSelectionConfig', () => { await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.events }) expect(selectable(ctx, resumedDisabled)).toBe(false) - expect(hasSubagentModelSelection(resumedDisabled.session)).toBe(false) + expect(ctx.sessionProjections.stateOf(resumedDisabled.session, 'subagentModelSelectionEnabled')).toBe(false) await ctx.fiber.dispose() }) it('rejects ambiguous static and settings-controlled configuration', async () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) expect(() => { tool.apply(ctx, { @@ -197,6 +200,7 @@ describe('SubagentModelSelectionConfig', () => { it('requires both the Host setting owner and a composition scope', async () => { const withoutSettings = new Context() await mountAgentLoopTestDependencies(withoutSettings) + await withoutSettings.plugin(SessionProjectionRegistry) await withoutSettings.plugin(SubagentRuntime) expect(() => { tool.apply(withoutSettings, { diff --git a/packages/subagent/tool-subagent/tests/model-selection.spec.ts b/packages/subagent/tool-subagent/tests/model-selection.spec.ts index 008ba66da4..d8e3d48924 100644 --- a/packages/subagent/tool-subagent/tests/model-selection.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection.spec.ts @@ -7,6 +7,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' @@ -296,6 +297,7 @@ describe('dsh-tool-subagent model selection', () => { it('rejects selected routes or configured efforts when the LLM service is absent', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -327,6 +329,7 @@ describe('dsh-tool-subagent model selection', () => { it('keeps pure inherited routing usable without an LLM service lookup', async () => { let starts = 0 const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index ae9220e567..d16598d933 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -12,6 +12,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' @@ -23,6 +24,13 @@ import * as tool from '../src/index.ts' import { Session, SessionId } from '@deepseek-ai/dsh-session' import { callSubagent, fakeAgent, setup, testToolSignal, text } from './harness.ts' +/** Create a package-test context with the tool's required projection seam. */ +async function projectedContext(): Promise { + const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + return ctx +} + /** * Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real * `ToolRuntime` + `SubagentRuntime`, with a package-local scripted child @@ -177,7 +185,7 @@ describe('dsh-tool-subagent', () => { // The defining multi-provider use case: two loads, two distinct tool names, // each bound to a different provider — the tool registry rejects duplicate // names, so a configurable name is what makes this work. - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -198,7 +206,7 @@ describe('dsh-tool-subagent', () => { it('treats an unknown (plugin-added) stop reason as an isError result', async () => { // SubagentStopReason is merge-extensible; the tool's stopReasonError default // arm must treat an unrecognized terminal reason as a failure, not success. - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -222,7 +230,7 @@ describe('dsh-tool-subagent', () => { it('merges model overrides over provider-owned route defaults before preflight', async () => { let seen: { agentOptions?: { provider?: string; model?: string; reasoningEffort?: string; maxTokens?: number } } | undefined - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) @@ -270,7 +278,7 @@ describe('dsh-tool-subagent', () => { it('does not inherit parent effort for a provider-owned route default', async () => { let seen: SubagentStartRequest | undefined - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) @@ -322,7 +330,7 @@ describe('dsh-tool-subagent', () => { // no-agentOptions branch are only reachable via a direct apply() that // bypasses schemastery — the same pattern acp-agent uses for its defaults. let seen: { agentOptions?: unknown } | undefined - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -357,7 +365,7 @@ describe('dsh-tool-subagent', () => { }) it('registers when the provider appears LATER — no load-order requirement (Loader starts siblings concurrently)', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -374,7 +382,7 @@ describe('dsh-tool-subagent', () => { }) it('keeps continuable guidance empty while its provider is absent', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -390,7 +398,7 @@ describe('dsh-tool-subagent', () => { }) it('mirrors the provider lifecycle: gone on backend dispose, re-derived wording on re-registration', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -409,7 +417,7 @@ describe('dsh-tool-subagent', () => { }) it('the tool PLUGIN fiber owns its lifecycle listeners: disposal unmounts, and a disposed fiber never zombie-mounts', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -445,7 +453,7 @@ describe('dsh-tool-subagent', () => { }) it('ignores lifecycle events for OTHER providers', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -485,7 +493,7 @@ describe('dsh-tool-subagent', () => { // Spy on the provider's run.dispose via a wrapping provider registered // directly on the service, then point the tool at it. const disposed = vi.fn() - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -508,7 +516,7 @@ describe('dsh-tool-subagent', () => { it('disposes the run on the error path too', async () => { const disposed = vi.fn() - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -532,7 +540,7 @@ describe('dsh-tool-subagent', () => { it('preserves independent foreground result and disposal failures', async () => { const disposed = vi.fn() - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -560,7 +568,7 @@ describe('dsh-tool-subagent', () => { }) it('reports a foreground disposal failure after a completed result', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -587,7 +595,7 @@ describe('dsh-tool-subagent', () => { it('passes the tool abort signal as the provider cancellation channel', async () => { const cancelled = vi.fn() - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -626,7 +634,7 @@ describe('dsh-tool-subagent', () => { it('skips provider startup for an already-aborted signal', async () => { const sawAborted = vi.fn() - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -653,10 +661,10 @@ describe('dsh-tool-subagent', () => { }) it('tools depend on the service: no `subagent` tool without ctx.subagents', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) - // No SubagentRuntime mounted. The tool injects its three required services so its + // No SubagentRuntime mounted. The tool injects its required services so its // apply never runs; the tool is absent rather than half-registered. let booted = true try { @@ -677,20 +685,20 @@ describe('dsh-tool-subagent', () => { // load with "cannot get property … without inject". Guard the shape directly. expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-subagent') - expect(tool.inject).toEqual(['tools', 'subagents', 'systemPrompt']) + expect(tool.inject).toEqual(['tools', 'subagents', 'systemPrompt', 'sessionProjections']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(tool) as Record expect(unwrapped).toBe(tool) expect(unwrapped.name).toBe('tool-subagent') - expect(unwrapped.inject).toEqual(['tools', 'subagents', 'systemPrompt']) + expect(unwrapped.inject).toEqual(['tools', 'subagents', 'systemPrompt', 'sessionProjections']) expect(typeof unwrapped.apply).toBe('function') expect(unwrapped.Config).toBeDefined() }) it('passes persona/toolFilter/maxDepth config through to the start request', async () => { let seen: { persona?: string; toolFilter?: unknown; maxDepth?: number } | undefined - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -735,8 +743,8 @@ describe('dsh-tool-subagent', () => { .rejects.toThrow() }) - it('validates maxDepth when apply() is invoked directly without Schemastery', () => { - const ctx = new Context() + it('validates maxDepth when apply() is invoked directly without Schemastery', async () => { + const ctx = await projectedContext() expect(() => { tool.apply(ctx, { provider: 'unused', @@ -747,7 +755,7 @@ describe('dsh-tool-subagent', () => { it('a partial toolFilter (deny only) does not materialize an empty allow-list (deny-all trap)', async () => { let seen: { toolFilter?: { readonly allow?: readonly string[]; readonly deny?: readonly string[] } } | undefined - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -777,7 +785,7 @@ describe('dsh-tool-subagent', () => { // which reads as present and puts a dishonest `agentOptions: {}` on every // start request. let seen: { agentOptions?: unknown } | undefined - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -802,7 +810,7 @@ describe('dsh-tool-subagent', () => { }) it('an explicit empty toolFilter fails at plugin load, not at first delegation', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -993,7 +1001,7 @@ describe('dsh-tool-subagent background mode', () => { }) it('rejects startup when the provider changes during asynchronous route preflight', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) @@ -1208,7 +1216,7 @@ describe('dsh-tool-subagent continuable background mode', () => { /** Boot the real continuable stack without any model-facing follow-up adapter. */ async function continuableSetup() { - const ctx = new Context() + const ctx = await projectedContext() await mountAgentLoopTestDependencies(ctx) const root = mkdtempSync(path.join(tmpdir(), 'dsh-tool-subagent-continuable-')) roots.push(root) @@ -1415,7 +1423,7 @@ describe('depth budget configuration', () => { /** Mount the tool over a request-capturing provider with full capabilities. */ async function captureSetup(config: Omit = {}) { const requests: SubagentStartRequest[] = [] - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -1453,7 +1461,7 @@ describe('depth budget configuration', () => { }) it('rejects a numeric maxDepth on a provider without the depthLimit capability at mount', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -1469,7 +1477,7 @@ describe('depth budget configuration', () => { it("'provider-managed' omits the cap so a capability-less provider mounts and starts", async () => { const requests: SubagentStartRequest[] = [] - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) diff --git a/packages/subagent/tool-subagent/tsconfig.json b/packages/subagent/tool-subagent/tsconfig.json index 57ee1dc9d6..bb755f39a0 100644 --- a/packages/subagent/tool-subagent/tsconfig.json +++ b/packages/subagent/tool-subagent/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../session/session-projection" + }, { "path": "../../core/scope" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96fb5a7d31..9b85e1fbb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8829,6 +8829,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ From e6bf040dc32c653538aead3194b38a145724eb61 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 13:51:24 +0800 Subject: [PATCH 030/188] fix(session-query): use renamed tool call id --- .../tool-session-query/tests/tool-session-query.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index 0f2b61675d..9db2a45da7 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -1655,7 +1655,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => const result = await ctx.tools.execute({ name: 'session_event_search', arguments: { query: 'q' }, - callId: CallId('call-no-boundary'), + callId: ToolCallId('call-no-boundary'), signal: new AbortController().signal, agent: fakeAgent(session), }) From ef4dde9fe28874f19a4ada450b42c16bdeea2033 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 13:51:32 +0800 Subject: [PATCH 031/188] docs: reconcile projection catalogs after master merge --- ...ession-projection-mandatory-seam.i18n.yaml | 2 +- ...19-session-projection-mandatory-seam.zh.md | 2 +- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +- .../feature/2026-07-06-sandbox.zh.md | 41 +- ...subagent-catalog-and-list-agents.i18n.yaml | 4 +- ...ble-subagent-catalog-and-list-agents.zh.md | 14 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.zh.md | 64 ++-- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.zh.md | 4 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.zh.md | 360 +++++++++--------- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 17 +- docs/subsystems/core.zh.md | 17 +- docs/subsystems/goal.i18n.yaml | 4 +- docs/subsystems/goal.md | 6 +- docs/subsystems/goal.zh.md | 16 +- docs/subsystems/permission-presets.i18n.yaml | 4 +- docs/subsystems/permission-presets.md | 4 +- docs/subsystems/permission-presets.zh.md | 10 +- docs/subsystems/plan.i18n.yaml | 4 +- docs/subsystems/plan.md | 4 +- docs/subsystems/plan.zh.md | 18 +- docs/subsystems/sandbox.i18n.yaml | 4 +- docs/subsystems/sandbox.md | 8 +- docs/subsystems/sandbox.zh.md | 14 +- docs/subsystems/session-title.i18n.yaml | 4 +- docs/subsystems/session-title.md | 4 +- docs/subsystems/session-title.zh.md | 8 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 78 ++-- docs/subsystems/subagent.zh.md | 2 +- docs/subsystems/token-meter.i18n.yaml | 4 +- docs/subsystems/token-meter.md | 39 +- docs/subsystems/token-meter.zh.md | 41 +- .../experimental/agent-team/README.i18n.yaml | 4 +- packages/experimental/agent-team/README.md | 2 +- packages/experimental/agent-team/README.zh.md | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 14 +- 40 files changed, 466 insertions(+), 381 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml index 31a3631ce7..351fafb9d6 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md 2026-08-19-session-projection-mandatory-seam.md: eb76ac90fa760108cbeff1033c8ec2db71c7fa11 -2026-08-19-session-projection-mandatory-seam.zh.md: d0fcb2d903b08b71d8f4c9af62d3739c9ec6970b +2026-08-19-session-projection-mandatory-seam.zh.md: 23a3cd0de99f91f09dcc22586f49de3cabcda3da diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md index d0fcb2d903..23a3cd0de9 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -本决策建立在[会话投影的 host 状态与客户端视图](2026-08-19-session-projection-state-and-client-views.md)所定义的拆分之上。 +本决策建立在[会话投影的 host 状态与客户端视图](2026-08-19-session-projection-state-and-client-views.zh.md)所定义的拆分之上。 每个贡献或读取投影单元的插件都把 `sessionProjections` 作为必需注入。正式组合在这些插件之前挂载注册表。`ApiProxyService` 遵循同一规则;较低层的 `createApiProxy` factory 对隔离测试和诊断保持容错。 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 404f863e75..e89b17232f 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: 491f103ebb59b2103d0140b3e1ad935d79018361 -2026-07-06-sandbox.zh.md: 0df77f595b82e0bb69f29aac21d4c46c21f7a47a +2026-07-06-sandbox.md: c4153c6ef47b6d11290094adf06b8107418be889 +2026-07-06-sandbox.zh.md: 9191b1b8d2d4e65acab8b935d0da170066876aea diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 0df77f595b..9191b1b8d2 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -14,11 +14,11 @@ harness 是一个 SDK,因此约束必须是开发者可组合的能力:是 ## 决策 -一个 seam、一条按平台的本地后端链、一个消费方,加上两个上层控制项:按调用的升级路径与按会话的运行时模式。以下所有内容均从叶子 `cordis.yml` 组合而来;不触及 `agent-loop`。跨工具族 fs 强制与按会话工作区根目录已经作为后续设计落到同一策略载体上;剩余阶段——`subagent-acp` 消费方、更多环境与 Windows 链——仍列在 § 延迟阶段。 +一个 seam、一条按平台的本地后端链、一个消费方,加上两个上层控制项:按调用的升级路径与按会话的运行时模式。以下所有内容均从叶子 `cordis.yml` 组合而来;不触及 `agent-loop`。跨工具族 fs 强制、按会话工作区根目录与 Windows 链已经作为后续设计落到同一策略载体上;`subagent-acp` 消费方与更多环境仍列在 § 延迟阶段。 ### 部署方式 -四条 `cordis.yml` 条目即可将一个无约束的编码 agent 转变为沙箱产品路径;[`examples/acp-agent`](../../../../examples/acp-agent/README.md) 默认使用此组合: +四条 `cordis.yml` 条目即可将一个无约束的编码 agent 转变为沙箱产品路径;[基础 profile 组合包](../../../../packages/bundle/base/cordis.patch.yml)默认使用此组合: ```yaml - id: sandbox @@ -40,7 +40,7 @@ harness 是一个 SDK,因此约束必须是开发者可组合的能力:是 配置错误会显式导致失败:`mode` 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 `confine()` 阶段抛出结构化的 `SANDBOX_UNAVAILABLE`,而非降级为无约束执行。如果所选 runner 以可归因的 `ENOENT` 或 `EACCES` 拒绝,消费方会在任何命令开始前通过 spawn 通道报告同一基础设施错误;其他 spawn 错误仍保留本地命令启动语义,同时也不会运行任何内容。`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner 钩子。 -被拒绝的文件操作返回 `[sandbox: file access denied under mode]` 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions` 和 `justification` 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 `the user rejected escalating this command to ""`,且不允许再次请求。由归属方派生的待处理策略上下文会说明当前文件策略,但不会取代这些强制执行边界。当 `dsh-permission-presets` 与某个 UI 适配器一起组合时,一个 preset 同时选定两个旋钮值;不匹配的组合折叠为 `custom`。[ACP(Agent Client Protocol)自动化组合](../../../../examples/acp-agent/README.md)不挂载该 UI 服务,而是显式选定其部署模式。 +被拒绝的文件操作返回 `[sandbox: file access denied under mode]` 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions` 和 `justification` 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 `the user rejected escalating this command to ""`,且不允许再次请求。由归属方派生的待处理策略上下文会说明当前文件策略,但不会取代这些强制执行边界。当 `dsh-permission-presets` 与某个 UI 适配器一起组合时,一个 preset 同时选定两个旋钮值;不匹配的组合折叠为 `custom`。[ACP(Agent Client Protocol)应用组合包](../../../../packages/bundle/acp-app/README.zh.md)不挂载该 UI 服务,而是显式选定其部署模式。 ### 设计细节 @@ -64,7 +64,7 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 launcher 是一个约 300 行的 C 程序(纯 C11,直接使用 Landlock UAPI——除静态链接的 musl 外无其他库,因此审计面仅为该文件加内核的稳定 syscall 约定):`--ro ` / `--rw ` 授权,`--`,被包装的 argv;它为自身安装规则集并执行 `exec`(规则集跨 `execve` 继承,且它在限制前设置 `no_new_privs`);`--probe` 在一个短生命周期子进程中强制最大规则集,仅当内核确实强制时才以 0 退出;所有 launcher 失败都会以 125 退出且不运行子进程,并打印一行致命的 `landlock-run:` 诊断。成功完成 exec 的子进程也可能返回 125,因此仅凭退出状态不能作为 launcher 失败的证据。较旧的 ABI 会在执行子进程之前打印精确的 `landlock-run: partial enforcement (older Landlock ABI)` 通知,因此该行不是致命证据。 -Landlock launcher 源码和包家族位于 `native/landlock-run`,与 harness 消费方同仓,并属于根 pnpm workspace。[仓库内 Landlock 发布决策](../process/2026-08-06-in-repository-landlock-release.md)负责共享锁文件、原生构建、打包演练和 npm 发布边界。平台二进制由 npm 选择,入口包拥有路径解析、探测、CLI(命令行界面)参数、致命前缀和部分强制执行通知,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 +Landlock launcher 源码和包家族位于 `native/landlock-run`,与 harness 消费方同仓,并属于根 pnpm workspace。[仓库内 Landlock 发布决策](../process/2026-08-06-in-repository-landlock-release.zh.md)负责共享锁文件、原生构建、打包演练和 npm 发布边界。平台二进制由 npm 选择,入口包拥有路径解析、探测、CLI(命令行界面)参数、致命前缀和部分强制执行通知,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 后端 profile 共享模式约定但在必要的主机授权上有所不同。Landlock 和 Seatbelt 在 read-only 模式下仅允许 `/dev/null`;workspace-write 还允许各自所需的主机临时目录根。每次包装携带后端特定的拒绝签名。Landlock 在较旧的 ABI 无法管控所有操作时报告 partial enforcement,而成功的 bwrap 和 Seatbelt profile 报告 full enforcement。 @@ -72,7 +72,7 @@ Landlock launcher 源码和包家族位于 `native/landlock-run`,与 harness `dsh-bash-sandbox` 扩展 `LocalBashExecutor`,把精确的 `['bash', '-c', command]` argv 交给 `ctx.sandbox`,并直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播。进程启动前,只有当调用方拥有的 workdir 经独立验证可用,Node 报告 `ENOENT` 或 `EACCES`,并且错误符合以下一种形态时,才判定为 runner 失败:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall` 为 `'spawn'` 或精确的 `'spawn '`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn '`。其他错误码、无效 workdir、资源失败、无关 syscall 与无结构拒绝保留本地命令启动语义。前台执行会将 runner 失败转为 `SANDBOX_UNAVAILABLE` 并附上原始详细信息;异步后台拒绝则盖章 `runnerFailed: true`、`denied: false`。如果 `SubprocessRuntime` 同步抛出同样能指明 runner 的形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,前台与后台共用一个 runner 失败分类器:先排除信息性行,再要求规则的退出码检查与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详细信息;结算后的 `ShellProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `job_output` 渲染它。 -模型会在归属方派生的 `sandbox:policy` 上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。 +模型会在归属方派生的 `sandbox:policy` 上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](2026-07-30-current-sandbox-policy-context.zh.md)负责该上下文的理由与边界。 #### 升级机制:拒绝后一次经批准的更宽重试 @@ -92,7 +92,7 @@ Landlock launcher 源码和包家族位于 `native/landlock-run`,与 harness effective(session) = sessionProjections.stateOf(session, '') ?? the composition-config default ``` -默认值是组合配置(`cordis.yml`)——由运维人员拥有、作用于整个进程。运行时切换是会话范围的覆盖,以一条仅日志事件记录在该会话的日志中;该旋钮的投影单元折叠这条事件,且所有单元的状态都会被统一检查点,因此重启后仍然有效与多会话隔离来自持久化投影缓存加日志回放,且不存在任何外部配置存储。进程内 subagent 驱动器在委派时对父级的显式覆盖项获取快照,并在子 agent 可选的 fork 前缀之后预置一条带来源标记的事件,因此委派无法回退到更宽的默认值([决策](2026-07-25-subagent-policy-inheritance.md))。 +默认值是组合配置(`cordis.yml`)——由运维人员拥有、作用于整个进程。运行时切换是会话范围的覆盖,以一条仅日志事件记录在该会话的日志中;该旋钮的投影单元折叠这条事件,且所有单元的状态都会被统一检查点,因此重启后仍然有效与多会话隔离来自持久化投影缓存加日志回放,且不存在任何外部配置存储。进程内 subagent 驱动器在委派时对父级的显式覆盖项获取快照,并在子 agent 可选的 fork 前缀之后预置一条带来源标记的事件,因此委派无法回退到更宽的默认值([决策](2026-07-25-subagent-policy-inheritance.zh.md))。 **每个旋钮一种事件,由其领域拥有**——这是每个既有事件族已遵循的可合并扩展 `SessionEventMap` 惯用法(`dsh-user-approval` 中的 `approval/*`、hooks 包中的 `hook/*`): @@ -103,7 +103,7 @@ interface SessionEventMap { } ``` -每个拥有者贡献同一套构成:事件声明,一个注册在必需注入的 `ctx.sessionProjections` 注册表上的投影单元(`key` 为 `sandboxMode` / `approvalPolicy`,`stateVersion` 1,一个校验持久化状态的 zod `stateSchema`,`init` 为 `null`,以及 `apply`——即 fold,一个对其旋钮事件的 find-last 形态转移,类型化到领域的封闭联合),以及唯一的写入路径(`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)`——切换即其事件;没有任何东西在带外修改状态)。注册表在每条已提交的会话事件上急切驱动所有单元,并统一检查点所有状态——host-only 单元也不例外,无 `persist` 开关——因此重启后仍然有效与多会话隔离依托持久化缓存加日志回放,而非每个包自带的读取辅助函数。它是共享的框架基础设施,而非带归属服务的 facts map:每个领域保留自己的事件与单元,第三个配置项只需在自己的包中写好事件、单元与 setter 并注册该单元——驱动、检查点与读取面都来自注册表,无需复制任何 fold 导出。host 读取方经 `stateOf(session, key)` 读取折叠后的覆盖(`SandboxPolicyService.overrideOf` 与 `ApprovalService.overrideOf` 就是这些读取),且贡献方与读取方都必需注入 `sessionProjections`([必需投影 seam](../architecture/2026-08-19-session-projection-mandatory-seam.md))。执行在两侧都遵循投影状态——bash 工具的按调用盖章将其作为 § 升级机制优先级链的中间层读取,approval seam 的 `'never'` 门控是[批准 Agent Note](2026-07-06-approval-seam.md) 同一模式的另一侧。 +每个拥有者贡献同一套构成:事件声明,一个注册在必需注入的 `ctx.sessionProjections` 注册表上的投影单元(`key` 为 `sandboxMode` / `approvalPolicy`,`stateVersion` 1,一个校验持久化状态的 zod `stateSchema`,`init` 为 `null`,以及 `apply`——即 fold,一个对其旋钮事件的 find-last 形态转移,类型化到领域的封闭联合),以及唯一的写入路径(`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)`——切换即其事件;没有任何东西在带外修改状态)。注册表在每条已提交的会话事件上急切驱动所有单元,并统一检查点所有状态——host-only 单元也不例外,无 `persist` 开关——因此重启后仍然有效与多会话隔离依托持久化缓存加日志回放,而非每个包自带的读取辅助函数。它是共享的框架基础设施,而非带归属服务的 facts map:每个领域保留自己的事件与单元,第三个配置项只需在自己的包中写好事件、单元与 setter 并注册该单元——驱动、检查点与读取面都来自注册表,无需复制任何 fold 导出。host 读取方经 `stateOf(session, key)` 读取折叠后的覆盖(`SandboxPolicyService.overrideOf` 与 `ApprovalService.overrideOf` 就是这些读取),且贡献方与读取方都必需注入 `sessionProjections`([必需投影 seam](../architecture/2026-08-19-session-projection-mandatory-seam.zh.md))。执行在两侧都遵循投影状态——bash 工具的按调用盖章将其作为 § 升级机制优先级链的中间层读取,approval seam 的 `'never'` 门控是[批准 Agent Note](2026-07-06-approval-seam.zh.md) 同一模式的另一侧。 每次拟议步骤之前,沙箱策略与批准策略都会渲染为一条目标策略上下文消息中的有序贡献。监听器将该消息与会话历史、已领取批次及其待处理的 `next-step` inbox 条目协调。消息一旦被领取并进入步骤,循环就会记录完整且带来源的 `user/message`;`'ask'` 与 `'never'` 都会明确写入,因此两个归属方都无需切换叙述或「上次告知」状态。 @@ -113,7 +113,7 @@ interface SessionEventMap { #### 进程内工具 -fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边界层面的策略。fs seam 现在通过沙箱提供方强制共享模式词汇(`dsh-fs-sandbox` 按模式限制 write/edit;见[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.md)),因此 `read-only`/`workspace-write` 对文件系统工具也是真实边界,而非仅限 bash 的近似。web/todo 仍不受限制(web 的唯一效果是网络,不在文件效果模式词汇内)。没有通用的按工具沙箱运行时:主机中介的工具仅通过返回主机验证的声明式效果来离开进程,那是一次重写而非包装层——后续设计选择了一个共享策略归属 `ctx.sandboxPolicy`,由各能力强制,而不是统一包装层。 +fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边界层面的策略。fs seam 现在通过沙箱提供方强制共享模式词汇(`dsh-fs-sandbox` 按模式限制 write/edit;见[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.zh.md)),因此 `read-only`/`workspace-write` 对文件系统工具也是真实边界,而非仅限 bash 的近似。web/todo 仍不受限制(web 的唯一效果是网络,不在文件效果模式词汇内)。没有通用的按工具沙箱运行时:主机中介的工具仅通过返回主机验证的声明式效果来离开进程,那是一次重写而非包装层——后续设计选择了一个共享策略归属 `ctx.sandboxPolicy`,由各能力强制,而不是统一包装层。 ### 测试 @@ -128,7 +128,6 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,由主仓库在 `native/` 下按 `@deepseek-ai/node-addon-landlock-run` 模板交付,加上其 profile 方言、拒绝签名和 runner 失败规则。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 ## 曾考虑的替代方案 @@ -150,7 +149,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 - **将重试硬匹配到先前的拒绝**:否决。命令字符串同一性脆弱(引号、`workdir`、env 前缀、作为失败阶段重试的管道)——要么误拒诚实的重试,要么被轻易满足;真正的边界是人看到命令 + 理由。仅在 `allow_always` 授权存储需要机器可检查的范围时才重新考虑。 - **通用 `env/state` facts map 加拥有者服务**:否决。approval 和沙箱独立组合,因此任何一方的状态都不应拖入第三个包;单键 fold 各自是一个 `findLast`,拥有者服务自然消解;没有跨旋钮的不变式,因此原子多键补丁无收益。 - **通过 `agent.inject()` 加总线事件逐次叙述切换**:否决。独立通知会暴露归属方顺序和中间组合,而一次 pre-step 组合可以原子排队完整的当前状态。 -- **在稳定系统提示词中声明沙箱模式**:先行交付,随后根据线上证据移除:每次请求都带有 `Bash commands run under the "read-only" file sandbox.` 时,模型会拒绝尝试本可在被拒后升级的工作(首次人工会话的十二个轮次中有五个以零工具调用结束),使沙箱变成软锁死。拒绝标记会在相关时刻指出模式,升级字段则承载恢复路径。[当前策略决策](2026-07-30-current-sandbox-policy-context.md)取代了省略策略的决策;这项测量和因果观察仍是任何替代方案必须进行反证测试的依据。 +- **在稳定系统提示词中声明沙箱模式**:先行交付,随后根据线上证据移除:每次请求都带有 `Bash commands run under the "read-only" file sandbox.` 时,模型会拒绝尝试本可在被拒后升级的工作(首次人工会话的十二个轮次中有五个以零工具调用结束),使沙箱变成软锁死。拒绝标记会在相关时刻指出模式,升级字段则承载恢复路径。[当前策略决策](2026-07-30-current-sandbox-policy-context.zh.md)取代了省略策略的决策;这项测量和因果观察仍是任何替代方案必须进行反证测试的依据。 - **用专门的簿记事件追踪「上次告知」**:否决。会话历史记录模型看到的确切策略上下文,已领取批次与待处理 inbox 条目则表明正在进入或已经排队的内容。重新计算目标消息取代了第二条簿记流——事件仅在它们本身即为存储时才需要。 - **相互独立的沙箱与批准选择器**:否决。一个部署定义的权限 preset 让两个策略旋钮对暴露运行时切换的 UI 客户端保持一致。 @@ -170,8 +169,8 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 代价与已接受的限制: - **单一包装层的幻觉被有意放弃。**`tools/pre-execute` 包装层加提示词约定无法解决沙箱批准——正确的设计需要结构化拒绝、原生 runner 探测、按调用策略承载和一致的跨工具族强制,本设计为此付出了代价。 -- **`read-only` 通过后续设计成为跨工具族边界。** 本 Agent Note 最初只交付 bash 强制;[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.md) 通过沙箱化的 `ctx.fs` 提供方把同一模式词汇扩展到文件系统工具,并将 mode/root 配置和 `sandbox/mode` 覆盖迁移到 `ctx.sandboxPolicy`(§ 进程内工具)。 -- **Windows 后端只提供部分强制执行。** 本 RFC 最初预留了一条空的、失败关闭的 win32 链;后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.md)以受限令牌 runner 填充了它。其 Everyone 与硬链接缺口报告为 `enforcement: 'partial'`,绝不提升为完整承诺。 +- **`read-only` 通过后续设计成为跨工具族边界。** 本 Agent Note 最初只交付 bash 强制;[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.zh.md) 通过沙箱化的 `ctx.fs` 提供方把同一模式词汇扩展到文件系统工具,并将 mode/root 配置和 `sandbox/mode` 覆盖迁移到 `ctx.sandboxPolicy`(§ 进程内工具)。 +- **Windows 后端只提供部分强制执行。** 本 RFC 最初预留了一条空的、失败关闭的 win32 链;后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)以受限令牌 runner 填充了它。其 Everyone 与硬链接缺口报告为 `enforcement: 'partial'`,绝不提升为完整承诺。 - **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此在 workdir 可用时,未来移除会表现为可归因于 runner 的 spawn 失败,可执行文件拒绝则通过其致命签名体现——两者都会变为 `SANDBOX_UNAVAILABLE`,且命令绝不会运行;失败关闭,绝不开放。 - **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 - **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。 @@ -186,10 +185,10 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 - **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。相关指令禁止通过绕过限制来重试;唯一被认可的动作是以升级请求重试同一命令一次。 - **如何区分损坏的沙箱与失败的命令?** 提供方 argv 的任何 spawn 拒绝都能证明受限启动从未开始,但只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可归因的 `ENOENT` 或 `EACCES` 时,才能据此判定 runner 损坏。没有精确错误路径的裸 `syscall: 'spawn'` 和其他所有拒绝仍是普通的命令启动错误。进程启动后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台失败会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 spawn 错误或匹配行作为详细信息;遭异步拒绝或已结算的后台任务则盖章 `sandbox.runnerFailed` 并渲染自己的标记。如果 `SubprocessRuntime` 同步抛出同样带有 runner 路径的 `ENOENT`/`EACCES` 形态,后台启动会抛出该结构化错误;其他同步错误原样传播。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。 -- **在没有后端的平台上会发生什么——今天的 Windows?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn;`win32` 是保留的空链,由测试固定为同样失败关闭,直到 Windows runner 填充它(§ 延迟阶段)。 +- **在没有后端的平台上会发生什么?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn。 - **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到已打包的 Landlock launcher,结论在提供方生命周期内缓存。 -- **沙箱限制网络或进程可见性吗?** 不——`SandboxMode` 仅声称文件操作;bwrap profile 刻意不 unshare pid,没有后端声称网络。网络限制是否成为自己的旋钮留在 § seam 中开放。 -- **哪些工具实际在约束下运行?** 通过 `ctx.shell` 的 OS 子进程——bash 工具及传递性的钩子命令——再加上通过沙箱化 `ctx.fs` 提供方运行的文件系统工具(`read`/`write`/`edit`,见[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.md)):bash 通过 OS runner 约束,fs 通过进程内路径围栏约束,二者都以同一个 `ctx.sandboxPolicy` 模式为键。web/todo 仍在进程内且不受限制(web 的唯一效果是网络,不在文件效果模式词汇内)。 +- **沙箱限制网络或进程可见性吗?** `SandboxMode` 仅声称文件操作,没有后端声称网络。进程可见性取决于后端:bwrap 会 unshare PID 并挂载匹配的 procfs,因为宿主 `/proc/` 魔法链接会绕过文件约束;Landlock 与 Seatbelt 则保持进程可见性不变([决策](../bug-fix/2026-08-06-bwrap-private-pid-namespace.zh.md))。网络限制是否成为自己的旋钮留在 § seam 中开放。 +- **哪些工具实际在约束下运行?** 通过 `ctx.shell` 的 OS 子进程——bash 工具及传递性的钩子命令——再加上通过沙箱化 `ctx.fs` 提供方运行的文件系统工具(`read`/`write`/`edit`,见[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.zh.md)):bash 通过 OS runner 约束,fs 通过进程内路径围栏约束,二者都以同一个 `ctx.sandboxPolicy` 模式为键。web/todo 仍在进程内且不受限制(web 的唯一效果是网络,不在文件效果模式词汇内)。 - **授权的升级会持久化吗?** 不会。授权由发起请求的确切前台或后台调用消费;每个相邻调用保留自己的有效模式。后续的后台拒绝通过 `job_output` 呈现,并且可以作为一次新的精确命令重试的依据。 - **运行时模式切换何时生效?** 一旦其会话事件提交,下一次 pre-step 策略上下文协调与下一次能力解析都会折叠新模式。带来源的上下文消息会记录模型收到的内容,之后的任何拒绝都会在使用点命名同一策略。 - **重启后什么存活——如果运维人员在进程停止期间改了配置默认值呢?** 覆盖从持久化投影缓存恢复并在会话日志上重新折叠(`effective = 投影覆盖 ?? config`),因此恢复的会话以零追赶机制保持其模式;离线漂移的默认值会进入下一条完整策略上下文消息。 @@ -199,9 +198,9 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 本设计复制或对比的仓库内先例: -- [能力 seam Agent Note](../architecture/2026-06-13-capability-seams.md)——Service Definition/Service Provider/Consumer 拆分与「不要过早拆分」的时机规则(第二个消费方满足了该规则)。 -- `dsh-shell` 的 request/spec 拆分([bash 词汇目录](../../../../docs/subsystems/shell.md))——完整的 `sandboxPolicy` 搭载其按调用载体,以及显式 `resolve()` 默认约定。 -- [批准 seam Agent Note](2026-07-06-approval-seam.md)——升级请求通过的通道;其应答器 waterfall(瀑布式事件)、审计对和单包理由记录在那里。 -- [会话投影作为必需 seam](../architecture/2026-08-19-session-projection-mandatory-seam.md)——旋钮单元注册所在的必需 `ctx.sessionProjections` 注册表;host 读取方经 `stateOf(session, key)` 读取折叠后的覆盖。 -- [事件溯源会话](../architecture/2026-06-11-event-sourced-sessions.md)与[独立纯日志事件](../simplification/2026-07-28-remove-synthetic-log-only-turns.md)——投影单元折叠所依赖的日志即存储基础,以及锚定设计遵守的显式持久性边界。 -- [拦截扩展点 Agent Note](2026-06-30-interception-extension-points.md)——`tools/pre-execute` 词汇,升级门控刻意不复用它(升级调用没有自己的 pre-execute 时刻)。 +- [能力 seam Agent Note](../architecture/2026-06-13-capability-seams.zh.md)——Service Definition/Service Provider/Consumer 拆分与「不要过早拆分」的时机规则(第二个消费方满足了该规则)。 +- `dsh-shell` 的 request/spec 拆分([bash 词汇目录](../../../../docs/subsystems/shell.zh.md))——完整的 `sandboxPolicy` 搭载其按调用载体,以及显式 `resolve()` 默认约定。 +- [批准 seam Agent Note](2026-07-06-approval-seam.zh.md)——升级请求通过的通道;其应答器 waterfall(瀑布式事件)、审计对和单包理由记录在那里。 +- [会话投影作为必需 seam](../architecture/2026-08-19-session-projection-mandatory-seam.zh.md)——旋钮单元注册所在的必需 `ctx.sessionProjections` 注册表;host 读取方经 `stateOf(session, key)` 读取折叠后的覆盖。 +- [事件溯源会话](../architecture/2026-06-11-event-sourced-sessions.zh.md)与[独立纯日志事件](../simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md)——投影单元折叠所依赖的日志即存储基础,以及锚定设计遵守的显式持久性边界。 +- [拦截扩展点 Agent Note](2026-06-30-interception-extension-points.zh.md)——`tools/pre-execute` 词汇,升级门控刻意不复用它(升级调用没有自己的 pre-execute 时刻)。 diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml index b765bccf9e..2832c8e125 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md -2026-07-22-durable-subagent-catalog-and-list-agents.md: 3ddc2f83196513969307f94715f3626a6c45edde -2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 7a40bdf27608747d9a231b674648da85c083528d +2026-07-22-durable-subagent-catalog-and-list-agents.md: 813ad0a57bf5399cf641f8a7271671da57ce3966 +2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: ab5351647da32384645c036c0c5e3e2281ba645a diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md index 7a40bdf276..ab5351647d 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -可继续的后台 subagent 会公开稳定的 child id,并将重建数据持久化在该 child 的会话中,因此 `send_message` 无需任何列表查询操作即可恢复已知 child。发现功能有两类需求不同的消费方:UI 可以同时展示一次性工作和可继续对话,而模型只应收到适合使用 `send_message` 的 child。[可继续 subagent](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md)负责持久化 Session 与 Activation 设计;本记录负责共享的持久化清单及面向模型的投影。 +可继续的后台 subagent 会公开稳定的 child id,并将重建数据持久化在该 child 的会话中,因此 `send_message` 无需任何列表查询操作即可恢复已知 child。发现功能有两类需求不同的消费方:UI 可以同时展示一次性工作和可继续对话,而模型只应收到适合使用 `send_message` 的 child。[可继续 subagent](../../implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md)负责持久化 Session 与 Activation 设计;本记录负责共享的持久化清单及面向模型的投影。 枚举必须交叉核对不可变的会话谱系、描述符有效性与实时优先的会话语料,而不能仅为展示就加载或恢复 Agent。追踪谱系可以提供候选项,却无法区分普通会话 fork 与 subagent,因此 child 日志需要持久化分类。约定还必须定义生命周期模式、缺失或损坏的记录、删除、不受支持的版本,以及反复加载大量 child 日志会如何影响服务与工具消费方。 ## 决策 -**列表读路径已被取代。**[subagent 列表经投影单元读取身份](../architecture/2026-08-06-subagent-list-identity-projection.md)取代了本记录的枚举与逐 child 读取设计:`listChildren` 现在直接合并存活会话存储与可选的会话持久化,并从注册的 `subagent` projection unit 读取每个 child 的 mode/label——不依赖会话查询,也不在列表时扫描描述符;当前的列表语义(含 diagnostic 映射)以该记录为准。本记录仍是描述符持久化、以 mode 判别的描述符作为持久身份、直接 parent 鉴权与面向模型的 `list_agents` 投影的权威;下文基于追踪的读取机制是决策背景,不再是当前行为。 +**列表读路径已被取代。**[subagent 列表经投影单元读取身份](../architecture/2026-08-06-subagent-list-identity-projection.zh.md)取代了本记录的枚举与逐 child 读取设计:`listChildren` 现在直接合并存活会话存储与可选的会话持久化,并从注册的 `subagent` projection unit 读取每个 child 的 mode/label——不依赖会话查询,也不在列表时扫描描述符;当前的列表语义(含 diagnostic 映射)以该记录为准。本记录仍是描述符持久化、以 mode 判别的描述符作为持久身份、直接 parent 鉴权与面向模型的 `list_agents` 投影的权威;下文基于追踪的读取机制是决策背景,不再是当前行为。 parent 到 child 的枚举是一项带消费方专用投影的服务功能。`SubagentRuntime.listChildren(parentSessionId: SessionId)`([subagent/src/index.ts](../../../../packages/subagent/subagent/src/index.ts))执行以下操作: @@ -23,7 +23,7 @@ parent 到 child 的枚举是一项带消费方专用投影的服务功能。`Su - 将语料活动状态单独报告为 `running` 或 `inactive`,但不暗示已完成或可恢复; - 按 `createdAt` 升序、再按 child id 升序稳定返回所有结果 child。 -每次普通的本地启动都会收到带可选、由调用方拥有之显示标签的 `one-shot` 描述符,而继续执行管理器会持久化带标签、包含附加重建字段的 `continuable` 描述符。面向模型的委派工具已经拥有简短 `description`,会将其用于一次性显示;workflow 等底层调用方无需凭空构造展示元数据。面向模型的 `list_agents` 适配器会将服务结果过滤为可继续 child,并通过在线 Agent 注册表细化状态(`running`/`idle`,以及对应仅存于存储的 [`ready`](../bug-fix/2026-08-06-list-agents-residency-vocabulary.md));UI 可以消费两种模式,并为无标签的一次性历史选择基于 id 的回退展示。描述符持久化、按 id 查找、直接 parent 鉴权和不依赖提供方的冷恢复仍归已实现的 Activation 约定负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。 +每次普通的本地启动都会收到带可选、由调用方拥有之显示标签的 `one-shot` 描述符,而继续执行管理器会持久化带标签、包含附加重建字段的 `continuable` 描述符。面向模型的委派工具已经拥有简短 `description`,会将其用于一次性显示;workflow 等底层调用方无需凭空构造展示元数据。面向模型的 `list_agents` 适配器会将服务结果过滤为可继续 child,并通过在线 Agent 注册表细化状态(`running`/`idle`,以及对应仅存于存储的 [`ready`](../bug-fix/2026-08-06-list-agents-residency-vocabulary.zh.md));UI 可以消费两种模式,并为无标签的一次性历史选择基于 id 的回退展示。描述符持久化、按 id 查找、直接 parent 鉴权和不依赖提供方的冷恢复仍归已实现的 Activation 约定负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。 ### 枚举决策 @@ -35,7 +35,7 @@ parent 到 child 的枚举是一项带消费方专用投影的服务功能。`Su 已发布的逻辑记录同时也是活动状态来源:`SessionRecord.live` 表示 `running`,而 `live: false, persisted: true` 表示 `inactive`。活动状态直接来自追踪结果,不会导致额外加载 child 日志。`inactive` 既不表示执行成功,也不表示可恢复:它可能表示已结算的一次性历史,也可能表示 `send_message` 可以为其物化另一次 Activation 的可继续 child。反过来,`running` 只表示会话存活:位于继续执行管理器对应 Activation 之外的存活可继续 Agent 仍会显示为 `running`,但 `send_message` 会将其作为所有权冲突拒绝。child 会话发布前不可见,也不会添加进程内 Activation 条目作为第二个候选来源或活动状态来源。列表查询是一份快照,可能与发布、dispose 或后续消息发生竞态;`send_message` 仍是消息送达时的权威操作。 -subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 `listChildren(parentSessionId: SessionId)` 方法只在被调用时才会解析这个可选服务,并动态加载可选的会话查询运行时;因此,普通 subagent 导入、start 和 follow-up 都不会触发该包求值。列表查询直接由 `SubagentRuntime` 负责:它解释查询返回的谱系、事件和存活状态,无需解析基于 Activation 的继续执行管理器,也不会查询 Agent 注册信息、Activation 或提供方;因此,仅包含会话、`subagents` 和 `sessionQuery` 的部署即使缺少 `agents` 也能执行列表查询。如果查询服务缺失,该方法会在加载运行时或执行查询工作前抛出 `SubagentError`,并携带稳定错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`。`@deepseek-ai/dsh-tool-subagent-control` 导出可分别加载的工具插件:`send_message` 适配器只要求 `subagents`,而 `list_agents` 适配器在加载时同时要求 `subagents` 和 `sessionQuery`。因此,部署可以在既不安装也不加载会话查询的情况下使用 `send_message`;列表工具 fiber 会在必需服务可用前保持未激活状态,而其他直接服务消费方会收到同一项明确的调用时约定。这一段的依赖姿态——可选 `sessionQuery`、其错误码与列表工具的加载要求——同属被取代的读路径:现行列表约定以[取代记录](../architecture/2026-08-06-subagent-list-identity-projection.md)为准;在那里 `sessionProjections` 是 `SubagentRuntime` 的必需注入,`listChildren` 直接读取它,投影注册表缺失会在激活期失败而非列表调用时失败,`SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`(会话存储缺席)仍是唯一的稳定可用性错误码。 +subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 `listChildren(parentSessionId: SessionId)` 方法只在被调用时才会解析这个可选服务,并动态加载可选的会话查询运行时;因此,普通 subagent 导入、start 和 follow-up 都不会触发该包求值。列表查询直接由 `SubagentRuntime` 负责:它解释查询返回的谱系、事件和存活状态,无需解析基于 Activation 的继续执行管理器,也不会查询 Agent 注册信息、Activation 或提供方;因此,仅包含会话、`subagents` 和 `sessionQuery` 的部署即使缺少 `agents` 也能执行列表查询。如果查询服务缺失,该方法会在加载运行时或执行查询工作前抛出 `SubagentError`,并携带稳定错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`。`@deepseek-ai/dsh-tool-subagent-control` 导出可分别加载的工具插件:`send_message` 适配器只要求 `subagents`,而 `list_agents` 适配器在加载时同时要求 `subagents` 和 `sessionQuery`。因此,部署可以在既不安装也不加载会话查询的情况下使用 `send_message`;列表工具 fiber 会在必需服务可用前保持未激活状态,而其他直接服务消费方会收到同一项明确的调用时约定。这一段的依赖姿态——可选 `sessionQuery`、其错误码与列表工具的加载要求——同属被取代的读路径:现行列表约定以[取代记录](../architecture/2026-08-06-subagent-list-identity-projection.zh.md)为准;在那里 `sessionProjections` 是 `SubagentRuntime` 的必需注入,`listChildren` 直接读取它,投影注册表缺失会在激活期失败而非列表调用时失败,`SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`(会话存储缺席)仍是唯一的稳定可用性错误码。 `listChildren(parentSessionId, signal?)` 会把调用方的取消信号转发给 `traceSession()` 和条件性精确 `readEvent()` 操作。`listEvents()` 不接受取消参数,因此列表查询路径会在等待该操作的前后,以及每个候选处理完成后检查信号。如果取消信号触发后有查询操作以拒绝结算,服务会将结果归一化为 `SubagentError`,并携带稳定错误码 `CANCELLED`;后端中止错误或可映射为 diagnostic 的查询错误均不会逃逸,也不会使调用以成功的部分列表返回。 @@ -52,7 +52,7 @@ subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务 有效描述符产生一个 child 条目,逐 child 检查失败产生一个 diagnostic 条目,缺少描述符的候选不产生条目。`mode` 是持久化创建策略;`activity` 是进程本地语料快照。活动状态既不是 `AgentStatus`、管理器内部的 Activation 状态,也不是持久化结果,结果不公开内部 `createdAt` 排序键。成功完成、失败、取消和停止原因等精确 Activation 状态与持久化结果需要单独的持久化激活记录,不在本功能范围内。 -面向模型的 `list_agents` 工具接受一个可选的 `scope: 'children' | 'descendants'` 参数,从当前执行 Agent 推导根 id,并在执行或渲染前通过显式的 request-to-spec 步骤解析请求(`undefined` → `children`)。解析后的 `children` scope 调用 `SubagentRuntime.listChildren(rootSessionId)`,`descendants` scope 则调用 `SubagentRuntime.listDescendants(rootSessionId)`。其内部输出投影中的 `id` 与 `parent` 会一直保持为品牌化的 `SessionId` 值,直到工具 JSON 边界。它保留 diagnostic,丢弃 `one-shot` child 条目,状态取自在线 Agent 注册表——driver 活跃为 `running`,驻留但处于轮次之间为 `idle`,没有在线 Agent 时为 `ready`(可恢复而非终态)——然后按稳定目录顺序渲染 ` [] —