From 7f4cdc809c2e3fcbe8fa503f7b9f8b159208f8ef Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:05:50 +0800 Subject: [PATCH 01/17] refactor(session-persistence): add borrowable prepared sessions --- .../message-feedback/tests/helpers.ts | 4 + .../tests/session-checkpoint-policy.spec.ts | 3 + .../session-persistence-jsonl/src/index.ts | 8 +- .../session-persistence-sqlite/src/index.ts | 5 + .../session-persistence/src/coordinator.ts | 67 +++++++- .../session/session-persistence/src/errors.ts | 12 ++ .../session/session-persistence/src/index.ts | 32 ++++ .../session-persistence/src/preparations.ts | 55 +++++- .../tests/persistence.spec.ts | 160 ++++++++++++++++++ .../tests/preparations.spec.ts | 72 ++++++++ 10 files changed, 412 insertions(+), 6 deletions(-) create mode 100644 packages/session/session-persistence/src/errors.ts diff --git a/packages/feedback/message-feedback/tests/helpers.ts b/packages/feedback/message-feedback/tests/helpers.ts index f902ba38d7..f653ac6d63 100644 --- a/packages/feedback/message-feedback/tests/helpers.ts +++ b/packages/feedback/message-feedback/tests/helpers.ts @@ -142,6 +142,10 @@ class TestPersistence extends SessionPersistence { : Promise.resolve(stored) } + borrowSession(_id: SessionId, _signal?: AbortSignal): ReturnType { + return Promise.reject(new Error('not used')) + } + async readFrom( id: SessionId, fromSeq: number, diff --git a/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index 21a04c90c2..851271aa9c 100644 --- a/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -24,6 +24,9 @@ class TestPersistence extends SessionPersistence { inspect(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { return Promise.reject(new Error('not used')) } + borrowSession(_id: SessionId, _signal?: AbortSignal): ReturnType { + return Promise.reject(new Error('not used')) + } readFrom(_id: SessionId, _fromSeq: number): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { return Promise.reject(new Error('not used')) } diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index dab2a75657..4bed7aefb9 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -17,8 +17,10 @@ import { randomBytes } from 'node:crypto' import { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, SessionFormatUnsupportedError, + type BorrowedSessionSource, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type SessionRawArtifact, + type SessionInspection, + type SessionPersistenceRevision as PersistenceRevision, type SessionRawArtifact, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { Session, SessionEvent, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' @@ -197,6 +199,10 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi return this.coordinator.inspect(id, signal) } + override borrowSession(id: SessionId, signal?: AbortSignal): Promise { + return this.coordinator.borrowSession(id, signal) + } + // JSONL is sequential media: no loadStoredFrom hook, so the coordinator // parses the stored prefix (both encodings) and skips forward to fromSeq. readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts index 50204be7ab..3d14d65b17 100644 --- a/packages/session/session-persistence-sqlite/src/index.ts +++ b/packages/session/session-persistence-sqlite/src/index.ts @@ -17,6 +17,7 @@ import { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, + type BorrowedSessionSource, PersistenceCoordinator, SessionPersistence, type SessionInspection, @@ -119,6 +120,10 @@ export class SqliteSessionPersistence extends SessionPersistence { return this.coordinator.inspect(id, signal) } + override borrowSession(id: SessionId, signal?: AbortSignal): Promise { + return this.coordinator.borrowSession(id, signal) + } + readFrom( id: SessionId, fromSeq: number, diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index 3b99afb723..37c9558137 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -17,7 +17,8 @@ import { } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import type { SessionInspection, SessionLocation } from './index.ts' +import type { BorrowedSessionSource, SessionInspection, SessionLocation } from './index.ts' +import { SessionPersistenceNotFoundError } from './errors.ts' import type { SessionPersistenceRevision } from './revision.ts' import { observeQueuedAbort, SessionPreparations } from './preparations.ts' import type { SessionPreparationReservation } from './preparations.ts' @@ -841,6 +842,64 @@ export class PersistenceCoordinator { } } + /** + * Borrow one exact logical view while pinning its reusable prepared Session. + * @param id - persisted session to observe. + * @param signal - optional cancellation for preparation work. + * @returns a disposable observation retaining the prepared source. + */ + async borrowSession(id: SessionId, signal?: AbortSignal): Promise { + for (;;) { + signal?.throwIfAborted() + if (this.retirements.has(id)) await this.waitForRetirement(id, signal) + const live = this.ctx.sessions.get(id) + if (live !== undefined) { + return { source: 'live', inspection: this.inspectLive(live), [Symbol.dispose]: () => {} } + } + const observation = await this.preparations.borrow( + id, + () => this.serialize(id, () => this.prepareCore(id)), + signal, + ) + const source = observation.source + try { + const attached = this.ctx.sessions.get(id) + if (attached !== undefined) { + observation[Symbol.dispose]() + return { source: 'live', inspection: this.inspectLive(attached), [Symbol.dispose]: () => {} } + } + const current = await this.serialize( + id, + () => this.isPreparedSourceCurrent(source, signal), + signal, + ) + const published = this.ctx.sessions.get(id) + if (published !== undefined) { + observation[Symbol.dispose]() + return { source: 'live', inspection: this.inspectLive(published), [Symbol.dispose]: () => {} } + } + if (current || this.preparations.discardReady(id, source) === 'retained') { + return { + source: 'prepared', + inspection: source.inspection, + revision: source.revision, + preparedSession: source.session, + [Symbol.dispose]: () => { observation[Symbol.dispose]() }, + } + } + } catch (error: unknown) { + observation[Symbol.dispose]() + signal?.throwIfAborted() + const attached = this.ctx.sessions.get(id) + if (attached !== undefined) { + return { source: 'live', inspection: this.inspectLive(attached), [Symbol.dispose]: () => {} } + } + throw error + } + observation[Symbol.dispose]() + } + } + /** * Read the stored events from `fromSeq` onward, detached and non-mutating * (the read-from-seq primitive behind the service's `readFrom`). Runs on @@ -876,7 +935,7 @@ export class PersistenceCoordinator { throw error } signal?.throwIfAborted() - if (suffix === undefined) throw new Error(`session "${id}" not found`) + if (suffix === undefined) throw new SessionPersistenceNotFoundError(id) this.assertStoredId(id, suffix.meta) this.assertVersion(suffix.meta) if (suffix.events.some(needsLegacyPrefix)) { @@ -900,7 +959,7 @@ export class PersistenceCoordinator { signal?.throwIfAborted() const stored = await this.backend.loadStored(id, signal) signal?.throwIfAborted() - if (stored === undefined) throw new Error(`session "${id}" not found`) + if (stored === undefined) throw new SessionPersistenceNotFoundError(id) this.assertStoredId(id, stored.meta) this.assertVersion(stored.meta) const events = snapshotStoredEvents(stored.events, id) @@ -914,7 +973,7 @@ export class PersistenceCoordinator { /** Read, repair in memory, validate, and freeze one cold source once. */ private async prepareCore(id: SessionId): Promise> { const stored = await this.backend.loadStored(id) - if (stored === undefined) throw new Error(`session "${id}" not found`) + if (stored === undefined) throw new SessionPersistenceNotFoundError(id) try { const { meta, events, revision, tornMarker } = stored this.assertStoredId(id, meta) diff --git a/packages/session/session-persistence/src/errors.ts b/packages/session/session-persistence/src/errors.ts new file mode 100644 index 0000000000..0731b0edc7 --- /dev/null +++ b/packages/session/session-persistence/src/errors.ts @@ -0,0 +1,12 @@ +/** Stable failures exposed by the session-persistence service. */ + +import type { SessionId } from '@deepseek-ai/dsh-session' + +/** The requested Session identity has no materialized durable log. */ +export class SessionPersistenceNotFoundError extends Error { + /** @param sessionId - absent durable Session identity. */ + constructor(readonly sessionId: SessionId) { + super(`session "${sessionId}" not found`) + this.name = 'SessionPersistenceNotFoundError' + } +} diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index e9c75107bb..627098c648 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -13,6 +13,7 @@ import type { SessionPersistenceRevision } from './revision.ts' // Re-export the metadata vocabulary so Consumers import it from the Service Definition. export type { SessionHeader } from '@deepseek-ai/dsh-session' export { SessionPersistenceRevision } from './revision.ts' +export { SessionPersistenceNotFoundError } from './errors.ts' /** Lightweight immutable source identity returned without loading a full log. */ export interface SessionPersistenceSnapshot { @@ -30,6 +31,26 @@ export interface SessionInspection { readonly events: readonly SessionEvent[] } +/** A borrowed exact Session source returned from a cold materialization or concurrent live owner. */ +export type BorrowedSessionSource = Disposable & ( + | { + /** A reusable unpublished Session is pinned until this observation is disposed. */ + readonly source: 'prepared' + /** Immutable header and logical event prefix observed together. */ + readonly inspection: SessionInspection + /** Durable revision represented by the prepared source. */ + readonly revision: SessionPersistenceRevision + /** Exact unpublished Session retained for a later {@link prepare}. */ + readonly preparedSession: Session + } + | { + /** A live Session won source resolution while the persistence read was starting. */ + readonly source: 'live' + /** Immutable live header and event prefix observed together. */ + readonly inspection: SessionInspection + } +) + /** A backend's own raw artifact text for one session, verbatim. */ export interface SessionRawArtifact { /** The session header parsed from the artifact's own first line. */ @@ -209,6 +230,17 @@ export abstract class SessionPersistence extends Service { */ abstract inspect(id: SessionId, signal?: AbortSignal): Promise + /** + * Borrow one exact inspection while retaining any reusable prepared source. + * A cold observation must pin the exact prepared Session that a later + * {@link prepare} reserves. Implementations must not degrade this operation + * to a detached {@link inspect} result. + * @param id - persisted session to observe. + * @param signal - optional cancellation for preparation work. + * @returns a disposable immutable observation. + */ + abstract borrowSession(id: SessionId, signal?: AbortSignal): Promise + /** * Read the stored events from `fromSeq` onward — the read-from-seq * primitive for read models that resume from a watermark (e.g. a persisted diff --git a/packages/session/session-persistence/src/preparations.ts b/packages/session/session-persistence/src/preparations.ts index 2a685f71f9..96ad4352f7 100644 --- a/packages/session/session-persistence/src/preparations.ts +++ b/packages/session/session-persistence/src/preparations.ts @@ -19,6 +19,13 @@ interface PreparationEntry { reservation?: SessionPreparationReservation reservationSettled?: Promise settleReservation?: () => void + pins: number +} + +/** A borrowed prepared source that remains outside ready-entry eviction until released. */ +export interface PreparationLease extends Disposable { + /** Shared immutable prepared source. */ + readonly source: Source } /** One exclusively held prepared source and its committed persistence state. */ @@ -64,6 +71,51 @@ export class SessionPreparations { return source } + /** + * Borrow one prepared source and pin its ready entry against LRU eviction. + * @param id - session identity. + * @param load - cold loader used when no entry exists. + * @param signal - optional cancellation signal while waiting. + * @returns a caller-owned observation lease. + */ + async borrow( + id: SessionId, + load: () => Promise, + signal?: AbortSignal, + ): Promise> { + const entry = this.entryFor(id, load) + const pinned = this.entries.get(id) === entry + if (pinned) entry.pins += 1 + let loaded: Source + try { + loaded = signal === undefined + ? await entry.result + : await observeQueuedAbort(entry.result, signal) + } catch (error: unknown) { + if (pinned && this.entries.get(id) === entry) { + entry.pins -= 1 + if (entry.phase === 'ready') this.touch(entry) + } + throw error + } + const source = entry.source ?? loaded + if (this.entries.get(id) !== entry) { + return { source, [Symbol.dispose]: () => {} } + } + if (entry.phase === 'ready') this.touch(entry) + let released = false + return { + source, + [Symbol.dispose]: () => { + if (released) return + released = true + if (this.entries.get(id) !== entry) return + entry.pins -= 1 + if (entry.phase === 'ready') this.touch(entry) + }, + } + } + /** * Reserve one ready source after committing its pending durable repair. * @param id - session identity. @@ -238,6 +290,7 @@ export class SessionPreparations { id, result: deferred.promise, phase: 'loading', + pins: 0, } this.entries.set(id, entry) let loading: Promise @@ -291,7 +344,7 @@ export class SessionPreparations { } if (readyCount <= this.capacity) return for (const [id, candidate] of this.entries) { - if (candidate.phase !== 'ready') continue + if (candidate.phase !== 'ready' || candidate.pins > 0) continue this.entries.delete(id) return } diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index d82627ffe9..a596ed0b11 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -118,6 +118,10 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend .then(loaded => ({ meta: loaded.meta, events: [...loaded.events] })) } + borrowSession(id: SessionId, signal?: AbortSignal): ReturnType { + return this.coordinator.borrowSession(id, signal) + } + readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { return this.coordinator.readFrom(id, fromSeq, signal) } @@ -1200,6 +1204,162 @@ describe('PersistenceCoordinator session preparations', () => { }) describe('PersistenceCoordinator observation cancellation', () => { + it('borrows live Sessions before, during, and after cold source validation', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const afterBorrowId = SessionId('borrow-became-live-before-validation') + const afterValidationId = SessionId('borrow-became-live-after-validation') + for (const id of [afterBorrowId, afterValidationId]) { + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + } + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + const immediate = ctx.sessions.create(SessionId('borrow-already-live')) + const immediateSource = await coordinator.borrowSession(immediate.id) + expect(immediateSource).toMatchObject({ source: 'live', inspection: { meta: { id: immediate.id } } }) + immediateSource[Symbol.dispose]() + + const afterBorrow = Session.create(afterBorrowId, oneTurnLog(), meta(afterBorrowId)) + const afterBorrowGet = vi.spyOn(ctx.sessions, 'get') + .mockReturnValueOnce(undefined) + .mockReturnValue(afterBorrow) + const attachedSource = await coordinator.borrowSession(afterBorrowId) + expect(attachedSource).toMatchObject({ source: 'live', inspection: { meta: { id: afterBorrowId } } }) + attachedSource[Symbol.dispose]() + afterBorrowGet.mockRestore() + + const afterValidation = Session.create(afterValidationId, oneTurnLog(), meta(afterValidationId)) + const afterValidationGet = vi.spyOn(ctx.sessions, 'get') + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(undefined) + .mockReturnValue(afterValidation) + const publishedSource = await coordinator.borrowSession(afterValidationId) + expect(publishedSource).toMatchObject({ + source: 'live', inspection: { meta: { id: afterValidationId } }, + }) + publishedSource[Symbol.dispose]() + afterValidationGet.mockRestore() + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('returns and releases a current prepared observation', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('borrow-current-prepared') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + const source = await coordinator.borrowSession(id) + expect(source).toMatchObject({ source: 'prepared', inspection: { meta: { id } } }) + source[Symbol.dispose]() + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('reloads a stale prepared observation and retains one claimed concurrently', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const staleId = SessionId('borrow-stale-prepared') + const retainedId = SessionId('borrow-retained-prepared') + for (const id of [staleId, retainedId]) { + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + } + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + const readRevision = backend.readStoredRevision.bind(backend) + const revision = vi.spyOn(backend, 'readStoredRevision') + .mockResolvedValueOnce(SessionPersistenceRevision('stale')) + .mockImplementation(readRevision) + const stale = await coordinator.borrowSession(staleId) + expect(stale.source).toBe('prepared') + expect(backend.loadAttempts).toBe(2) + stale[Symbol.dispose]() + revision.mockRestore() + + const preparations = (coordinator as unknown as { + preparations: { discardReady: (id: SessionId, source: unknown) => string } + }).preparations + vi.spyOn(backend, 'readStoredRevision').mockResolvedValue(SessionPersistenceRevision('changed')) + const discard = vi.spyOn(preparations, 'discardReady').mockReturnValue('retained') + const retained = await coordinator.borrowSession(retainedId) + expect(retained.source).toBe('prepared') + expect(discard).toHaveBeenCalledOnce() + retained[Symbol.dispose]() + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('falls back to a concurrently attached Session after revision validation fails', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('borrow-failed-validation-became-live') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + const attached = Session.create(id, oneTurnLog(), meta(id)) + const get = vi.spyOn(ctx.sessions, 'get') + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(undefined) + .mockReturnValue(attached) + vi.spyOn(backend, 'readStoredRevision').mockRejectedValue(new Error('revision failed')) + + try { + const source = await coordinator.borrowSession(id) + expect(source).toMatchObject({ source: 'live', inspection: { meta: { id } } }) + source[Symbol.dispose]() + } finally { + get.mockRestore() + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('rethrows revision validation failure when no live Session won the race', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('borrow-failed-validation') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + const failure = new Error('revision failed') + vi.spyOn(backend, 'readStoredRevision').mockRejectedValue(failure) + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + await expect(coordinator.borrowSession(id)).rejects.toBe(failure) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + it('promptly rejects a queued inspect without invoking it and keeps the same-id chain healthy', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/session/session-persistence/tests/preparations.spec.ts b/packages/session/session-persistence/tests/preparations.spec.ts index 5e12f29a79..7635ffd223 100644 --- a/packages/session/session-persistence/tests/preparations.spec.ts +++ b/packages/session/session-persistence/tests/preparations.spec.ts @@ -160,6 +160,78 @@ describe('SessionPreparations inspection', () => { }) }) +describe('SessionPreparations borrowing', () => { + it('returns a detached lease when loading invalidates its own entry', async () => { + const preparations = new SessionPreparations(1) + const id = SessionId('borrow-invalidated-load') + const source = prepared(id) + + const lease = await preparations.borrow(id, () => { + preparations.invalidate(id) + return Promise.resolve(source) + }) + + expect(lease.source).toBe(source) + expect(preparations.has(id)).toBe(false) + expect(() => { lease[Symbol.dispose]() }).not.toThrow() + }) + + it('releases pins after cancellation while loading and after readiness', async () => { + const preparations = new SessionPreparations(1) + const loadingId = SessionId('borrow-cancelled-loading') + const loading = Promise.withResolvers() + const loadingAbort = new AbortController() + const pending = preparations.borrow(loadingId, () => loading.promise, loadingAbort.signal) + loadingAbort.abort(new Error('cancelled while loading')) + await expect(pending).rejects.toThrow('cancelled while loading') + loading.resolve(prepared(loadingId)) + await loading.promise + await Promise.resolve() + + const readyId = SessionId('borrow-cancelled-ready') + const ready = prepared(readyId) + await preparations.inspect(readyId, () => Promise.resolve(ready)) + const readyAbort = new AbortController() + readyAbort.abort(new Error('cancelled while ready')) + await expect(preparations.borrow(readyId, () => Promise.resolve(ready), readyAbort.signal)) + .rejects.toThrow('cancelled while ready') + + await preparations.inspect(SessionId('borrow-eviction'), () => Promise.resolve(prepared('borrow-eviction'))) + expect(preparations.has(loadingId)).toBe(false) + }) + + it('makes borrowed lease disposal idempotent across ready, invalidated, and reserved entries', async () => { + const preparations = new SessionPreparations(3) + + const ready = prepared('borrow-ready-release') + const readyLease = await preparations.borrow(ready.session.id, () => Promise.resolve(ready)) + readyLease[Symbol.dispose]() + readyLease[Symbol.dispose]() + + const invalidated = prepared('borrow-invalidated-release') + const invalidatedLease = await preparations.borrow( + invalidated.session.id, + () => Promise.resolve(invalidated), + ) + preparations.invalidate(invalidated.session.id) + invalidatedLease[Symbol.dispose]() + + const reserved = prepared('borrow-reserved-release') + const reservation = await preparations.reserve( + reserved.session.id, + () => Promise.resolve(reserved), + committed, + ) + expect(reservation).toBeDefined() + const reservedLease = await preparations.borrow( + reserved.session.id, + () => Promise.resolve(prepared('unused')), + ) + reservedLease[Symbol.dispose]() + preparations.release(reservation!, false) + }) +}) + describe('SessionPreparations reservation', () => { it('waits for an existing reservation, republishes the exact Session, and attaches once', async () => { const preparations = new SessionPreparations(2) From 7fb2ca07e4c76f9ac20a494fe57445fc099bdf90 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:06:03 +0800 Subject: [PATCH 02/17] feat(session-query): add shared projected observations --- .../session-query-sqlite/tests/sqlite.spec.ts | 4 + .../session-query/session-query/package.json | 10 + .../session-query/session-query/src/index.ts | 21 ++ .../session-query/src/observation.ts | 213 ++++++++++++++++++ .../session-query/tests/observation.spec.ts | 141 ++++++++++++ .../session-query/tests/session-query.spec.ts | 4 + .../session-query/tests/tracing.spec.ts | 4 + .../session-query/session-query/tsconfig.json | 6 + .../session-projection-cache/src/index.ts | 52 ++++- .../tests/cache.spec.ts | 19 +- .../session/session-projection/src/index.ts | 186 +++++++++++++-- .../session-projection/tests/registry.spec.ts | 27 ++- 12 files changed, 648 insertions(+), 39 deletions(-) create mode 100644 packages/session-query/session-query/src/observation.ts create mode 100644 packages/session-query/session-query/tests/observation.spec.ts diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 0a565fe021..03f87ec610 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -91,6 +91,10 @@ class TestPersistence extends SessionPersistence { return undefined } + borrowSession(_id: SessionIdType, _signal?: AbortSignal): ReturnType { + return Promise.reject(new Error('not used')) + } + static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { this.entries = new Map() this.revisions = new Map() diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 9d4f7bd1f2..1e6e488d0b 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -39,11 +39,19 @@ "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-session-persistence": { "optional": true + }, + "@deepseek-ai/dsh-session-projection": { + "optional": true + }, + "@deepseek-ai/dsh-session-projection-cache": { + "optional": true } }, "devDependencies": { @@ -54,6 +62,8 @@ "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 31d4237a16..7be87e2750 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -37,6 +37,11 @@ import { type Config, } from './config.ts' import { SessionCorpus } from './corpus.ts' +import { + SessionObservationReader, + type SessionObservation, + type SessionObservationOptions, +} from './observation.ts' import { buildSessionEventSearchDocuments } from './documents.ts' import { filterSessionEventDocuments, @@ -64,6 +69,7 @@ export { materializeSessionResultFilters, } from './filters.ts' export { assertSessionHeadersCompatible } from './sources.ts' +export type { SessionObservation, SessionObservationOptions } from './observation.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -83,6 +89,7 @@ export abstract class SessionQueryEngine extends Service { private readonly _readWindowMax: number private readonly _corpus: SessionCorpus + private readonly _observations: SessionObservationReader constructor(ctx: Context, config: Config = {}) { super(ctx, 'sessionQuery') @@ -102,6 +109,20 @@ export abstract class SessionQueryEngine extends Service { ) } this._corpus = new SessionCorpus(ctx, persistedInspectConcurrency) + this._observations = new SessionObservationReader(ctx) + } + + /** + * Observe one exact live or prepared Session without a persistence listing preflight. + * @param sessionId - logical Session identity. + * @param options - cancellation and projection selection for this read. + * @returns a caller-owned observation lease. + */ + observeSession( + sessionId: SessionId, + options: SessionObservationOptions = {}, + ): Promise { + return this._observations.read(sessionId, options) } /** diff --git a/packages/session-query/session-query/src/observation.ts b/packages/session-query/session-query/src/observation.ts new file mode 100644 index 0000000000..8810e1972f --- /dev/null +++ b/packages/session-query/session-query/src/observation.ts @@ -0,0 +1,213 @@ +/** Shared live/prepared observations for Session page and lifecycle consumers. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { + BorrowedSessionSource, + SessionPersistenceRevision, +} from '@deepseek-ai/dsh-session-persistence' +import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' +import type {} from '@deepseek-ai/dsh-session-projection-cache' +import { SessionQueryError } from './config.ts' + +/** One exact immutable Session cut retained for the caller's read lifetime. */ +export interface SessionObservation extends Disposable { + /** Whether the cut came from an attached Session or a retained preparation. */ + readonly source: 'live' | 'prepared' + /** Immutable Session identity metadata. */ + readonly header: SessionHeader + /** Immutable contiguous events at {@link cursor}. */ + readonly events: readonly SessionEvent[] + /** Last observed event seq, or -1 for an empty log. */ + readonly cursor: number + /** Durable source revision for a cold prepared observation. */ + readonly revision?: SessionPersistenceRevision + /** Exact projection baseline at {@link cursor}, when the registry is mounted. */ + readonly projections?: ProjectionSnapshot + /** + * Retain the same immutable cut for another Host owner. + * @returns an independently disposable lease over this observation. + */ + retain(): SessionObservation +} + +/** Projection work and cancellation requested for one exact observation. */ +export interface SessionObservationOptions { + /** Optional cancellation while resolving a cold source. */ + readonly signal?: AbortSignal + /** Whether to compute every projection or leave projection state untouched. */ + readonly projectionMode?: 'all' | 'none' +} + +/** Builds point observations without a corpus listing preflight. */ +export class SessionObservationReader { + /** @param ctx - context carrying Session and optional persistence/projection services. */ + constructor(private readonly ctx: Context) {} + + /** + * Observe one live-preferred Session and retain a cold preparation until disposal. + * @param sessionId - logical Session identity. + * @param options - cancellation and all-or-none projection computation for this read. + * @returns one exact immutable observation. + */ + async read( + sessionId: SessionId, + options: SessionObservationOptions = {}, + ): Promise { + const { signal, projectionMode = 'all' } = options + for (;;) { + throwIfObservationAborted(signal) + const live = this.ctx.sessions.get(sessionId) + if (live !== undefined) return this.live(live, projectionMode) + const persistence = this.ctx.get('sessionPersistence') + if (persistence === undefined) throw notFound(sessionId) + + let borrowed: BorrowedSessionSource + try { + borrowed = await persistence.borrowSession(sessionId, signal) + } catch (error: unknown) { + throwIfObservationAborted(signal) + if (hasErrorName(error, 'SessionPersistenceNotFoundError')) throw notFound(sessionId, error) + if (hasErrorName(error, 'SessionPersistenceCorruptionError')) { + throw new SessionQueryError( + `stored session "${sessionId}" is corrupt: ${error.message}`, + 'SESSION_QUERY_CORRUPT_SESSION', + { cause: error }, + ) + } + throw new SessionQueryError( + `failed to observe session "${sessionId}": ${errorMessage(error)}`, + 'SESSION_QUERY_PERSISTENCE_FAILED', + { cause: error }, + ) + } + + try { + throwIfObservationAborted(signal) + if (borrowed.inspection.meta.id !== sessionId) { + throw new SessionQueryError( + `session persistence returned "${borrowed.inspection.meta.id}" for "${sessionId}"`, + 'SESSION_QUERY_SOURCE_CONFLICT', + ) + } + const attached = this.ctx.sessions.get(sessionId) + if (attached !== undefined) { + const liveObservation = this.live(attached, projectionMode) + borrowed[Symbol.dispose]() + return liveObservation + } + if (borrowed.source === 'live') { + // The live Session disappeared between persistence's race check and + // this read. Retry against its now-cold durable identity. + borrowed[Symbol.dispose]() + continue + } + const prepared = borrowed + const events = prepared.inspection.events + let projections: ProjectionSnapshot | undefined + try { + projections = projectionMode === 'none' + ? undefined + : this.preparedProjections(prepared, events) + } catch (error: unknown) { + throw new SessionQueryError( + `failed to project session "${sessionId}": ${errorMessage(error)}`, + 'SESSION_QUERY_CORRUPT_SESSION', + { cause: error }, + ) + } + let references = 1 + const lease = (): SessionObservation => { + let disposed = false + return { + source: 'prepared', + header: prepared.inspection.meta, + events, + cursor: events.at(-1)?.seq ?? -1, + revision: prepared.revision, + ...projections === undefined ? {} : { projections }, + retain: () => { + if (disposed || references === 0) throw new Error(`session observation "${sessionId}" is disposed`) + references += 1 + return lease() + }, + [Symbol.dispose]: () => { + if (disposed) return + disposed = true + references -= 1 + if (references === 0) prepared[Symbol.dispose]() + }, + } + } + return lease() + } catch (error: unknown) { + borrowed[Symbol.dispose]() + throw error + } + } + } + + private live( + session: Session, + projectionMode: NonNullable, + ): SessionObservation { + const events = Object.freeze([...session.events]) + const projections = projectionMode === 'none' + ? undefined + : this.ctx.get('sessionProjections')?.snapshot(session) + const lease = (): SessionObservation => { + let disposed = false + return { + source: 'live', + header: session.header, + events, + cursor: events.at(-1)?.seq ?? -1, + ...projections === undefined ? {} : { projections }, + retain: () => { + if (disposed) throw new Error(`session observation "${session.id}" is disposed`) + return lease() + }, + [Symbol.dispose]: () => { disposed = true }, + } + } + return lease() + } + + private preparedProjections( + observation: Extract, + events: readonly SessionEvent[], + ): ProjectionSnapshot | undefined { + const registry = this.ctx.get('sessionProjections') + if (registry === undefined) return undefined + const prepared = observation.preparedSession + const cache = this.ctx.get('sessionProjectionCache') + return cache === undefined + ? registry.hydrate(prepared, {}, events, 0) + : cache.hydratePrepared(prepared, observation.inspection.meta, events) + } +} + +function throwIfObservationAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted !== true) return + throw new SessionQueryError( + 'session observation was aborted', + 'SESSION_QUERY_ABORTED', + { cause: signal.reason }, + ) +} + +function notFound(sessionId: SessionId, cause?: unknown): SessionQueryError { + return new SessionQueryError( + `session "${sessionId}" not found`, + 'SESSION_QUERY_SESSION_NOT_FOUND', + cause === undefined ? undefined : { cause }, + ) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'unknown error' +} + +function hasErrorName(error: unknown, name: string): error is Error { + return error instanceof Error && error.name === name +} diff --git a/packages/session-query/session-query/tests/observation.spec.ts b/packages/session-query/session-query/tests/observation.spec.ts new file mode 100644 index 0000000000..91bd1e8bc9 --- /dev/null +++ b/packages/session-query/session-query/tests/observation.spec.ts @@ -0,0 +1,141 @@ +import { Context } from '@deepseek-ai/cordis' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionHeader } from '@deepseek-ai/dsh-session' +import { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' +import type { BorrowedSessionSource } from '@deepseek-ai/dsh-session-persistence' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { describe, expect, it, vi } from 'vitest' +import { SessionObservationReader } from '../src/observation.ts' + +function header(id: string): SessionHeader { + return { version: 0, id: SessionId(id), createdAt: 1, cwd: '/workspace' } +} + +function preparedSource( + meta: SessionHeader, + dispose = vi.fn(), +): BorrowedSessionSource { + const preparedSession = Session.create(meta.id, [], meta) + return { + source: 'prepared', + inspection: { meta: preparedSession.header, events: preparedSession.events }, + revision: SessionPersistenceRevision(`fixture:${meta.id}`), + preparedSession, + [Symbol.dispose]: dispose, + } +} + +describe('SessionObservationReader', () => { + it('prefers a live Session that attaches while a prepared source is borrowed', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const meta = header('attached-during-borrow') + const dispose = vi.fn() + const prepared = preparedSource(meta, dispose) + ctx.provide('sessionPersistence', { + borrowSession: () => { + ctx.sessions.create(meta.id, { meta }) + return Promise.resolve(prepared) + }, + } as never) + + using observed = await new SessionObservationReader(ctx).read(meta.id, { projectionMode: 'none' }) + + expect(observed.source).toBe('live') + expect(dispose).toHaveBeenCalledOnce() + await ctx.fiber.dispose() + }) + + it('releases a borrowed source once when the winning live projection fails', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const meta = header('attached-projection-failure') + const dispose = vi.fn() + const prepared = preparedSource(meta, dispose) + ctx.provide('sessionPersistence', { + borrowSession: () => { + ctx.sessions.create(meta.id, { meta }) + return Promise.resolve(prepared) + }, + } as never) + vi.spyOn(ctx.sessionProjections, 'snapshot').mockImplementation(() => { + throw new Error('projection failed') + }) + + await expect(new SessionObservationReader(ctx).read(meta.id)).rejects.toThrow('projection failed') + expect(dispose).toHaveBeenCalledOnce() + await ctx.fiber.dispose() + }) + + it('retries when persistence reports a live source that has already detached', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const meta = header('detached-live-source') + const disposeLive = vi.fn() + const prepared = preparedSource(meta) + const borrowSession = vi.fn() + .mockResolvedValueOnce({ + source: 'live', inspection: { meta, events: [] }, [Symbol.dispose]: disposeLive, + } satisfies BorrowedSessionSource) + .mockResolvedValueOnce(prepared) + ctx.provide('sessionPersistence', { borrowSession } as never) + + using observed = await new SessionObservationReader(ctx).read(meta.id, { projectionMode: 'none' }) + + expect(observed.source).toBe('prepared') + expect(borrowSession).toHaveBeenCalledTimes(2) + expect(disposeLive).toHaveBeenCalledOnce() + await ctx.fiber.dispose() + }) + + it('reference-counts prepared leases and rejects retention after disposal', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const meta = header('prepared-leases') + const dispose = vi.fn() + ctx.provide('sessionPersistence', { + borrowSession: () => Promise.resolve(preparedSource(meta, dispose)), + } as never) + const observed = await new SessionObservationReader(ctx).read(meta.id, { projectionMode: 'none' }) + const retained = observed.retain() + + observed[Symbol.dispose]() + observed[Symbol.dispose]() + expect(dispose).not.toHaveBeenCalled() + expect(() => observed.retain()).toThrow('is disposed') + retained[Symbol.dispose]() + expect(dispose).toHaveBeenCalledOnce() + await ctx.fiber.dispose() + }) + + it('creates independent live leases and rejects retention after disposal', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('live-leases'), { meta: { cwd: '/workspace' } }) + const reader = new SessionObservationReader(ctx) + const observed = await reader.read(session.id, { projectionMode: 'none' }) + const retained = observed.retain() + + observed[Symbol.dispose]() + expect(() => observed.retain()).toThrow('is disposed') + expect(retained.source).toBe('live') + retained[Symbol.dispose]() + await ctx.fiber.dispose() + }) + + it('contains a non-Error persistence rejection', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + ctx.provide('sessionPersistence', { + // Exercise containment of a backend that violates the Error rejection convention. + borrowSession: () => Promise.reject('offline'), // oxlint-disable-line typescript/prefer-promise-reject-errors + } as never) + + await expect(new SessionObservationReader(ctx).read(SessionId('failed'))).rejects.toMatchObject({ + code: 'SESSION_QUERY_PERSISTENCE_FAILED', + message: expect.stringContaining('unknown error') as string, + }) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 119188eb5a..db90be7b57 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -64,6 +64,10 @@ class TestPersistence extends SessionPersistence { return undefined } + borrowSession(_id: SessionIdType, _signal?: AbortSignal): ReturnType { + return Promise.reject(new Error('not used')) + } + create(meta: SessionHeader): Promise { TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] }) return Promise.resolve() diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 0bff6c1154..74ec4a41f5 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -54,6 +54,10 @@ class TracePersistence extends SessionPersistence { return undefined } + borrowSession(_id: SessionIdType, _signal?: AbortSignal): ReturnType { + return Promise.reject(new Error('not used')) + } + create(meta: SessionHeader): Promise { TracePersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] }) return Promise.resolve() diff --git a/packages/session-query/session-query/tsconfig.json b/packages/session-query/session-query/tsconfig.json index 408f51652b..37fb5bf98c 100644 --- a/packages/session-query/session-query/tsconfig.json +++ b/packages/session-query/session-query/tsconfig.json @@ -32,6 +32,12 @@ { "path": "../../session/session-persistence" }, + { + "path": "../../session/session-projection" + }, + { + "path": "../../session/session-projection-cache" + }, { "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..f1f50bbba0 100644 --- a/packages/session/session-projection-cache/src/index.ts +++ b/packages/session/session-projection-cache/src/index.ts @@ -19,7 +19,11 @@ import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek- // Empty type import: applies the package's cordis Context merge // (`ctx.sessionPersistence`), which this service reads on the cold path. import type {} from '@deepseek-ai/dsh-session-persistence' -import type { ProjectionCheckpoint, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' +import type { + ProjectionCheckpoint, + ProjectionSnapshot, + SessionProjectionMap, +} from '@deepseek-ai/dsh-session-projection' import type { KvTable } from '@deepseek-ai/dsh-storage-domain' import { projectionCacheDomainSpec } from './spec.ts' import type { CheckpointIdentity, CheckpointRecord } from './spec.ts' @@ -113,22 +117,54 @@ export class SessionProjectionCache extends Service { * paths (the history tail baseline, {@link coldSnapshot}) supersede these * values whenever a session is actually 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 * `undefined` when no usable row exists for this lifecycle. */ - cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined { + cachedSnapshot( + meta: SessionHeader, + keys?: readonly Extract[], + ): ProjectionSnapshot | undefined { const record = this.recordFor(meta.id, identityOf(meta)) if (record === undefined) return undefined - const values = this.ctx.sessionProjections.viewCheckpoint(record.rows) - const keys = Object.keys(values) - if (keys.length === 0) return undefined + const values = this.ctx.sessionProjections.viewCheckpoint(record.rows, keys) + 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). - const asOfSeq = Math.min(...keys.map(key => (record.rows[key] as { seq: number }).seq)) + const asOfSeq = Math.min(...servedKeys.map(key => (record.rows[key] as { seq: number }).seq)) return { asOfSeq, values } } + /** + * Hydrate projection cells for an already-prepared Session without another + * persistence read. The cache seeds matching rows; the supplied exact log + * advances every unit to the observation cut. No checkpoint is written + * because the logical observation may contain recovery events not yet durable. + * @param session - exact unpublished Session retained by persistence. + * @param meta - observed lifecycle header. + * @param events - exact logical event prefix represented by the observation. + * @returns all projection values at the event cut. + */ + hydratePrepared( + session: Session, + meta: SessionHeader, + events: readonly SessionEvent[], + ): ProjectionSnapshot { + const record = this.recordFor(meta.id, identityOf(meta)) + if (record === undefined) { + return this.ctx.sessionProjections.hydrate(session, {}, events, 0) + } + try { + return this.ctx.sessionProjections.hydrate(session, record.rows, events, 0) + } catch { + // Cached rows are disposable derived data. Retry from the exact log so a + // stale schema cannot make a valid Session unreadable. + return this.ctx.sessionProjections.hydrate(session, {}, events, 0) + } + } + /** * Durably checkpoint one live session NOW (both mandatory points call * this; tests and carriers may too). The registry cut is snapshotted at @@ -183,13 +219,13 @@ 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, tail.meta) } 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, whole.meta) } 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..9615fb0baf 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -11,8 +11,8 @@ import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import Storage from '@deepseek-ai/dsh-storage' import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +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 type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' @@ -250,6 +250,21 @@ describe('SessionProjectionCache cold read', () => { }) } + it('retries prepared hydration without a malformed cached checkpoint', async () => { + const pool = new MemoryMediaPool() + const id = SessionId('prepared-cache-fallback') + seedRow(pool, id, { ver: 1, seq: 1, val: { marks: 'malformed' } }) + const events = storedLog([['fresh']]) + const { cache } = await harness({ pool }) + const meta = headerOf(id) + const session = Session.create(id, events, meta) + + expect(cache.hydratePrepared(session, meta, events)).toEqual({ + asOfSeq: 2, + values: { 'cache-test/marks': { marks: ['fresh'] } }, + }) + }) + it('serves a cold session from the cache row plus a bounded tail read, and writes the refresh back', async () => { const pool = new MemoryMediaPool() const logs = new Map([['cold', storedLog([['a'], ['a', 'b']])]]) diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 89d356df02..2b870d7bef 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -19,7 +19,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { ZodType } from 'zod' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' declare module '@deepseek-ai/cordis' { interface Context { @@ -48,10 +48,11 @@ export interface ProjectionDefinition< /** Validates persisted state before it seeds a fold. */ stateSchema: ZodType /** - * State for the empty log. + * State for the empty log and its immutable Session metadata. + * @param header - immutable metadata for the Session being projected. * @returns the initial state. */ - init(): NoInfer + init(header: SessionHeader): 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 +130,7 @@ export type ProjectionCheckpoint = Record interface ErasedDefinition { key: string stateSchema: { parse(value: unknown): unknown } - init(): unknown + init(header: SessionHeader): unknown apply(state: unknown, event: SessionEvent): unknown wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined stateVersion: number @@ -187,6 +188,16 @@ export class SessionProjectionRegistry extends Service { */ constructor(ctx: Context) { super(ctx, 'sessionProjections') + ctx.on('session/created', (session: Session) => { + if (session.seq !== 0) return + for (const registration of this.registrations.values()) { + if (registration.cells.has(session)) continue + registration.cells.set(session, { + state: registration.def.init(session.header), + observedSeq: -1, + }) + } + }) ctx.on('session/event', (session: Session, event: SessionEvent) => { this.drive(session, event) }) @@ -230,7 +241,7 @@ export class SessionProjectionRegistry extends Service { const erased: ErasedDefinition = { key: definition.key, stateSchema: definition.stateSchema, - init: () => definition.init(), + init: header => definition.init(header), apply: (state, event) => definition.apply(state as S, event), wire: wire === undefined ? undefined @@ -279,7 +290,8 @@ export class SessionProjectionRegistry extends Service { } /** - * Read one unit's current host state without computing unrelated views. + * Read one unit's current host state after materializing every registered + * unit at the Session cursor. Unrelated wire views are not produced. * The returned value is live; callers must not mutate it. * @param session - the session whose state is read. * @param key - the registered unit key. @@ -291,6 +303,7 @@ export class SessionProjectionRegistry extends Service { ): SessionProjectionStateMap[K] | undefined { const registration = this.registrations.get(key) if (registration === undefined) return undefined + this.materializeCells(session) return this.cellFor(registration, session).state as SessionProjectionStateMap[K] } @@ -300,18 +313,53 @@ export class SessionProjectionRegistry extends Service { * Fully synchronous — every value and `asOfSeq` reflect the same log * position. Each value passes its unit's `viewSchema` before leaving. * @param session - the session whose projection values are read. - * @returns the snapshot; `values` is empty when no client-visible unit is registered. + * @param keys - optional client-visible outputs; state materialization remains complete. + * @returns the snapshot; `values` is empty when no selected client-visible unit is registered. */ - snapshot(session: Session): ProjectionSnapshot { + snapshot( + session: Session, + keys?: readonly Extract[], + ): ProjectionSnapshot { const values: Record = {} + const selected = keys === undefined ? undefined : new Set(keys) + this.materializeCells(session) for (const registration of this.registrations.values()) { if (registration.def.wire === undefined) continue + if (selected !== undefined && !selected.has(registration.def.key)) continue const cell = this.cellFor(registration, session) - values[registration.def.key] = registration.def.wire.viewSchema.parse(registration.def.wire.view(cell.state)) + values[registration.def.key] = this.viewCell(registration, cell) } return { asOfSeq: session.seq - 1, values } } + /** + * Read only already-materialized client-visible cells without folding history. + * Values may trail the live Session and are therefore hints, not a complete + * baseline. Missing cells are omitted. + * @param session - attached Session whose cached cells are inspected. + * @param keys - optional wire keys to view. + * @returns the lowest common cached cut, or `undefined` when no wire cell exists. + */ + cachedSnapshot( + session: Session, + keys?: readonly Extract[], + ): ProjectionSnapshot | undefined { + const values: Record = {} + let asOfSeq: number | undefined + const selected = keys === undefined ? undefined : new Set(keys) + for (const registration of this.registrations.values()) { + if (registration.def.wire === undefined) continue + if (selected !== undefined && !selected.has(registration.def.key)) continue + const cell = registration.cells.get(session) + if (cell === undefined) continue + values[registration.def.key] = this.viewCell(registration, cell) + asOfSeq = asOfSeq === undefined + ? cell.observedSeq + : Math.min(asOfSeq, cell.observedSeq) + } + return asOfSeq === undefined ? undefined : { asOfSeq, values } + } + /** * State-level checkpoint of every persisted unit for one session, read * from the watermark cache (missing cells fold lazily over the in-memory @@ -375,13 +423,19 @@ export class SessionProjectionRegistry extends Service { * fuller read path refolds it). The zero-I/O rung of the read ladder — * values are as stale as their rows, never wrong. * @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. */ - viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial { + viewCheckpoint( + checkpoint: ProjectionCheckpoint, + keys?: readonly Extract[], + ): Partial { const values: Record = {} + const selected = keys === undefined ? undefined : new Set(keys) for (const registration of this.registrations.values()) { const def = registration.def if (def.wire === undefined) continue + if (selected !== undefined && !selected.has(def.key)) continue const row = checkpoint[def.key] if (row === undefined || row.ver !== def.stateVersion) continue let state: unknown @@ -413,6 +467,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 header - immutable metadata for the Session being restored. * @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 +476,7 @@ export class SessionProjectionRegistry extends Service { checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, + header: SessionHeader, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } { const endSeq = events.at(-1)?.seq ?? baseSeq - 1 @@ -439,10 +495,16 @@ 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(header) const from = usable ? row.seq : baseSeq - 1 - for (const event of events) { - if (event.seq > from) state = def.apply(state, event) + const startIndex = from - baseSeq + 1 + for (let index = startIndex; index < events.length; index++) { + const event = events[index] + const expectedSeq = baseSeq + index + if (event === undefined || event.seq !== expectedSeq) { + throw new Error(`session projection ${JSON.stringify(def.key)} cannot restore across missing seq ${String(expectedSeq)}`) + } + state = def.apply(state, event) } if (def.wire !== undefined) values[def.key] = def.wire.viewSchema.parse(def.wire.view(state)) refreshed[def.key] = { ver: def.stateVersion, seq: endSeq, val: state } @@ -453,9 +515,66 @@ export class SessionProjectionRegistry extends Service { } } + /** + * Restore an exact cut and install its states on the supplied prepared Session. + * A later publication reuses these cells; ordinary live reads and event drive + * advance any constructor-owned suffix exactly once. + * @param session - exact prepared Session that owns the restored log prefix. + * @param checkpoint - persisted rows for this Session lifecycle. + * @param events - exact events at the observation cut. + * @param baseSeq - first supplied event sequence. + * @returns all projection values at the supplied cut. + */ + hydrate( + session: Session, + checkpoint: ProjectionCheckpoint, + events: readonly SessionEvent[], + baseSeq: number, + ): ProjectionSnapshot { + const endSeq = events.at(-1)?.seq ?? baseSeq - 1 + let complete = true + for (const registration of this.registrations.values()) { + const current = registration.cells.get(session) + if (current?.observedSeq !== endSeq) { + complete = false + break + } + } + if (complete) { + const values: Record = {} + for (const registration of this.registrations.values()) { + if (registration.def.wire === undefined) continue + const current = registration.cells.get(session) as UnitCell + values[registration.def.key] = this.viewCell(registration, current) + } + return { asOfSeq: endSeq, values } + } + const restored = this.restore(checkpoint, events, baseSeq, session.header) + for (const registration of this.registrations.values()) { + const row = restored.checkpoint[registration.def.key] + if (row === undefined) continue + const current = registration.cells.get(session) + if (current !== undefined && current.observedSeq > row.seq) continue + registration.cells.set(session, { + state: row.val, + observedSeq: row.seq, + }) + } + return restored.snapshot + } + + /** Materialize every registered unit cell at the Session's current cursor. */ + private materializeCells(session: Session): void { + for (const registration of this.registrations.values()) this.cellFor(registration, session) + } + /** 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, + header: SessionHeader, + events: readonly SessionEvent[], + ): UnitCell { + let state = def.init(header) for (const event of events) state = def.apply(state, event) return { state, observedSeq: (events.at(-1)?.seq ?? -1) } } @@ -464,34 +583,65 @@ 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.header, session.events) registration.cells.set(session, cell) + } else { + this.advanceCell(registration.def, cell, session.events, session.seq - 1) } return cell } + /** Advance one existing cell through a contiguous Session prefix. */ + private advanceCell( + def: ErasedDefinition, + cell: UnitCell, + events: readonly SessionEvent[], + throughSeq: number, + ): void { + if (cell.observedSeq >= throughSeq) return + for (let seq = cell.observedSeq + 1; seq <= throughSeq; seq++) { + const event = events[seq] + if (event === undefined || event.seq !== seq) { + throw new Error(`session projection ${JSON.stringify(def.key)} cannot advance across missing seq ${String(seq)}`) + } + const next = def.apply(cell.state, event) + cell.state = next + cell.observedSeq = seq + } + } + /** Eager drive: pass one committed event through every registered unit; notify on changed references. */ private drive(session: Session, event: SessionEvent): void { for (const registration of this.registrations.values()) { let cell = registration.cells.get(session) + if (cell !== undefined && cell.observedSeq >= event.seq) continue 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.header, session.events.slice(0, event.seq)) registration.cells.set(session, cell) + } else { + this.advanceCell(registration.def, cell, session.events, event.seq - 1) } const next = registration.def.apply(cell.state, event) const changed = !Object.is(next, cell.state) cell.state = next cell.observedSeq = event.seq if (changed && registration.def.wire !== undefined && this.listeners.size > 0) { - const value = registration.def.wire.viewSchema.parse(registration.def.wire.view(next)) + const value = this.viewCell(registration, cell) for (const listener of this.listeners) { listener(session, registration.def.key as Extract, value, event.seq) } } } } + + /** Return one schema-validated wire value. */ + private viewCell(registration: Registration, cell: UnitCell): unknown { + const wire = registration.def.wire + if (wire === undefined) throw new Error(`session projection ${JSON.stringify(registration.def.key)} has no wire view`) + return wire.viewSchema.parse(wire.view(cell.state)) + } } export default SessionProjectionRegistry diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index 3ee2491d4f..d5d99b3cb4 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -10,8 +10,8 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' -import SessionStore from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' @@ -33,6 +33,11 @@ declare module '@deepseek-ai/dsh-session/types' { } type MarksState = { marks: string[] } | null +const RESTORE_HEADER: SessionHeader = { + version: 0, + id: SessionId('projection-restore'), + createdAt: 0, +} /** Whole-value unit: latest test/mark event wins; unrelated events return the same reference. */ const marksUnit = (): Omit, 'wire'> & { wire: NonNullable['wire']> } => ({ @@ -280,7 +285,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, RESTORE_HEADER)).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 +296,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, RESTORE_HEADER) expect(snapshot.asOfSeq).toBe(4) expect(snapshot.values['test/marks']).toEqual({ marks: ['new'] }) expect('test/count' in snapshot.values).toBe(false) @@ -312,7 +317,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, RESTORE_HEADER) 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 +329,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, RESTORE_HEADER) 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 +360,7 @@ describe('SessionProjectionRegistry drive', () => { 'test/marks': { marks: ['stored'] }, }) - const restored = ctx.sessionProjections.restore(rows, [], 5) + const restored = ctx.sessionProjections.restore(rows, [], 5, RESTORE_HEADER) expect(restored.snapshot.values).toEqual({ 'test/marks': { marks: ['stored'] }, }) @@ -370,7 +375,7 @@ describe('SessionProjectionRegistry drive', () => { } expect(ctx.sessionProjections.viewCheckpoint(drifted)).toEqual({}) - expect(() => ctx.sessionProjections.restore(drifted, [], 3)).toThrow() + expect(() => ctx.sessionProjections.restore(drifted, [], 3, RESTORE_HEADER)).toThrow() }) it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => { @@ -383,18 +388,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, RESTORE_HEADER) 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, RESTORE_HEADER)).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, RESTORE_HEADER) expect(snapshot.asOfSeq).toBe(1) expect(snapshot.values).toEqual({}) expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 1, val: 2 }) From e7952d82ed0cd90c47ba8388caaad70ba57f82b1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:07:11 +0800 Subject: [PATCH 03/17] refactor(session): open journal streams from snapshots --- .../api/gateway/src/client/journal-stream.ts | 120 ++-- .../tests/journal-stream.client.spec.ts | 577 +++++++++++------- .../src/client/contract/snapshot.ts | 6 +- .../src/client/sessions/session.ts | 15 +- .../src/client/transport.ts | 36 +- .../api/session-controller/src/history.ts | 199 +++--- packages/api/session-controller/src/types.ts | 80 ++- .../session-history-journal.host.spec.ts | 23 +- .../tests/session.client.spec.ts | 60 +- .../tests/transport.client.spec.ts | 90 ++- .../tests/transport.host.spec.ts | 305 ++++++--- .../client-runtime/src/sessions.ts | 8 - .../tests/runtime.client.spec.tsx | 7 - packages/todo/tool-todo/package.json | 1 - .../todo/tool-todo/tests/projection.spec.ts | 12 +- 15 files changed, 938 insertions(+), 601 deletions(-) diff --git a/packages/api/gateway/src/client/journal-stream.ts b/packages/api/gateway/src/client/journal-stream.ts index f21e79f241..306779ba94 100644 --- a/packages/api/gateway/src/client/journal-stream.ts +++ b/packages/api/gateway/src/client/journal-stream.ts @@ -7,9 +7,9 @@ import type { RemoteStreamOptions, } from './remote-stream.ts' -/** Transport-neutral opening cursor or journal entry. */ -export type RemoteJournalFrame = - | { readonly type: 'opened'; readonly cursor: Cursor } +/** Transport-neutral opening snapshot or journal entry. */ +export type RemoteJournalFrame = + | { readonly type: 'opened'; readonly cursor: Cursor; readonly page: Page } | { readonly type: 'entry'; readonly entry: Entry } /** One committed journal-window update. */ @@ -28,7 +28,7 @@ export type RemoteJournalChange = } | { readonly type: 'append'; readonly entry: Entry } -type JournalStreamItem = RemoteStreamItem> +type JournalStreamItem = RemoteStreamItem> /** Gateway capability used to create one reconnecting Remote stream. */ export interface RemoteStreamFactory { @@ -65,13 +65,13 @@ export interface RemoteJournalStreamOptions { } /** - * Owns follow-before-page opening, ordered live delivery, pagination, and repair. + * Owns snapshot-first opening, ordered live delivery, pagination, and repair. * * The domain retains its published window during reconnection. A replacement is - * published only after a tail page reaches the generation's opening cursor. + * published only after the opening page reaches the generation's cursor. */ export abstract class RemoteJournalStream { - private readonly stream: RemoteStream> + private readonly stream: RemoteStream> private initialRequest!: PageRequest private resumeCursor: Cursor | undefined private hasResumeCursor = false @@ -83,7 +83,7 @@ export abstract class RemoteJournalStream | undefined private closing: Promise | undefined - private pendingNext: Promise>> | undefined + private pendingNext: Promise>> | undefined /** * @param remote - Gateway factory for the reconnecting physical-generation stream. @@ -93,12 +93,9 @@ export abstract class RemoteJournalStream, ) { - this.stream = remote.$stream>({ + this.stream = remote.$stream>({ name: options.name, - open: signal => this.follow( - this.hasResumeCursor ? this.resumeCursor : undefined, - signal, - ), + open: signal => this.follow(this.initialRequest, signal), ended: accepted => accepted ? new RemoteStreamCarrierError(`${options.name} ended without a terminal result`) : new Error( @@ -111,15 +108,15 @@ export abstract class RemoteJournalStream> + ): AsyncIterable> /** * Read one journal page through the addressed domain source. @@ -143,7 +140,7 @@ export abstract class RemoteJournalStream>, + iterator: AsyncIterator>, ): Promise { try { while (true) { @@ -225,7 +222,7 @@ export abstract class RemoteJournalStream, - iterator: AsyncIterator>, + private replaceGeneration( + initial: JournalStreamItem, resumed: boolean, - ): Promise { - let item = initial - let isResumed = resumed - while (true) { - const cursor = this.opening(item, isResumed) - this.setResumeCursor(cursor) - const superseded = await this.replaceThrough( - request, - cursor, - item.generation, - item.signal, - iterator, - [], - ) - if (superseded === undefined) return - item = superseded - isResumed = true - } + ): void { + const opening = this.opening(initial, resumed) + this.replaceFromOpening(opening.page, opening.cursor) } private opening( - item: RemoteStreamItem>, + item: RemoteStreamItem>, resumed: boolean, - ): Cursor { + ): { readonly cursor: Cursor; readonly page: Page } { if (item.value.type !== 'opened') { throw new Error(`${resumed ? 'resumed ' : ''}${this.options.name} emitted an entry before its opening cursor`) } @@ -279,13 +259,30 @@ export abstract class RemoteJournalStream, - iterator: AsyncIterator>, + item: JournalStreamItem, + iterator: AsyncIterator>, ): Promise { const cursor = this.options.cursor(entry) const last = this.lastCursor as Cursor @@ -301,7 +298,7 @@ export abstract class RemoteJournalStream>, + iterator: AsyncIterator>, queued: Entry[], - ): Promise | undefined> { + ): Promise | undefined> { let read = await this.readPageWhileFollowing( request, requiredCursor, @@ -351,6 +348,7 @@ export abstract class RemoteJournalStream>, + iterator: AsyncIterator>, queued: Entry[], ): Promise< | { readonly type: 'page'; readonly page: Page } - | { readonly type: 'superseded'; readonly item: JournalStreamItem } + | { readonly type: 'superseded'; readonly item: JournalStreamItem } > { const page = this.readPage(request, through, signal).then( value => ({ type: 'page' as const, value }), @@ -410,12 +408,12 @@ export abstract class RemoteJournalStream>, - initial: Promise>>, - ): Promise<{ readonly type: 'superseded'; readonly item: JournalStreamItem }> { + iterator: AsyncIterator>, + initial: Promise>>, + ): Promise<{ readonly type: 'superseded'; readonly item: JournalStreamItem }> { let pending = initial while (true) { - let next: IteratorResult> + let next: IteratorResult> try { next = await pending } finally { @@ -461,15 +459,15 @@ export abstract class RemoteJournalStream>, - ): Promise>> { + iterator: AsyncIterator>, + ): Promise>> { this.pendingNext ??= iterator.next() return this.pendingNext } private async takeNext( - iterator: AsyncIterator>, - ): Promise>> { + iterator: AsyncIterator>, + ): Promise>> { const pending = this.nextResult(iterator) try { return await pending diff --git a/packages/api/gateway/tests/journal-stream.client.spec.ts b/packages/api/gateway/tests/journal-stream.client.spec.ts index 38362c5598..cc364bbf51 100644 --- a/packages/api/gateway/tests/journal-stream.client.spec.ts +++ b/packages/api/gateway/tests/journal-stream.client.spec.ts @@ -25,9 +25,12 @@ interface PageRequest { readonly limit?: number } +type JournalFrame = RemoteJournalFrame +type ScriptedFrame = JournalFrame + interface Generation { readonly frames: readonly ( - RemoteJournalFrame | Promise> + ScriptedFrame | Promise )[] readonly terminal?: Error readonly hold?: boolean @@ -67,7 +70,7 @@ class FixtureJournal extends RemoteJournalStream[], failed: (error: unknown) => void, factory: RemoteStreamFactory = STREAM_FACTORY, @@ -87,11 +90,11 @@ class FixtureJournal extends RemoteJournalStream> { + ): AsyncIterable { this.calls.push('follow') - this.followCursors.push(after) + this.followRequests.push(request) const generation = this.generations.shift() if (generation === undefined) throw new Error('no scripted journal generation') for (const [index, frame] of generation.frames.entries()) { @@ -138,12 +141,12 @@ function journalFixture( readonly calls: string[] readonly pageRequests: PageRequest[] readonly pageCursors: number[] - readonly followCursors: (number | undefined)[] + readonly followRequests: PageRequest[] } { const calls: string[] = [] const pageRequests: PageRequest[] = [] const pageCursors: number[] = [] - const followCursors: (number | undefined)[] = [] + const followRequests: PageRequest[] = [] const changes: RemoteJournalChange[] = [] const failed = vi.fn() const journal = new FixtureJournal( @@ -152,24 +155,28 @@ function journalFixture( calls, pageRequests, pageCursors, - followCursors, + followRequests, changes, failed, factory, ) - return { journal, changes, failed, calls, pageRequests, pageCursors, followCursors } + return { journal, changes, failed, calls, pageRequests, pageCursors, followRequests } +} + +function opened(cursor: number, value: Page): JournalFrame { + return { type: 'opened', cursor, page: value } } function remoteItem( generation: number, - value: RemoteJournalFrame, + value: ScriptedFrame, signal: AbortSignal, -): RemoteStreamItem> { +): RemoteStreamItem { return { generation, value, signal, accept: vi.fn() } } function controlledFactory( - next: () => Promise>>>, + next: () => Promise>>, ): RemoteStreamFactory { const lifetime = new AbortController() return { @@ -189,17 +196,17 @@ function controlledFactory( } describe('RemoteJournalStream', () => { - it('opens follow before page, removes overlap, appends live entries, and prepends history', async () => { + it('opens from the follow snapshot, removes overlap, appends live entries, and prepends history', async () => { const fixture = journalFixture( [{ frames: [ - { type: 'opened', cursor: 3 }, + opened(3, page('tail', [2, 3], true)), { type: 'entry', entry: { seq: 3 } }, { type: 'entry', entry: { seq: 4 } }, ], hold: true, }], - [page('tail', [2, 3], true), page('older', [0, 1])], + [page('older', [0, 1])], ) await fixture.journal.open({ limit: 2 }) @@ -207,8 +214,8 @@ describe('RemoteJournalStream', () => { await fixture.journal.prepend({ before: 2, limit: 2 }) expect(fixture.calls.slice(0, 2)).toEqual(['follow', 'page']) - expect(fixture.pageRequests).toEqual([{ limit: 2 }, { before: 2, limit: 2 }]) - expect(fixture.pageCursors).toEqual([3, 4]) + expect(fixture.pageRequests).toEqual([{ before: 2, limit: 2 }]) + expect(fixture.pageCursors).toEqual([4]) expect(fixture.changes).toEqual([ { type: 'replace', page: page('tail', [2, 3], true), entries: entries(2, 3), hasMore: true }, { type: 'append', entry: { seq: 4 } }, @@ -220,8 +227,8 @@ describe('RemoteJournalStream', () => { it('exposes its shared cancellation signal', async () => { const fixture = journalFixture( - [{ frames: [{ type: 'opened', cursor: -1 }], hold: true }], - [page('empty', [])], + [{ frames: [opened(-1, page('empty', []))], hold: true }], + [], ) expect(fixture.journal.signal.aborted).toBe(false) @@ -239,10 +246,10 @@ describe('RemoteJournalStream', () => { const finish = Promise.withResolvers() const resumed = journalFixture( [ - { frames: [{ type: 'opened', cursor: 0 }], waitAfterFrames: finish.promise }, + { frames: [opened(0, page('initial', [0]))], waitAfterFrames: finish.promise }, { frames: [] }, ], - [page('initial', [0])], + [], ) await resumed.journal.open({}) finish.resolve(undefined) @@ -255,8 +262,8 @@ describe('RemoteJournalStream', () => { it('prepends into an empty window and accepts its first live entry', async () => { const empty = journalFixture( - [{ frames: [{ type: 'opened', cursor: -1 }], hold: true }], - [page('empty', []), page('older', [0]), page('oldest', [])], + [{ frames: [opened(-1, page('empty', []))], hold: true }], + [page('older', [0]), page('oldest', [])], ) await empty.journal.open({}) await empty.journal.prepend({}) @@ -269,10 +276,10 @@ describe('RemoteJournalStream', () => { }) await empty.journal.dispose() - const live = Promise.withResolvers>() + const live = Promise.withResolvers() const followed = journalFixture( - [{ frames: [{ type: 'opened', cursor: -1 }, live.promise], hold: true }], - [page('empty', [])], + [{ frames: [opened(-1, page('empty', [])), live.promise], hold: true }], + [], ) await followed.journal.open({}) live.resolve({ type: 'entry', entry: { seq: 0 } }) @@ -281,61 +288,27 @@ describe('RemoteJournalStream', () => { await followed.journal.dispose() }) - it('publishes one sorted replacement from an exact page and live entries queued while it loads', async () => { - let resolvePage!: (value: Page) => void - const openingPage = new Promise((resolve) => { resolvePage = resolve }) - const fixture = journalFixture( - [{ - frames: [ - { type: 'opened', cursor: 15 }, - { type: 'entry', entry: { seq: 17 } }, - { type: 'entry', entry: { seq: 16 } }, - ], - hold: true, - }], - [openingPage], - ) - - const opening = fixture.journal.open({ limit: 6 }) - await vi.waitFor(() => { - expect(fixture.calls.filter(call => call === 'page')).toHaveLength(1) - }) - expect(fixture.changes).toEqual([]) - - resolvePage(page('opening', [10, 11, 12, 13, 14, 15])) - await opening - - expect(fixture.changes).toEqual([{ - type: 'replace', - page: page('opening', [10, 11, 12, 13, 14, 15]), - entries: entries(10, 11, 12, 13, 14, 15, 16, 17), - hasMore: false, - }]) - expect(fixture.pageCursors).toEqual([15]) - await fixture.journal.dispose() - }) - it('repairs a replacement generation through one tail page and drops replay overlap', async () => { const lost = new RemoteStreamCarrierError('carrier lost') const fixture = journalFixture( [ { frames: [ - { type: 'opened', cursor: 1 }, + opened(1, page('initial', [0, 1])), { type: 'entry', entry: { seq: 2 } }, ], terminal: lost, }, { frames: [ - { type: 'opened', cursor: 4 }, + opened(4, page('replacement', [0, 1, 2, 3, 4])), { type: 'entry', entry: { seq: 3 } }, { type: 'entry', entry: { seq: 4 } }, ], hold: true, }, ], - [page('initial', [0, 1]), page('repair', [0, 1, 2, 3, 4])], + [], ) await fixture.journal.open({ limit: 5 }) @@ -343,10 +316,10 @@ describe('RemoteJournalStream', () => { expect(fixture.changes.map(change => change.type)).toEqual(['replace', 'append', 'replace']) expect(fixture.changes[2]).toMatchObject({ - type: 'replace', page: { marker: 'repair' }, entries: entries(0, 1, 2, 3, 4), + type: 'replace', page: { marker: 'replacement' }, entries: entries(0, 1, 2, 3, 4), }) - expect(fixture.followCursors).toEqual([undefined, 2]) - expect(fixture.pageCursors).toEqual([1, 4]) + expect(fixture.followRequests).toEqual([{ limit: 5 }, { limit: 5 }]) + expect(fixture.pageCursors).toEqual([]) expect(fixture.failed).not.toHaveBeenCalled() await fixture.journal.dispose() }) @@ -355,11 +328,14 @@ describe('RemoteJournalStream', () => { const fixture = journalFixture( [ { - frames: [{ type: 'opened', cursor: 1 }], + frames: [ + opened(1, page('initial', [0, 1])), + { type: 'entry', entry: { seq: 3 } }, + ], terminal: new RemoteStreamCarrierError('carrier lost during page'), }, { - frames: [{ type: 'opened', cursor: 2 }], + frames: [opened(3, page('replacement', [0, 1, 2, 3]))], hold: true, }, ], @@ -369,20 +345,28 @@ describe('RemoteJournalStream', () => { signal.addEventListener('abort', aborted, { once: true }) if (signal.aborted) aborted() }), - page('replacement', [0, 1, 2]), ], ) await fixture.journal.open({ limit: 3 }) + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) }) - expect(fixture.changes).toEqual([{ - type: 'replace', - page: page('replacement', [0, 1, 2]), - entries: entries(0, 1, 2), - hasMore: false, - }]) - expect(fixture.pageCursors).toEqual([1, 2]) - expect(fixture.followCursors).toEqual([undefined, 1]) + expect(fixture.changes).toEqual([ + { + type: 'replace', + page: page('initial', [0, 1]), + entries: entries(0, 1), + hasMore: false, + }, + { + type: 'replace', + page: page('replacement', [0, 1, 2, 3]), + entries: entries(0, 1, 2, 3), + hasMore: false, + }, + ]) + expect(fixture.pageCursors).toEqual([3]) + expect(fixture.followRequests).toEqual([{ limit: 3 }, { limit: 3 }]) expect(fixture.failed).not.toHaveBeenCalled() await fixture.journal.dispose() }) @@ -391,12 +375,12 @@ describe('RemoteJournalStream', () => { const fixture = journalFixture( [{ frames: [ - { type: 'opened', cursor: 1 }, + opened(1, page('initial', [0, 1])), { type: 'entry', entry: { seq: 4 } }, ], hold: true, }], - [page('initial', [0, 1]), page('repair', [0, 1, 2, 3, 4])], + [page('repair', [0, 1, 2, 3, 4])], ) await fixture.journal.open({}) @@ -404,24 +388,22 @@ describe('RemoteJournalStream', () => { expect(fixture.changes.map(change => change.type)).toEqual(['replace', 'replace']) expect(fixture.changes[1]).toMatchObject({ page: { marker: 'repair' } }) - expect(fixture.pageCursors).toEqual([1, 4]) + expect(fixture.pageCursors).toEqual([4]) await fixture.journal.dispose() }) it('replaces a superseded live-gap repair with the next generation', async () => { - const gap = Promise.withResolvers>() + const gap = Promise.withResolvers() const fixture = journalFixture( [ { - frames: [{ type: 'opened', cursor: 1 }, gap.promise], + frames: [opened(1, page('initial', [0, 1])), gap.promise], terminal: new RemoteStreamCarrierError('generation lost'), }, - { frames: [{ type: 'opened', cursor: 4 }], hold: true }, + { frames: [opened(4, page('replacement', [0, 1, 2, 3, 4]))], hold: true }, ], [ - page('initial', [0, 1]), () => new Promise(() => {}), - page('replacement', [0, 1, 2, 3, 4]), ], ) @@ -435,103 +417,173 @@ describe('RemoteJournalStream', () => { }) it('replaces a superseded second repair page with the next generation', async () => { - const live = Promise.withResolvers>() - const liveConsumed = Promise.withResolvers() - const openingPage = Promise.withResolvers() + const firstLive = Promise.withResolvers() + const secondLive = Promise.withResolvers() + const secondConsumed = Promise.withResolvers() + const firstRepair = Promise.withResolvers() const finish = Promise.withResolvers() const fixture = journalFixture( [ { - frames: [{ type: 'opened', cursor: 1 }, live.promise], + frames: [ + opened(1, page('initial', [0, 1])), + firstLive.promise, + secondLive.promise, + ], waitAfterFrames: finish.promise, terminal: new RemoteStreamCarrierError('generation lost'), - afterFrame: (index) => { if (index === 1) liveConsumed.resolve(undefined) }, + afterFrame: (index) => { if (index === 2) secondConsumed.resolve(undefined) }, }, - { frames: [{ type: 'opened', cursor: 4 }], hold: true }, + { frames: [opened(5, page('replacement', [0, 1, 2, 3, 4, 5]))], hold: true }, ], [ - openingPage.promise, - () => new Promise(() => {}), - page('replacement', [0, 1, 2, 3, 4]), + firstRepair.promise, + signal => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('page aborted')) }, { once: true }) + }), ], ) - const opening = fixture.journal.open({}) - await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([1]) }) - live.resolve({ type: 'entry', entry: { seq: 3 } }) - await liveConsumed.promise - openingPage.resolve(page('opening', [0, 1])) - await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([1, 3]) }) + await fixture.journal.open({}) + firstLive.resolve({ type: 'entry', entry: { seq: 3 } }) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([3]) }) + secondLive.resolve({ type: 'entry', entry: { seq: 5 } }) + await secondConsumed.promise + firstRepair.resolve(page('first-repair', [0, 1, 2, 3])) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([3, 5]) }) finish.resolve(undefined) - await opening + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) }) - expect(fixture.pageCursors).toEqual([1, 3, 4]) - expect(fixture.changes).toEqual([{ - type: 'replace', - page: page('replacement', [0, 1, 2, 3, 4]), - entries: entries(0, 1, 2, 3, 4), - hasMore: false, - }]) + expect(fixture.pageCursors).toEqual([3, 5]) + expect(fixture.changes).toEqual([ + { + type: 'replace', + page: page('initial', [0, 1]), + entries: entries(0, 1), + hasMore: false, + }, + { + type: 'replace', + page: page('replacement', [0, 1, 2, 3, 4, 5]), + entries: entries(0, 1, 2, 3, 4, 5), + hasMore: false, + }, + ]) await fixture.journal.dispose() }) - it('rereads the tail when queued entries advance beyond the opening page', async () => { - const live = Promise.withResolvers>() - const liveConsumed = Promise.withResolvers() - const openingPage = Promise.withResolvers() + it('rereads the tail when queued entries advance beyond the first repair page', async () => { + const firstLive = Promise.withResolvers() + const secondLive = Promise.withResolvers() + const secondConsumed = Promise.withResolvers() + const firstRepair = Promise.withResolvers() const fixture = journalFixture( [{ - frames: [{ type: 'opened', cursor: 1 }, live.promise], + frames: [ + opened(1, page('initial', [0, 1])), + firstLive.promise, + secondLive.promise, + ], hold: true, - afterFrame: (index) => { if (index === 1) liveConsumed.resolve(undefined) }, + afterFrame: (index) => { if (index === 2) secondConsumed.resolve(undefined) }, }], - [openingPage.promise, page('repair', [0, 1, 2, 3])], + [firstRepair.promise, page('repair', [0, 1, 2, 3, 4, 5])], ) - const opening = fixture.journal.open({ limit: 4 }) - await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([1]) }) - live.resolve({ type: 'entry', entry: { seq: 3 } }) - await liveConsumed.promise - openingPage.resolve(page('opening', [0, 1])) - await opening + await fixture.journal.open({ limit: 4 }) + firstLive.resolve({ type: 'entry', entry: { seq: 3 } }) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([3]) }) + secondLive.resolve({ type: 'entry', entry: { seq: 5 } }) + await secondConsumed.promise + firstRepair.resolve(page('first-repair', [0, 1, 2, 3])) + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) }) - expect(fixture.pageCursors).toEqual([1, 3]) - expect(fixture.changes).toEqual([{ - type: 'replace', page: page('repair', [0, 1, 2, 3]), entries: entries(0, 1, 2, 3), hasMore: false, - }]) + expect(fixture.pageCursors).toEqual([3, 5]) + expect(fixture.changes.at(-1)).toEqual({ + type: 'replace', + page: page('repair', [0, 1, 2, 3, 4, 5]), + entries: entries(0, 1, 2, 3, 4, 5), + hasMore: false, + }) + await fixture.journal.dispose() + }) + + it('merges contiguous entries that arrive while a replacement page is loading', async () => { + const firstLive = Promise.withResolvers() + const secondLive = Promise.withResolvers() + const secondConsumed = Promise.withResolvers() + const repair = Promise.withResolvers() + const fixture = journalFixture( + [{ + frames: [ + opened(1, page('initial', [0, 1])), + firstLive.promise, + secondLive.promise, + ], + hold: true, + afterFrame: (index) => { if (index === 2) secondConsumed.resolve(undefined) }, + }], + [repair.promise], + ) + + await fixture.journal.open({}) + firstLive.resolve({ type: 'entry', entry: { seq: 3 } }) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([3]) }) + secondLive.resolve({ type: 'entry', entry: { seq: 4 } }) + await secondConsumed.promise + repair.resolve(page('repair', [0, 1, 2, 3])) + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) }) + + expect(fixture.changes.at(-1)).toEqual({ + type: 'replace', + page: page('repair', [0, 1, 2, 3]), + entries: entries(0, 1, 2, 3, 4), + hasMore: false, + }) await fixture.journal.dispose() }) it('rejects when queued entries advance beyond the second repair page', async () => { - const firstLive = Promise.withResolvers>() - const secondLive = Promise.withResolvers>() - const firstConsumed = Promise.withResolvers() + const firstLive = Promise.withResolvers() + const secondLive = Promise.withResolvers() + const thirdLive = Promise.withResolvers() const secondConsumed = Promise.withResolvers() - const openingPage = Promise.withResolvers() - const repairPage = Promise.withResolvers() + const thirdConsumed = Promise.withResolvers() + const firstRepair = Promise.withResolvers() + const secondRepair = Promise.withResolvers() const fixture = journalFixture( [{ - frames: [{ type: 'opened', cursor: 1 }, firstLive.promise, secondLive.promise], + frames: [ + opened(1, page('initial', [0, 1])), + firstLive.promise, + secondLive.promise, + thirdLive.promise, + ], hold: true, afterFrame: (index) => { - if (index === 1) firstConsumed.resolve(undefined) if (index === 2) secondConsumed.resolve(undefined) + if (index === 3) thirdConsumed.resolve(undefined) }, }], - [openingPage.promise, repairPage.promise], + [firstRepair.promise, secondRepair.promise], ) - const opening = fixture.journal.open({}) - await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([1]) }) + await fixture.journal.open({}) firstLive.resolve({ type: 'entry', entry: { seq: 3 } }) - await firstConsumed.promise - openingPage.resolve(page('opening', [0, 1])) - await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([1, 3]) }) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([3]) }) secondLive.resolve({ type: 'entry', entry: { seq: 5 } }) await secondConsumed.promise - repairPage.resolve(page('repair', [0, 1, 2, 3])) + firstRepair.resolve(page('first-repair', [0, 1, 2, 3])) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([3, 5]) }) + thirdLive.resolve({ type: 'entry', entry: { seq: 7 } }) + await thirdConsumed.promise + secondRepair.resolve(page('second-repair', [0, 1, 2, 3, 4, 5])) - await expect(opening).rejects.toThrow('page did not reach its opening cursor') + await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() }) + expect(fixture.failed.mock.calls[0]?.[0]).toMatchObject({ + message: 'fixture journal page did not reach its opening cursor', + }) + await fixture.journal.dispose() }) it('reports a resumed generation that emits an entry before its cursor', async () => { @@ -539,13 +591,13 @@ describe('RemoteJournalStream', () => { const fixture = journalFixture( [ { - frames: [{ type: 'opened', cursor: 0 }], + frames: [opened(0, page('initial', [0]))], waitAfterFrames: finish.promise, terminal: new RemoteStreamCarrierError('lost'), }, { frames: [{ type: 'entry', entry: { seq: 1 } }] }, ], - [page('initial', [0])], + [], ) await fixture.journal.open({}) @@ -558,14 +610,14 @@ describe('RemoteJournalStream', () => { }) it('reports a duplicate opening cursor after the initial page is published', async () => { - const duplicate = Promise.withResolvers>() + const duplicate = Promise.withResolvers() const fixture = journalFixture( - [{ frames: [{ type: 'opened', cursor: 0 }, duplicate.promise], hold: true }], - [page('initial', [0])], + [{ frames: [opened(0, page('initial', [0])), duplicate.promise], hold: true }], + [], ) await fixture.journal.open({}) - duplicate.resolve({ type: 'opened', cursor: 0 }) + duplicate.resolve(opened(0, page('duplicate', [0]))) await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() }) expect(fixture.failed.mock.calls[0]?.[0]).toMatchObject({ message: 'fixture journal emitted more than one opening cursor', @@ -573,47 +625,16 @@ describe('RemoteJournalStream', () => { await fixture.journal.dispose() }) - it('propagates follow failures and duplicate cursors while an opening page is pending', async () => { - const pendingPage = new Promise(() => {}) + it('reports a follow failure after publishing its opening snapshot', async () => { const failedFollow = journalFixture( - [{ frames: [{ type: 'opened', cursor: 0 }], terminal: new Error('follow failed') }], - [pendingPage], - ) - await expect(failedFollow.journal.open({})).rejects.toThrow('follow failed') - - const duplicate = Promise.withResolvers>() - const duplicatePage = new Promise(() => {}) - const duplicateOpening = journalFixture( - [{ frames: [{ type: 'opened', cursor: 0 }, duplicate.promise] }], - [duplicatePage], - ) - const opening = duplicateOpening.journal.open({}) - await vi.waitFor(() => { expect(duplicateOpening.pageCursors).toEqual([0]) }) - duplicate.resolve({ type: 'opened', cursor: 0 }) - await expect(opening).rejects.toThrow('more than one opening cursor') - }) - - it('rejects an iterator that ends while its opening page is pending', async () => { - const generation = new AbortController() - const results = [ - Promise.resolve>>>({ - done: false, - value: remoteItem(1, { type: 'opened', cursor: 0 }, generation.signal), - }), - Promise.resolve>>>({ - done: true, - value: undefined, - }), - ] - const fixture = journalFixture( + [{ frames: [opened(0, page('initial', [0]))], terminal: new Error('follow failed') }], [], - [new Promise(() => {})], - controlledFactory(() => results.shift() ?? Promise.resolve({ done: true, value: undefined })), - ) - - await expect(fixture.journal.open({})).rejects.toThrow( - 'ended while reading its replacement page', ) + await failedFollow.journal.open({}) + await vi.waitFor(() => { expect(failedFollow.failed).toHaveBeenCalledOnce() }) + expect(failedFollow.failed.mock.calls[0]?.[0]).toMatchObject({ message: 'follow failed' }) + expect(failedFollow.changes).toHaveLength(1) + await failedFollow.journal.dispose() }) it('rejects an iterator that ends before its opening cursor', async () => { @@ -627,17 +648,17 @@ describe('RemoteJournalStream', () => { it('suppresses a consumer failure after disposal begins', async () => { const generation = new AbortController() - const next = Promise.withResolvers>>>() + const next = Promise.withResolvers>>() const results = [ - Promise.resolve>>>({ + Promise.resolve>>({ done: false, - value: remoteItem(1, { type: 'opened', cursor: 0 }, generation.signal), + value: remoteItem(1, opened(0, page('initial', [0])), generation.signal), }), next.promise, ] const fixture = journalFixture( [], - [page('initial', [0])], + [], controlledFactory(() => results.shift() ?? Promise.resolve({ done: true, value: undefined })), ) @@ -645,7 +666,7 @@ describe('RemoteJournalStream', () => { const closing = fixture.journal.dispose() next.resolve({ done: false, - value: remoteItem(1, { type: 'opened', cursor: 0 }, generation.signal), + value: remoteItem(1, opened(0, page('duplicate', [0])), generation.signal), }) await closing expect(fixture.failed).not.toHaveBeenCalled() @@ -658,17 +679,17 @@ describe('RemoteJournalStream', () => { final: undefined, message: 'more than one opening cursor', }, - ])('rejects when an aborted page generation $name', async ({ final, message }) => { + ])('reports when an aborted repair generation $name', async ({ final, message }) => { const generation = new AbortController() - const pending = Promise.withResolvers>>>() - const nextPending = Promise.withResolvers>>>() + const gap = Promise.withResolvers>>() + const replacement = Promise.withResolvers>>() const results = [ - Promise.resolve>>>({ + Promise.resolve>>({ done: false, - value: remoteItem(1, { type: 'opened', cursor: 0 }, generation.signal), + value: remoteItem(1, opened(0, page('initial', [0])), generation.signal), }), - pending.promise, - nextPending.promise, + gap.promise, + replacement.promise, ] const fixture = journalFixture( [], @@ -678,47 +699,154 @@ describe('RemoteJournalStream', () => { controlledFactory(() => results.shift() ?? Promise.resolve({ done: true, value: undefined })), ) - const opening = fixture.journal.open({}) - await vi.waitFor(() => { expect(results).toHaveLength(1) }) + await fixture.journal.open({}) + gap.resolve({ + done: false, + value: remoteItem(1, { type: 'entry', entry: { seq: 2 } }, generation.signal), + }) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([2]) }) generation.abort() if (final === undefined) { - pending.resolve({ + replacement.resolve({ done: false, - value: remoteItem(1, { type: 'entry', entry: { seq: 1 } }, generation.signal), - }) - await vi.waitFor(() => { expect(results).toHaveLength(0) }) - nextPending.resolve({ - done: false, - value: remoteItem(1, { type: 'opened', cursor: 1 }, generation.signal), + value: remoteItem(1, opened(2, page('duplicate', [0, 1, 2])), generation.signal), }) } else { - pending.resolve(final) + replacement.resolve(final) } - await expect(opening).rejects.toThrow(message) + await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() }) + const failure: unknown = fixture.failed.mock.calls[0]?.[0] + expect(failure).toBeInstanceOf(Error) + if (!(failure instanceof Error)) throw new Error('journal failure was not an Error') + expect(failure.message).toContain(message) + await fixture.journal.dispose() + }) + + it('discards old-generation entries while waiting for the replacement opening', async () => { + const generation = new AbortController() + const gap = Promise.withResolvers>>() + const stale = Promise.withResolvers>>() + const replacement = Promise.withResolvers>>() + const results = [ + Promise.resolve>>({ + done: false, + value: remoteItem(1, opened(0, page('initial', [0])), generation.signal), + }), + gap.promise, + stale.promise, + replacement.promise, + ] + const fixture = journalFixture( + [], + [signal => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('page aborted')) }, { once: true }) + })], + controlledFactory(() => results.shift() ?? Promise.resolve({ done: true, value: undefined })), + ) + + await fixture.journal.open({}) + gap.resolve({ + done: false, + value: remoteItem(1, { type: 'entry', entry: { seq: 2 } }, generation.signal), + }) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([2]) }) + generation.abort() + stale.resolve({ + done: false, + value: remoteItem(1, { type: 'entry', entry: { seq: 1 } }, generation.signal), + }) + replacement.resolve({ + done: false, + value: remoteItem(2, opened(2, page('replacement', [0, 1, 2])), new AbortController().signal), + }) + + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) }) + expect(fixture.changes.at(-1)).toMatchObject({ page: { marker: 'replacement' } }) + await fixture.journal.dispose() + }) + + it.each([ + { + name: 'rejects', + settle: ( + _resolve: (value: IteratorResult>) => void, + reject: (reason?: unknown) => void, + ) => { reject(new Error('replacement follow failed')) }, + message: 'replacement follow failed', + }, + { + name: 'ends', + settle: (resolve: (value: IteratorResult>) => void) => { + resolve({ done: true, value: undefined }) + }, + message: 'ended while reading its replacement page', + }, + { + name: 'opens twice', + settle: (resolve: (value: IteratorResult>) => void) => { + resolve({ + done: false, + value: remoteItem(1, opened(2, page('duplicate', [0, 1, 2])), new AbortController().signal), + }) + }, + message: 'more than one opening cursor', + }, + ])('reports when a follow $name during live-gap repair', async ({ settle, message }) => { + const generation = new AbortController() + const gap = Promise.withResolvers>>() + const next = Promise.withResolvers>>() + const results = [ + Promise.resolve>>({ + done: false, + value: remoteItem(1, opened(0, page('initial', [0])), generation.signal), + }), + gap.promise, + next.promise, + ] + const fixture = journalFixture( + [], + [() => new Promise(() => {})], + controlledFactory(() => results.shift() ?? Promise.resolve({ done: true, value: undefined })), + ) + + await fixture.journal.open({}) + gap.resolve({ + done: false, + value: remoteItem(1, { type: 'entry', entry: { seq: 2 } }, generation.signal), + }) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([2]) }) + settle(next.resolve, next.reject) + + await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() }) + const failure: unknown = fixture.failed.mock.calls[0]?.[0] + expect(failure).toBeInstanceOf(Error) + if (!(failure instanceof Error)) throw new Error('journal failure was not an Error') + expect(failure.message).toContain(message) + await fixture.journal.dispose() }) it('rejects malformed opening and page sequences', async () => { const beforeOpening = journalFixture( [{ frames: [{ type: 'entry', entry: { seq: 0 } }] }], - [page('unused', [])], + [], ) await expect(beforeOpening.journal.open({})).rejects.toThrow('entry before its opening cursor') const discontinuousPage = journalFixture( - [{ frames: [{ type: 'opened', cursor: 3 }], hold: true }], - [page('bad', [0, 2, 3])], + [{ frames: [opened(3, page('bad', [0, 2, 3]))], hold: true }], + [], ) await expect(discontinuousPage.journal.open({})).rejects.toThrow('page contains discontinuous entries') const shortPage = journalFixture( - [{ frames: [{ type: 'opened', cursor: 3 }], hold: true }], - [page('short', [0, 1])], + [{ frames: [opened(3, page('short', [0, 1]))], hold: true }], + [], ) await expect(shortPage.journal.open({})).rejects.toThrow('page did not end at its requested cursor') const longPage = journalFixture( - [{ frames: [{ type: 'opened', cursor: 1 }], hold: true }], - [page('long', [0, 1, 2])], + [{ frames: [opened(1, page('long', [0, 1, 2]))], hold: true }], + [], ) await expect(longPage.journal.open({})).rejects.toThrow('page did not end at its requested cursor') }) @@ -726,9 +854,12 @@ describe('RemoteJournalStream', () => { it('reports duplicate and regressed generation cursors as terminal failures', async () => { const duplicate = journalFixture( [{ - frames: [{ type: 'opened', cursor: 1 }, { type: 'opened', cursor: 1 }], + frames: [ + opened(1, page('initial', [0, 1])), + opened(1, page('duplicate', [0, 1])), + ], }], - [page('initial', [0, 1])], + [], ) await duplicate.journal.open({}) await vi.waitFor(() => { expect(duplicate.failed).toHaveBeenCalledOnce() }) @@ -740,12 +871,12 @@ describe('RemoteJournalStream', () => { const regressed = journalFixture( [ { - frames: [{ type: 'opened', cursor: 1 }, { type: 'entry', entry: { seq: 2 } }], + frames: [opened(1, page('initial', [0, 1])), { type: 'entry', entry: { seq: 2 } }], terminal: new RemoteStreamCarrierError('lost'), }, - { frames: [{ type: 'opened', cursor: 1 }] }, + { frames: [opened(1, page('regressed', [0, 1]))] }, ], - [page('initial', [0, 1])], + [], ) await regressed.journal.open({}) await vi.waitFor(() => { expect(regressed.failed).toHaveBeenCalledOnce() }) @@ -757,8 +888,8 @@ describe('RemoteJournalStream', () => { it('rejects a discontinuous older page after publishing the fail-soft pagination state', async () => { const fixture = journalFixture( - [{ frames: [{ type: 'opened', cursor: 4 }], hold: true }], - [page('initial', [3, 4], true), page('older', [0, 1], true)], + [{ frames: [opened(4, page('initial', [3, 4], true))], hold: true }], + [page('older', [0, 1], true)], ) await fixture.journal.open({}) @@ -771,8 +902,8 @@ describe('RemoteJournalStream', () => { it('guards lifecycle operations before and after open', async () => { const fixture = journalFixture( - [{ frames: [{ type: 'opened', cursor: -1 }], hold: true }], - [page('empty', [])], + [{ frames: [opened(-1, page('empty', []))], hold: true }], + [], ) await expect(fixture.journal.prepend({})).rejects.toThrow('is not open') diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index 7304c21bb8..dff2317359 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -29,7 +29,11 @@ export interface SessionSnapshot { readonly sessionId: SessionId readonly queue: readonly QueuedMessage[] readonly running: boolean - readonly subagent: { readonly address: SubagentAddress; readonly parentAvailable: boolean } | null + readonly subagent: { + readonly address: SubagentAddress + /** Absent until the direct-parent catalog resolves. */ + readonly parentAvailable?: boolean + } | null readonly removed: boolean readonly openState: OpenState readonly openError: ClientFailure | null diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index d51d475474..7239e3149a 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -45,7 +45,7 @@ export const PAGE_MESSAGES = 50 export interface SessionOptions { /** Catalog-discovered address selecting non-activating subagent transport. */ address?: SubagentAddress - /** Whether the exact direct parent Agent was live at the latest catalog read. */ + /** Whether the exact direct parent Agent was live at the latest catalog read; absent before that read. */ parentAvailable?: boolean /** * First ACCEPTED prompt on a blank session (fires at most once, on the @@ -86,7 +86,7 @@ export class Session implements SessionFace { private readonly queueMirror = new SessionQueueMirror() private running = false private address: SubagentAddress | undefined - private parentAvailable = false + private parentAvailable: boolean | undefined /** * Sticky send marker, private input of the composerPhase derivation: set * synchronously before prompt()'s first await, never reset — the blank → @@ -144,7 +144,7 @@ export class Session implements SessionFace { ) { this.projections = options.projections ?? new ProjectionValueStore() this.address = options.address - this.parentAvailable = options.parentAvailable ?? false + this.parentAvailable = options.parentAvailable this.notifier = new Notifier(() => { this.snapshotCache = this.buildSnapshot() }) @@ -467,9 +467,9 @@ export class Session implements SessionFace { * Install or clear the catalog-discovered transport address. A changed * address rebuilds an already-open window through its new history route. * @param address - direct parent/child address, or undefined for ordinary transport. - * @param parentAvailable - latest exact-parent availability hint. + * @param parentAvailable - latest exact-parent availability hint, or undefined before a catalog read. */ - configureSubagent(address: SubagentAddress | undefined, parentAvailable = false): void { + configureSubagent(address: SubagentAddress | undefined, parentAvailable?: boolean): void { const same = this.address?.parentSessionId === address?.parentSessionId && this.address?.childSessionId === address?.childSessionId && this.address?.mode === address?.mode @@ -623,7 +623,10 @@ export class Session implements SessionFace { running: this.running, subagent: this.address === undefined ? null - : { address: this.address, parentAvailable: this.parentAvailable }, + : { + address: this.address, + ...(this.parentAvailable === undefined ? {} : { parentAvailable: this.parentAvailable }), + }, removed: this.removed, openState: this.openState, openError: this.openError, diff --git a/packages/api/session-controller/src/client/transport.ts b/packages/api/session-controller/src/client/transport.ts index 2b7452056c..0e8ca83d28 100644 --- a/packages/api/session-controller/src/client/transport.ts +++ b/packages/api/session-controller/src/client/transport.ts @@ -17,6 +17,7 @@ import type { SessionEventEntry, SessionPage, SessionPageRequest, + SessionProjectionBaseline, } from '../types.ts' export { @@ -30,8 +31,13 @@ export type ClientSessionPageRequest = Omit +export type SessionJournalChange = RemoteJournalChange type SessionControlBaselineFrame = Extract type SessionControlDeltaFrame = Exclude @@ -93,7 +99,7 @@ export function createSessionControlStream( /** Gateway-owned event journal bound to one ordinary or direct-subagent Session address. */ export class SessionEventStream extends RemoteJournalStream< - SessionPage, + SessionJournalPage, SessionEventEntry, number, ClientSessionPageRequest @@ -126,15 +132,23 @@ export class SessionEventStream extends RemoteJournalStream< /** @inheritdoc */ protected override async * follow( - afterSeq: number | undefined, + request: ClientSessionPageRequest, signal: AbortSignal, - ): AsyncIterable> { - const request = afterSeq === undefined - ? { address: this.address } - : { address: this.address, afterSeq } - for await (const frame of this.remote.session.follow(request, signal)) { - if (frame.type === 'opened') { - yield frame + ): AsyncIterable> { + for await (const frame of this.remote.session.follow({ + address: this.address, + ...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }), + }, signal)) { + if (frame.type === 'snapshot') { + yield { + type: 'opened', + cursor: frame.cursor, + page: { + events: frame.events, + hasMore: frame.hasMore, + projections: frame.projections, + }, + } continue } const { type: _type, ...entry } = frame @@ -147,7 +161,7 @@ export class SessionEventStream extends RemoteJournalStream< request: ClientSessionPageRequest, throughSeq: number, signal: AbortSignal, - ): Promise { + ): Promise { const result = await this.remote.session.page( { address: this.address, throughSeq, ...request }, signal, diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index e30b4f1139..a1fb6b541b 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -2,9 +2,9 @@ import type { Context } from '@deepseek-ai/cordis' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' -import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' +import type {} from '@deepseek-ai/dsh-subagent' import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import type { SessionAddress, @@ -13,7 +13,7 @@ import type { SessionFollowFrame, SessionPage, SessionPageRequest, - SessionProjectionsBlock, + SessionProjectionBaseline, SessionProjectionValues, SessionWireEvent, } from './types.ts' @@ -21,16 +21,18 @@ import type { const DEFAULT_MAX_MESSAGES = 50 const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) -type SessionSource = - | { readonly kind: 'attached'; readonly session: Session } - | { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: readonly SessionEvent[] } - /** Implements cold-safe history operations delegated by the Session Controller. */ export class SessionHistoryController { private readonly closeFollowers = new Set<() => void>() - /** @param ctx - Host context carrying Session, persistence, and projection services. */ - constructor(private readonly ctx: Context) { + /** + * @param ctx - Host context carrying Session query and projection services. + * @param promote - starts ordinary Session activation after snapshot delivery. + */ + constructor( + private readonly ctx: Context, + private readonly promote: (observation: SessionObservation) => void, + ) { ctx.effect(() => () => { for (const close of this.closeFollowers) close() this.closeFollowers.clear() @@ -41,13 +43,13 @@ export class SessionHistoryController { * Read one message-aligned history page without activating an Agent. * @param request - durable address and backwards-page cursor. * @param signal - caller cancellation for persistence reads. - * @returns a contiguous event page and a projection baseline on tail reads. + * @returns a contiguous event page. */ async page(request: SessionPageRequest, signal: AbortSignal): Promise { validatePageRequest(request) - const source = await this.sourceFor(request.address, signal) + using source = await this.sourceFor(request.address, signal, false) signal.throwIfAborted() - const sourceLog = sourceEvents(source) + const sourceLog = source.events const sourceCursor = sourceLog.at(-1)?.seq ?? -1 if (request.throughSeq > sourceCursor) { reject( @@ -56,19 +58,20 @@ export class SessionHistoryController { {}, ) } - const events = sourceLog.filter(event => event.seq <= request.throughSeq) - if ((events.at(-1)?.seq ?? -1) !== request.throughSeq) { + /* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */ + if (request.throughSeq >= 0 && sourceLog[request.throughSeq]?.seq !== request.throughSeq) { reject('internal', `session log does not contain through seq ${String(request.throughSeq)}`, {}) } - const page = paginate(events, request.beforeSeq, request.maxMessages ?? DEFAULT_MAX_MESSAGES) + const page = paginate( + sourceLog, + request.beforeSeq, + request.maxMessages ?? DEFAULT_MAX_MESSAGES, + request.throughSeq, + ) const entries = page.events.map(entryFor) - const projections = request.beforeSeq === undefined - ? this.projectionsFor(request.address, source, events) - : undefined return { events: entries, hasMore: page.hasMore, - ...(projections === undefined ? {} : { projections }), } } @@ -76,13 +79,14 @@ export class SessionHistoryController { * Follow events appended after an initial cursor on one durable address. * @param request - durable address and last committed sequence already held by the caller. * @param signal - stream cancellation owned by the Remote carrier. - * @returns an opened cursor followed by gap-free event frames. + * @returns a complete opening snapshot followed by gap-free event frames. */ async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { validateFollowRequest(request) - const { address, afterSeq } = request + const { address } = request const target = addressId(address) const buffered: SessionEvent[] = [] + let snapshotCursor: number | undefined let wake: (() => void) | undefined const notify = (): void => { const resume = wake @@ -102,35 +106,44 @@ export class SessionHistoryController { }, { global: true }) const disposeCreated = this.ctx.on('session/created', (session) => { if (session.id !== target) return - // Session construction appends session/end-seed before attachment, so the - // marker has no session/event notification. Earlier session/created listeners - // may publish later setup events first; this suffix must precede those notifications. - const suffix = session.events.slice(session.firstLiveSeq) + // Constructor seed events have no session/event notification. Normally + // only the end-seed suffix is new; if persistence advanced after the + // opening observation, replay everything beyond that snapshot cursor. + const suffix = session.events.slice(snapshotCursor === undefined + ? session.firstLiveSeq + : snapshotCursor + 1) buffered.unshift(...suffix) notify() }, { global: true }) const onAbort = (): void => { notify() } signal.addEventListener('abort', onAbort, { once: true }) try { - const source = await this.sourceFor(address, signal) - const events = [...sourceEvents(source)] + using source = await this.sourceFor(address, signal, true) + const events = source.events signal.throwIfAborted() - const cursor = events.at(-1)?.seq ?? -1 - if (afterSeq !== undefined && afterSeq > cursor) { - reject('bad-request', `session event resume seq ${String(afterSeq)} is past cursor ${String(cursor)}`, {}) + const cursor = source.cursor + snapshotCursor = cursor + const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES) + yield { + type: 'snapshot', + header: source.header, + cursor, + events: page.events.map(entryFor), + hasMore: page.hasMore, + projections: source.projections === undefined + ? { asOfSeq: cursor, values: {} } + : projectionBlock(source.projections), } - let nextSeq = (afterSeq ?? cursor) + 1 - yield { type: 'opened', cursor } - if (afterSeq !== undefined) { - for (const event of events) { - if (event.seq < nextSeq) continue - if (event.seq !== nextSeq) { - reject('internal', `session event replay skipped seq ${String(nextSeq)}`, {}) - } - nextSeq++ - yield { type: 'event', ...entryFor(event) } + if (address.kind === 'session' && source.source === 'prepared') { + const promotion = source.retain() + try { + this.promote(promotion) + } catch (error: unknown) { + promotion[Symbol.dispose]() + throw error } } + let nextSeq = cursor + 1 while (!follower.closed && !signal.aborted) { const item = buffered.shift() if (item === undefined) { @@ -152,49 +165,44 @@ export class SessionHistoryController { } } - private async sourceFor(address: SessionAddress, signal: AbortSignal): Promise { + private async sourceFor( + address: SessionAddress, + signal: AbortSignal, + withProjections: boolean, + ): Promise { const sessionId = addressId(address) - const attached = this.ctx.sessions.get(sessionId) - if (attached !== undefined) { - validateAddress(address, attached.header, attached.events) - return { kind: 'attached', session: attached } + try { + const observation = await this.ctx.sessionQuery.observeSession(sessionId, { + signal, + projectionMode: withProjections || address.kind === 'subagent' ? 'all' : 'none', + }) + if (observation.header.cwd === undefined) { + observation[Symbol.dispose]() + rejectNotFound(address) + } + try { + validateAddress(address, observation.header, observation.projections) + } catch (error: unknown) { + observation[Symbol.dispose]() + throw error + } + return observation + } catch (error: unknown) { + if (error instanceof SessionQueryError + && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') rejectNotFound(address) + throw error } - const persistence = this.ctx.get('sessionPersistence') - if (persistence === undefined) { - reject('internal', 'session persistence is not configured', {}) - } - signal.throwIfAborted() - const header = (await persistence.list(signal)).find(candidate => candidate.id === sessionId) - if (header === undefined || header.cwd === undefined) rejectNotFound(address) - const inspected: SessionInspection = await persistence.inspect(sessionId, signal) - signal.throwIfAborted() - if (inspected.meta.cwd === undefined) rejectNotFound(address) - validateAddress(address, inspected.meta, inspected.events) - return { kind: 'detached', header: inspected.meta, events: inspected.events } } - private projectionsFor( - address: SessionAddress, - source: SessionSource, - events: readonly SessionEvent[], - ): SessionProjectionsBlock | undefined { - const registry = this.ctx.get('sessionProjections') - if (registry === undefined) return undefined - try { - const throughSeq = events.at(-1)?.seq ?? -1 - const snapshot = source.kind === 'attached' && source.session.seq - 1 === throughSeq - ? registry.snapshot(source.session) - : registry.restore({}, events, 0).snapshot - return { - asOfSeq: snapshot.asOfSeq, - // Projection definitions validate whole JSON values before snapshot publication. - values: snapshot.values as SessionProjectionValues, - } - } catch (error) { - if (address.kind === 'session') throw error - this.ctx.logger.warn(`session.page: projections for "${address.childSessionId}" failed: ${String(error)}`) - return undefined - } +} + +function projectionBlock( + snapshot: NonNullable, +): SessionProjectionBaseline { + return { + asOfSeq: snapshot.asOfSeq, + // Projection definitions validate whole JSON values before snapshot publication. + values: snapshot.values as SessionProjectionValues, } } @@ -213,9 +221,9 @@ function validatePageRequest(request: SessionPageRequest): void { } function validateFollowRequest(request: SessionFollowRequest): void { - if (request.afterSeq !== undefined - && (!Number.isSafeInteger(request.afterSeq) || request.afterSeq < -1)) { - reject('bad-request', 'afterSeq must be an integer greater than or equal to -1', {}) + if (request.maxMessages !== undefined + && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) { + reject('bad-request', 'maxMessages must be a positive safe integer', {}) } } @@ -226,7 +234,7 @@ function addressId(address: SessionAddress): SessionId { function validateAddress( address: SessionAddress, header: SessionHeader, - events: readonly SessionEvent[], + projections: SessionObservation['projections'], ): void { if (address.kind === 'session') { if (header.origin === 'subagent') { @@ -241,24 +249,22 @@ function validateAddress( childSessionId: address.childSessionId, }) } - let descriptor - try { - descriptor = foldSubagentDescriptor(events.slice(header.seedLength ?? 0)) - } catch { + const identity = projections?.values.subagent + if (identity === null) { reject('subagent-catalog-diagnostic', 'subagent descriptor is corrupt', { parentSessionId: address.parentSessionId, childSessionId: address.childSessionId, reason: 'corrupt', }) } - if (descriptor === undefined) { + if (identity === undefined || identity.seq < (header.seedLength ?? 0)) { reject('subagent-catalog-diagnostic', 'subagent descriptor is unavailable', { parentSessionId: address.parentSessionId, childSessionId: address.childSessionId, reason: 'unsupported', }) } - if (descriptor.mode !== address.mode) { + if (identity.mode !== address.mode) { reject('subagent-unauthorized', 'subagent mode does not match the supplied address', { childSessionId: address.childSessionId, }) @@ -279,20 +285,17 @@ function reject(code: string, message: string, details: object): never { throw new TypertRemoteFailure({ code, message, details }) } -function sourceEvents(source: SessionSource): readonly SessionEvent[] { - return source.kind === 'attached' ? source.session.events : source.events -} - function paginate( events: readonly SessionEvent[], beforeSeq: number | undefined, maxMessages: number, + throughSeq = events.at(-1)?.seq ?? -1, ): { readonly events: SessionEvent[]; readonly hasMore: boolean } { - const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq) + const end = Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1) let count = 0 let cut = 0 - for (let index = window.length - 1; index >= 0; index--) { - const event = window[index] as SessionEvent + for (let index = end - 1; index >= 0; index--) { + const event = events[index] as SessionEvent if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue count++ const sources = (event as { readonly sourceEventSeqs?: readonly number[] }).sourceEventSeqs @@ -305,7 +308,7 @@ function paginate( break } } - return { events: window.filter(event => event.seq >= cut), hasMore: cut > 0 } + return { events: events.slice(cut, end), hasMore: cut > 0 } } function entryFor(event: SessionEvent): SessionEventEntry { diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index adcf8f6c06..33e6d0ef46 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -6,7 +6,7 @@ import type { import type { Branded } from '@deepseek-ai/dsh-brand' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { JsonValue, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/types' +import type { JsonValue, SessionHeader, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/types' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' @@ -17,12 +17,26 @@ declare module '@deepseek-ai/dsh-session-projection/types' { sessionListMetadata: SessionListMetadata /** Host state for the boot-constant image-limit view. */ imageLimits: null + /** Durable model selection already used by a request and still pending for a later request. */ + modelSelection: ModelSelectionProjectionState } interface SessionProjectionMap { /** Persisted facts used to summarize a Session without activating it. */ sessionListMetadata: SessionListMetadata /** Image-intake limits enforced by the Session prompt endpoint. */ imageLimits: ImageAttachmentLimits + /** Durable model selection already used and selected for the next request. */ + modelSelection: ModelSelectionProjection + } +} + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * Complete validated model selection requested for subsequent prompt + * assembly. Log-only: it never enters derived model history. + */ + 'model/selection': ModelSelection } } @@ -34,10 +48,17 @@ export interface SessionListMetadata { readonly lastPromptAt: number | null } -/** Projection values and the durable event position they represent. */ -export interface SessionProjectionsBlock { +/** Every available cached wire value used as partial, possibly stale Session-list hints. */ +export interface SessionProjectionHints { readonly asOfSeq: number - /** Provider-validated values across the merge-extensible projection key space. */ + /** Provider-validated values present in the cache; omitted keys remain unknown. */ + readonly values: SessionProjectionValues +} + +/** Complete projection values at an exact Session event cursor. */ +export interface SessionProjectionBaseline { + readonly asOfSeq: number + /** Provider-validated values; omitted keys are absent capabilities at this cut. */ readonly values: SessionProjectionValues } @@ -62,6 +83,22 @@ export interface ModelSelection { readonly reasoningEffort?: string } +/** Host fold state for durable model selection. */ +export interface ModelSelectionProjectionState { + /** Selection consumed by the latest recorded model request. */ + readonly lastUsed: ModelSelection | null + /** Later user selection not yet consumed by a matching model request. */ + readonly pending: ModelSelection | null +} + +/** Client view of the durable model-selection fold. */ +export interface ModelSelectionProjection { + /** Selection consumed by the latest recorded model request. */ + readonly lastUsed: ModelSelection | null + /** Selection the next request should use, falling back to {@link lastUsed}. */ + readonly next: ModelSelection | null +} + /** One adapter-owned reasoning effort for an exact model route. */ export interface ModelReasoningEffort { readonly id: string @@ -97,10 +134,11 @@ export interface ModelCatalogFailure { readonly message: string } -/** Detached model-directory snapshot for one Session. */ -export interface SessionModels { - readonly current: ModelSelection - readonly routable: boolean +/** Host-generation model catalog and the default used by unconfigured Sessions. */ +export interface ModelCatalog { + readonly default: ModelSelection + /** Provider routes currently able to serve a request, including empty catalogs. */ + readonly routableProviders: readonly string[] readonly groups: readonly ModelProviderGroup[] readonly failures: readonly ModelCatalogFailure[] } @@ -120,8 +158,7 @@ export interface SessionSummary { readonly parentSessionId?: SessionId readonly origin?: 'subagent' readonly cwd?: string - readonly agentPreset?: string - readonly projections?: SessionProjectionsBlock + readonly projections?: SessionProjectionHints } /** One session-content search result. */ @@ -220,11 +257,6 @@ export interface SessionCreateValue { readonly agentPreset?: string } -/** Model-directory request. */ -export interface SessionModelsRequest { - readonly sessionId: SessionId -} - /** Session model-selection request. */ export interface SessionSelectModelRequest extends ModelSelection { readonly sessionId: SessionId @@ -352,22 +384,28 @@ export interface SessionPageRequest { readonly maxMessages?: number } -/** One live event request, optionally resuming after an already-applied event. */ +/** One live event request for a durable Session address. */ export interface SessionFollowRequest { readonly address: SessionAddress - readonly afterSeq?: number + readonly maxMessages?: number } /** One contiguous backwards page of a Session log. */ export interface SessionPage { readonly events: readonly SessionEventEntry[] readonly hasMore: boolean - readonly projections?: SessionProjectionsBlock } -/** Initial cursor followed by ordered events appended after that cursor. */ +/** Complete opening window followed by ordered events appended after its cursor. */ export type SessionFollowFrame = - | { readonly type: 'opened'; readonly cursor: number } + | { + readonly type: 'snapshot' + readonly header: SessionHeader + readonly cursor: number + readonly events: readonly SessionEventEntry[] + readonly hasMore: boolean + readonly projections: SessionProjectionBaseline + } | ({ readonly type: 'event' } & SessionEventEntry) /** One pending inbox occurrence in the authoritative queue snapshot. */ @@ -396,7 +434,7 @@ export interface SessionJob { export interface SessionControlBaseline { readonly queues: Readonly> readonly jobs: Readonly> - readonly projections: Readonly> + readonly projections: Readonly> } /** One finished projection value and its durable watermark. */ diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts index b4c442d9f2..56b2fe5948 100644 --- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts +++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts @@ -8,7 +8,7 @@ import { CallId, createMessage, createToolResultMessage, createUserMessage } fro import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types' -import { createSessionTestRemote } from './test-remote.ts' +import { createSessionTestRemote, installSessionReadTestServices } from './test-remote.ts' /** Append a production-shaped human prompt to the session surface. */ function appendUserText(session: Session, text: string): SessionEvent { @@ -43,6 +43,7 @@ async function harness(): Promise<{ ctx: Context }> { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + installSessionReadTestServices(ctx) return { ctx } } @@ -71,7 +72,7 @@ async function openFollow( }, signal)[Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ done: false, - value: { type: 'opened' }, + value: { type: 'snapshot' }, }) return { [Symbol.asyncIterator]: () => iterator } } @@ -79,8 +80,8 @@ async function openFollow( describe('Session history raw journal', () => { it('follows raw tool events and preserves result metadata without a Tools service', async () => { const { ctx } = await harness() - const session = ctx.sessions.create() - const history = new SessionHistoryController(ctx) + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) + const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) const abort = new AbortController() const stream = await openFollow(history, session.id, abort.signal) const collected = collect(stream, 2, abort) @@ -108,8 +109,8 @@ describe('Session history raw journal', () => { it('follows live results without rescanning Session history', async () => { const { ctx } = await harness() - const session = ctx.sessions.create() - const history = new SessionHistoryController(ctx) + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) + const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) const abort = new AbortController() const stream = await openFollow(history, session.id, abort.signal) const iterator = stream[Symbol.asyncIterator]() @@ -147,7 +148,7 @@ describe('Session history raw journal', () => { it('serves raw call and result entries without parsing tool arguments', async () => { const { ctx } = await harness() const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - const session = ctx.sessions.create() + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const start = session.append('turn/start', { turn: 1 }) const call = session.append('tool/call', { turn: 1, step: 1, callId: CallId('history-call'), name: 'custom', arguments: '{broken', @@ -178,7 +179,7 @@ describe('Session history raw journal', () => { it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => { const { ctx } = await harness() const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - const session = ctx.sessions.create() + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) const first = appendUserText(session, 'first prompt') appendAssistantText(session, 'first reply', 1) @@ -227,7 +228,7 @@ describe('Session history raw journal', () => { it('paginates a message with many provenance sources without variadic argument expansion', async () => { const { ctx } = await harness() const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - const session = ctx.sessions.create() + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) const sources = Array.from({ length: 128 }, (_unused, index) => session.append('assistant/chunk', { turn: 1, @@ -265,8 +266,8 @@ describe('Session history raw journal', () => { it('follows a result after turn/end without reading the addressed Session log', async () => { const { ctx } = await harness() - const session = ctx.sessions.create() - const history = new SessionHistoryController(ctx) + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) + const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) const abort = new AbortController() const stream = await openFollow(history, session.id, abort.signal) const iterator = stream[Symbol.asyncIterator]() diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index aa8f26de24..79fac7b7b8 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -69,11 +69,12 @@ describe('Session open', () => { expect(session.eventSource.getSnapshot().change).toMatchObject({ kind: 'replace' }) }) - it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => { + it('is idempotent: concurrent opens share one follow, reopening when open is a no-op', async () => { const { api, session } = makeSession() await Promise.all([session.open(), session.open()]) await session.open() - expect(api.callsOf('session.history')).toHaveLength(1) + expect(api.callsOf('session.follow')).toHaveLength(1) + expect(api.callsOf('session.history')).toEqual([]) }) it('lands an error result in openState=error with the RpcError kept', async () => { @@ -98,7 +99,7 @@ describe('Session open', () => { const gate = deferred>>() api.onHistory = () => gate.promise const opening = session.open() - // Three live frames land mid-open; seq 15 overlaps the page tail (page covers 10..15). + // Three live frames land while the opening snapshot is pending; seq 15 overlaps its tail. const page = plainTurn(10, 0, '早', '安') const deliveries = [ follow(api, ev.turnStart(15, 1)), @@ -151,7 +152,7 @@ describe('live event path', () => { // seq 9 with tail 5 → gap; the event detours to the buffer and one history refetch fires. await follow(api, ev.assistant(9, 1, 'd')) await vi.waitFor(() => { - expect(api.callsOf('session.history').length).toBe(2) + expect(api.callsOf('session.history')).toHaveLength(1) }) await vi.waitFor(() => { expect(eventSeqs(session)).toEqual( @@ -172,8 +173,8 @@ describe('paging', () => { await session.open() await session.loadOlder() const snapshot = session.getSnapshot() + expect(api.callsOf('session.follow')).toHaveLength(1) expect(api.callsOf('session.history')).toMatchObject([ - { sessionId: SID, throughSeq: 11 }, { sessionId: SID, throughSeq: 11, beforeSeq: 6 }, ]) expect(snapshot.hasMore).toBe(false) @@ -231,7 +232,8 @@ describe('paging', () => { modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, })) await Promise.all([first, second]) - expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two + expect(api.callsOf('session.follow')).toHaveLength(1) + expect(api.callsOf('session.history')).toHaveLength(1) }) }) @@ -248,9 +250,15 @@ describe('prompt and cancel errors', () => { expect(prompted).toEqual({ ok: true, value: { accepted: true } }) expect(cancelled).toEqual({ ok: true, value: { accepted: true } }) - expect(api.callsOf('subagent.history')).toEqual([ - { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', throughSeq: -1, maxMessages: 50 }, + expect(api.callsOf('session.follow')).toEqual([ + { + address: { + kind: 'subagent', parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', + }, + maxMessages: 50, + }, ]) + expect(api.callsOf('subagent.history')).toEqual([]) expect(api.callsOf('subagent.prompt')).toEqual([ { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', @@ -300,9 +308,15 @@ describe('prompt and cancel errors', () => { expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } }) expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } }) - expect(api.callsOf('subagent.history')).toEqual([ - { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', throughSeq: -1, maxMessages: 50 }, + expect(api.callsOf('session.follow')).toEqual([ + { + address: { + kind: 'subagent', parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', + }, + maxMessages: 50, + }, ]) + expect(api.callsOf('subagent.history')).toEqual([]) expect(api.callsOf('subagent.prompt')).toEqual([]) expect(api.callsOf('subagent.interrupt')).toEqual([]) expect(api.callsOf('session.cancel')).toEqual([]) @@ -572,7 +586,7 @@ describe('remaining branches', () => { const repairPull = deferred>>() api.onHistory = () => repairPull.promise const delivery = follow(api, ev.user(9, '洞')) - await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(2) }) + await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(1) }) api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd')) const resynced = session.resync() // bumps the generation repairPull.resolve(ok({ @@ -626,7 +640,7 @@ describe('remaining branches', () => { }) describe('resync', () => { - it('keeps the old feed until one sorted page-and-live replacement is ready', async () => { + it('keeps the old feed until the reconnect snapshot, then repairs queued live gaps', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, '旧', '窗')) await session.open() @@ -640,11 +654,16 @@ describe('resync', () => { }) const syncing = session.resync() - await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(2) }) + await vi.waitFor(() => { expect(api.callsOf('session.follow')).toHaveLength(2) }) expect(session.eventSource.getSnapshot()).toBe(oldWindow) expect(publications).toEqual([]) - await Promise.all([ + api.onHistory = () => histResponse([ + ...plainTurn(10, 2, '终', '页'), + ev.user(16, '后到低位'), + ev.user(17, '后到高位'), + ]) + const liveDeliveries = Promise.all([ follow(api, ev.user(17, '后到高位')), follow(api, ev.user(16, '后到低位')), ]) @@ -654,12 +673,15 @@ describe('resync', () => { hasMore: false, modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, })) - await syncing + await Promise.all([syncing, liveDeliveries]) + await vi.waitFor(() => { + expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15, 16, 17]) + }) - expect(publications).toHaveLength(1) - expect(publications[0]?.entries).not.toHaveLength(0) - expect(publications[0]?.change.kind).toBe('replace') - expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15, 16, 17]) + expect(publications).toHaveLength(2) + expect(publications.map(snapshot => snapshot.change.kind)).toEqual(['replace', 'replace']) + expect(publications[0]?.entries.map(entry => entry.event.seq)).toEqual([10, 11, 12, 13, 14, 15]) + expect(publications[1]?.entries.map(entry => entry.event.seq)).toEqual([10, 11, 12, 13, 14, 15, 16, 17]) off() }) diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 46c2064c9e..3960e05285 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -43,6 +43,25 @@ function page(events: readonly SessionEventEntry[], hasMore = false): SessionPag return { events, hasMore } } +function snapshot( + cursor: number, + events: readonly SessionEventEntry[], + hasMore = false, +): SessionFollowFrame { + return { + type: 'snapshot', + header: { + version: 0, + id: ADDRESS.kind === 'session' ? ADDRESS.sessionId : ADDRESS.childSessionId, + createdAt: 0, + }, + cursor, + events, + hasMore, + projections: { asOfSeq: cursor, values: {} }, + } +} + function sessionClient(remote: SessionTransportRemote) { return { session: remote as SessionRemote, @@ -108,14 +127,13 @@ describe('Session Client stream adapters', () => { const remote = new ScriptedSessionRemote( [{ frames: [ - { type: 'opened', cursor: 3 }, + snapshot(3, [entry(2), entry(3)], true), { type: 'event', ...entry(3) }, { type: 'event', ...entry(4) }, ], hold: true, }], [ - { ok: true, value: page([entry(2), entry(3)], true) }, { ok: true, value: page([entry(0), entry(1)], false) }, ], ) @@ -129,9 +147,8 @@ describe('Session Client stream adapters', () => { await vi.waitFor(() => { expect(changes).toHaveLength(2) }) await stream.prepend({ beforeSeq: 2, maxMessages: 50 }) - expect(remote.followRequests).toEqual([{ address: ADDRESS }]) + expect(remote.followRequests).toEqual([{ address: ADDRESS, maxMessages: 50 }]) expect(remote.pageRequests).toEqual([ - { address: ADDRESS, throughSeq: 3, maxMessages: 50 }, { address: ADDRESS, throughSeq: 4, beforeSeq: 2, maxMessages: 50 }, ]) expect(changes).toMatchObject([ @@ -143,20 +160,17 @@ describe('Session Client stream adapters', () => { expect(remote.signals[0]?.aborted).toBe(true) }) - it('resumes after the applied cursor and repairs through the addressed tail page', async () => { + it('replaces the retained window from each reconnect snapshot', async () => { const lost = new RemoteStreamCarrierError('lost') const remote = new ScriptedSessionRemote( [ { - frames: [{ type: 'opened', cursor: 1 }, { type: 'event', ...entry(2) }], + frames: [snapshot(1, [entry(0), entry(1)]), { type: 'event', ...entry(2) }], terminal: lost, }, - { frames: [{ type: 'opened', cursor: 4 }], hold: true }, - ], - [ - { ok: true, value: page([entry(0), entry(1)]) }, - { ok: true, value: page([entry(0), entry(1), entry(2), entry(3), entry(4)]) }, + { frames: [snapshot(4, [entry(0), entry(1), entry(2), entry(3), entry(4)])], hold: true }, ], + [], ) const changes: SessionJournalChange[] = [] const carrierFailed = vi.fn() @@ -170,13 +184,10 @@ describe('Session Client stream adapters', () => { await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) }) expect(remote.followRequests).toEqual([ - { address: ADDRESS }, - { address: ADDRESS, afterSeq: 2 }, - ]) - expect(remote.pageRequests).toEqual([ - { address: ADDRESS, throughSeq: 1, maxMessages: 50 }, - { address: ADDRESS, throughSeq: 4, maxMessages: 50 }, + { address: ADDRESS, maxMessages: 50 }, + { address: ADDRESS, maxMessages: 50 }, ]) + expect(remote.pageRequests).toEqual([]) expect(changes.map(change => change.type)).toEqual(['replace', 'append', 'replace']) expect(carrierFailed).toHaveBeenCalledWith(lost) await stream.dispose() @@ -187,16 +198,13 @@ describe('Session Client stream adapters', () => { const remote = new ScriptedSessionRemote( [ { - frames: [{ type: 'opened', cursor: 0 }], + frames: [snapshot(0, [entry(0)])], waitAfterFrames: finish.promise, terminal: new RemoteStreamCarrierError('lost'), }, - { frames: [{ type: 'opened', cursor: 1 }], hold: true }, - ], - [ - { ok: true, value: page([entry(0)]) }, - { ok: true, value: page([entry(0), entry(1)]) }, + { frames: [snapshot(1, [entry(0), entry(1)])], hold: true }, ], + [], ) const stream = new SessionEventStream(sessionClient(remote), ADDRESS, { publish: vi.fn(), @@ -205,18 +213,33 @@ describe('Session Client stream adapters', () => { await stream.open({}) finish.resolve(undefined) - await vi.waitFor(() => { expect(remote.pageRequests).toHaveLength(2) }) - expect(remote.pageRequests).toEqual([ - { address: ADDRESS, throughSeq: 0 }, - { address: ADDRESS, throughSeq: 1 }, - ]) + await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) }) + expect(remote.followRequests).toEqual([{ address: ADDRESS }, { address: ADDRESS }]) + expect(remote.pageRequests).toEqual([]) await stream.dispose() }) - it('turns a page failure into a typed stream failure and closes follow', async () => { + it('repairs a live gap without adding an absent message limit', async () => { + const remote = new ScriptedSessionRemote( + [{ frames: [snapshot(0, [entry(0)]), { type: 'event', ...entry(2) }], hold: true }], + [{ ok: true, value: page([entry(0), entry(1), entry(2)]) }], + ) + const changes: SessionJournalChange[] = [] + const stream = new SessionEventStream(sessionClient(remote), ADDRESS, { + publish: (change) => { changes.push(change) }, + failed: vi.fn(), + }) + + await stream.open({}) + await vi.waitFor(() => { expect(changes).toHaveLength(2) }) + expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: 2 }]) + await stream.dispose() + }) + + it('turns a pagination failure into a typed stream failure', async () => { const failure = { code: 'session-not-found', message: 'missing', details: { sessionId: 'session-1' } } as const const remote = new ScriptedSessionRemote( - [{ frames: [{ type: 'opened', cursor: -1 }], hold: true }], + [{ frames: [snapshot(-1, [])], hold: true }], [{ ok: false, error: failure }], ) const stream = new SessionEventStream(sessionClient(remote), ADDRESS, { @@ -224,13 +247,16 @@ describe('Session Client stream adapters', () => { failed: vi.fn(), }) - await expect(stream.open({})).rejects.toBeInstanceOf(RemoteStreamError) + await stream.open({}) + await expect(stream.prepend({})).rejects.toBeInstanceOf(RemoteStreamError) await expect(stream.open({})).rejects.toThrow('already opened') expect(sessionStreamFailure(new RemoteStreamError(failure.code, failure.message, failure.details))) .toEqual(failure) expect(sessionStreamFailure(new Error('local'))).toBeUndefined() - expect(remote.signals[0]?.aborted).toBe(true) + expect(remote.signals[0]?.aborted).toBe(false) expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: -1 }]) + await stream.dispose() + expect(remote.signals[0]?.aborted).toBe(true) }) it('maps the Host-wide control baseline and deltas into one snapshot stream', async () => { diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index c30d3f095f..65338e27ae 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -2,9 +2,12 @@ import { Context } from '@deepseek-ai/cordis' import { createScope } from '@deepseek-ai/dsh-scope' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import { describe, expect, it, vi } from 'vitest' import { SessionHistoryController } from '../src/history.ts' +import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' const signal = (): AbortSignal => new AbortController().signal @@ -22,7 +25,13 @@ function append( } function event(type: string, seq: number, data: unknown = {}): SessionEvent { - return { type, seq, time: seq + 1, data } as SessionEvent + return { + type, + seq, + time: seq + 1, + data, + ...type.startsWith('fixture/') ? { ignorable: true } : {}, + } as SessionEvent } function cold( @@ -30,10 +39,10 @@ function cold( header: SessionHeader, events: readonly SessionEvent[], ): void { - ctx.provide('sessionPersistence', { + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve([header]), inspect: () => Promise.resolve({ meta: header, events }), - } as never) + }) as never) } interface Deferred { @@ -50,7 +59,9 @@ function deferred(): Deferred { async function setup(): Promise<{ ctx: Context; transport: SessionHistoryController }> { const ctx = new Context() await ctx.plugin(SessionStore) - const transport = new SessionHistoryController(ctx) + installSessionReadTestServices(ctx) + ctx.sessionProjections.register(subagentIdentityProjectionDefinition) + const transport = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) return { ctx, transport } } @@ -65,7 +76,7 @@ describe('SessionHistoryController', () => { abort.signal, )[Symbol.asyncIterator]() - expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'opened', cursor: 0 } }) + expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(await iterator.next()).toMatchObject({ done: false, @@ -85,10 +96,13 @@ describe('SessionHistoryController', () => { it('ends active followers when the owning Controller unloads', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) let transport!: SessionHistoryController const owner = ctx.plugin(Object.assign( - (inner: Context) => { transport = new SessionHistoryController(inner) }, - { inject: ['sessions'] }, + (inner: Context) => { + transport = new SessionHistoryController(inner, (observation) => { observation[Symbol.dispose]() }) + }, + { inject: ['sessions', 'sessionQuery'] }, )) await owner.await() const session = ctx.sessions.create(SessionId('controller-unload'), { meta: { cwd: '/workspace' } }) @@ -97,9 +111,9 @@ describe('SessionHistoryController', () => { new AbortController().signal, )[Symbol.asyncIterator]() - await expect(iterator.next()).resolves.toEqual({ + await expect(iterator.next()).resolves.toMatchObject({ done: false, - value: { type: 'opened', cursor: -1 }, + value: { type: 'snapshot', cursor: -1 }, }) const pending = iterator.next() await owner.dispose() @@ -107,7 +121,7 @@ describe('SessionHistoryController', () => { await ctx.fiber.dispose() }) - it('resumes from the last applied seq before delivering later live events', async () => { + it('reconnects with a complete replacement snapshot before later live events', async () => { const { ctx, transport } = await setup() const session = ctx.sessions.create(SessionId('resume'), { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) @@ -116,12 +130,16 @@ describe('SessionHistoryController', () => { const abort = new AbortController() const iterator = transport.follow({ address: { kind: 'session', sessionId: session.id }, - afterSeq: 0, }, abort.signal)[Symbol.asyncIterator]() - expect(await iterator.next()).toEqual({ done: false, value: { type: 'opened', cursor: 2 } }) - expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 1 } } }) - expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 2 } } }) + expect(await iterator.next()).toMatchObject({ + done: false, + value: { + type: 'snapshot', + cursor: 2, + events: [{ event: { seq: 0 } }, { event: { seq: 1 } }, { event: { seq: 2 } }], + }, + }) session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 3 } } }) @@ -133,34 +151,74 @@ describe('SessionHistoryController', () => { const { ctx, transport } = await setup() const sessionId = SessionId('cold-race') const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } - const listed = deferred() - ctx.provide('sessionPersistence', { - list: () => listed.promise, - inspect: () => Promise.resolve({ meta: header, events: [event('fixture/start', 0)] }), - } as never) + const inspected = deferred<{ meta: SessionHeader; events: readonly SessionEvent[] }>() + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + inspect: () => inspected.promise, + }) as never) const abort = new AbortController() const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal) [Symbol.asyncIterator]() const opening = iterator.next() - ctx.emit('session/event', { id: SessionId('unrelated') } as Session, event('fixture/other', 0)) - ctx.emit('session/event', { id: sessionId } as Session, event('fixture/start', 0)) - listed.resolve([header]) - await expect(opening).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 0 } }) + ctx.emit('session/event', { + id: SessionId('unrelated'), events: [event('fixture/other', 0)], + } as unknown as Session, event('fixture/other', 0)) + ctx.emit('session/event', { + id: sessionId, events: [event('fixture/start', 0)], + } as unknown as Session, event('fixture/start', 0)) + inspected.resolve({ meta: header, events: [event('fixture/start', 0)] }) + await expect(opening).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) const waiting = iterator.next() abort.abort() await expect(waiting).resolves.toMatchObject({ done: true }) }) + it('buffers creation while the opening observation is unresolved', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const sessionId = SessionId('created-during-observation') + const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + const observed = deferred() + ctx.provide('sessionQuery', { observeSession: () => observed.promise } as never) + const transport = new SessionHistoryController(ctx, vi.fn()) + const abort = new AbortController() + const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal) + [Symbol.asyncIterator]() + const opening = iterator.next() + + const attached = ctx.sessions.create(sessionId, { meta: header, seed: [event('fixture/seed', 0)] }) + observed.resolve({ + source: 'live', + header: attached.header, + events: attached.events, + cursor: attached.seq - 1, + projections: { asOfSeq: attached.seq - 1, values: {} }, + retain: vi.fn(), + [Symbol.dispose]: vi.fn(), + } as unknown as SessionObservation) + await expect(opening).resolves.toMatchObject({ + done: false, + value: { + type: 'snapshot', cursor: 1, events: [{ event: { seq: 0 } }, { event: { seq: 1 } }], + }, + }) + expect(attached.id).toBe(sessionId) + abort.abort() + await expect(iterator.next()).resolves.toMatchObject({ done: true }) + }) + it('bridges the unpublished end-seed boundary when a cold source attaches', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) let transport!: SessionHistoryController let agentCtx!: Context await ctx.plugin(Object.assign( - (inner: Context) => { transport = new SessionHistoryController(inner) }, - { inject: ['sessions'] }, + (inner: Context) => { + transport = new SessionHistoryController(inner, (observation) => { observation[Symbol.dispose]() }) + }, + { inject: ['sessions', 'sessionQuery'] }, )) await ctx.plugin(Object.assign( (inner: Context) => { agentCtx = createScope(inner, { name: 'agent' }).ctx }, @@ -179,7 +237,7 @@ describe('SessionHistoryController', () => { const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal) [Symbol.asyncIterator]() - await expect(iterator.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 0 } }) + await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) agentCtx.sessions.create(SessionId('unrelated-created'), { meta: { cwd: '/workspace' } }) const attached = agentCtx.sessions.prepare(sessionId, { meta: header, seed }) agentCtx.sessions.enter(attached) @@ -212,11 +270,9 @@ describe('SessionHistoryController', () => { const replayHeader = { version: 0, id: replayId, createdAt: 1, cwd: '/workspace' } cold(replay.ctx, replayHeader, [event('fixture/start', 0), event('fixture/gap', 2)]) const replayed = replay.transport.follow({ - address: { kind: 'session', sessionId: replayId }, afterSeq: -1, + address: { kind: 'session', sessionId: replayId }, }, signal())[Symbol.asyncIterator]() - await expect(replayed.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 2 } }) - await expect(replayed.next()).resolves.toMatchObject({ done: false, value: { event: { seq: 0 } } }) - await expect(replayed.next()).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(replayed.next()).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' }) const live = await setup() const session = live.ctx.sessions.create(SessionId('live-gap'), { meta: { cwd: '/workspace' } }) @@ -225,8 +281,13 @@ describe('SessionHistoryController', () => { const followed = live.transport.follow({ address: { kind: 'session', sessionId: session.id }, }, signal())[Symbol.asyncIterator]() - await expect(followed.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 0 } }) - live.ctx.emit('session/event', session, event('fixture/gap', 2)) + await expect(followed.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) + const skipped = event('fixture/skipped', 1) + const gap = event('fixture/gap', 2) + live.ctx.emit('session/event', { + id: session.id, + events: [event('fixture/start', 0), skipped, gap], + } as unknown as Session, gap) await expect(followed.next()).rejects.toMatchObject({ failure: { code: 'internal' } }) }) @@ -237,7 +298,7 @@ describe('SessionHistoryController', () => { const iterator = transport.follow({ address: { kind: 'session', sessionId: session.id }, }, abort.signal)[Symbol.asyncIterator]() - await expect(iterator.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: -1 } }) + await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: -1 } }) await expect(transport.page({ address: { kind: 'session', sessionId: session.id }, throughSeq: -1, }, signal())).resolves.toMatchObject({ events: [], hasMore: false }) @@ -245,6 +306,59 @@ describe('SessionHistoryController', () => { await expect(iterator.next()).resolves.toMatchObject({ done: true }) }) + it('publishes an empty projection baseline when the query has no registry', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const sessionId = SessionId('projectionless-follow') + const meta = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + ctx.provide('sessionQuery', { + observeSession: () => Promise.resolve({ + source: 'live', header: meta, events: [], cursor: -1, + retain: vi.fn(), [Symbol.dispose]: vi.fn(), + } satisfies SessionObservation), + } as never) + const history = new SessionHistoryController(ctx, vi.fn()) + const abort = new AbortController() + const iterator = history.follow({ address: { kind: 'session', sessionId } }, abort.signal) + [Symbol.asyncIterator]() + + await expect(iterator.next()).resolves.toMatchObject({ + value: { type: 'snapshot', projections: { asOfSeq: -1, values: {} } }, + }) + abort.abort() + await expect(iterator.next()).resolves.toMatchObject({ done: true }) + await ctx.fiber.dispose() + }) + + it('disposes a retained promotion when background activation rejects synchronously', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const sessionId = SessionId('promotion-failure') + const meta = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + const disposePromotion = vi.fn() + const promotion = { + source: 'prepared', header: meta, events: [], cursor: -1, + projections: { asOfSeq: -1, values: {} }, + retain: vi.fn(), [Symbol.dispose]: disposePromotion, + } as unknown as SessionObservation + const source = { + ...promotion, + retain: () => promotion, + [Symbol.dispose]: vi.fn(), + } as SessionObservation + ctx.provide('sessionQuery', { + observeSession: () => Promise.resolve(source), + } as never) + const history = new SessionHistoryController(ctx, () => { throw new Error('activation failed') }) + const iterator = history.follow({ address: { kind: 'session', sessionId } }, signal()) + [Symbol.asyncIterator]() + + await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) + await expect(iterator.next()).rejects.toThrow('activation failed') + expect(disposePromotion).toHaveBeenCalledOnce() + await ctx.fiber.dispose() + }) + it('requires the durable parent and mode for a direct subagent address', async () => { const { ctx, transport } = await setup() const parentSessionId = SessionId('parent') @@ -288,15 +402,18 @@ describe('SessionHistoryController', () => { const sessionId = SessionId('corrupt-cold') const failure = new Error('cold log is corrupt') const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } - ctx.provide('sessionPersistence', { + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve([header]), inspect: () => Promise.reject(failure), - } as never) + }) as never) await expect(transport.page({ address: { kind: 'session', sessionId }, throughSeq: -1, - }, new AbortController().signal)).rejects.toBe(failure) + }, new AbortController().signal)).rejects.toMatchObject({ + code: 'SESSION_QUERY_PERSISTENCE_FAILED', + cause: failure, + }) }) it('rejects malformed page and follow cursors at the service boundary', async () => { @@ -325,25 +442,24 @@ describe('SessionHistoryController', () => { ) await expect(corrupt.transport.page({ address: { kind: 'session', sessionId: corruptId }, throughSeq: 1, - }, signal())).rejects.toMatchObject({ failure: { code: 'internal' } }) - for (const afterSeq of [-2, 0.5]) { - const iterator = transport.follow({ address, afterSeq }, signal())[Symbol.asyncIterator]() + }, signal())).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' }) + for (const maxMessages of [0, 0.5]) { + const iterator = transport.follow({ address, maxMessages }, signal())[Symbol.asyncIterator]() await expect(iterator.next()).rejects.toMatchObject({ failure: { code: 'bad-request' } }) } - const past = transport.follow({ address, afterSeq: 0 }, signal())[Symbol.asyncIterator]() - await expect(past.next()).rejects.toMatchObject({ failure: { code: 'bad-request' } }) }) it('reports missing ordinary and subagent sources without fabricating inspection failures', async () => { const { ctx, transport } = await setup() const ordinary = { kind: 'session' as const, sessionId: SessionId('missing') } await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal())) - .rejects.toMatchObject({ failure: { code: 'internal' } }) + .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) - ctx.provide('sessionPersistence', { + const inspect = vi.fn(() => Promise.resolve(undefined)) + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve([]), - inspect: () => Promise.reject(new Error('must not inspect')), - } as never) + inspect, + }) as never) await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal())) .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) await expect(transport.page({ @@ -355,25 +471,28 @@ describe('SessionHistoryController', () => { }, throughSeq: -1, }, signal())).rejects.toMatchObject({ failure: { code: 'subagent-not-found' } }) + expect(inspect).toHaveBeenCalledTimes(2) }) it('rejects incomplete cold metadata before serving a source', async () => { const first = await setup() const sessionId = SessionId('incomplete') const address = { kind: 'session' as const, sessionId } - first.ctx.provide('sessionPersistence', { - list: () => Promise.resolve([{ version: 0, id: sessionId, createdAt: 1 }]), - inspect: () => Promise.reject(new Error('must not inspect')), - } as never) + const firstHeader = { version: 0, id: sessionId, createdAt: 1 } + first.ctx.provide('sessionPersistence', testSessionPersistence(first.ctx, { + list: () => Promise.resolve([firstHeader]), + inspect: () => Promise.resolve({ meta: firstHeader, events: [] }), + }) as never) await expect(first.transport.page({ address, throughSeq: -1 }, signal())) .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) const second = await setup() const listed = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } - second.ctx.provide('sessionPersistence', { + const inspected = { version: 0, id: sessionId, createdAt: 1 } + second.ctx.provide('sessionPersistence', testSessionPersistence(second.ctx, { list: () => Promise.resolve([listed]), - inspect: () => Promise.resolve({ meta: { ...listed, cwd: undefined }, events: [] }), - } as never) + inspect: () => Promise.resolve({ meta: inspected, events: [] }), + }) as never) await expect(second.transport.page({ address, throughSeq: -1 }, signal())) .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) }) @@ -407,7 +526,7 @@ describe('SessionHistoryController', () => { const missing = await setup() cold(missing.ctx, childHeader, []) await expect(missing.transport.page({ address: childAddress, throughSeq: -1 }, signal())) - .rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'unsupported' } } }) + .rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } }) const corrupt = await setup() cold(corrupt.ctx, childHeader, [event('subagent/descriptor', 0, { version: 'bad' })]) @@ -421,44 +540,48 @@ describe('SessionHistoryController', () => { .rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } }) }) - it('uses attached and detached projection cuts and isolates a child projection failure', async () => { - const attached = await setup() - const session = attached.ctx.sessions.create(SessionId('projected'), { meta: { cwd: '/workspace' } }) + it('reports an unavailable descriptor when an observed child has no projection value', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const parentSessionId = SessionId('missing-projection-parent') + const childSessionId = SessionId('missing-projection-child') + const meta: SessionHeader = { + version: 0, + id: childSessionId, + createdAt: 1, + cwd: '/workspace', + origin: 'subagent', + parentSession: parentSessionId, + } + ctx.provide('sessionQuery', { + observeSession: () => Promise.resolve({ + source: 'live', header: meta, events: [], cursor: -1, + projections: { asOfSeq: -1, values: {} }, + retain: vi.fn(), [Symbol.dispose]: vi.fn(), + } as unknown as SessionObservation), + } as never) + const history = new SessionHistoryController(ctx, vi.fn()) + + await expect(history.page({ + address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' }, + throughSeq: -1, + }, signal())).rejects.toMatchObject({ + failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'unsupported' } }, + }) + await ctx.fiber.dispose() + }) + + it('keeps pages projection-free and computes projections only for child authorization', async () => { + const ordinary = await setup() + const session = ordinary.ctx.sessions.create(SessionId('projected'), { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) - const snapshot = vi.fn(() => ({ asOfSeq: 0, values: { title: 'attached' } })) - attached.ctx.provide('sessionProjections', { snapshot, restore: vi.fn() } as never) - await expect(attached.transport.page({ + const ordinarySnapshot = vi.spyOn(ordinary.ctx.sessionProjections, 'snapshot') + const ordinaryPage = await ordinary.transport.page({ address: { kind: 'session', sessionId: session.id }, throughSeq: 0, - }, signal())).resolves.toMatchObject({ projections: { asOfSeq: 0, values: { title: 'attached' } } }) - expect(snapshot).toHaveBeenCalledWith(session) - const older = await attached.transport.page({ - address: { kind: 'session', sessionId: session.id }, throughSeq: 0, beforeSeq: 1, }, signal()) - expect('projections' in older).toBe(false) - - const detached = await setup() - const coldId = SessionId('projected-cold') - const header = { version: 0, id: coldId, createdAt: 1, cwd: '/workspace' } - cold(detached.ctx, header, [event('turn/start', 0, { turn: 1 })]) - const restore = vi.fn(() => ({ snapshot: { asOfSeq: 0, values: { title: 'cold' } } })) - detached.ctx.provide('sessionProjections', { snapshot: vi.fn(), restore } as never) - await expect(detached.transport.page({ - address: { kind: 'session', sessionId: coldId }, - throughSeq: 0, - }, signal())).resolves.toMatchObject({ projections: { values: { title: 'cold' } } }) - expect(restore).toHaveBeenCalledWith({}, expect.any(Array), 0) - - const failed = await setup() - cold(failed.ctx, header, [event('turn/start', 0, { turn: 1 })]) - failed.ctx.provide('sessionProjections', { - snapshot: vi.fn(), - restore: () => { throw new Error('projection failed') }, - } as never) - await expect(failed.transport.page({ - address: { kind: 'session', sessionId: coldId }, - throughSeq: 0, - }, signal())).rejects.toThrow('projection failed') + expect('projections' in ordinaryPage).toBe(false) + expect(ordinarySnapshot).not.toHaveBeenCalled() const child = await setup() const parentSessionId = SessionId('projection-parent') @@ -469,17 +592,13 @@ describe('SessionHistoryController', () => { childSession.append('subagent/descriptor', snapshotSubagentDescriptor({ mode: 'continuable', provider: 'test', label: 'child', })) - const warn = vi.spyOn(child.ctx.logger, 'warn').mockImplementation(() => undefined) - child.ctx.provide('sessionProjections', { - snapshot: () => { throw new Error('child projection failed') }, - restore: vi.fn(), - } as never) + const childSnapshot = vi.spyOn(child.ctx.sessionProjections, 'snapshot') const page = await child.transport.page({ address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' }, throughSeq: 0, }, signal()) expect('projections' in page).toBe(false) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('child projection failed')) + expect(childSnapshot).toHaveBeenCalledWith(childSession) }) it('keeps message-aligned pagination contiguous across replacement provenance', async () => { diff --git a/packages/test-support/client-runtime/src/sessions.ts b/packages/test-support/client-runtime/src/sessions.ts index afb24fa287..cbd3b9a2e2 100644 --- a/packages/test-support/client-runtime/src/sessions.ts +++ b/packages/test-support/client-runtime/src/sessions.ts @@ -445,14 +445,6 @@ export class TestSessions implements ISessions { return Promise.resolve() } - /** Apply a confirmed preset switch into the fixture list, as production does. */ - noteAgentPreset(sessionId: SessionId, agentPreset: string): void { - this.list.update((draft) => { - const summary = draft.byId[sessionId] - if (summary !== undefined) draft.byId[sessionId] = { ...summary, agentPreset } - }) - } - /** Clear the current selection (recorded; the production no-session flow). */ clear(): void { this.calls.push({ method: 'clear', args: [] }) diff --git a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx index f98b93bb0c..02d239b117 100644 --- a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx @@ -170,13 +170,6 @@ describe('sessions', () => { .toMatchObject({ displayTitle: 'renamed', running: true }) runtime.sessions.setSubagentCatalogOpen('s2' as SessionId, true) await runtime.sessions.refreshSubagents('s2' as SessionId) - // The confirmed-switch write-back lands on the row it names and ignores - // one the fixture never added, exactly as production's list upsert does. - runtime.sessions.noteAgentPreset('s1' as SessionId, 'minimal') - runtime.sessions.noteAgentPreset('missing' as SessionId, 'minimal') - await runtime.flush() - expect(runtime.sessions.list.getSnapshot().byId['s1' as SessionId]) - .toMatchObject({ agentPreset: 'minimal' }) runtime.sessions.open('s1' as SessionId) await runtime.flush() expect(runtime.sessions.list.getSnapshot().current).toBe('s1') diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index c1cc4c19de..e710fcfa30 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -54,7 +54,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index 592f158872..05a3662ef3 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -1,8 +1,8 @@ /** * The `todos` projection provider (session-projection RFC knife 4 — the "a * fourth domain is just its own registrations" acceptance probe): mounting - * tool-todo beside the registry serves the whole current list on the history - * tail page with a consistent asOfSeq (= last event seq); before any write the value is null; a + * tool-todo beside the registry serves the whole current list with a + * consistent asOfSeq (= last event seq); before any write the value is null; a * composition without tool-todo has no `todos` key; unmounting tool-todo * removes it (HMR safety). The carrier and framework are exercised unmodified. */ @@ -17,7 +17,6 @@ import type { Session } from '@deepseek-ai/dsh-session' import type { TodoItem } from '@deepseek-ai/dsh-tool-todo' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' -import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import UserQuestionService from '@deepseek-ai/dsh-user-questions' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' @@ -39,16 +38,11 @@ async function harness(withTodoTool: boolean): Promise { if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) - const history = new SessionHistoryController(ctx) return { ctx, session, async tailProjections() { - return (await history.page({ - address: { kind: 'session', sessionId: session.id }, - throughSeq: session.seq - 1, - }, new AbortController().signal)) - .projections + return ctx.sessionProjections.snapshot(session) }, } } From 69fad4b8db095266c90044254c91b0c2a1632206 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:07:26 +0800 Subject: [PATCH 04/17] perf(session-controller): serve cache-first session state --- .../src/client/contract/sessions.ts | 8 - .../src/client/sessions/lineage.ts | 2 - .../src/client/sessions/manager.ts | 58 ++--- .../src/client/sessions/projection-store.ts | 7 +- .../src/client/sessions/service.ts | 11 - .../api/session-controller/src/control.ts | 14 +- packages/api/session-controller/src/index.ts | 54 +++-- packages/api/session-controller/src/list.ts | 205 ++++++++--------- .../tests/control-jobs.host.spec.ts | 35 ++- .../tests/controller.host.spec.ts | 122 +++++++++- .../tests/manager.client.spec.ts | 35 ++- .../tests/session-cold.host.spec.ts | 216 +++++++++++++----- .../tests/session-projections.host.spec.ts | 175 ++++++++++---- .../tests/session-search.host.spec.ts | 179 ++++++++------- .../tests/sessions-service.client.spec.ts | 69 ++++-- .../session-controller/tests/test-remote.ts | 86 ++++++- 16 files changed, 840 insertions(+), 436 deletions(-) diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts index d2129e7dd6..6520e5fa08 100644 --- a/packages/api/session-controller/src/client/contract/sessions.ts +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -66,14 +66,6 @@ export interface ISessions { */ refreshSubagents(parentSessionId: SessionId): Promise - /** - * Record the composition one session now runs. The agent-preset seat calls - * this after a successful blank-session switch, so the header label moves - * with the composition instead of waiting for the next full list refresh. - * @param sessionId - the switched session. - * @param agentPreset - the preset id the host confirmed. - */ - noteAgentPreset(sessionId: SessionId, agentPreset: string): void /** Clear the current selection into the no-session view state. */ clear(): void /** diff --git a/packages/api/session-controller/src/client/sessions/lineage.ts b/packages/api/session-controller/src/client/sessions/lineage.ts index 3f6e21dbc9..09551369b6 100644 --- a/packages/api/session-controller/src/client/sessions/lineage.ts +++ b/packages/api/session-controller/src/client/sessions/lineage.ts @@ -25,8 +25,6 @@ export interface SessionListEntry { /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' cwd?: string - /** Agent preset the session's agent was composed from (summary passthrough). */ - agentPreset?: string /** Current host-computed projection values for list consumers. */ projectionValues?: Readonly> /** Finished running while not selected and not yet opened — the sidebar's green "done" reminder (clears on select or the next run). */ diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index ddfae1d9e2..1a76102eaa 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -61,11 +61,19 @@ export interface SessionListSnapshot { } /** One parent-addressed durable catalog projected through the sessions snapshot. */ -export interface SubagentCatalogSnapshot extends SubagentCatalog { +export type SubagentCatalogSnapshot = Omit & { + /** Absent until the first successful catalog read. */ + readonly parentAvailable?: boolean state: 'loading' | 'ready' | 'error' error: ClientFailure | null } +function catalogAvailability(parentAvailable: boolean | undefined): { + readonly parentAvailable?: boolean +} { + return parentAvailable === undefined ? {} : { parentAvailable } +} + interface CatalogInflight { readonly promise: Promise readonly expandableRows: Set @@ -166,8 +174,8 @@ export class SessionManager { this.sessions.get(sessionId)?.configureSubagent( address, address === undefined - ? false - : this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false, + ? undefined + : this.catalogs.get(address.parentSessionId)?.parentAvailable, ) this.selected = sessionId // Looking at the session consumes its completion reminder (dot clears). @@ -187,7 +195,7 @@ export class SessionManager { throw new Error(`sessions.selectSubagent: ${address.childSessionId} is not a healthy catalog child`) } this.addresses.set(address.childSessionId, address) - this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable ?? false) + this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable) this.selected = address.childSessionId this.completedNotifications.delete(address.childSessionId) void this.refreshSubagents(address.childSessionId) @@ -310,10 +318,13 @@ export class SessionManager { private createSession(sessionId: SessionId): Session { const address = this.addresses.get(sessionId) + const parentAvailable = address === undefined + ? undefined + : this.catalogs.get(address.parentSessionId)?.parentAvailable return new Session(sessionId, this.api, this.remote, { ...(address === undefined ? {} : { address, - parentAvailable: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false, + ...catalogAvailability(parentAvailable), }), // The sender's local first-send flip mirrors into the list row so the // session surfaces (lists filter on blank) before any host frame lands. @@ -349,7 +360,9 @@ export class SessionManager { const activityRows = new Map() this.catalogs.set(parentSessionId, { entries: previous?.entries ?? [], - parentAvailable: previous?.parentAvailable ?? false, + ...(previous?.parentAvailable === undefined + ? {} + : { parentAvailable: previous.parentAvailable }), state: 'loading', error: null, }) @@ -376,8 +389,10 @@ export class SessionManager { entries: this.withCatalogMutations( previous?.entries ?? [], expandableRows, activityRows, ), - parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride - ?? previous?.parentAvailable ?? false, + ...catalogAvailability( + this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? previous?.parentAvailable, + ), state: 'error', error: result.error, }) @@ -388,8 +403,10 @@ export class SessionManager { entries: this.withCatalogMutations( previous?.entries ?? [], expandableRows, activityRows, ), - parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride - ?? previous?.parentAvailable ?? false, + ...catalogAvailability( + this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? previous?.parentAvailable, + ), state: 'error', error: folded.ok ? null : folded.error, }) @@ -555,7 +572,6 @@ export class SessionManager { this.recordMutation({ kind: 'upsert', summary: { sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true, ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), - ...(result.value.agentPreset !== undefined ? { agentPreset: result.value.agentPreset } : {}), } }) } else { const publishedSessionId = workspaceAttachSessionId(result.error) @@ -621,17 +637,6 @@ export class SessionManager { this.recordMutation({ kind: 'upsert', summary }) } - /** - * Record a host-confirmed composition switch (see ISessions.noteAgentPreset). - * @param sessionId - the switched session. - * @param agentPreset - the preset id the host confirmed. - */ - noteAgentPreset(sessionId: SessionId, agentPreset: string): void { - this.recordMutation({ kind: 'upsert', summary: { - sessionId, updatedAt: Date.now(), running: false, blank: true, agentPreset, - } }) - } - /** Apply immediately and retain for replay when a list response is in flight. */ private recordMutation(mutation: SessionListMutation): void { this.listMutations?.push(mutation) @@ -932,7 +937,7 @@ export class SessionManager { const prev = this.entryCache.get(entry.sessionId) if ( prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running - && prev.blank === entry.blank && prev.agentPreset === entry.agentPreset + && prev.blank === entry.blank && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth && prev.projectionValues === entry.projectionValues @@ -980,15 +985,10 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi ? { parentSessionId: mutation.summary.parentSessionId } : {}), ...(existing.origin === undefined && mutation.summary.origin !== undefined ? { origin: mutation.summary.origin } : {}), - // Newest wins, not fill-only: a blank-session preset switch replaces - // the creation-time value, and every producer of this field (the - // create echo, the select echo, a list row) reports the CURRENT one. - ...(mutation.summary.agentPreset !== undefined - ? { agentPreset: mutation.summary.agentPreset } : {}), } if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId && filled.origin === existing.origin && filled.blank === existing.blank - && filled.agentPreset === existing.agentPreset) return [...summaries] + ) return [...summaries] return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary) } case 'remove': 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 4ffa2368bb..fd0ad1b5d2 100644 --- a/packages/api/session-controller/src/client/sessions/projection-store.ts +++ b/packages/api/session-controller/src/client/sessions/projection-store.ts @@ -2,8 +2,8 @@ * 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 Session page's - * projections block and updated by Session Controller `projection` frames, + * 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). @@ -40,8 +40,7 @@ export type UseProjection = { } /** - * Tail-page projections baseline — structurally identical to Session - * Controller's `SessionProjectionsBlock`, restated here so the + * Follow-opening projection baseline, restated here so the * React-free store depends only on the type table, not the wire package's * response vocabulary. */ diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 0194edef27..1dc484c615 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -45,12 +45,6 @@ export interface SessionSummary { /** Human-facing label: durable title, project basename, then session id. */ displayTitle: string cwd?: string - /** - * Agent preset this session's agent was composed from; absent when the - * deployment composes no presets. The session header labels what the - * session actually runs rather than the deployment's current default. - */ - agentPreset?: string parentId?: SessionId /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' @@ -317,10 +311,6 @@ export class ClientSessions implements ISessions { return this.manager.refreshSubagents(parentSessionId) } - noteAgentPreset(sessionId: SessionId, agentPreset: string): void { - this.manager.noteAgentPreset(sessionId, agentPreset) - } - /** * Clear the current selection so the layout shows the no-session empty * state (new-session affordance and the workspace preselection flow). @@ -611,7 +601,6 @@ export class ClientSessions implements ISessions { ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), ...(entry.origin !== undefined ? { origin: entry.origin } : {}), - ...(entry.agentPreset !== undefined ? { agentPreset: entry.agentPreset } : {}), } } if (current !== undefined && currentAddress !== undefined) { diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index 4a28710a05..4068b5536d 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -10,7 +10,7 @@ import type { SessionControlBaseline, SessionControlFrame, SessionJob, - SessionProjectionsBlock, + SessionProjectionBaseline, SessionProjectionValues, SessionQueuedItem, } from './types.ts' @@ -22,10 +22,6 @@ export class SessionControlController { /** @param ctx - Host context carrying live Agent, projection, and jobs services. */ constructor(private readonly ctx: Context) { ctx.on('session/event', (session, event) => { this.onSessionEvent(session, event) }) - ctx.on('session/created', (session) => { - const jobs = this.jobsFor(this.ctx.agents.get(session.id)) - if (jobs.length > 0) this.broadcast({ type: 'jobs', sessionId: session.id, jobs }) - }) ctx.inject(['sessionProjections'], (projectionCtx) => { projectionCtx.sessionProjections.onChanged((session, key, value, seq) => { this.broadcast({ @@ -40,6 +36,10 @@ export class SessionControlController { ctx.inject(['jobs'], (jobsCtx) => { jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) }) }) + ctx.on('session/created', (session) => { + const jobs = this.jobsFor(this.ctx.agents.get(session.id)) + if (jobs.length > 0) this.broadcast({ type: 'jobs', sessionId: session.id, jobs }) + }) ctx.effect(() => () => { for (const stream of this.streams) stream.end() this.streams.clear() @@ -82,9 +82,9 @@ export class SessionControlController { private projectionBaseline( sessions: readonly Session[], - ): Readonly> { + ): Readonly> { const registry = this.ctx.get('sessionProjections') - const blocks = Object.create(null) as Record + const blocks = Object.create(null) as Record for (const session of sessions) { const snapshot = registry?.snapshot(session) blocks[session.id] = snapshot === undefined diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index d4c9cbd8e1..af71281853 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -4,6 +4,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { ApiSessionAgentController, @@ -14,6 +15,7 @@ import { SessionCommandController } from './commands.ts' import { SessionControlController } from './control.ts' import { SessionHistoryController } from './history.ts' import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts' +import { installModelSelectionProjection } from './model-selection-projection.ts' import type { SessionAttachmentRequest, SessionAttachmentValue, @@ -28,8 +30,6 @@ import type { SessionForkValue, SessionListRequest, SessionListValue, - SessionModels, - SessionModelsRequest, SessionPage, SessionPageRequest, SessionPromptRequest, @@ -56,7 +56,7 @@ declare module '@deepseek-ai/cordis' { /** Session Controller deployment policy. */ export interface Config { - /** Maximum cold Session artifact size read to determine blankness. */ + /** Maximum cold Session artifact size eligible for one full projection observation. */ readonly coldBlankProbeMaxBytes?: number } @@ -68,6 +68,7 @@ export class SessionController extends TypertRemoteService { 'attachments', 'llm', 'sessions', + 'sessionProjections', 'sessionQuery', 'typert', 'workspaceRegistry', @@ -82,17 +83,24 @@ export class SessionController extends TypertRemoteService { private readonly controlState: SessionControlController private readonly history: SessionHistoryController private readonly listState: ApiSessionList + private readonly promotions = new Set>() /** * @param ctx - Host context containing the Session capability assembly. - * @param config - cold-list read policy. + * @param config - cold-list observation policy. */ constructor(ctx: Context, config: Config) { super(ctx, 'sessionController', { namespace: 'session' }) + installModelSelectionProjection(ctx) this.agents = new ApiSessionAgentController(ctx) this.commands = new SessionCommandController(ctx, this.agents, process.cwd()) this.controlState = new SessionControlController(ctx) - this.history = new SessionHistoryController(ctx) + // Registered before history so reverse-order teardown closes every + // follower before waiting for already-admitted promotions. + ctx.effect(() => async () => { + await Promise.allSettled([...this.promotions]) + }, 'session-controller.promotions') + this.history = new SessionHistoryController(ctx, observation => { this.promote(observation) }) this.listState = new ApiSessionList( ctx, config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES, @@ -111,11 +119,33 @@ export class SessionController extends TypertRemoteService { ctx.emit('api-session/error', agent.id, errorChain(error)) }) ctx.on('session/event', (session, event) => { + if (event.type === 'request/header') { + const agent = ctx.agents.get(session.id) + if (agent?.session === session) this.agents.consumeSelection( + agent, + event.data.header.config.provider, + event.data.header.config.model, + event.data.header.config.reasoningEffort, + ) + } if (event.type !== 'user/message' || event.data.source.kind !== 'user') return ctx.emit('api-session/activity', session.id, event.time) }) } + private promote(observation: SessionObservation): void { + const sessionId = observation.header.id + const task = (async () => { + using ownedObservation = observation + const result = await this.agents.resolveObservedAgent(ownedObservation) + if ('error' in result) this.ctx.emit('api-session/error', sessionId, result.error.message) + })().catch((error: unknown) => { + this.ctx.logger.error(`session-controller: background activation for "${sessionId}" failed: ${errorChain(error)}`) + }) + this.promotions.add(task) + void task.finally(() => { this.promotions.delete(task) }) + } + /** * Resolve or resume one ordinary Session for another Host API domain. * @param sessionId - Session identity whose Agent owns the operation. @@ -174,16 +204,6 @@ export class SessionController extends TypertRemoteService { return this.commands.create(request) } - /** - * Read model choices after explicitly resuming the addressed Session. - * @param request - Session whose model state is requested. - * @returns the current selection and available model groups. - */ - @Remote('models') - models(request: SessionModelsRequest): Promise { - return this.commands.models(request) - } - /** * Select one Session-local model after explicitly resuming the Session. * @param request - Session identity and requested model selection. @@ -260,7 +280,7 @@ export class SessionController extends TypertRemoteService { * Read one cold-safe, message-aligned Session history page. * @param request - durable address, backward cursor, and page budget. * @param signal - cancellation for persistence reads. - * @returns one chronological page and optional latest projections. + * @returns one chronological page. */ @Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise { @@ -271,7 +291,7 @@ export class SessionController extends TypertRemoteService { * Follow one Session log from its opening or resume cursor. * @param request - durable address and last committed sequence already held by the caller. * @param signal - cancellation owned by the Remote stream carrier. - * @returns an opened cursor followed by gap-free event frames. + * @returns a complete opening snapshot followed by gap-free event frames. */ @Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index 845e7fae86..c9a53a6bd0 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -2,10 +2,9 @@ import { stat } from 'node:fs/promises' import type { Context } from '@deepseek-ai/cordis' -import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-agent-presets' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-session-projection-cache' import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' @@ -16,11 +15,11 @@ import { SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, } from './types.ts' import type { - SessionListMetadata, SessionProjectionsBlock, SessionProjectionValues, SessionSearchItem, + SessionListMetadata, SessionProjectionHints, SessionProjectionValues, SessionSearchItem, SessionSearchValue, SessionSummary, } from './types.ts' -/** Default maximum artifact size eligible for one cold blankness read. */ +/** Default maximum artifact size eligible for one cold projection observation. */ export const DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024 const COLD_SUMMARY_BATCH_SIZE = 16 @@ -61,17 +60,6 @@ export function applySessionListMetadata( : { blank, lastPromptAt } } -/** - * Fold exact list metadata for an attached Session. - * @param events - complete attached Session event log. - * @returns metadata derived from the event prefix. - */ -export function sessionListMetadata(events: readonly SessionEvent[]): SessionListMetadata { - let state: SessionListMetadata = { blank: true, lastPromptAt: null } - for (const event of events) state = applySessionListMetadata(state, event) - return state -} - /** * Return the longest prefix containing at most `maximum` Unicode code points. * @param value - source text. @@ -89,11 +77,11 @@ export function truncateUnicodeCodePoints(value: string, maximum: number): strin return value } -/** Owns list projection registration, cold summaries, and authorized search. */ +/** Owns list projection registration, bounded cold summaries, and authorized search. */ export class ApiSessionList { /** - * @param ctx - Host context carrying Session, persistence, and projection services. - * @param coldBlankProbeMaxBytes - maximum physical artifact size read to verify cold blankness. + * @param ctx - Host context carrying Session, query, persistence, and projection services. + * @param coldBlankProbeMaxBytes - maximum physical artifact size eligible for a full observation. */ constructor( private readonly ctx: Context, @@ -130,14 +118,14 @@ export class ApiSessionList { * @returns current list metadata and available projections. */ summaryFor(session: Session): SessionSummary { - const metadata = sessionListMetadata(session.events) const projections = this.projectionsFor(session.header, session) + const metadata = projections?.values.sessionListMetadata return { sessionId: session.id, updatedAt: updatedAt(session.header, metadata), running: this.ctx.agents.get(session.id)?.status === 'running', - blank: metadata.blank, - ...listFields(session.header, session.events), + blank: metadata?.blank ?? session.seq === 0, + ...listFields(session.header), ...(projections === undefined ? {} : { projections }), } } @@ -149,41 +137,86 @@ export class ApiSessionList { */ async list(signal?: AbortSignal): Promise { signal?.throwIfAborted() - const items = this.ctx.sessions.list().map(session => this.summaryFor(session)) - const attached = new Set(items.map(item => item.sessionId)) - const persistence = this.ctx.get('sessionPersistence') - if (persistence !== undefined) { - const cold = (await persistence.list(signal)) - .filter(meta => !attached.has(meta.id) && meta.cwd !== undefined) - signal?.throwIfAborted() - for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) { - const settled = await Promise.allSettled(cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE) - .map(async (meta) => { - const projections = this.projectionsFor(meta, undefined) - const summary = await summarizeCold( - this.ctx, - persistence, - meta, - projections?.values.sessionListMetadata, - this.coldBlankProbeMaxBytes, - signal, - ) - const raced = this.ctx.sessions.get(meta.id) - if (raced !== undefined) return this.summaryFor(raced) - return { ...summary, ...(projections === undefined ? {} : { projections }) } - })) - const summaries = settled.map((result) => { - if (result.status === 'rejected') throw result.reason - return result.value - }) - signal?.throwIfAborted() - items.push(...summaries) + const records = await this.ctx.sessionQuery.listSessions(signal) + signal?.throwIfAborted() + const items: SessionSummary[] = [] + const cold: SessionHeader[] = [] + for (const record of records) { + const live = this.ctx.sessions.get(record.header.id) + if (live !== undefined) { + items.push(this.summaryFor(live)) + continue + } + if (record.header.cwd === undefined) continue + cold.push(record.header) + } + for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) { + const settled = await Promise.allSettled(cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE) + .map(header => this.summarizeCold(header, signal))) + for (const result of settled) { + if (result.status === 'rejected') throw result.reason + items.push(result.value) } } items.sort((left, right) => right.updatedAt - left.updatedAt) return items } + private async summarizeCold( + header: SessionHeader, + signal: AbortSignal | undefined, + ): Promise { + const cached = this.projectionsFor(header, undefined) + const projections = cached?.values.sessionListMetadata?.blank === false + ? cached + : await this.probeSmallCold(header, signal) ?? cached + const raced = this.ctx.sessions.get(header.id) + if (raced !== undefined) return this.summaryFor(raced) + const metadata = projections?.values.sessionListMetadata + return { + sessionId: header.id, + updatedAt: updatedAt(header, metadata), + running: false, + // A large or inaccessible cache miss remains unknown and visible. + blank: metadata?.blank ?? false, + ...listFields(header), + ...(projections === undefined ? {} : { projections }), + } + } + + private async probeSmallCold( + header: SessionHeader, + signal: AbortSignal | undefined, + ): Promise { + if (this.coldBlankProbeMaxBytes === 0) return undefined + const persistence = this.ctx.get('sessionPersistence') + const location = persistence?.locate(header) + if (location === undefined) return undefined + signal?.throwIfAborted() + try { + if ((await stat(location.path)).size > this.coldBlankProbeMaxBytes) return undefined + } catch { + signal?.throwIfAborted() + return undefined + } + try { + using observation = await this.ctx.sessionQuery.observeSession(header.id, { + ...(signal === undefined ? {} : { signal }), + projectionMode: 'all', + }) + const block = observation.projections + return block === undefined + ? undefined + : { asOfSeq: block.asOfSeq, values: block.values as SessionProjectionValues } + } catch (error: unknown) { + signal?.throwIfAborted() + this.ctx.logger.warn( + `api-session.list: small cold observation for "${header.id}" failed; serving it as visible: ${String(error)}`, + ) + return undefined + } + } + /** * Search current visible message content without activating any matching Session. * @param query - literal message-content query. @@ -202,10 +235,12 @@ export class ApiSessionList { ) } try { - const visible = await this.list(signal) + const visible = await provider.listSessions(signal) signal.throwIfAborted() - if (visible.length === 0) return { items: [], hasMore: false } - const visibleIds = new Set(visible.map(item => item.sessionId)) + const visibleIds = new Set(visible + .filter(record => record.header.cwd !== undefined) + .map(record => record.header.id)) + if (visibleIds.size === 0) return { items: [], hasMore: false } const authorized: SessionSearchItem[] = [] const acceptedIds = new Set() const seenCursors = new Set() @@ -293,15 +328,16 @@ export class ApiSessionList { private projectionsFor( header: SessionHeader, session: Session | undefined, - ): SessionProjectionsBlock | undefined { + ): SessionProjectionHints | undefined { try { const block = session === undefined ? this.ctx.get('sessionProjectionCache')?.cachedSnapshot(header) - : this.ctx.get('sessionProjections')?.snapshot(session) + : this.ctx.get('sessionProjections')?.cachedSnapshot(session) return block !== undefined && Object.keys(block.values).length > 0 ? { asOfSeq: block.asOfSeq, - // Projection definitions validate whole JSON values before snapshot publication. + // Listing hints contain every currently cached wire value but remain + // partial: missing cells and cache rows are never materialized here. values: block.values as SessionProjectionValues, } : undefined @@ -340,69 +376,14 @@ function updatedAt(header: SessionHeader, metadata: SessionListMetadata | undefi return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0) } -function listFields(header: SessionHeader, events: readonly SessionEvent[] = []): { +function listFields(header: SessionHeader): { readonly parentSessionId?: SessionId readonly origin?: 'subagent' readonly cwd?: string - readonly agentPreset?: string } { - const agentPreset = resolveSessionPreset({ header, events }) return { ...(header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }), ...(header.origin === undefined ? {} : { origin: header.origin }), ...(header.cwd === undefined ? {} : { cwd: header.cwd }), - ...(agentPreset === undefined ? {} : { agentPreset }), - } -} - -async function summarizeCold( - ctx: Context, - persistence: SessionPersistence, - header: SessionHeader, - metadata: SessionListMetadata | undefined, - blankProbeMaxBytes: number, - signal?: AbortSignal, -): Promise { - const probed = metadata?.blank === false - ? undefined - : await probeColdMetadata(ctx, persistence, header, blankProbeMaxBytes, signal) - return { - sessionId: header.id, - updatedAt: updatedAt(header, probed ?? metadata), - running: false, - blank: metadata?.blank === false ? false : probed?.blank ?? false, - ...listFields(header), - } -} - -async function probeColdMetadata( - ctx: Context, - persistence: SessionPersistence, - header: SessionHeader, - maxBytes: number, - signal?: AbortSignal, -): Promise { - if (maxBytes === 0) return undefined - signal?.throwIfAborted() - const location = persistence.locate(header) - if (location === undefined) return undefined - let size: number - try { - size = (await stat(location.path)).size - } catch { - signal?.throwIfAborted() - return undefined - } - if (size > maxBytes) return undefined - try { - const { events } = await persistence.readFrom(header.id, 0, signal) - signal?.throwIfAborted() - return sessionListMetadata(events) - } catch (error) { - signal?.throwIfAborted() - ctx.logger.warn( - `api-session.list: blank probe for "${header.id}" failed; serving it as visible: ${String(error)}`, - ) - return undefined } } diff --git a/packages/api/session-controller/tests/control-jobs.host.spec.ts b/packages/api/session-controller/tests/control-jobs.host.spec.ts index 0ad49b586c..f83fa1b3c8 100644 --- a/packages/api/session-controller/tests/control-jobs.host.spec.ts +++ b/packages/api/session-controller/tests/control-jobs.host.spec.ts @@ -104,6 +104,29 @@ describe('Session control jobs baseline', () => { }) describe('Session control jobs updates', () => { + it('publishes existing unowned jobs when a Session attaches after the stream opens', async () => { + const { ctx, control } = await harness(true) + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'baseline' } }) + const task = producer('already running') + const id = ctx.jobs.start(task.spec) + await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'jobs' } }) + + const created = ctx.sessions.create(SessionId('late-session')) + await expect(iterator.next()).resolves.toMatchObject({ + value: { + type: 'jobs', + sessionId: created.id, + jobs: [expect.objectContaining({ id, label: 'already running' })], + }, + }) + + task.settle({ status: 'completed' }) + abort.abort() + await iterator.return?.() + }) + it('pushes the owner whole set on registration, stopping, and settlement', async () => { const { ctx, session, agent, control } = await harness(true) const abort = new AbortController() @@ -200,16 +223,4 @@ describe('Session control jobs updates', () => { expect(task.reads.count).toBe(0) }) - it('publishes existing unowned jobs for a session created after stream open', async () => { - const { ctx, control } = await harness(true) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 2, abort) - - ctx.jobs.start(producer('visible to every caller').spec) - const created = ctx.sessions.create() - - const frames = await collected - const forNew = frames.filter(frame => frame.sessionId === created.id) - expect(forNew.at(-1)?.jobs[0]?.label).toBe('visible to every caller') - }) }) diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index f34745e326..d3785b806a 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -6,7 +6,8 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { describe, expect, it, vi } from 'vitest' import SessionController from '../src/index.ts' -import { createSessionTestController } from './test-remote.ts' +import type { ApiSessionAgentController } from '../src/agent.ts' +import { createSessionTestController, testSessionPersistence } from './test-remote.ts' const defaults = { defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), @@ -31,10 +32,10 @@ describe('SessionController facade', () => { } const events: SessionEvent[] = [] const inspect = vi.fn(() => Promise.resolve({ meta: header, events })) - ctx.provide('sessionPersistence', { + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve([header]), inspect, - } as never) + }) as never) const controller = createSessionTestController(ctx, defaults) const status = vi.fn() const failure = vi.fn() @@ -54,6 +55,10 @@ describe('SessionController facade', () => { ctx, } as Agent ctx.agents.register(agent) + const consumeSelection = vi.spyOn( + (controller as unknown as { agents: ApiSessionAgentController }).agents, + 'consumeSelection', + ) await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent }) await expect(controller.inspect(sessionId)).resolves.toEqual({ meta: header, events }) @@ -67,6 +72,21 @@ describe('SessionController facade', () => { expect(status).toHaveBeenCalledWith(sessionId, true) expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure')) expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number)) + session.append('request/header', { + header: { config: { provider: 'fixture', model: 'fixture-model' } }, + reason: 'initial', + }) + expect(consumeSelection).toHaveBeenCalledWith( + agent, 'fixture', 'fixture-model', undefined, + ) + const unowned = ctx.sessions.create(SessionId('controller-unowned'), { + meta: { cwd: '/workspace' }, + }) + unowned.append('request/header', { + header: { config: { provider: 'fixture', model: 'other-model' } }, + reason: 'initial', + }) + expect(consumeSelection).toHaveBeenCalledTimes(1) const abort = new AbortController() const iterator = controller.follow({ @@ -74,9 +94,103 @@ describe('SessionController facade', () => { }, abort.signal)[Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ done: false, - value: { type: 'opened', cursor: 0 }, + value: { type: 'snapshot', cursor: 1 }, }) abort.abort() await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) }) + + it.each(['success', 'domain-error', 'throw'] as const)( + 'promotes a prepared follow observation in the background: %s', + async (outcome) => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const sessionId = SessionId(`background-${outcome}`) + const header: SessionHeader = { + version: 0, id: sessionId, createdAt: 1, cwd: '/workspace', + } + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([header]), + inspect: () => Promise.resolve({ meta: header, events: [] }), + }) as never) + const controller = createSessionTestController(ctx, defaults) + const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents + const apiError = vi.fn() + ctx.on('api-session/error', apiError) + const logError = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + const live = { id: sessionId, session: { id: sessionId }, ctx, status: 'idle' } as unknown as Agent + const resolve = vi.spyOn(agents, 'resolveObservedAgent') + if (outcome === 'success') resolve.mockResolvedValue({ agent: live }) + else if (outcome === 'domain-error') { + resolve.mockResolvedValue({ + error: { code: 'internal', message: 'activation unavailable', details: {} }, + }) + } else { + resolve.mockRejectedValue(new Error('activation crashed')) + } + const abort = new AbortController() + const iterator = controller.follow({ + address: { kind: 'session', sessionId }, + }, abort.signal)[Symbol.asyncIterator]() + + await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) + const waiting = iterator.next() + await vi.waitFor(() => { expect(resolve).toHaveBeenCalledOnce() }) + if (outcome === 'domain-error') { + await vi.waitFor(() => { + expect(apiError).toHaveBeenCalledWith(sessionId, 'activation unavailable') + }) + } else if (outcome === 'throw') { + await vi.waitFor(() => { + expect(logError).toHaveBeenCalledWith(expect.stringContaining('activation crashed')) + }) + } else { + expect(apiError).not.toHaveBeenCalled() + } + abort.abort() + await expect(waiting).resolves.toMatchObject({ done: true }) + await ctx.fiber.dispose() + }, + ) + + it('waits for an admitted background promotion during teardown', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const sessionId = SessionId('background-disposal') + const header: SessionHeader = { + version: 0, id: sessionId, createdAt: 1, cwd: '/workspace', + } + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([header]), + inspect: () => Promise.resolve({ meta: header, events: [] }), + }) as never) + const controller = createSessionTestController(ctx, defaults) + const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents + const started = Promise.withResolvers() + const release = Promise.withResolvers() + vi.spyOn(agents, 'resolveObservedAgent').mockImplementation(async () => { + started.resolve() + await release.promise + return { + agent: { id: sessionId, session: { id: sessionId }, ctx, status: 'idle' } as unknown as Agent, + } + }) + const iterator = controller.follow({ + address: { kind: 'session', sessionId }, + }, new AbortController().signal)[Symbol.asyncIterator]() + + await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) + const waiting = iterator.next() + await started.promise + let disposed = false + const disposal = ctx.fiber.dispose().then(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) + + release.resolve() + await disposal + await expect(waiting).resolves.toMatchObject({ done: true }) + }) }) diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 26443dbb5f..d21d3b25bd 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -310,9 +310,15 @@ describe('subagent catalogs', () => { }) await manager.get(S2).open() await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue') - expect(api.callsOf('subagent.history')).toEqual([ - { parentSessionId: S1, childSessionId: S2, mode: 'continuable', throughSeq: -1, maxMessages: 50 }, + expect(api.callsOf('session.follow')).toEqual([ + { + address: { + kind: 'subagent', parentSessionId: S1, childSessionId: S2, mode: 'continuable', + }, + maxMessages: 50, + }, ]) + expect(api.callsOf('subagent.history')).toEqual([]) expect(api.callsOf('subagent.prompt')).toEqual([ { parentSessionId: S1, childSessionId: S2, mode: 'continuable', @@ -507,6 +513,7 @@ describe('subagent catalogs', () => { api.onSubagentList = () => first.promise const manager = new SessionManager(api, fakeRemote(api), root) const refresh = manager.refreshSubagents(root) + manager.setSubagentCatalogOpen(root, true) // A membership frame arrives while the pull is in flight; the debounced // refresh it schedules fires 50ms later and is coalesced into the pull — @@ -768,17 +775,37 @@ describe('connected generation', () => { expect(api.callsOf('session.history')).toHaveLength(historyCallsBefore) }) - it('reloads the durable parent address for a restored child selection', async () => { + it('retains the durable parent address and refreshes its catalogs across reconnect', async () => { const api = new FakeApiClient() const address = { parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const, } + const parent = deferred>>() + const child = deferred>>() + api.onSubagentList = payload => ( + (payload as { parentSessionId: SessionId }).parentSessionId === S1 + ? parent.promise + : child.promise + ) const manager = new SessionManager(api, fakeRemote(api), S2, address) manager.handleConnected() + expect(manager.get(S2).getSnapshot().subagent).toEqual({ address }) + parent.resolve(ok({ entries: [], parentAvailable: true })) + child.resolve(ok({ entries: [], parentAvailable: true })) await vi.waitFor(() => { - expect(api.callsOf('subagent.list')).toContainEqual({ parentSessionId: S1 }) + expect(api.callsOf('session.list')).toHaveLength(1) + }) + await vi.waitFor(() => { + expect(api.callsOf('subagent.list')).toEqual([ + { parentSessionId: S1 }, + { parentSessionId: S2 }, + ]) + }) + expect(manager.get(S2).getSnapshot().subagent).toEqual({ + address, + parentAvailable: true, }) expect(manager.getListSnapshot().currentAddress).toEqual(address) }) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index aa51d159f6..7771bc112d 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -4,20 +4,21 @@ * isolation, and prompt failure mapping. */ +import { describe, expect, it, vi } from 'vitest' import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' +import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' import { PersistenceCoordinator, @@ -25,7 +26,12 @@ import { type PersistenceBackend, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' -import { createSessionTestRemote } from './test-remote.ts' +import { ApiSessionList } from '../src/list.ts' +import { + createSessionTestRemote, + installSessionReadTestServices, + testSessionPersistence, +} from './test-remote.ts' const sid = (id: string): SessionId => id as SessionId @@ -47,8 +53,12 @@ function header(id: string, createdAt: number, extra: Partial = { return { version: 0, id: sid(id), createdAt, cwd: '/proj', ...extra } } +function providePersistence(ctx: Context, persistence: Record): () => void { + return ctx.provide('sessionPersistence', testSessionPersistence(ctx, persistence) as never) +} + describe('sessions.list cold merge', () => { - it('verifies only small possibly-blank artifacts and treats every unavailable probe as visible', async () => { + it('fully observes only small possibly-blank artifacts and treats unavailable probes as visible', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const root = mkdtempSync(join(tmpdir(), 'dsh-cold-')) @@ -64,8 +74,9 @@ describe('sessions.list cold merge', () => { header('locationless', 500, { parentSession: sid('session-parent'), origin: 'subagent' }), header('vanished', 600), header('read-failure', 700), + { version: 0, id: sid('missing-cwd'), createdAt: 800 }, ] - const readFrom = vi.fn(async (id: SessionId) => { + const inspect = vi.fn(async (id: SessionId) => { if (id === sid('small-blank')) { return { meta: metas[0]!, @@ -88,7 +99,7 @@ describe('sessions.list cold merge', () => { if (id === sid('read-failure')) throw new Error('simulated read failure') throw new Error(`unexpected cold read: ${id}`) }) - ctx.provide('sessionPersistence', { + providePersistence(ctx, { list: () => Promise.resolve(metas), locate: (meta: SessionHeader) => { if (meta.id === sid('large-unknown')) return { kind: 'jsonl', path: largePath } @@ -96,8 +107,8 @@ describe('sessions.list cold merge', () => { if (meta.id === sid('vanished')) return { kind: 'jsonl', path: join(root, 'vanished.log') } return { kind: 'jsonl', path: smallPath } }, - readFrom, - } as never) + inspect, + }) ctx.provide('sessionProjectionCache', { cachedSnapshot: (meta: SessionHeader) => { if (meta.id === sid('small-blank')) { @@ -111,6 +122,8 @@ describe('sessions.list cold merge', () => { } return undefined }, + hydratePrepared: (session: Session, _meta: SessionHeader, events: readonly SessionEvent[]) => + ctx.sessionProjections.hydrate(session, {}, events, 0), } as never) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) @@ -119,10 +132,8 @@ describe('sessions.list cold merge', () => { if (!response.ok) throw new Error('unreachable') const byId = Object.fromEntries(response.value.items.map(item => [item.sessionId, item])) expect(byId['small-blank']).toMatchObject({ blank: true, updatedAt: 100, running: false }) - // A stale true hint cannot hide the turn found in the bounded read. expect(byId['small-conversation']).toMatchObject({ blank: false, updatedAt: 1200 }) expect(byId['large-unknown']).toMatchObject({ blank: false, updatedAt: 300 }) - // false is monotonic, so this row skips stat/read and keeps cached recency. expect(byId['cached-nonblank']).toMatchObject({ blank: false, updatedAt: 1000 }) expect(byId['locationless']).toMatchObject({ blank: false, @@ -132,24 +143,25 @@ describe('sessions.list cold merge', () => { }) expect(byId['vanished']).toMatchObject({ blank: false, updatedAt: 600 }) expect(byId['read-failure']).toMatchObject({ blank: false, updatedAt: 700 }) - expect(readFrom).toHaveBeenCalledTimes(3) - expect(readFrom.mock.calls.map(([id]) => id)).toEqual(expect.arrayContaining([ + expect(byId['missing-cwd']).toBeUndefined() + expect(inspect).toHaveBeenCalledTimes(3) + expect(inspect.mock.calls.map(([id]) => id)).toEqual(expect.arrayContaining([ sid('small-blank'), sid('small-conversation'), sid('read-failure'), ])) }) - it('can disable bounded blank probes without hiding cold Sessions', async () => { + it('can disable bounded cold observations without hiding cold Sessions', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const meta = header('probe-disabled', 100) - const readFrom = vi.fn() - ctx.provide('sessionPersistence', { + const inspect = vi.fn() + providePersistence(ctx, { list: () => Promise.resolve([meta]), locate: () => ({ kind: 'jsonl', path: '/not-read' }), - readFrom, - } as never) + inspect, + }) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', @@ -161,31 +173,23 @@ describe('sessions.list cold merge', () => { expect(response.value.items).toEqual([ expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: meta.createdAt }), ]) - expect(readFrom).not.toHaveBeenCalled() + expect(inspect).not.toHaveBeenCalled() }) - it('replaces a probed cold row with the live Session that attached during the read', async () => { + it('prefers a live row attached during the query without folding its seed', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - const meta = header('attached-during-probe', 100) - const root = mkdtempSync(join(tmpdir(), 'dsh-cold-race-')) - const path = join(root, 'small.log') - writeFileSync(path, 'x') + const meta = header('attached-during-list', 100) const started = Promise.withResolvers() const release = Promise.withResolvers() - ctx.provide('sessionPersistence', { - list: () => Promise.resolve([meta]), - locate: () => ({ kind: 'jsonl', path }), - readFrom: async () => { + providePersistence(ctx, { + list: async () => { started.resolve(undefined) await release.promise - return { - meta, - events: [{ type: 'session/end-seed', seq: 0, time: 110, data: {} }] as SessionEvent[], - } + return [meta] }, - } as never) + }) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const listing = remote.list(request({})) @@ -214,10 +218,89 @@ describe('sessions.list cold merge', () => { sessionId: meta.id, blank: false, running: true, - updatedAt: 300, + updatedAt: 100, }), ]) }) + + it('prefers a Session that attaches during its bounded cold observation', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const root = mkdtempSync(join(tmpdir(), 'dsh-cold-race-')) + const path = join(root, 'small.log') + writeFileSync(path, 'small') + const meta = header('attached-during-probe', 100) + providePersistence(ctx, { + list: () => Promise.resolve([meta]), + locate: () => ({ kind: 'jsonl', path }), + inspect: () => { + const session = ctx.sessions.create(meta.id, { + meta, + seed: [{ type: 'turn/start', seq: 0, time: 200, data: { turn: 1 } }], + }) + ctx.agents.register({ id: session.id, session, status: 'running', ctx } as Agent) + return Promise.resolve({ meta, events: [] }) + }, + }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', + }) + + const response = await remote.list(request({})) + if (!response.ok) throw new Error('list failed') + expect(response.value.items).toEqual([ + expect.objectContaining({ sessionId: meta.id, running: true, blank: false }), + ]) + await ctx.fiber.dispose() + }) + + it('propagates a cold location failure instead of returning a partial list', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const meta = header('broken-cache', 100) + providePersistence(ctx, { + list: () => Promise.resolve([meta]), + locate: () => { throw new Error('location failed') }, + }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', + }) + + await expect(remote.list(request({}))).resolves.toMatchObject({ + ok: false, + error: { message: expect.stringContaining('location failed') as string }, + }) + await ctx.fiber.dispose() + }) + + it('supports an unsignalled probe whose observation has no projection registry', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + installSessionReadTestServices(ctx) + const root = mkdtempSync(join(tmpdir(), 'dsh-cold-unprojected-')) + const path = join(root, 'small.log') + writeFileSync(path, 'small') + const meta = header('unprojected-small', 100) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + locate: () => ({ kind: 'jsonl', path }), + } as never) + vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([{ + header: meta, live: false, persisted: true, + }]) + vi.spyOn(ctx.sessionQuery, 'observeSession').mockResolvedValue({ + source: 'prepared', header: meta, events: [], cursor: -1, + retain: vi.fn(), [Symbol.dispose]: vi.fn(), + }) + const list = new ApiSessionList(ctx, 1024) + + await expect(list.list()).resolves.toEqual([ + expect.objectContaining({ sessionId: meta.id, blank: false }), + ]) + await ctx.fiber.dispose() + }) }) describe('attached updatedAt tracks human prompts', () => { @@ -226,6 +309,7 @@ describe('attached updatedAt tracks human prompts', () => { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + await new Promise(resolve => setTimeout(resolve, 0)) // Old work, resumed just now: the log tail would report the pickup. const worked = 1_000_000 @@ -249,7 +333,7 @@ describe('attached updatedAt tracks human prompts', () => { const listed = await remote.list(request({})) if (!listed.ok) throw new Error('list failed') const summary = listed.value.items.find(item => item.sessionId === 'resumed-untouched') - expect(summary?.updatedAt).toBe(worked) + expect(summary?.updatedAt).toBe(500) // A lifecycle boundary is not a human update. resumed.append('turn/start', { turn: 2 }) @@ -291,11 +375,12 @@ describe('cold history recovery view', () => { list: () => Promise.resolve([structuredClone(meta)]), } const coordinator = new PersistenceCoordinator(ctx, backend) - ctx.provide('sessionPersistence', { + providePersistence(ctx, { list: (signal?: AbortSignal) => backend.list(signal), inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal), + borrowSession: (id: SessionId, signal?: AbortSignal) => coordinator.borrowSession(id, signal), locate: () => undefined, - } as never) + }) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const history = await remote.page({ @@ -342,11 +427,11 @@ describe('Remote Agent and Session lookup policy', () => { const sessionId = sid('session-remote-cold') const meta = header(sessionId, 1000) const inspect = vi.fn(() => Promise.resolve({ meta, events: [] as SessionEvent[] })) - ctx.provide('sessionPersistence', { + providePersistence(ctx, { list: () => Promise.resolve([meta]), inspect, locate: () => undefined, - } as never) + }) const resumedSession = { id: sessionId, header: meta, events: [] } as unknown as import('@deepseek-ai/dsh-session').Session const resumedAgent = { id: sessionId, session: resumedSession, status: 'idle', ctx } as Agent const release = Promise.withResolvers() @@ -386,11 +471,11 @@ describe('Remote Agent and Session lookup policy', () => { origin: 'subagent', }) const inspect = vi.fn(() => Promise.resolve({ meta: coldMeta, events: [] as SessionEvent[] })) - ctx.provide('sessionPersistence', { + providePersistence(ctx, { list: () => Promise.resolve([coldMeta]), inspect, locate: () => undefined, - } as never) + }) const liveSession = ctx.sessions.create(sid('session-remote-live-child'), { meta: { cwd: '/proj', parentSession: sid('session-parent'), origin: 'subagent' }, }) @@ -442,7 +527,7 @@ describe('subagent ownership fence', () => { type: 'user/message', seq: 1, time: 2, - data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }, + data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }), surfaceOp: 'append', }, { @@ -458,15 +543,19 @@ describe('subagent ownership fence', () => { { type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, ] as SessionEvent[] const inspect = vi.fn(() => Promise.resolve({ meta, events })) - ctx.provide('sessionPersistence', { + providePersistence(ctx, { list: () => Promise.resolve([meta]), inspect, locate: () => undefined, - } as never) + }) const resume = vi.spyOn(ctx.agents, 'resume') const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + ctx.sessionProjections.register(subagentIdentityProjectionDefinition) - const history = await new SessionHistoryController(ctx).page({ + const history = await new SessionHistoryController( + ctx, + (observation) => { observation[Symbol.dispose]() }, + ).page({ address: { kind: 'subagent', parentSessionId: meta.parentSession as SessionId, @@ -516,11 +605,11 @@ describe('subagent ownership fence', () => { data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' }, }, ] as SessionEvent[] - ctx.provide('sessionPersistence', { + providePersistence(ctx, { list: () => Promise.resolve([meta]), inspect: () => Promise.resolve({ meta, events }), locate: () => undefined, - } as never) + }) // Stores whose headers predate `origin` classify a child only through the // descriptor event; the pre-release decision stops recognizing them, so // the ownership fence lets generic resume reach the registry instead of @@ -583,9 +672,13 @@ describe('subagent ownership fence', () => { if (!queued.ok) expect(queued.error.code).toBe('agent-busy') expect(updateInbox).not.toHaveBeenCalled() - const models = await remote.models(request({ sessionId: startingChild.id })) - expect(models.ok).toBe(false) - if (!models.ok) expect(models.error.code).toBe('agent-busy') + const selection = await remote.selectModel(request({ + sessionId: startingChild.id, + provider: 'p', + model: 'm', + })) + expect(selection.ok).toBe(false) + if (!selection.ok) expect(selection.error.code).toBe('agent-busy') const create = await remote.create(request({ sessionId: originChild.id, cwd: '/proj' })) expect(create.ok).toBe(false) @@ -690,7 +783,7 @@ describe('subagent ownership fence', () => { }) describe('degenerate composition (no persistence, no factory)', () => { - it('list skips the cold merge and history reports missing persistence as internal', async () => { + it('lists no cold rows and reports an absent point source as not found', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) @@ -707,20 +800,19 @@ describe('degenerate composition (no persistence, no factory)', () => { }) expect(response.ok).toBe(false) if (!response.ok) { - expect(response.error.code).toBe('internal') - expect(response.error.message).toMatch(/session persistence is not configured/) + expect(response.error.code).toBe('session-not-found') } }) - it('maps a persistence catalog miss to session-not-found without inspection', async () => { + it('maps a missing direct persistence read to session-not-found', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) const inspect = vi.fn() - ctx.provide('sessionPersistence', { + providePersistence(ctx, { list: () => Promise.resolve([]), inspect, - } as never) + }) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const response = await remote.page({ @@ -729,7 +821,7 @@ describe('degenerate composition (no persistence, no factory)', () => { }) expect(response.ok).toBe(false) if (!response.ok) expect(response.error.code).toBe('session-not-found') - expect(inspect).not.toHaveBeenCalled() + expect(inspect).toHaveBeenCalledOnce() }) }) @@ -772,11 +864,11 @@ describe('sessions.prompt synchronous rejection', () => { await ctx.plugin(AgentRegistry) const sessionId = sid('race-resume') const meta: SessionHeader = header('race-resume', 1000) - ctx.provide('sessionPersistence', { + providePersistence(ctx, { list: () => Promise.resolve([meta]), inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }), locate: () => undefined, - } as never) + }) // The raced winner: a live parent-owned subagent publishes the identity // while the generic cold resume is in flight, so the resume collides. const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } }) @@ -794,10 +886,10 @@ describe('sessions.prompt synchronous rejection', () => { }) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - const models = await remote.models(request({ sessionId })) - expect(models.ok).toBe(false) - if (!models.ok) { - expect(models.error).toMatchObject({ + const selection = await remote.selectModel(request({ sessionId, provider: 'p', model: 'm' })) + expect(selection.ok).toBe(false) + if (!selection.ok) { + expect(selection.error).toMatchObject({ code: 'agent-busy', details: { reason: 'use subagent delivery for this child session' }, }) 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 dfd55d6a52..ee6c777e80 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -12,6 +12,7 @@ import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -19,7 +20,7 @@ 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 { SessionControlController } from '@deepseek-ai/dsh-api-session-controller/src/control.ts' -import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' +import type { SessionControlFrame, SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types' import { createSessionTestRemote, type TestSessionRemote } from './test-remote.ts' declare module '@deepseek-ai/dsh-session-projection/types' { @@ -48,6 +49,24 @@ function page( }) } +/** Read and close one snapshot-first follow generation. */ +async function opening( + remote: TestSessionRemote, + sessionId: SessionId, + maxMessages?: number, +): Promise> { + const abort = new AbortController() + const iterator = remote.follow({ + address: { kind: 'session', sessionId }, + ...(maxMessages === undefined ? {} : { maxMessages }), + }, abort.signal)[Symbol.asyncIterator]() + const first = await iterator.next() + abort.abort() + await iterator.return?.() + if (first.done || first.value.type !== 'snapshot') throw new Error('follow did not open with a snapshot') + return first.value +} + /** Whole-value unit folding the latest user/message text; null before the first. */ type LastUserState = { text: string } | null const lastUserUnit = () => ({ @@ -77,7 +96,7 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) if (withRegistry) await ctx.plugin(SessionProjectionRegistry) - const session = ctx.sessions.create() + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) // The gateway reads both the session and durable inbox baseline. ctx.agents.register({ id: session.id, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', ctx } as Agent) return { ctx, session } @@ -96,33 +115,57 @@ function seedMessages(session: Session, count: number): void { const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) describe('session.history projections block', () => { + it('tracks pending and used model selections across repeated request headers', async () => { + const { ctx, session } = await harness(true) + remote(ctx) + await new Promise(resolve => setTimeout(resolve, 0)) + const selected = { provider: 'p', model: 'next' } + session.append('model/selection', selected) + session.append('model/selection', selected) + session.append('request/header', { + header: { config: { provider: 'p', model: 'used' } }, reason: 'initial', + }) + session.append('request/header', { + header: { config: { provider: 'p', model: 'used' } }, reason: 'initial', + }) + + expect(ctx.sessionProjections.snapshot(session).values.modelSelection).toEqual({ + lastUsed: { provider: 'p', model: 'used' }, + next: selected, + }) + + session.append('request/header', { + header: { config: selected }, reason: 'initial', + }) + expect(ctx.sessionProjections.snapshot(session).values.modelSelection).toEqual({ + lastUsed: selected, + next: selected, + }) + }) + it('serves the unit value on the tail page with asOfSeq = last event seq', async () => { const { ctx, session } = await harness(true) ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 3) - const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 })) - expect(response.ok).toBe(true) - if (!response.ok) throw new Error('unreachable') - const { events, projections } = response.value - expect(projections).toBeDefined() - expect(projections?.asOfSeq).toBe(session.seq - 1) - expect(projections?.values['test/last-user']).toEqual({ text: 'm2' }) + const snapshot = await opening(remote(ctx), session.id) + const { events, projections } = snapshot + expect(projections.asOfSeq).toBe(session.seq - 1) + expect(projections.values['test/last-user']).toEqual({ text: 'm2' }) // asOfSeq IS the window tail: the last served event carries it. - expect(events.at(-1)?.event.seq).toBe(projections?.asOfSeq) + expect(events.at(-1)?.event.seq).toBe(projections.asOfSeq) }) - it('cuts attached projections and events at the requested follow cursor', async () => { + it('returns a complete current replacement cut on each follow generation', async () => { const { ctx, session } = await harness(true) ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 2) - const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: 0 })) - if (!response.ok) throw new Error('history failed') + const snapshot = await opening(remote(ctx), session.id) - expect(response.value.events.map(entry => entry.event.seq)).toEqual([0]) - expect(response.value.projections?.asOfSeq).toBe(0) - expect(response.value.projections?.values).toEqual( - expect.objectContaining({ 'test/last-user': { text: 'm0' } }), + expect(snapshot.events.map(entry => entry.event.seq)).toEqual([0, 1]) + expect(snapshot.projections.asOfSeq).toBe(1) + expect(snapshot.projections.values).toEqual( + expect.objectContaining({ 'test/last-user': { text: 'm1' } }), ) }) @@ -130,12 +173,11 @@ describe('session.history projections block', () => { const { ctx, session } = await harness(true) ctx.sessionProjections.register(lastUserUnit()) - const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: -1 })) - if (!response.ok) throw new Error('history failed') + const snapshot = await opening(remote(ctx), session.id) - expect(response.value.events).toEqual([]) - expect(response.value.projections?.asOfSeq).toBe(-1) - expect(response.value.projections?.values).toEqual( + expect(snapshot.events).toEqual([]) + expect(snapshot.projections.asOfSeq).toBe(-1) + expect(snapshot.projections.values).toEqual( expect.objectContaining({ 'test/last-user': null }), ) }) @@ -157,10 +199,10 @@ describe('session.history projections block', () => { readImage(): Promise { return Promise.reject(new Error('unused')) } }) const gateway = remote(ctx) + await new Promise(resolve => setTimeout(resolve, 0)) seedMessages(session, 2) - const response = await page(gateway, request({ sessionId: session.id, throughSeq: session.seq - 1 })) - if (!response.ok) throw new Error('history failed') - expect(response.value.projections?.values['imageLimits']).toEqual(limits) + const snapshot = await opening(gateway, session.id) + expect(snapshot.projections.values['imageLimits']).toEqual(limits) // Constant unit: appending events must never broadcast an imageLimits projection. await new Promise(resolve => setTimeout(resolve, 0)) const abort = new AbortController() @@ -186,10 +228,8 @@ describe('session.history projections block', () => { it('leaves the imageLimits key absent while no attachment service is composed', async () => { const { ctx, session } = await harness(true) seedMessages(session, 1) - const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 })) - if (!response.ok) throw new Error('history failed') - expect(response.value.projections).toBeDefined() - expect('imageLimits' in (response.value.projections?.values ?? {})).toBe(false) + const snapshot = await opening(remote(ctx), session.id) + expect('imageLimits' in snapshot.projections.values).toBe(false) }) it('never carries the block on loadOlder pages (beforeSeq present)', async () => { @@ -236,9 +276,8 @@ describe('session.history projections block', () => { abort.abort() await iterator.return?.() - const history = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 })) - if (!history.ok) throw new Error('history failed') - expect('test/internal-count' in (history.value.projections?.values ?? {})).toBe(false) + const history = await opening(proxy, session.id) + expect('test/internal-count' in history.projections.values).toBe(false) const listing = await proxy.list(request({})) if (!listing.ok) throw new Error('listing failed') const row = listing.value.items.find(item => item.sessionId === session.id) @@ -250,18 +289,16 @@ describe('session.history projections block', () => { const dispose = ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 1) const proxy = remote(ctx) - const before = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 })) - if (!before.ok) throw new Error('unreachable') - expect(before.value.projections?.values['test/last-user']).toEqual({ text: 'm0' }) + const before = await opening(proxy, session.id) + expect(before.projections.values['test/last-user']).toEqual({ text: 'm0' }) dispose() - const after = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 })) - if (!after.ok) throw new Error('unreachable') + const after = await opening(proxy, session.id) // The registry stays mounted; only the disposed key leaves while the // gateway-owned Session-list unit remains. - expect(after.value.projections?.asOfSeq).toBe(session.seq - 1) - expect('test/last-user' in (after.value.projections?.values ?? {})).toBe(false) - expect(after.value.projections?.values.sessionListMetadata).toEqual({ + expect(after.projections.asOfSeq).toBe(session.seq - 1) + expect('test/last-user' in after.projections.values).toBe(false) + expect(after.projections.values.sessionListMetadata).toEqual({ blank: true, lastPromptAt: session.events.at(-1)?.time, }) @@ -284,7 +321,7 @@ describe('session.history projections block', () => { }) describe('session.list projections column', () => { - it('serves attached rows from the live registry cut, watermarked for client seeding', async () => { + it('serves every already-materialized wire value from the live registry without folding', async () => { const { ctx, session } = await harness(true) ctx.sessionProjections.register(lastUserUnit()) const gateway = remote(ctx) @@ -302,6 +339,37 @@ describe('session.list projections column', () => { expect(row?.projections?.asOfSeq).toBe(session.seq - 1) }) + it('lists the latest preset selected by a blank Session instead of its creation preset', async () => { + const { ctx } = await harness(true) + const session = ctx.sessions.create(SessionId('preset-list'), { + meta: { cwd: '/workspace', agentPreset: 'standard' }, + }) + ctx.sessionProjections.register(agentPresetProjectionDefinition) + const gateway = remote(ctx) + await new Promise(resolve => setTimeout(resolve, 0)) + session.append('agent-preset/selected', { agentPreset: 'minimal' }) + + const response = await gateway.list(request({})) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === session.id) + expect(row?.projections?.values.agentPreset).toBe('minimal') + }) + + it('omits an unmaterialized live projection instead of folding history for listing', async () => { + const { ctx, session } = await harness(true) + seedMessages(session, 1) + const unit = lastUserUnit() + const apply = vi.fn(unit.apply) + ctx.sessionProjections.register({ ...unit, apply }) + + const response = await remote(ctx).list(request({})) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === session.id) + expect(row).toBeDefined() + expect('test/last-user' in (row?.projections?.values ?? {})).toBe(false) + expect(apply).not.toHaveBeenCalled() + }) + it('omits the column entirely when no registry is mounted', async () => { const { ctx, session } = await harness(false) seedMessages(session, 1) @@ -312,7 +380,7 @@ describe('session.list projections column', () => { expect(row !== undefined && 'projections' in row).toBe(false) }) - it('serves cold rows from the persisted projection cache with zero log loads', async () => { + it('serves every available cold projection hint from the cache with zero log loads', async () => { const { ctx } = await harness(true) const coldId = SessionId('session-cold-listing') const load = () => { throw new Error('list must not load event logs') } @@ -327,14 +395,28 @@ describe('session.list projections column', () => { // The carrier hands the listed header through as the identity witness. cachedSnapshot: (meta: { id: unknown; createdAt: number }) => (meta.id === coldId && meta.createdAt === 5 - ? { asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } } + ? { + asOfSeq: 7, + values: { + 'test/last-user': { text: 'cached' }, + sessionListMetadata: { blank: false, lastPromptAt: 6 }, + title: 'Cached title', + }, + } : undefined), } as never) const response = await remote(ctx).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === coldId) expect(row?.running).toBe(false) - expect(row?.projections).toEqual({ asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } }) + expect(row?.projections).toEqual({ + asOfSeq: 7, + values: { + 'test/last-user': { text: 'cached' }, + sessionListMetadata: { blank: false, lastPromptAt: 6 }, + title: 'Cached title', + }, + }) }) it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => { @@ -421,9 +503,8 @@ describe('Session control projection frames', () => { { type: 'projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 300 }, seq: 2 }, ]) // Frame seq aligns with the tail block's asOfSeq vocabulary (higher-seq-wins compatible). - const tail = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 })) - if (!tail.ok) throw new Error('unreachable') - expect(tail.value.projections?.asOfSeq).toBe(pushes.at(-1)?.seq) + const tail = await opening(proxy, session.id) + expect(tail.projections.asOfSeq).toBe(pushes.at(-1)?.seq) }) it('emits no projection frames when the composition has no registry', async () => { diff --git a/packages/api/session-controller/tests/session-search.host.spec.ts b/packages/api/session-controller/tests/session-search.host.spec.ts index 173d20cf9e..49bc900566 100644 --- a/packages/api/session-controller/tests/session-search.host.spec.ts +++ b/packages/api/session-controller/tests/session-search.host.spec.ts @@ -6,22 +6,18 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { stat } from 'node:fs/promises' import AgentRegistry from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { + SessionQueryEngine, SessionQueryError, type SessionSearchHit, type SessionSearchRequest, } from '@deepseek-ai/dsh-session-query' import { createSessionTestRemote } from './test-remote.ts' - -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, stat: vi.fn(actual.stat) } -}) +import { ApiSessionList } from '../src/list.ts' const sid = (value: string): SessionId => value as SessionId const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' } @@ -63,7 +59,48 @@ async function baseContext(): Promise { return ctx } +/** Real query core with a programmable full-text provider for Host search tests. */ +class SearchSessionQuery extends SessionQueryEngine { + constructor( + ctx: Context, + private readonly search: ( + ...args: Parameters + ) => Promise, + ) { + super(ctx) + } + + override searchSessions( + ...args: Parameters + ): ReturnType { + return this.search(...args) as ReturnType + } + + override searchEvents(): Promise { + return Promise.reject(new Error('event search is not configured in this test')) + } +} + +function installSearchQuery( + ctx: Context, + searchSessions: ( + ...args: Parameters + ) => Promise, +): void { + new SearchSessionQuery(ctx, searchSessions) +} + describe('session.search', () => { + it('rejects search when the query service is absent', async () => { + const ctx = await baseContext() + const list = new ApiSessionList(ctx, 0) + + await expect(list.search('query', new AbortController().signal)).rejects.toMatchObject({ + failure: { code: 'internal' }, + }) + await ctx.fiber.dispose() + }) + it('searches only list-visible ids and current conversation-message events', async () => { const ctx = await baseContext() const live = ctx.sessions.create(sid('live'), { meta: header('live', '/live') }) @@ -111,7 +148,7 @@ describe('session.search', () => { }, ], })) - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const remote = createSessionTestRemote(ctx, defaults) const signal = new AbortController().signal @@ -147,7 +184,7 @@ describe('session.search', () => { const ctx = await baseContext() ctx.sessions.create(sid('visible'), { meta: header('visible') }) const searchSessions = vi.fn() - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const remote = createSessionTestRemote(ctx, defaults) for (const query of ['', ' ', 'contains\0nul', 'x'.repeat(501)]) { @@ -161,7 +198,7 @@ describe('session.search', () => { it('returns an empty page without invoking the index when no session is visible', async () => { const ctx = await baseContext() const searchSessions = vi.fn() - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const remote = createSessionTestRemote(ctx, defaults) const response = await remote.search( @@ -187,16 +224,14 @@ describe('session.search', () => { const base = hit('visible', index) return { ...base, bestMatch: { ...base.bestMatch, ...bestMatch } } } - ctx.provide('sessionQuery', { - searchSessions: () => Promise.resolve({ - items: [ - withBestMatch(0, { sessionId: sid('hidden') }), - withBestMatch(1, { surface: 'shadowed' }), - withBestMatch(2, { type: 'tool/result' }), - withBestMatch(3, { type: 'user/message', snippet: 'allowed snippet' }), - ], - }), - } as never) + installSearchQuery(ctx, () => Promise.resolve({ + items: [ + withBestMatch(0, { sessionId: sid('hidden') }), + withBestMatch(1, { surface: 'shadowed' }), + withBestMatch(2, { type: 'tool/result' }), + withBestMatch(3, { type: 'user/message', snippet: 'allowed snippet' }), + ], + })) const response = await createSessionTestRemote(ctx, defaults).search( request('match'), @@ -224,9 +259,7 @@ describe('session.search', () => { nextCursor: 'page-2', }) .mockResolvedValueOnce({ items: items.slice(19) }) - ctx.provide('sessionQuery', { - searchSessions, - } as never) + installSearchQuery(ctx, searchSessions) const response = await createSessionTestRemote(ctx, defaults).search( request('match'), new AbortController().signal, @@ -266,7 +299,7 @@ describe('session.search', () => { ...end < items.length ? { nextCursor: `offset-${end}` } : {}, }) }) - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const response = await createSessionTestRemote(ctx, defaults).search( request('adaptive-page-limit'), @@ -309,7 +342,7 @@ describe('session.search', () => { nextCursor: `page-${searchSessions.mock.calls.length}`, }) }) - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const response = await createSessionTestRemote(ctx, defaults).search( request('endless-pages'), @@ -374,7 +407,7 @@ describe('session.search', () => { return Promise.reject(new Error('unexpected provider call')) } }) - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const response = await createSessionTestRemote(ctx, defaults).search( request('stale-restart'), @@ -413,7 +446,7 @@ describe('session.search', () => { nextCursor: `cursor-${searchSessions.mock.calls.length}`, }) }) - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const response = await createSessionTestRemote(ctx, defaults).search( request('stale-churn'), @@ -442,7 +475,7 @@ describe('session.search', () => { controller.abort() return Promise.reject(stale) }) - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const response = await createSessionTestRemote(ctx, defaults).search( request('abort-stale'), @@ -463,7 +496,7 @@ describe('session.search', () => { 'provider generation changed before paging', 'SESSION_QUERY_STALE_CURSOR', ))) - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const response = await createSessionTestRemote(ctx, defaults).search( request('first-page-stale'), @@ -487,7 +520,7 @@ describe('session.search', () => { 'continuation limit is invalid', 'SESSION_QUERY_INVALID_LIMIT', )) - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const response = await createSessionTestRemote(ctx, defaults).search( request('continuation-invalid-limit'), @@ -514,7 +547,7 @@ describe('session.search', () => { 'SESSION_QUERY_INVALID_LIMIT', ), )) - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const response = await createSessionTestRemote(ctx, defaults).search( request('minimum-page-limit'), @@ -540,7 +573,7 @@ describe('session.search', () => { 'SESSION_QUERY_INVALID_LIMIT', )) }) - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const response = await createSessionTestRemote(ctx, defaults).search( request('abort-invalid-limit'), @@ -559,7 +592,7 @@ describe('session.search', () => { ctx.sessions.create(sid('visible'), { meta: header('visible') }) const oversized = Array.from({ length: 21 }, (_, index) => hit(`oversized-${index}`)) const searchSessions = vi.fn(() => Promise.resolve({ items: oversized })) - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const response = await createSessionTestRemote(ctx, defaults).search( request('oversized-page'), @@ -585,7 +618,7 @@ describe('session.search', () => { } return Promise.resolve({ items: oversized }) }) - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const response = await createSessionTestRemote(ctx, defaults).search( request('adapted-oversized-page'), @@ -611,9 +644,7 @@ describe('session.search', () => { snippet: `${expected}${'y'.repeat(10_000)}`, }, } - ctx.provide('sessionQuery', { - searchSessions: () => Promise.resolve({ items: [overlong] }), - } as never) + installSearchQuery(ctx, () => Promise.resolve({ items: [overlong] })) const response = await createSessionTestRemote(ctx, defaults).search( request('bounded-snippet'), @@ -635,7 +666,7 @@ describe('session.search', () => { const searchSessions = vi.fn() .mockResolvedValueOnce({ items: [], nextCursor: 'repeated' }) .mockResolvedValueOnce({ items: [], nextCursor: 'repeated' }) - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const response = await createSessionTestRemote(ctx, defaults).search( request('repeated-cursor'), @@ -658,7 +689,7 @@ describe('session.search', () => { const searchSessions = vi.fn() .mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'repeated' }) .mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' }) - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const response = await createSessionTestRemote(ctx, defaults).search( request('repeated-lookahead-cursor'), @@ -685,7 +716,7 @@ describe('session.search', () => { .mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-2' }) .mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-3' }) .mockResolvedValueOnce({ items: items.slice(20) }) - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const response = await createSessionTestRemote(ctx, defaults).search( request('duplicate-pages'), @@ -713,7 +744,7 @@ describe('session.search', () => { controller.abort() return Promise.resolve({ items: [] }) }) - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const response = await createSessionTestRemote(ctx, defaults).search( request('cancel-continuation'), @@ -743,7 +774,7 @@ describe('session.search', () => { const searchSessions = vi.fn((_request: SessionSearchRequest) => Promise.resolve({ items: [hit('cold-32750')], })) - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const response = await createSessionTestRemote(ctx, defaults).search( request('large corpus'), @@ -761,12 +792,13 @@ describe('session.search', () => { expect(searchSessions.mock.calls[0]?.[0]).not.toHaveProperty('sessionFilters') }) - it('propagates cancellation through visible-session collection and stops cold-summary work', async () => { + it('propagates cancellation through the lightweight visibility listing', async () => { const ctx = await baseContext() const controller = new AbortController() const cold = Array.from({ length: 32 }, (_, index) => header(`cold-${index}`, `/cold-${index}`)) const list = vi.fn((signal?: AbortSignal) => { expect(signal).toBe(controller.signal) + controller.abort() return Promise.resolve(cold) }) let locateCalls = 0 @@ -774,12 +806,11 @@ describe('session.search', () => { list, locate: () => { locateCalls++ - controller.abort() return undefined }, } as never) const searchSessions = vi.fn() - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const response = await createSessionTestRemote(ctx, defaults).search( request('cancel-during-visibility'), @@ -791,53 +822,34 @@ describe('session.search', () => { error: { code: 'cancelled' }, }) expect(list).toHaveBeenCalledOnce() - expect(locateCalls).toBe(1) + expect(locateCalls).toBe(0) expect(searchSessions).not.toHaveBeenCalled() }) - it('awaits every started cold-summary stat before returning cancellation', async () => { + it('does not stat or locate cold artifacts while collecting search visibility', async () => { const ctx = await baseContext() - const controller = new AbortController() const cold = Array.from({ length: 16 }, (_, index) => header(`cold-${index}`, `/cold-${index}`)) - const statGates = cold.map(() => Promise.withResolvers<{ mtimeMs: number }>()) - const statMock = vi.mocked(stat) - statMock.mockClear() - for (const gate of statGates) { - statMock.mockImplementationOnce((() => gate.promise) as never) - } + const locate = vi.fn((meta: SessionHeader) => ({ kind: 'jsonl', path: `/logs/${meta.id}.jsonl` })) ctx.provide('sessionPersistence', { list: () => Promise.resolve(cold), - locate: (meta: SessionHeader) => ({ kind: 'jsonl', path: `/logs/${meta.id}.jsonl` }), + locate, } as never) - const searchSessions = vi.fn() - ctx.provide('sessionQuery', { searchSessions } as never) + const searchSessions = vi.fn(() => Promise.resolve({ items: [] })) + installSearchQuery(ctx, searchSessions) - let settled = false - const responsePromise = createSessionTestRemote(ctx, defaults).search( - request('cancel-during-cold-stats'), - controller.signal, - ).finally(() => { - settled = true - }) - await vi.waitFor(() => { - expect(statMock).toHaveBeenCalledTimes(16) - }) - - controller.abort() - statGates[0]!.resolve({ mtimeMs: 101 }) - await new Promise(resolve => setImmediate(resolve)) - expect(settled).toBe(false) - - for (const gate of statGates.slice(1)) gate.resolve({ mtimeMs: 102 }) - const response = await responsePromise + const response = await createSessionTestRemote(ctx, defaults).search( + request('header-only-visibility'), + new AbortController().signal, + ) expect(response).toMatchObject({ - ok: false, - error: { code: 'cancelled' }, + ok: true, + value: { items: [], hasMore: false }, }) - expect(searchSessions).not.toHaveBeenCalled() + expect(locate).not.toHaveBeenCalled() + expect(searchSessions).toHaveBeenCalledOnce() }) - it('maps missing composition, query cancellation, and provider failure', async () => { + it('maps preflight cancellation, query cancellation, and provider failure', async () => { const missingCtx = await baseContext() missingCtx.sessions.create(sid('visible'), { meta: header('visible') }) const missingApi = createSessionTestRemote(missingCtx, defaults) @@ -852,22 +864,13 @@ describe('session.search', () => { error: { code: 'cancelled' }, }) - const missing = await missingApi.search( - request('needle'), - new AbortController().signal, - ) - expect(missing.ok).toBe(false) - if (missing.ok) throw new Error('unreachable') - expect(missing.error.code).toBe('internal') - expect(missing.error.message).toContain('does not mount') - const ctx = await baseContext() ctx.sessions.create(sid('visible'), { meta: header('visible') }) const aborted = new SessionQueryError('provider stopped', 'SESSION_QUERY_ABORTED') const searchSessions = vi.fn() .mockRejectedValueOnce(aborted) .mockRejectedValueOnce(new Error('database unavailable')) - ctx.provide('sessionQuery', { searchSessions } as never) + installSearchQuery(ctx, searchSessions) const remote = createSessionTestRemote(ctx, defaults) const cancelled = await remote.search( diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index 461190f3ce..f602607e02 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -45,7 +45,7 @@ type FeedRow = { origin?: 'subagent' running?: boolean blank?: boolean - agentPreset?: string + projections?: Record } async function feedList(b: Bench, rows: FeedRow[]): Promise { @@ -55,7 +55,9 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise { ...(r.cwd !== undefined ? { cwd: r.cwd } : {}), ...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}), ...(r.origin !== undefined ? { origin: r.origin } : {}), - ...(r.agentPreset !== undefined ? { agentPreset: r.agentPreset } : {}), + ...(r.projections === undefined + ? {} + : { projections: { asOfSeq: 0, values: r.projections } }), })), }) as never) await b.svc.refresh() @@ -81,19 +83,17 @@ describe('list store projection', () => { expect(state.byId[sid('s2')]?.title).toBeUndefined() }) - it('reprojects a blank session whose composition switched and nothing else moved', async () => { + it('reprojects a blank session from the generic agent-preset projection', async () => { const b = bench() - await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }]) - expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('standard') + await feedList(b, [{ id: 's1', blank: true, projections: { agentPreset: 'standard' } }]) + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.projectionValues?.agentPreset).toBe('standard') - // A confirmed switch moves the preset alone: the row keeps its updatedAt, - // title, running, and blank bits, so an identity guard blind to the preset - // would serve the old row forever — and every reader (the hero chip's own - // no-op check, the header label) would keep the composition it replaced. - b.svc.noteAgentPreset(sid('s1'), 'minimal') + b.svc.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'agentPreset', value: 'minimal', seq: 1, + }) await Promise.resolve() - expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal') + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.projectionValues?.agentPreset).toBe('minimal') }) it('reflects live increments (host stream via manager) into the store', async () => { @@ -214,6 +214,7 @@ describe('scope tree', () => { b.svc.open(sid('s2')) await vi.waitFor(() => { expect(b.api.activeFollows(sid('s1'))).toBe(0) }) + notified.mockClear() await b.api.pushFollow(sid('s1'), { type: 'event', event: { seq: 0, timestamp: 0, type: 'turn/start', data: { turn: 0 } } as never, @@ -252,7 +253,7 @@ describe('Agent scope disposal lifecycle', () => { ...remote, session: { ...remote.session, - follow: (_request, signal) => { + follow: (request, signal) => { if (signal === undefined) throw new Error('fixture requires a signal') followSignal = signal let opened = false @@ -263,7 +264,20 @@ describe('Agent scope disposal lifecycle', () => { opened = true return Promise.resolve({ done: false, - value: { type: 'opened', cursor: -1 } as const, + value: { + type: 'snapshot', + header: { + version: 0, + id: request.address.kind === 'session' + ? request.address.sessionId + : request.address.childSessionId, + createdAt: 0, + }, + cursor: -1, + events: [], + hasMore: false, + projections: { asOfSeq: -1, values: {} }, + } as const, }) } return new Promise((_resolve, reject) => { @@ -325,7 +339,14 @@ describe('Agent scope disposal lifecycle', () => { opened = true return Promise.resolve({ done: false, - value: { type: 'opened', cursor: -1 } as const, + value: { + type: 'snapshot', + header: { version: 0, id: sessionId, createdAt: 0 }, + cursor: -1, + events: [], + hasMore: false, + projections: { asOfSeq: -1, values: {} }, + } as const, }) } return new Promise>((_resolve, reject) => { @@ -457,22 +478,22 @@ describe('binding and stage lifecycle', () => { it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async () => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) - const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history') + const followStarts = () => b.api.followStarts.map(String) // Resolution is addressing, not staging: no window pull. b.svc.scope(sid('s1')) b.svc.binding(sid('s1')) - expect(historyCalls()).toHaveLength(0) + expect(followStarts()).toEqual([]) b.svc.open(sid('s1')) await vi.waitFor(() => { - expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1']) + expect(followStarts()).toEqual(['s1']) }) // Same current again: no second pull. b.svc.open(sid('s1')) - expect(historyCalls()).toHaveLength(1) + expect(followStarts()).toHaveLength(1) // Stage moves: the new occupant opens. b.svc.open(sid('s2')) await vi.waitFor(() => { - expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1', 's2']) + expect(followStarts()).toEqual(['s1', 's2']) }) }) @@ -486,11 +507,10 @@ describe('binding and stage lifecycle', () => { }) try { const b = bench() - expect(b.api.calls.filter(c => c.method === 'session.history')).toHaveLength(0) + expect(b.api.followStarts).toEqual([]) await feedList(b, [{ id: 's1' }]) // projection validates the persisted id → current lands → stage follows await vi.waitFor(() => { - const historyCalls = b.api.calls.filter(c => c.method === 'session.history') - expect(historyCalls.map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1']) + expect(b.api.followStarts.map(String)).toEqual(['s1']) }) } finally { vi.unstubAllGlobals() @@ -827,13 +847,12 @@ describe('coverage tails (branch duals)', () => { const b = bench() await feedList(b, [{ id: 's1' }]) b.svc.open(sid('s1')) - const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history') - await vi.waitFor(() => { expect(historyCalls()).toHaveLength(1) }) + await vi.waitFor(() => { expect(b.api.followStarts).toHaveLength(1) }) await feedList(b, []) // removed while staged: current masks to undefined, stage holds → deferred expect(b.svc.scope(sid('s1'))).toBeDefined() // Resurfacing re-projects current = s1: same stage occupant, no second pull. await feedList(b, [{ id: 's1' }]) - expect(historyCalls()).toHaveLength(1) + expect(b.api.followStarts).toHaveLength(1) expect(b.svc.list.getSnapshot().current).toBe('s1') }) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 2014938f3a..ac131ab628 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -2,6 +2,16 @@ import type { Context } from '@deepseek-ai/cordis' import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { + SessionPersistenceCorruptionError, + SessionPersistenceNotFoundError, + SessionPersistenceRevision, + type BorrowedSessionSource, + type SessionInspection, +} from '@deepseek-ai/dsh-session-persistence' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SessionQueryEngine from '@deepseek-ai/dsh-session-query' import { vi } from 'vitest' import { TypertRemoteFailure, @@ -18,10 +28,10 @@ import type { SessionCreateValue, SessionForkRequest, SessionForkValue, + SessionFollowFrame, + SessionFollowRequest, SessionListRequest, SessionListValue, - SessionModels, - SessionModelsRequest, SessionPage, SessionPageRequest, SessionPromptRequest, @@ -41,7 +51,6 @@ export interface TestSessionRemote { list(request: SessionListRequest, signal?: AbortSignal): Promise> search(request: SessionSearchRequest, signal?: AbortSignal): Promise> create(request: SessionCreateRequest): Promise> - models(request: SessionModelsRequest): Promise> selectModel(request: SessionSelectModelRequest): Promise> rename(request: SessionRenameRequest): Promise> fork(request: SessionForkRequest): Promise> @@ -50,6 +59,7 @@ export interface TestSessionRemote { updateQueue(request: SessionUpdateQueueRequest): Promise> cancel(request: SessionCancelRequest): Promise> page(request: SessionPageRequest, signal?: AbortSignal): Promise> + follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable control(signal?: AbortSignal): AsyncIterable } @@ -63,6 +73,73 @@ export interface TestSessionRemoteDefaults { const installed = new WeakMap() +type LegacyTestPersistence = Record & { + readonly inspect?: ( + sessionId: SessionId, + signal?: AbortSignal, + ) => Promise + readonly borrowSession?: ( + sessionId: SessionId, + signal?: AbortSignal, + ) => Promise +} + +/** Add the preparation-backed point-read contract to compact persistence doubles. */ +export function testSessionPersistence( + ctx: Context, + persistence: LegacyTestPersistence, +): LegacyTestPersistence { + if (persistence.borrowSession !== undefined) return persistence + return { + ...persistence, + borrowSession: async (sessionId, signal) => { + signal?.throwIfAborted() + const inspection = await persistence.inspect?.(sessionId, signal) + signal?.throwIfAborted() + if (inspection === undefined) throw new SessionPersistenceNotFoundError(sessionId) + try { + const preparedSession = ctx.sessions.prepare(inspection.meta.id, { + seed: [...inspection.events], + meta: inspection.meta, + seedSource: 'persistence', + }) + return { + source: 'prepared', + inspection: { + meta: preparedSession.header, + events: Object.freeze([...inspection.events]), + }, + revision: SessionPersistenceRevision(`test:${sessionId}:${String(preparedSession.seq)}`), + preparedSession, + [Symbol.dispose]: () => {}, + } + } catch (error: unknown) { + throw new SessionPersistenceCorruptionError( + `test session "${sessionId}" failed validation: ${String(error)}`, + { cause: error }, + ) + } + }, + } +} + +/** Concrete point-read query used by Session Controller tests that do not exercise search. */ +class TestSessionQuery extends SessionQueryEngine { + override searchSessions(): Promise { + return Promise.reject(new Error('session search is not configured in this test')) + } + + override searchEvents(): Promise { + return Promise.reject(new Error('event search is not configured in this test')) + } +} + +/** Install the required projection and point-query services for direct controller tests. */ +export function installSessionReadTestServices(ctx: Context): void { + if (ctx.get('sessionProjections') === undefined) new SessionProjectionRegistry(ctx) + if (ctx.get('sessionQuery') === undefined) new TestSessionQuery(ctx) +} + function installControllers( ctx: Context, defaults: TestSessionRemoteDefaults, @@ -93,6 +170,7 @@ function installControllers( }, } as never) } + installSessionReadTestServices(ctx) const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd) let controller: SessionController try { @@ -151,7 +229,6 @@ export function createSessionTestRemote( signal, ), create: request => remoteResult(() => direct.create(request)), - models: request => remoteResult(() => direct.models(request)), selectModel: request => remoteResult(() => direct.selectModel(request)), rename: request => remoteResult(() => direct.rename(request)), fork: request => remoteResult(() => direct.fork(request)), @@ -166,6 +243,7 @@ export function createSessionTestRemote( () => direct.page(request, signal), signal, ), + follow: (request, signal = new AbortController().signal) => direct.follow(request, signal), control: (signal = new AbortController().signal) => direct.control(signal), } } From 822d735356092051236a29c6f19b45ac6c3c3382 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:07:42 +0800 Subject: [PATCH 05/17] feat(session): persist model selection and share its catalog --- apps/web/tests/default-model.e2e.ts | 11 +- .../tests/onboarding-deepseek-config.e2e.ts | 2 +- apps/web/tests/startup-auto-selection.e2e.ts | 106 ++++----- packages/api/remotes/src/client/index.ts | 2 +- packages/api/session-controller/package.json | 1 - packages/api/session-controller/src/agent.ts | 203 ++++++++++++++---- .../api/session-controller/src/catalog.ts | 18 +- .../api/session-controller/src/commands.ts | 53 ++--- .../src/model-selection-projection.ts | 83 +++++++ .../tests/agent.host.spec.ts | 170 ++++++++++++--- .../tests/commands-create-fork.host.spec.ts | 35 ++- .../commands-queue-attachment.host.spec.ts | 57 ++++- .../tests/session-fork.host.spec.ts | 40 ++-- .../tests/session-models.host.spec.ts | 60 +++--- .../tests/session-presets.host.spec.ts | 8 +- .../api/session-controller/tsconfig.host.json | 3 +- packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/fixture.ts | 139 ++++++++---- .../client/connection/src/client/index.ts | 2 +- .../connection/tests/fake-api.client.ts | 7 +- .../connection/tests/fixture.client.spec.ts | 105 +++------ .../client/ui-model-selection/package.json | 3 +- .../src/client/ModelSelect.tsx | 26 ++- .../ui-model-selection/src/client/catalog.ts | 89 ++++++++ .../src/client/directory.ts | 147 +++++++------ .../ui-model-selection/src/client/index.ts | 17 +- .../ui-model-selection/src/client/locales.ts | 2 + .../ui-model-selection/src/client/service.ts | 26 ++- .../ui-model-selection/src/client/slots.ts | 2 +- .../tests/browser-plugin.client.spec.ts | 136 +++++++++--- .../tests/catalog.client.spec.ts | 93 ++++++++ .../tests/model-select.client.spec.tsx | 34 ++- .../client/ui-model-selection/tsconfig.json | 3 + .../core/session/src/known-event-types.ts | 1 + 34 files changed, 1170 insertions(+), 516 deletions(-) create mode 100644 packages/api/session-controller/src/model-selection-projection.ts create mode 100644 packages/client/ui-model-selection/src/client/catalog.ts create mode 100644 packages/client/ui-model-selection/tests/catalog.client.spec.ts diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index d4393bf64b..34a9fab2f9 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -46,9 +46,14 @@ describe('web e2e: the composer model switch is the default for later sessions', return response.sessionId } - /** The route the gateway reports for one session, through the real wire face. */ - const currentOf = async (sessionId: string): Promise => { - return (await scaffold.ctx.sessionController.models({ sessionId: SessionId(sessionId) })).current + /** The route the Client derives from the Session projection and Host default. */ + const currentOf = (sessionId: string): Promise => { + const session = scaffold.ctx.sessions.get(SessionId(sessionId)) + if (session === undefined) throw new Error(`session "${sessionId}" is not live`) + return Promise.resolve( + scaffold.ctx.sessionProjections.snapshot(session).values.modelSelection?.next + ?? scaffold.ctx.agentDefaultModel.currentSelection(), + ) } beforeAll(async () => { diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index c84b600c9b..59b7e3c987 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -234,7 +234,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup // trigger — on the page; the scaffold boots without one. await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e') - const modelTrigger = page.getByRole('button', { name: '选择模型', exact: true }) + const modelTrigger = page.getByRole('button', { name: /^选择模型/ }) await modelTrigger.waitFor({ timeout: 10_000 }) await modelTrigger.click() await page.getByRole('menuitem', { name: /模型/ }).click() diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index 39cfece887..d85343d7f9 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -1,46 +1,19 @@ -// Web e2e scenario: startup auto-selection keeps the hero on screen. -// -// A page load with a workspace already registered runs -// `WorkspaceRuntime.startInitialSelection`: it connects the most recent -// workspace and opens its blank session. `openState` flips to `loading` the -// moment `open()` lands; driving `data-phase=settling` on the conversation -// root from that flip would hide the composer seat and the header -// (`visibility:hidden`) for the whole `session.page` round-trip — the -// center column blanks and repaints like a full-page refresh on every launch. -// -// The unit spec pins the phase condition over hand-built stores. What only the -// assembled application can show is that the path a user actually takes -// reaches it: the real selection service, the real client session opening over -// the real /api transport, and a real browser deciding what is painted. -// The initial Workspace pick also records the resident Hero/composer nodes and -// proves that opening the first blank Session fills the strict outlets without -// replacing those nodes. -// -// The round-trip against a loopback host is far too fast to observe, so this -// scenario HOLDS the `session.page` response open in the browser's network -// handler and asserts the visible frame while it is in flight. That wait is -// what makes the assertions non-vacuous: without the phase exemption, the held -// window is exactly when `settling` would be painted and the composer hidden. -// -// Zero model calls: registering a workspace and opening its blank session are -// host RPCs with no model involvement. A stray stream would fail loud with -// NO_ADAPTER. +/** Web acceptance that startup Session opening preserves the resident Hero tree. */ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' +import { + acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, type WebScaffold, +} from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' -/** Wire path of the history round-trip the conversation root waits out. */ -const HISTORY_ROUTE = '**/api/session/page' - /** * The conversation root's own phase attribute. `div` disambiguates it from the * composer textarea, which carries an unrelated `data-phase` of its own. */ const ROOT_PHASE = 'div[data-phase]' -/** Every distinct `data-phase` the conversation root shows, in order, across one page load. */ +/** Every distinct conversation-root phase observed during one page load. */ function recordedPhases(page: Page): Promise { return page.evaluate(() => (window as unknown as { __conversationPhases: string[] }).__conversationPhases) } @@ -114,10 +87,8 @@ describe('web e2e: startup auto-selection', () => { expect(tripwire.pageErrors).toEqual([]) }, 120_000) - it('keeps the hero and the composer on screen while the auto-selected blank session opens', async () => { + it('keeps the hero and composer visible while the opening follow snapshot is pending', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-startup-auto-selection')) - // Runs before any page script on the reload below, so the first phase the - // root ever renders is recorded, not just the ones after a listener attaches. await page.addInitScript(() => { const phases: string[] = [] ;(window as unknown as { __conversationPhases: string[] }).__conversationPhases = phases @@ -128,41 +99,42 @@ describe('web e2e: startup auto-selection', () => { }, 8) }) - let releaseHistory = (): void => {} - const historyHeld = new Promise((resolve) => { releaseHistory = resolve }) - let historyRequested = (): void => {} - const historyInFlight = new Promise((resolve) => { historyRequested = resolve }) + let releaseOpening = (): void => {} + const openingHeld = new Promise((resolve) => { releaseOpening = resolve }) + let openingRequested = (): void => {} + const openingInFlight = new Promise((resolve) => { openingRequested = resolve }) let gated = false - await page.route(HISTORY_ROUTE, async (route) => { - // Only the auto-selection's own round-trip is held; later pages must not - // deadlock behind a gate this test has already released. - if (gated) { await route.continue(); return } - gated = true - historyRequested() - await historyHeld - await route.continue() - }) + const readObservation = scaffold.ctx.sessionQuery.observeSession + .bind(scaffold.ctx.sessionQuery) + const observe = vi.spyOn(scaffold.ctx.sessionQuery, 'observeSession') + .mockImplementation(async (sessionId, options) => { + const observation = await readObservation(sessionId, options) + if (gated) return observation + gated = true + openingRequested() + await openingHeld + return observation + }) const warningsBefore = tripwire.warnings.length - await page.reload({ waitUntil: 'commit' }) - await historyInFlight + try { + await page.reload({ waitUntil: 'commit' }) + await openingInFlight - // The frame a user sees while the session is still opening: hero phase, the - // hero title, and a composer that is actually painted (`settling` hides the - // seat with `visibility:hidden`, which Playwright reports as not visible). - await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 }) - expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero') - expect(await page.getByText('Into the Unknown').isVisible()).toBe(true) - expect(await page.locator('textarea').first().isVisible()).toBe(true) + await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 }) + expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero') + expect(await page.getByText('Into the Unknown').isVisible()).toBe(true) + expect(await page.locator('textarea').first().isVisible()).toBe(true) - releaseHistory() - await page.locator('textarea:enabled[placeholder="Describe what you want to build"]') - .waitFor({ timeout: 15_000 }) - acknowledgeReloadConnectionLoss(tripwire, warningsBefore) - - // Settling is not merely absent from the frame sampled above: the root - // never entered it at any point of the load. - expect(await recordedPhases(page)).toEqual(['hero']) - expect(tripwire.pageErrors).toEqual([]) + releaseOpening() + await page.locator('textarea:enabled[placeholder="Describe what you want to build"]') + .waitFor({ timeout: 15_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningsBefore) + expect(await recordedPhases(page)).toEqual(['hero']) + expect(tripwire.pageErrors).toEqual([]) + } finally { + releaseOpening() + observe.mockRestore() + } }, 120_000) }) diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 528d78dc89..859230eff7 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -49,7 +49,7 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types' export type { ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock, CredentialView, DirectoryListing, DiscoveredModelView, IApiClient, - MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, + MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, SubagentAddress, SubagentCatalog, diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 4fb6a87747..dabfd8fd6a 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -104,7 +104,6 @@ "peerDependenciesMeta": { "@deepseek-ai/dsh-jobs": { "optional": true }, "@deepseek-ai/dsh-session-persistence": { "optional": true }, - "@deepseek-ai/dsh-session-projection": { "optional": true }, "@deepseek-ai/dsh-session-projection-cache": { "optional": true } }, "devDependencies": { diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 98b22a417d..c2b7b03e61 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -7,12 +7,13 @@ import type { Agent, AgentOptions, AgentSetup, ModelSelection as AgentModelSelection, ModelSelectionRef, } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' -import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-agent-presets' +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-persistence' +import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-typert-registry' -import type { SessionError } from './types.ts' +import type { ModelSelection, SessionError } from './types.ts' /** Cold Session identity absent from persistence. */ export class ApiSessionNotFound extends Error {} @@ -66,7 +67,10 @@ export type ApiSessionAgentResult = | { readonly agent: Agent } | { readonly error: ApiSessionAgentError } -type InstalledSelection = ModelSelectionRef & { current: AgentModelSelection } +type InstalledSelection = ModelSelectionRef & { + current: AgentModelSelection + consume(provider: string, model: string, reasoningEffort: string | undefined): boolean +} /** * Test whether generic Session routing must leave an identity to subagent routing. @@ -112,19 +116,22 @@ export async function inspectApiSession( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - const persistence = ctx.get('sessionPersistence') - if (persistence === undefined) { - throw new Error('session persistence is not configured (load a dsh-session-persistence backend)') + try { + using observation = await ctx.sessionQuery.observeSession(sessionId, { + ...(signal === undefined ? {} : { signal }), + projectionMode: 'none', + }) + if (observation.header.cwd === undefined) { + throw new ApiSessionNotFound(`session "${sessionId}" not found`) + } + return { meta: observation.header, events: [...observation.events] } + } catch (error: unknown) { + if (error instanceof SessionQueryError + && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { + throw new ApiSessionNotFound(`session "${sessionId}" not found`) + } + throw error } - const meta = (await persistence.list(signal)).find(candidate => candidate.id === sessionId) - if (meta === undefined || meta.cwd === undefined) { - throw new ApiSessionNotFound(`session "${sessionId}" not found`) - } - const inspected = await persistence.inspect(sessionId, signal) - if (inspected.meta.cwd === undefined) { - throw new ApiSessionNotFound(`session "${sessionId}" not found`) - } - return { meta: inspected.meta, events: [...inspected.events] } } /** Owns every operation that may create, resume, or configure a Web Agent. */ @@ -159,6 +166,22 @@ export class ApiSessionAgentController { * @returns the live Agent or a stable Session-domain failure. */ async resolveAgent(sessionId: SessionId): Promise { + return this.resolve(sessionId) + } + + /** + * Resolve one ordinary Session from an already-retained exact observation. + * @param observation - Host-owned observation whose preparation stays pinned through setup. + * @returns the live Agent or a stable Session-domain failure. + */ + async resolveObservedAgent(observation: SessionObservation): Promise { + return this.resolve(observation.header.id, observation) + } + + private async resolve( + sessionId: SessionId, + observation?: SessionObservation, + ): Promise { const live = this.liveAgent(sessionId) if (live !== undefined) return live const attached = this.ctx.sessions.get(sessionId) @@ -168,7 +191,7 @@ export class ApiSessionAgentController { let resume = this.resumes.get(sessionId) if (resume === undefined) { - resume = this.resume(sessionId).finally(() => { this.resumes.delete(sessionId) }) + resume = this.resume(sessionId, observation).finally(() => { this.resumes.delete(sessionId) }) this.resumes.set(sessionId, resume) } try { @@ -240,7 +263,9 @@ export class ApiSessionAgentController { if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { throw new ApiSessionSubagentOwnership(sessionId) } - this.assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session)) + if (presetId !== undefined) { + this.assertPresetUnchanged(sessionId, presetId, this.presetForSession(agent.session)) + } if (agent.session.header.cwd !== cwd) { throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd) } @@ -255,7 +280,13 @@ export class ApiSessionAgentController { selectionFor(agent: Agent): InstalledSelection { const installed = this.selections.get(agent) if (installed !== undefined) return installed - let picked: AgentModelSelection | undefined + const projectionState = this.ctx.sessionProjections.stateOf(agent.session, 'modelSelection') + if (projectionState === undefined) { + throw new Error('api-session: required modelSelection projection is not registered') + } + let picked = projectionState.pending === null + ? undefined + : agentModelSelection(projectionState.pending) const defaultModel = this.ctx.agentDefaultModel const selection: InstalledSelection = { get current(): AgentModelSelection { @@ -271,6 +302,13 @@ export class ApiSessionAgentController { set current(next: AgentModelSelection) { picked = next }, + consume(provider: string, model: string, reasoningEffort: string | undefined): boolean { + if (picked?.provider !== provider + || picked.model !== model + || picked.reasoningEffort !== reasoningEffort) return false + picked = undefined + return true + }, assembled: undefined, } installModelSelection(agent.ctx, selection) @@ -278,6 +316,42 @@ export class ApiSessionAgentController { return selection } + /** + * Commit and cache one validated selection for the next prompt assembly. + * @param agent - live Agent that owns the selection. + * @param selection - validated selection to record and apply. + */ + selectForNextRequest(agent: Agent, selection: AgentModelSelection): void { + agent.session.append('model/selection', selection) + this.selectionFor(agent).current = selection + } + + /** + * Let a matching durable request header retire the execution cache. + * @param agent - live Agent whose request was recorded. + * @param provider - provider route used by the request. + * @param model - provider-owned model used by the request. + * @param reasoningEffort - adapter-owned effort used by the request. + * @returns whether the pending selection was consumed. + */ + consumeSelection( + agent: Agent, + provider: string, + model: string, + reasoningEffort: string | undefined, + ): boolean { + return this.selections.get(agent)?.consume(provider, model, reasoningEffort) ?? false + } + + /** + * Read the current Agent preset from the Session projection. + * @param session - live Session whose projection state is available. + * @returns the current preset, or undefined when the capability is absent. + */ + presetForSession(session: Session): string | undefined { + return this.ctx.sessionProjections.stateOf(session, 'agentPreset') ?? undefined + } + /** * Serialize image admission and model selection for one Agent. * @param agent - live Agent that owns the serialization chain. @@ -319,15 +393,31 @@ export class ApiSessionAgentController { : { agent } } - private async resume(sessionId: SessionId): Promise { - const inspected = await inspectApiSession(this.ctx, sessionId) - if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) { + private async resume(sessionId: SessionId, supplied?: SessionObservation): Promise { + if (supplied !== undefined) return this.resumeObserved(sessionId, supplied) + try { + using observation = await this.ctx.sessionQuery.observeSession(sessionId) + return await this.resumeObserved(sessionId, observation) + } catch (error: unknown) { + if (error instanceof SessionQueryError + && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { + throw new ApiSessionNotFound(`session "${sessionId}" not found`) + } + throw error + } + } + + private async resumeObserved( + sessionId: SessionId, + observation: SessionObservation, + ): Promise { + if (observation.header.id !== sessionId || observation.header.cwd === undefined) { + throw new ApiSessionNotFound(`session "${sessionId}" not found`) + } + if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) { throw new ApiSessionSubagentOwnership(sessionId) } - const composition = await this.composeAgent(resolveSessionPreset({ - header: inspected.meta, - events: inspected.events, - })) + const composition = await this.composeAgent(this.presetForObservation(observation)) const published = this.ctx.sessions.get(sessionId) const live = this.ctx.agents.get(sessionId) if (published !== undefined && hasApiSessionSubagentOwner(this.ctx, published, live)) { @@ -353,26 +443,27 @@ export class ApiSessionAgentController { } if (live !== undefined) return live - const persistence = checkPersistedIdentity ? this.ctx.get('sessionPersistence') : undefined - const stored = persistence === undefined - ? undefined - : (await persistence.list()).find(header => header.id === sessionId) - if (persistence !== undefined && stored !== undefined) { - const inspected = await persistence.inspect(sessionId) - if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) { - throw new ApiSessionSubagentOwnership(sessionId) + if (checkPersistedIdentity) { + try { + using observation = await this.ctx.sessionQuery.observeSession(sessionId) + if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + if (observation.header.cwd !== cwd) { + throw new ApiSessionCwdConflict(sessionId, cwd, observation.header.cwd) + } + const storedPreset = this.presetForObservation(observation) + this.assertPresetUnchanged(sessionId, presetId, storedPreset) + const composition = await this.composeAgent(storedPreset) + return (await this.ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: this.agentOptions(), + setup: composition.setup, + })).agent + } catch (error: unknown) { + if (!(error instanceof SessionQueryError) + || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error } - if (inspected.meta.cwd !== cwd) { - throw new ApiSessionCwdConflict(sessionId, cwd, inspected.meta.cwd) - } - const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events }) - this.assertPresetUnchanged(sessionId, presetId, storedPreset) - const composition = await this.composeAgent(storedPreset) - return (await this.ctx.agents.resume({ - resumeSessionId: sessionId, - agentOptions: this.agentOptions(), - setup: composition.setup, - })).agent } try { @@ -403,6 +494,18 @@ export class ApiSessionAgentController { this.selectionFor(agent) } + /** + * Read the current Agent preset from an all-projections observation. + * @param observation - exact Session observation carrying its projection snapshot. + * @returns the current preset, or undefined when the capability is absent. + */ + presetForObservation(observation: SessionObservation): string | undefined { + if (observation.projections === undefined) { + throw new Error('api-session: Agent activation requires a projected Session observation') + } + return observation.projections.values.agentPreset ?? undefined + } + private assertPresetUnchanged( sessionId: SessionId, requested: string | undefined, @@ -412,3 +515,13 @@ export class ApiSessionAgentController { throw new ApiSessionPresetConflict(sessionId, requested, existing) } } + +function agentModelSelection(selection: ModelSelection): AgentModelSelection { + return { + provider: selection.provider, + model: selection.model, + ...(selection.reasoningEffort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(selection.reasoningEffort) }), + } +} diff --git a/packages/api/session-controller/src/catalog.ts b/packages/api/session-controller/src/catalog.ts index 0f91bfaed6..0c97107f03 100644 --- a/packages/api/session-controller/src/catalog.ts +++ b/packages/api/session-controller/src/catalog.ts @@ -2,21 +2,23 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ModelCatalogFailure, - ModelProviderGroup, + ModelCatalog, ModelReasoning, + ModelSelection, } from './types.ts' /** * Build the browser model catalog without requiring a Session. * @param ctx - Host context carrying the live LLM registry. + * @param defaultSelection - deployment default used before a Session selects a model. * @returns successful non-empty provider groups and isolated provider failures. */ -export async function buildModelCatalog(ctx: Context): Promise<{ - readonly groups: ModelProviderGroup[] - readonly failures: ModelCatalogFailure[] -}> { - const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => { +export async function buildModelCatalog( + ctx: Context, + defaultSelection: ModelSelection = ctx.agentDefaultModel.currentSelection(), +): Promise { + const providers = ctx.llm.listProviders() + const catalog = await Promise.all(providers.map(async (provider) => { try { const models = await ctx.llm.listModels(provider.id) const entries = await Promise.all(models.map(async (model) => { @@ -56,6 +58,8 @@ export async function buildModelCatalog(ctx: Context): Promise<{ } })) return { + default: { ...defaultSelection }, + routableProviders: providers.map(provider => provider.id), groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : []) .filter(group => group.models.length > 0), failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []), diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 2f40aa1972..48c41f3aa1 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -3,9 +3,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' -import { - PresetMountError, UnknownPresetError, resolveSessionPreset, -} from '@deepseek-ai/dsh-agent-presets' +import { PresetMountError, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { @@ -13,7 +11,8 @@ import { } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session' +import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import type { Workspace } from '@deepseek-ai/dsh-workspace' @@ -27,7 +26,6 @@ import { hasApiSessionSubagentOwner, inspectApiSession, } from './agent.ts' -import { buildModelCatalog } from './catalog.ts' import type { SessionAttachmentRequest, SessionAttachmentValue, @@ -37,8 +35,6 @@ import type { SessionCreateValue, SessionForkRequest, SessionForkValue, - SessionModels, - SessionModelsRequest, SessionPromptRequest, SessionPromptValue, SessionRenameRequest, @@ -110,27 +106,10 @@ export class SessionCommandController { ) } } - const agentPreset = resolveSessionPreset(adopted.session) + const agentPreset = this.agents.presetForSession(adopted.session) return { sessionId, ...(agentPreset === undefined ? {} : { agentPreset }) } } - /** - * Read the current selection and advisory model catalog, explicitly resuming the Session. - * @param request - Session whose model state is requested. - * @returns the current selection and available model groups. - */ - async models(request: SessionModelsRequest): Promise { - const agent = await this.resolveAgent(request.sessionId) - const current = this.agents.selectionFor(agent).current - const { groups, failures } = await buildModelCatalog(this.ctx) - return { - current: { ...current }, - routable: routeServed(this.ctx, current.provider), - groups, - failures, - } - } - /** * Validate and install one Session-local model selection. * @param request - Session identity and requested model selection. @@ -154,7 +133,7 @@ export class SessionCommandController { ? {} : { reasoningEffort: resolved.reasoningEffort }), } - this.agents.selectionFor(agent).current = selected + this.agents.selectForNextRequest(agent, selected) try { await this.ctx.agentDefaultModel.saveSelection(selected) } catch (error) { @@ -210,12 +189,15 @@ export class SessionCommandController { && (!Number.isInteger(request.atSeq) || request.atSeq < 0)) { reject('bad-request', 'atSeq must be a non-negative integer', {}) } - let source: SessionReadState + let observed: SessionObservation try { - source = await this.readSessionState(request.sessionId) + observed = await this.ctx.sessionQuery.observeSession(request.sessionId) } catch (error) { - if (error instanceof ApiSessionNotFound) { - reject('session-not-found', error.message, { sessionId: request.sessionId }) + if (error instanceof SessionQueryError + && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { + reject('session-not-found', `session "${request.sessionId}" not found`, { + sessionId: request.sessionId, + }) } reject( 'internal', @@ -223,6 +205,7 @@ export class SessionCommandController { {}, ) } + using source = observed const lastSeq = source.events.at(-1)?.seq ?? -1 const atSeq = request.atSeq const anchoredBoundary = atSeq === undefined @@ -245,7 +228,7 @@ export class SessionCommandController { while (cut < source.events.length && source.events[cut]?.type !== 'turn/start') cut++ let workspace: Workspace | undefined try { - workspace = await this.forkWorkspace(source) + workspace = await this.forkWorkspace(source.header) } catch (error) { reject( 'internal', @@ -254,7 +237,7 @@ export class SessionCommandController { ) } const childId = SessionId(`session-${randomUUID()}`) - const composition = await this.agents.composeAgent(resolveSessionPreset(source)) + const composition = await this.agents.composeAgent(this.agents.presetForObservation(source)) try { const { provider, model } = this.ctx.agentDefaultModel.currentSelection() await this.ctx.agents.create({ @@ -262,7 +245,7 @@ export class SessionCommandController { seed: source.events.slice(0, cut), meta: { ...(source.header.cwd === undefined ? {} : { cwd: source.header.cwd }), - parentSession: source.id, + parentSession: source.header.id, seedLength: cut, ...(composition.agentPreset === undefined ? {} @@ -507,10 +490,10 @@ export class SessionCommandController { return { id: inspected.meta.id, header: inspected.meta, events: inspected.events } } - private async forkWorkspace(source: Pick): Promise { + private async forkWorkspace(source: SessionHeader): Promise { const workspaces = this.ctx.workspaceRegistry.list() const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id)) - if (direct !== undefined || source.header.origin !== 'subagent') return direct + if (direct !== undefined || source.origin !== 'subagent') return direct const lineage = await this.ctx.sessionQuery.traceSession(source.id) for (const ancestor of lineage.ancestors) { const workspace = workspaces.find(candidate => candidate.sessionIds.includes(ancestor.header.id)) diff --git a/packages/api/session-controller/src/model-selection-projection.ts b/packages/api/session-controller/src/model-selection-projection.ts new file mode 100644 index 0000000000..a914dedb97 --- /dev/null +++ b/packages/api/session-controller/src/model-selection-projection.ts @@ -0,0 +1,83 @@ +/** Durable model-selection intent and request-use projection. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import { z } from 'zod' +import type { + ModelSelection, + ModelSelectionProjection, + ModelSelectionProjectionState, +} from './types.ts' + +const modelSelectionSchema = z.object({ + provider: z.string().min(1), + model: z.string().min(1), + reasoningEffort: z.string().min(1).optional(), +}) as unknown as z.ZodType + +const modelSelectionProjectionStateSchema = z.object({ + lastUsed: modelSelectionSchema.nullable(), + pending: modelSelectionSchema.nullable(), +}) as unknown as z.ZodType + +const modelSelectionProjectionSchema = z.object({ + lastUsed: modelSelectionSchema.nullable(), + next: modelSelectionSchema.nullable(), +}) as unknown as z.ZodType + +/** + * Advance durable model-selection state by one Session event. + * @param state - selection state before the event. + * @param event - next committed Session event. + * @returns the original or advanced selection state. + */ +function applyModelSelectionProjection( + state: ModelSelectionProjectionState, + event: SessionEvent, +): ModelSelectionProjectionState { + if (event.type === 'model/selection') { + return sameSelection(state.pending, event.data) + ? state + : { lastUsed: state.lastUsed, pending: event.data } + } + if (event.type !== 'request/header') return state + const lastUsed: ModelSelection = { + provider: event.data.header.config.provider, + model: event.data.header.config.model, + ...(event.data.header.config.reasoningEffort === undefined + ? {} + : { reasoningEffort: String(event.data.header.config.reasoningEffort) }), + } + const pending = sameSelection(state.pending, lastUsed) ? null : state.pending + return sameSelection(state.lastUsed, lastUsed) && pending === state.pending + ? state + : { lastUsed, pending } +} + +const modelSelectionProjection = { + key: 'modelSelection', + stateSchema: modelSelectionProjectionStateSchema, + init: () => ({ lastUsed: null, pending: null }), + apply: applyModelSelectionProjection, + wire: { + viewSchema: modelSelectionProjectionSchema, + view: state => ({ lastUsed: state.lastUsed, next: state.pending ?? state.lastUsed }), + }, + stateVersion: 2, +} satisfies ProjectionDefinition<'modelSelection', ModelSelectionProjectionState> + +function sameSelection(left: ModelSelection | null, right: ModelSelection | null): boolean { + return left === right || (left !== null && right !== null + && left.provider === right.provider + && left.model === right.model + && left.reasoningEffort === right.reasoningEffort) +} + +/** + * Register the durable model-selection projection when the registry is present. + * @param ctx - Session Controller context. + */ +export function installModelSelectionProjection(ctx: Context): void { + ctx.sessionProjections.register(modelSelectionProjection) +} diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 7256f6b5fe..3f4e721228 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -4,8 +4,10 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' +import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -16,6 +18,8 @@ import { ApiSessionSubagentOwnership, inspectApiSession, } from '../src/agent.ts' +import { installModelSelectionProjection } from '../src/model-selection-projection.ts' +import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' const roots: Context[] = [] @@ -29,6 +33,9 @@ async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentControl await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + installSessionReadTestServices(ctx) + ctx.sessionProjections.register(agentPresetProjectionDefinition) + installModelSelectionProjection(ctx) ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), saveSelection: () => Promise.resolve(), @@ -45,6 +52,10 @@ function header(id: string, cwd: string | null = '/workspace'): SessionHeader { } } +function providePersistence(ctx: Context, persistence: Record): () => void { + return ctx.provide('sessionPersistence', testSessionPersistence(ctx, persistence) as never) +} + function agent(ctx: Context, meta: SessionHeader): Agent { const session = ctx.sessions.create(meta.id, { meta }) return { id: meta.id, session, status: 'idle', ctx } as Agent @@ -67,48 +78,94 @@ describe('ApiSession identity failures', () => { .toContain('belongs to "/existing"') }) - it('rejects absent persistence, catalog misses, and cwd-less inspected artifacts', async () => { + it('maps absent and cwd-less point observations to not found', async () => { const ctx = new Context() roots.push(ctx) + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) await expect(inspectApiSession(ctx, SessionId('missing'))) - .rejects.toThrow('session persistence is not configured') + .rejects.toBeInstanceOf(ApiSessionNotFound) - const inspect = vi.fn(() => Promise.resolve({ meta: header('missing'), events: [] as SessionEvent[] })) - const disposeMissing = ctx.provide('sessionPersistence', { + const inspect = vi.fn(() => Promise.resolve(undefined)) + const disposeMissing = providePersistence(ctx, { list: () => Promise.resolve([]), inspect, - } as never) + }) await expect(inspectApiSession(ctx, SessionId('missing'))).rejects.toBeInstanceOf(ApiSessionNotFound) - expect(inspect).not.toHaveBeenCalled() + expect(inspect).toHaveBeenCalledOnce() disposeMissing() const listed = header('cwd-less-catalog', null) - const disposeListed = ctx.provide('sessionPersistence', { + const disposeListed = providePersistence(ctx, { list: () => Promise.resolve([listed]), - inspect, - } as never) + inspect: () => Promise.resolve({ meta: listed, events: [] }), + }) await expect(inspectApiSession(ctx, listed.id)).rejects.toBeInstanceOf(ApiSessionNotFound) disposeListed() const catalog = header('cwd-less-inspect') const inspected = header('cwd-less-inspect', null) - ctx.provide('sessionPersistence', { + providePersistence(ctx, { list: () => Promise.resolve([catalog]), inspect: () => Promise.resolve({ meta: inspected, events: [] }), - } as never) + }) await expect(inspectApiSession(ctx, catalog.id)).rejects.toBeInstanceOf(ApiSessionNotFound) }) + + it('forwards an explicit inspection signal', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) + const meta = header('signalled-inspection') + const inspect = vi.fn(() => Promise.resolve({ meta, events: [] })) + providePersistence(ctx, { inspect }) + const signal = new AbortController().signal + + await expect(inspectApiSession(ctx, meta.id, signal)).resolves.toEqual({ meta, events: [] }) + expect(inspect).toHaveBeenCalledWith(meta.id, signal) + }) }) describe('ApiSession Agent lookup and recovery', () => { + it('resumes directly from a retained observation and rejects an invalid observed header', async () => { + const { ctx, agents } = await harness() + const meta = header('observed-resume') + const resumed = unpublishedAgent(ctx, meta) + const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({ + agent: resumed, + dispose: () => Promise.resolve(), + }) + const observed = { + source: 'prepared', + header: meta, + events: [], + cursor: -1, + projections: { asOfSeq: -1, values: {} }, + retain: vi.fn(), + [Symbol.dispose]: vi.fn(), + } as unknown as SessionObservation + + await expect(agents.resolveObservedAgent(observed)).resolves.toEqual({ agent: resumed }) + expect(resume).toHaveBeenCalledWith(expect.objectContaining({ resumeSessionId: meta.id })) + + const invalid = { + ...observed, + header: header('observed-without-cwd', null), + } as SessionObservation + await expect(agents.resolveObservedAgent(invalid)).resolves.toMatchObject({ + error: { code: 'session-not-found' }, + }) + }) + it('projects live Agent contexts and maps missing cold identities through Typert lookup failures', async () => { const { ctx } = await harness() const live = agent(ctx, header('live')) ctx.agents.register(live) - ctx.provide('sessionPersistence', { + providePersistence(ctx, { list: () => Promise.resolve([]), inspect: vi.fn(), - } as never) + }) const host = ctx.typert.contexts.getHost('agent') if (host === undefined) throw new Error('Agent Context resolver was not registered') @@ -119,10 +176,10 @@ describe('ApiSession Agent lookup and recovery', () => { it('returns raced ordinary Agents and ownership failures after resume throws', async () => { const ordinary = await harness() const ordinaryMeta = header('ordinary-race') - ordinary.ctx.provide('sessionPersistence', { + providePersistence(ordinary.ctx, { list: () => Promise.resolve([ordinaryMeta]), inspect: () => Promise.resolve({ meta: ordinaryMeta, events: [] }), - } as never) + }) const winner = agent(ordinary.ctx, ordinaryMeta) vi.spyOn(ordinary.ctx.agents, 'resume').mockImplementation(async () => { ordinary.ctx.agents.register(winner) @@ -132,10 +189,10 @@ describe('ApiSession Agent lookup and recovery', () => { const child = await harness() const childMeta = header('child-race') - child.ctx.provide('sessionPersistence', { + providePersistence(child.ctx, { list: () => Promise.resolve([childMeta]), inspect: () => Promise.resolve({ meta: childMeta, events: [] }), - } as never) + }) vi.spyOn(child.ctx.agents, 'resume').mockImplementation(async () => { child.ctx.sessions.create(childMeta.id, { meta: { ...childMeta, parentSession: SessionId('parent'), origin: 'subagent' }, @@ -149,25 +206,84 @@ describe('ApiSession Agent lookup and recovery', () => { it('reports not-found and ordinary resume failures without fabricating an Agent', async () => { const missing = await harness() - missing.ctx.provide('sessionPersistence', { + providePersistence(missing.ctx, { list: () => Promise.resolve([]), inspect: vi.fn(), - } as never) + }) await expect(missing.agents.resolveAgent(SessionId('missing'))).resolves.toMatchObject({ error: { code: 'session-not-found' }, }) const failed = await harness() const meta = header('failed') - failed.ctx.provide('sessionPersistence', { + providePersistence(failed.ctx, { list: () => Promise.resolve([meta]), inspect: () => Promise.resolve({ meta, events: [] }), - } as never) + }) vi.spyOn(failed.ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable')) await expect(failed.agents.resolveAgent(meta.id)).resolves.toMatchObject({ error: { code: 'internal', message: expect.stringContaining('factory unavailable') as string }, }) }) + + it('requires projected observations before activation', async () => { + const { agents } = await harness() + const meta = header('unprojected-observation') + const observed = { + source: 'prepared', + header: meta, + events: [], + cursor: -1, + retain: vi.fn(), + [Symbol.dispose]: vi.fn(), + } as unknown as SessionObservation + + expect(() => agents.presetForObservation(observed)).toThrow( + 'Agent activation requires a projected Session observation', + ) + }) +}) + +describe('ApiSession model selection', () => { + it('requires the model-selection projection', async () => { + const { ctx, agents } = await harness() + const live = agent(ctx, header('missing-model-projection')) + vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined) + + expect(() => agents.selectionFor(live)).toThrow('required modelSelection projection') + }) + + it('reads a reasoning-free request and consumes only the exact pending selection', async () => { + const { ctx, agents } = await harness() + const logged = agent(ctx, header('logged-model')) + logged.session.append('request/header', { + header: { config: { provider: 'logged-provider', model: 'logged-model' } }, + reason: 'initial', + }) + expect(agents.selectionFor(logged).current).toEqual({ + provider: 'logged-provider', + model: 'logged-model', + }) + + const pending = agent(ctx, header('pending-model')) + const selection = agents.selectionFor(pending) + agents.selectForNextRequest(pending, { + provider: 'selected-provider', + model: 'selected-model', + reasoningEffort: 'high' as never, + }) + expect(selection.current).toMatchObject({ + provider: 'selected-provider', model: 'selected-model', reasoningEffort: 'high', + }) + expect(agents.consumeSelection(pending, 'other-provider', 'selected-model', 'high')).toBe(false) + expect(agents.consumeSelection(pending, 'selected-provider', 'other-model', 'high')).toBe(false) + expect(agents.consumeSelection(pending, 'selected-provider', 'selected-model', 'low')).toBe(false) + expect(agents.consumeSelection(pending, 'selected-provider', 'selected-model', 'high')).toBe(true) + expect(selection.current).toEqual({ provider: 'fixture', model: 'fixture-model' }) + + const untouched = agent(ctx, header('uninstalled-model')) + expect(agents.consumeSelection(untouched, 'fixture', 'fixture-model', undefined)).toBe(false) + }) }) describe('ApiSession create or adoption', () => { @@ -252,10 +368,10 @@ describe('ApiSession create or adoption', () => { time: 1, data: { agentPreset: 'minimal' }, }] as SessionEvent[] - ctx.provide('sessionPersistence', { + providePersistence(ctx, { list: () => Promise.resolve([meta]), inspect: () => Promise.resolve({ meta, events }), - } as never) + }) ctx.provide('agentPresets', { resolve: (id?: string) => Promise.resolve({ id: id ?? 'minimal' }), mount: () => Promise.resolve(), @@ -278,10 +394,10 @@ describe('ApiSession create or adoption', () => { it('rejects an ownership race before resume and a persisted cwd conflict', async () => { const child = await harness() const childMeta = header('resume-child-race') - child.ctx.provide('sessionPersistence', { + providePersistence(child.ctx, { list: () => Promise.resolve([childMeta]), inspect: () => Promise.resolve({ meta: childMeta, events: [] }), - } as never) + }) child.ctx.provide('agentPresets', { resolve: () => { child.ctx.sessions.create(childMeta.id, { @@ -297,10 +413,10 @@ describe('ApiSession create or adoption', () => { const conflict = await harness() const stored = header('stored-cwd-conflict', '/stored') - conflict.ctx.provide('sessionPersistence', { + providePersistence(conflict.ctx, { list: () => Promise.resolve([stored]), inspect: () => Promise.resolve({ meta: stored, events: [] }), - } as never) + }) await expect(conflict.agents.ensureSession(stored.id, '/requested', true)) .rejects.toBeInstanceOf(ApiSessionCwdConflict) }) diff --git a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts index b702c60a2c..88b1450970 100644 --- a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts +++ b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts @@ -11,6 +11,7 @@ import { ApiSessionCwdConflict, } from '../src/agent.ts' import { SessionCommandController } from '../src/commands.ts' +import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' async function expectFailure(operation: Promise, code: string): Promise { await expect(operation).rejects.toMatchObject({ failure: { code } }) @@ -20,6 +21,8 @@ function controllerAgents(overrides: object = {}): ApiSessionAgentController { return { ensureSession: () => Promise.resolve(), composeAgent: () => Promise.resolve({ setup: () => {} }), + presetForSession: () => undefined, + presetForObservation: () => undefined, ...overrides, } as unknown as ApiSessionAgentController } @@ -28,6 +31,7 @@ async function baseContext(): Promise { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + installSessionReadTestServices(ctx) ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), saveSelection: () => Promise.resolve(), @@ -167,23 +171,23 @@ function resolvedHandle(ctx: Context, sessionId: SessionId): AgentHandle { } describe('Session fork failures', () => { - it('distinguishes missing cold sources from unavailable persistence', async () => { - const unavailable = await baseContext() - unavailable.provide('workspaceRegistry', { list: () => [] } as never) + it('maps missing cold sources with and without persistence', async () => { + const withoutPersistence = await baseContext() + withoutPersistence.provide('workspaceRegistry', { list: () => [] } as never) const unavailableController = new SessionCommandController( - unavailable, controllerAgents(), '/default', + withoutPersistence, controllerAgents(), '/default', ) await expectFailure(unavailableController.fork({ sessionId: SessionId('missing'), - }), 'internal') - await unavailable.fiber.dispose() + }), 'session-not-found') + await withoutPersistence.fiber.dispose() const missing = await baseContext() missing.provide('workspaceRegistry', { list: () => [] } as never) - missing.provide('sessionPersistence', { + missing.provide('sessionPersistence', testSessionPersistence(missing, { list: () => Promise.resolve([]), inspect: vi.fn(), - } as never) + }) as never) const missingController = new SessionCommandController(missing, controllerAgents(), '/default') await expectFailure(missingController.fork({ sessionId: SessionId('missing'), @@ -191,6 +195,16 @@ describe('Session fork failures', () => { await missing.fiber.dispose() }) + it('maps an observation failure to an internal fork error', async () => { + const ctx = await baseContext() + ctx.provide('workspaceRegistry', { list: () => [] } as never) + vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline')) + const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + + await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'internal') + await ctx.fiber.dispose() + }) + it('rejects a Session with no completed turn', async () => { const ctx = await baseContext() ctx.provide('workspaceRegistry', { list: () => [] } as never) @@ -204,9 +218,8 @@ describe('Session fork failures', () => { it('maps lineage lookup and Agent creation failures', async () => { const lineage = await baseContext() lineage.provide('workspaceRegistry', { list: () => [] } as never) - lineage.provide('sessionQuery', { - traceSession: () => Promise.reject(new Error('lineage unavailable')), - } as never) + vi.spyOn(lineage.sessionQuery, 'traceSession') + .mockRejectedValue(new Error('lineage unavailable')) const child = completedSession(lineage, 'subagent-source', '/workspace', { parentSession: SessionId('parent'), origin: 'subagent', diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index cb71c8f009..d108e98222 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -3,12 +3,13 @@ import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' +import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { describe, expect, it, vi } from 'vitest' import { ApiSessionAgentController } from '../src/agent.ts' import { SessionCommandController } from '../src/commands.ts' +import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' async function commandHarness(): Promise<{ ctx: Context @@ -142,10 +143,11 @@ async function persistedController( await ctx.plugin(SessionStore) const sessionId = SessionId('cold-attachment') const meta: SessionHeader = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } - ctx.provide('sessionPersistence', { + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve([meta]), inspect: () => Promise.resolve({ meta, events }), - } as never) + }) as never) + installSessionReadTestServices(ctx) ctx.provide('attachments', { readImage } as never) const agents = { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), sessionId } @@ -158,15 +160,31 @@ describe('Session attachment authorization', () => { const inserted = imageRef('inserted') const streamed = imageRef('streamed') const events = [ - event('fixture/direct', 0, { + { ...event('fixture/direct', 0, { content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, { type: 'tool-result', content: [{ type: 'image', attachment: nested }], }], + }), ignorable: true as const }, + { ...event('assistant/message', 1, { + turn: 1, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'image', attachment: message }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }), surfaceOp: 'append' as const }, + event('agent/inbox/spliced', 2, { + target: 'next-turn', + start: 0, + inserted: [createUserMessage({ + content: [{ type: 'image', attachment: inserted }], + source: { kind: 'user' }, + })], }), - event('assistant/message', 1, { message: { content: [{ type: 'image', attachment: message }] } }), - event('agent/inbox/spliced', 2, { inserted: [{ content: [{ type: 'image', attachment: inserted }] }] }), event('assistant/chunk', 3, { - chunk: { type: 'block-end', block: { type: 'image', attachment: streamed } }, + turn: 1, + step: 1, + chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: streamed } }, }), ] const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, data: Uint8Array.of(1) })) @@ -183,6 +201,7 @@ describe('Session attachment authorization', () => { it('maps missing persistence identities and attachment backend failures', async () => { const noPersistence = new Context() await noPersistence.plugin(SessionStore) + installSessionReadTestServices(noPersistence) const noPersistenceController = new SessionCommandController( noPersistence, { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController, @@ -190,14 +209,15 @@ describe('Session attachment authorization', () => { ) await expectFailure(noPersistenceController.attachment({ sessionId: SessionId('missing'), attachmentId: AttachmentId('att'), - }), 'internal') + }), 'session-not-found') const missing = new Context() await missing.plugin(SessionStore) - missing.provide('sessionPersistence', { + missing.provide('sessionPersistence', testSessionPersistence(missing, { list: () => Promise.resolve([]), inspect: vi.fn(), - } as never) + }) as never) + installSessionReadTestServices(missing) const missingController = new SessionCommandController( missing, { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController, @@ -223,4 +243,21 @@ describe('Session attachment authorization', () => { await fixture.ctx.fiber.dispose() } }) + + it('maps a cold observation failure to an internal authorization error', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) + vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline')) + const controller = new SessionCommandController( + ctx, + { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController, + '/workspace', + ) + + await expectFailure(controller.attachment({ + sessionId: SessionId('unreadable'), attachmentId: AttachmentId('att'), + }), 'internal') + await ctx.fiber.dispose() + }) }) diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index 481f51f825..06e7b5a3dd 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -10,7 +10,9 @@ import SessionStore from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { Workspace } from '@deepseek-ai/dsh-workspace' -import { createSessionTestRemote } from './test-remote.ts' +import { + createSessionTestRemote, installSessionReadTestServices, testSessionPersistence, +} from './test-remote.ts' const sid = (id: string): SessionId => id as SessionId @@ -23,6 +25,7 @@ async function composed(workspaces: readonly Workspace[] = []): Promise await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(AgentRegistry) + installSessionReadTestServices(ctx) ctx.provide('workspaceRegistry', { list: () => workspaces } as never) ctx.agents.setFactory({ createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise => { @@ -116,18 +119,16 @@ describe('sessions.fork', () => { parentSession: child.id, origin: 'subagent', }) - ctx.provide('sessionQuery', { - traceSession: vi.fn(() => Promise.resolve({ - target: { header: grandchild.header, live: true, persisted: false }, - ancestors: [ - { header: child.header, live: true, persisted: false }, - { header: owner.header, live: true, persisted: false }, - ], - descendants: [], - complete: true, - root: { header: owner.header, live: true, persisted: false }, - })), - } as never) + vi.spyOn(ctx.sessionQuery, 'traceSession').mockResolvedValue({ + target: { header: grandchild.header, live: true, persisted: false }, + ancestors: [ + { header: child.header, live: true, persisted: false }, + { header: owner.header, live: true, persisted: false }, + ], + descendants: [], + complete: true, + root: { header: owner.header, live: true, persisted: false }, + }) const response = await remote(ctx).fork(request({ sessionId: grandchild.id })) @@ -165,19 +166,10 @@ describe('sessions.fork', () => { }, { type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }, ] as SessionEvent[] - ctx.provide('sessionPersistence', { + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve([header]), inspect: () => Promise.resolve({ meta: header, events }), - } as never) - ctx.provide('sessionQuery', { - traceSession: () => Promise.resolve({ - target: { header, live: false, persisted: true }, - ancestors: [], - descendants: [], - complete: true, - root: { header, live: false, persisted: true }, - }), - } as never) + }) as never) const resume = vi.spyOn(ctx.agents, 'resume') const response = await remote(ctx).fork(request({ sessionId: sourceId })) diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index e67c414c44..65b9f5f7e7 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -19,6 +19,7 @@ import type { import SessionStore from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' +import { buildModelCatalog } from '../src/catalog.ts' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import { createSessionTestRemote } from './test-remote.ts' @@ -146,6 +147,14 @@ function registerTextOnly(ctx: Context): void { }('Text Only', [])) } +/** Resolve the Client-visible next selection from durable state and the Host default. */ +function currentSelection(ctx: Context, sessionId: SessionId) { + const session = ctx.sessions.get(sessionId) + if (session === undefined) throw new Error('expected a live test Session') + return ctx.sessionProjections.snapshot(session).values.modelSelection?.next + ?? ctx.agentDefaultModel.currentSelection() +} + describe('Web session model selection', () => { it('validates an ordered image batch before persisting any member', async () => { const { ctx, agent, sessionId } = await harness() @@ -227,7 +236,7 @@ describe('Web session model selection', () => { type: 'image' as const, attachment: { attachmentId: 'att-history', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 }, } - agent.session.append('user/message', { + const imageEvent = agent.session.append('user/message', { id: 'image-message', role: 'user', source: { kind: 'user' }, content: [image], } as never, { surfaceOp: 'append' }) expect(expectValue(await remote.selectModel(request({ @@ -238,8 +247,8 @@ describe('Web session model selection', () => { id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' }, content: [{ type: 'text', text: 'image summarized' }], } as never, { - surfaceOp: { op: 'replace', start: 0, end: agent.session.events.length - 1 }, - sourceEventSeqs: agent.session.events.map(event => event.seq), + surfaceOp: { op: 'replace', start: imageEvent.seq, end: imageEvent.seq }, + sourceEventSeqs: [imageEvent.seq], }) ;(agent.inbox.nextTurn as UserMessage[]).push({ id: 'pending-image', role: 'user', source: { kind: 'user' }, content: [image], @@ -290,10 +299,10 @@ describe('Web session model selection', () => { model: 'private-preview', reasoningEffort: ReasoningEffortId('max'), }) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) + createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) - const catalog = expectValue(await remote.models(request({ sessionId }))) - expect(catalog.current).toEqual({ + const catalog = await buildModelCatalog(ctx) + expect(currentSelection(ctx, sessionId)).toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max', @@ -324,7 +333,7 @@ describe('Web session model selection', () => { }) it('preserves optional catalog metadata and string provider failures', async () => { - const { ctx, sessionId } = await harness() + const { ctx } = await harness() ctx.llm.registerAdapter(['plain'], new CatalogAdapter('Plain', [ { provider: 'plain', id: 'plain-model', name: 'Plain Model' }, ])) @@ -339,12 +348,12 @@ describe('Web session model selection', () => { return Promise.reject('string catalog failure') } }('String Failure', [])) - const remote = createSessionTestRemote(ctx, { + createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) - const catalog = expectValue(await remote.models(request({ sessionId }))) + const catalog = await buildModelCatalog(ctx) expect(catalog.groups).toEqual(expect.arrayContaining([ { id: 'plain', name: 'Plain', models: [{ id: 'plain-model', name: 'Plain Model' }] }, { @@ -371,10 +380,8 @@ describe('Web session model selection', () => { const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } const signal = new AbortController().signal - expect(expectValue(await remote.models(request({ sessionId }))).current) + expect(currentSelection(ctx, sessionId)) .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) - expect((await ctx.systemPrompt.assemble()).variables) - .toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' }) const selected = expectValue(await remote.selectModel(request({ sessionId, @@ -389,7 +396,7 @@ describe('Web session model selection', () => { }) await expect(agentEvents(ctx, agent).waterfall( 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed), - )).resolves.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' }) + )).resolves.toEqual(seed) expect((await ctx.systemPrompt.assemble()).variables) .toMatchObject({ provider: 'deepseek-official', model: 'private-preview' }) @@ -440,7 +447,7 @@ describe('Web session model selection', () => { details: { provider: 'remote-rejected' }, }, }) - expect(expectValue(await remote.models(request({ sessionId }))).current) + expect(currentSelection(ctx, sessionId)) .toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' }) await ctx.fiber.dispose() }) @@ -448,18 +455,18 @@ describe('Web session model selection', () => { it('reads the Agent default live for a session whose log names no selection', async () => { const { ctx, sessionId } = await harness() let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } - const remote = createSessionTestRemote(ctx, { + createSessionTestRemote(ctx, { defaultModelSelection: () => stored, cwd: '/tmp', }) - expect(expectValue(await remote.models(request({ sessionId }))).current) + expect(currentSelection(ctx, sessionId)) .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) // The default moving after the session exists still reaches it: New // Session reuses a blank session rather than minting another, so a seed // captured at creation would show the superseded model there. stored = { provider: 'deepseek-official', model: 'deepseek-reasoner' } - expect(expectValue(await remote.models(request({ sessionId }))).current) + expect(currentSelection(ctx, sessionId)) .toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) await ctx.fiber.dispose() }) @@ -470,13 +477,13 @@ describe('Web session model selection', () => { model: 'deepseek-chat', }) let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } - const remote = createSessionTestRemote(ctx, { + createSessionTestRemote(ctx, { defaultModelSelection: () => stored, cwd: '/tmp', }) stored = { provider: 'duplicate', model: 'same' } - expect(expectValue(await remote.models(request({ sessionId }))).current) + expect(currentSelection(ctx, sessionId)) .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) await ctx.fiber.dispose() }) @@ -512,7 +519,7 @@ describe('Web session model selection', () => { sessionId, provider: 'deepseek-official', model: 'deepseek-chat', }))) expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' }) - expect(expectValue(await remote.models(request({ sessionId }))).current) + expect(currentSelection(ctx, sessionId)) .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' }) await ctx.fiber.dispose() }) @@ -533,15 +540,16 @@ describe('Web session model selection', () => { ok: false, error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } }, }) - expect(expectValue(await remote.models(request({ sessionId }))).routable).toBe(false) + const unavailableCatalog = await buildModelCatalog(ctx) + expect(unavailableCatalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(false) // An advisory-unlisted model on a live route is NOT this: the route // serves it, so the prompt goes through and nothing blocks. expectValue(await remote.selectModel(request({ sessionId, provider: 'deepseek-official', model: 'unlisted-but-served', }))) - const catalog = expectValue(await remote.models(request({ sessionId }))) - expect(catalog.routable).toBe(true) + const catalog = await buildModelCatalog(ctx) + expect(catalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(true) expect(catalog.groups.flatMap(group => group.models.map(model => model.id))) .not.toContain('unlisted-but-served') await ctx.fiber.dispose() @@ -549,18 +557,18 @@ describe('Web session model selection', () => { it('serves a session and its catalog when the stored default names a route that is gone', async () => { const { ctx, sessionId } = await harness() - const remote = createSessionTestRemote(ctx, { + createSessionTestRemote(ctx, { // What a Models-page removal leaves behind: the settings document still // names the route the user last picked, and nothing serves it. defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), cwd: '/tmp', }) - const catalog = expectValue(await remote.models(request({ sessionId }))) + const catalog = await buildModelCatalog(ctx) // Passed through rather than repaired: matching no group is precisely what // makes the composer seat prompt for a selection instead of naming a model // the deployment cannot reach. - expect(catalog.current).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' }) + expect(currentSelection(ctx, sessionId)).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' }) expect(catalog.groups.flatMap(group => group.models.map(model => `${group.id}/${model.id}`))) .not.toContain('deleted-gateway/deleted-model') await ctx.fiber.dispose() diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index 87d4e038e1..10ce0e4269 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -6,7 +6,7 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' -import { UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' +import { agentPresetProjectionDefinition, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import { describe, expect, it } from 'vitest' @@ -38,8 +38,9 @@ async function harness(presets?: readonly string[]) { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never) - if (presets !== undefined) ctx.provide('agentPresets', roster(presets) as never) + if (presets !== undefined) { + ctx.provide('agentPresets', roster(presets) as never) + } const factory: AgentFactory = { async createAgent(_ownerCtx, options) { @@ -63,6 +64,7 @@ async function harness(presets?: readonly string[]) { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd, }) + if (presets !== undefined) ctx.sessionProjections.register(agentPresetProjectionDefinition) return { ctx, remote } } diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index a778326d62..d3256426e2 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -15,7 +15,8 @@ "src/commands.ts", "src/control.ts", "src/history.ts", - "src/list.ts" + "src/list.ts", + "src/model-selection-projection.ts" ], "references": [ { "path": "../../../vendor/cordis" }, diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 2c899ef0e7..2d0170cb5f 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -10,7 +10,7 @@ export type { DirectoryEntry, DirectoryListing, ResponseValue, SkillsApi, SkillEntry, - ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelSelection, GoalsApi, GoalRef, SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 22a0f64e55..c27a1cae4c 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -19,6 +19,7 @@ import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-atta import type { JsonValue, SessionEvent, + SessionHeader, SessionId, } from '@deepseek-ai/dsh-session/types' import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client' @@ -86,7 +87,7 @@ type FixtureSessionAddress = interface FixtureFollowRequest { readonly address: FixtureSessionAddress - readonly afterSeq?: number + readonly maxMessages?: number } interface FixturePageRequest { @@ -97,7 +98,14 @@ interface FixturePageRequest { } type FixtureFollowFrame = - | { readonly type: 'opened'; readonly cursor: number } + | { + readonly type: 'snapshot' + readonly header: SessionHeader + readonly cursor: number + readonly events: readonly FixtureHistoryEntry[] + readonly hasMore: boolean + readonly projections: FixtureProjectionsBlock + } | ({ readonly type: 'event' } & FixtureHistoryEntry) type FixtureFollowEventFrame = Extract @@ -207,7 +215,6 @@ interface FixtureSessionApi { readonly beforeSeq?: number readonly maxMessages?: number }): Promise> - models(request: { readonly sessionId: SessionId }): Promise> selectModel(request: { readonly sessionId: SessionId readonly provider: string @@ -522,7 +529,7 @@ const OPENAI_REASONING = { defaultEffort: 'medium', } -/** Catalog served by `session.models` and `llm.models` alike (fresh copies per call). */ +/** Catalog served by `llm.models` (fresh copies per call). */ function fixtureModelGroups(): ModelProviderGroup[] { return [ { @@ -1206,6 +1213,7 @@ function contextPressureOf( function projectionValuesOf(log: readonly SessionEvent[]): Record { const values: Record = {} + values['modelSelection'] = modelSelectionProjectionOf(log) const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title') if (titleEvent !== undefined) { values['title'] = (titleEvent as unknown as { data: { title: string } }).data.title @@ -1242,6 +1250,37 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record 0) await new Promise(resolve => setTimeout(resolve, delay)) if (doomed) throw new Error('fixture: simulated history transport failure') - return sessionOk({ ...page, ...projections === undefined ? {} : { projections } }) + return sessionOk(page) }, - models: request => sessionOk({ - current: modelSelections.get(request.sessionId) - ?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - // The fixture's routes all serve; a surface exercising the blocked - // posture drives it through its own stub. - routable: true, - groups: fixtureModelGroups(), - failures: [], - }), selectModel: (request) => { const selected: ModelSelection = { provider: request.provider, @@ -2653,6 +2686,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { ? {} : { reasoningEffort: request.reasoningEffort }, } + append(request.sessionId, { type: 'model/selection', data: selected }) modelSelections.set(request.sessionId, selected) return sessionOk({ selected }) }, @@ -2717,6 +2751,25 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // log-only, appended inside the open turn, and deduplicated against the // route already recorded (the fixture never varies contextWindow). const selection = modelSelections.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' } + const previousHeader = logOf(id).findLast(event => event.type === 'request/header') + const previousSelection = previousHeader?.type === 'request/header' + ? { + provider: previousHeader.data.header.config.provider, + model: previousHeader.data.header.config.model, + ...(previousHeader.data.header.config.reasoningEffort === undefined + ? {} + : { reasoningEffort: previousHeader.data.header.config.reasoningEffort }), + } + : null + if (!sameModelSelection(previousSelection, selection)) { + append(id, { + type: 'request/header', + data: { + header: { config: selection }, + reason: previousHeader === undefined ? 'initial' : 'change', + }, + }) + } if (lastRequestContext(logOf(id))?.model !== selection.model) { append(id, { type: 'request/context', @@ -2897,23 +2950,27 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { streamBreakers.add(breakNow) const snapshot = [...logOf(sessionId)] const cursor = snapshot.at(-1)?.seq ?? -1 - if (request.afterSeq !== undefined && request.afterSeq > cursor) { - throw new Error( - `fixture: session event resume seq ${String(request.afterSeq)} is past cursor ${String(cursor)}`, - ) - } - let nextSeq = (request.afterSeq ?? cursor) + 1 + const summary = summaryOf(sessionId) + /* v8 ignore next -- existence was checked before the stream registered. */ + if (summary === undefined) throw new Error(`fixture: no session ${sessionId}`) + const initial = pageOf(snapshot, undefined, request.maxMessages ?? 50) + let nextSeq = cursor + 1 try { - yield { type: 'opened', cursor } - if (request.afterSeq !== undefined) { - for (const event of snapshot) { - if (event.seq < nextSeq) continue - if (event.seq !== nextSeq) { - throw new Error(`fixture: session event replay skipped seq ${String(nextSeq)}`) - } - nextSeq++ - yield { type: 'event', event } - } + yield { + type: 'snapshot', + header: { + version: 0, + id: sessionId, + createdAt: summary.updatedAt, + ...(summary.cwd === undefined ? {} : { cwd: summary.cwd }), + ...(summary.parentSessionId === undefined ? {} : { parentSession: summary.parentSessionId }), + ...(summary.origin === undefined ? {} : { origin: summary.origin }), + ...(summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset }), + }, + cursor, + events: initial.events, + hasMore: initial.hasMore, + projections: { asOfSeq: cursor, values: projectionValuesOf(snapshot) }, } for await (const frame of conn.drain(signal)) { if (frame.event.seq < nextSeq) continue @@ -3346,7 +3403,12 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { { provider: 'acme-gateway', displayName: 'Acme Gateway', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'acme-gateway'], active: true, declared: true }, ], }), - models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }), + models: request => ok(request, { + default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routableProviders: ['deepseek-official', 'openai', 'acme-gateway'], + groups: fixtureModelGroups(), + failures: [], + }), // The fixture endpoint is imaginary, so the interrogation answers the // catalog it already serves — enough for a surface to exercise adopting // candidates without a reachable provider. @@ -3411,9 +3473,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { case 'session/create': return sessionApi.create( request as Parameters[0], ) - case 'session/models': return sessionApi.models( - request as Parameters[0], - ) case 'session/selectModel': return sessionApi.selectModel( request as Parameters[0], ) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 438303580f..0b01c1378a 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -33,7 +33,7 @@ export type { ApiProxy, HostApi, DirectoryEntry, DirectoryListing, SkillsApi, SkillEntry, - ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, MessageId, ModelReasoningEffort, ModelSelection, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index f0c39b3657..74e153f15c 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -141,7 +141,12 @@ export class FakeApiClient implements IApiClient { readonly llm: IApiClient['llm'] = { providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))), - models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))), + models: payload => this.record('llm.models', payload, Promise.resolve(ok({ + default: { provider: 'fixture', model: 'fixture' }, + routableProviders: [], + groups: [], + failures: [], + }))), discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))), } diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index 814fe53aa8..918b802aab 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { - ModelProviderGroup, ModelSelection, RpcMessage, RpcRequest, @@ -42,14 +41,19 @@ interface FixtureHistoryEntry { interface FixturePage { readonly events: readonly FixtureHistoryEntry[] readonly hasMore: boolean - readonly projections?: { - readonly asOfSeq: number - readonly values: Readonly> - } } type FixtureFollowFrame = - | { readonly type: 'opened'; readonly cursor: number } + | { + readonly type: 'snapshot' + readonly cursor: number + readonly events: readonly FixtureHistoryEntry[] + readonly hasMore: boolean + readonly projections: { + readonly asOfSeq: number + readonly values: Readonly> + } + } | ({ readonly type: 'event' } & FixtureHistoryEntry) type FixtureControlFrame = @@ -88,7 +92,6 @@ interface FixtureSessionRequests { readonly beforeSeq?: number readonly maxMessages?: number } - models: { readonly sessionId: SessionId } selectModel: { readonly sessionId: SessionId readonly provider: string @@ -114,12 +117,6 @@ interface FixtureSessionValues { search: { readonly items: readonly { readonly sessionId: SessionId; readonly snippet: string }[]; readonly hasMore: boolean } create: { readonly sessionId: SessionId } history: FixturePage - models: { - readonly current: ModelSelection - readonly routable: boolean - readonly groups: readonly ModelProviderGroup[] - readonly failures: readonly unknown[] - } selectModel: { readonly selected: ModelSelection } prompt: { readonly accepted: true } cancel: Record @@ -141,7 +138,7 @@ type FixtureSessionClient = { } interface FixtureSessionRemote { - follow(sessionId: SessionId, signal: AbortSignal, afterSeq?: number): AsyncIterable + follow(sessionId: SessionId, signal: AbortSignal): AsyncIterable control(signal: AbortSignal): AsyncIterable } @@ -331,7 +328,6 @@ function createSessionApi(rpc: ClientConnectionRpc): FixtureSessionApi { search: (request, signal) => call('search', request, signal), create: (request, signal) => call('create', request, signal), history: (request, signal) => call('history', request, signal), - models: (request, signal) => call('models', request, signal), selectModel: (request, signal) => call('selectModel', request, signal), prompt: (request, signal) => call('prompt', request, signal), cancel: (request, signal) => call('cancel', request, signal), @@ -346,7 +342,6 @@ function createSessionClient(rpc: ClientConnectionRpc): FixtureSessionClient { search: (request, signal) => api.search(req(request), signal), create: (request, signal) => api.create(req(request), signal), history: (request, signal) => api.history(req(request), signal), - models: (request, signal) => api.models(req(request), signal), selectModel: (request, signal) => api.selectModel(req(request), signal), prompt: (request, signal) => api.prompt(req(request), signal), cancel: (request, signal) => api.cancel(req(request), signal), @@ -361,11 +356,8 @@ function createSessionRemote(rpc: ClientConnectionRpc): FixtureSessionRemote { return stream as AsyncIterable } return { - follow: (sessionId, signal, afterSeq) => open('session/follow', { - request: { - address: { kind: 'session', sessionId }, - ...afterSeq === undefined ? {} : { afterSeq }, - }, + follow: (sessionId, signal) => open('session/follow', { + request: { address: { kind: 'session', sessionId } }, }, signal), control: signal => open('session/control', {}, signal), } @@ -499,7 +491,7 @@ async function nextRemoteEvent( async function readOpeningCursor(remote: FixtureSessionRemote, sessionId: SessionId): Promise { const abort = new AbortController() for await (const frame of remote.follow(sessionId, abort.signal)) { - if (frame.type !== 'opened') continue + if (frame.type !== 'snapshot') continue abort.abort() return frame.cursor } @@ -599,52 +591,10 @@ describe('createFixtureApi', () => { const clamped = await api.sessions.history(req({ sessionId: sid('fx-alpha'), beforeSeq: -5, maxMessages: 10 })) if (!clamped.result.ok) throw new Error('clamped failed') expect(clamped.result.value.events).toEqual([]) - // Unknown session: empty page, not an error (history of a bare id). The - // tail block still rides it — empty-log cut at -1, the host convention. + // Unknown session: empty page, not an error (history of a bare id). const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 })) if (!empty.result.ok) throw new Error('empty failed') - // Fixture composes the todos + plan units (host parallel when tool-todo - // and plan-mode are mounted): the empty-log values. - expect(empty.result.value).toEqual({ - events: [], hasMore: false, projections: { asOfSeq: -1, values: { - todos: null, - // Permission unit composed: the composition-default select. - permissions: { - options: [ - { value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' }, - { value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' }, - ], - currentValue: 'workspace-write', - }, - plan: { active: false, pending: false }, - goal: null, - tokenUsage: { - uncachedInputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }, - // No request ran, so neither pressure nor capacity is known yet. - contextPressure: {}, - contextBreakdown: { - systemTokens: 0, - toolsTokens: 0, - messageTokens: 0, - }, - // Session-stats unit composed: no figure accrues on the empty log. - sessionStats: { - turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, - }, - imageLimits: { - maxImageBytes: 5 * 1024 * 1024, - maxImagesPerMessage: 20, - maxMessageImageBytes: 100 * 1024 * 1024, - maxImagePixels: 40_000_000, - maxImageDimension: 2000, - mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], - }, - } }, - }) + expect(empty.result.value).toEqual({ events: [], hasMore: false }) }) it('serves raw history entries with replayable tool-result metadata', async () => { @@ -693,7 +643,7 @@ describe('createFixtureApi', () => { it('serves grouped models and keeps a selection for later history and fixture requests', async () => { const api = createFixtureApi() const sessionId = sid('fx-alpha') - const catalog = await api.sessions.models(req({ sessionId })) + const catalog = await api.llm.models(req({})) if (!catalog.result.ok) throw new Error('models failed') expect(catalog.result.value.groups.map(group => group.name)).toEqual(['DeepSeek', 'OpenAI']) expect(catalog.result.value.groups[0]?.models.map(model => model.id)) @@ -1462,33 +1412,29 @@ describe('createFixtureApi', () => { const gapAbort = new AbortController() const gapIterator = api.sessionRemote.follow(sid('fx-alpha'), gapAbort.signal)[Symbol.asyncIterator]() const opening = await gapIterator.next() - if (opening.done || opening.value.type !== 'opened') throw new Error('follow opening cursor missing') - const resumeCursor = opening.value.cursor + if (opening.done || opening.value.type !== 'snapshot') throw new Error('follow opening snapshot missing') hooks.appendSilent('fx-alpha', '静默丢帧') hooks.appendUser('fx-alpha', '正常直播') await expect(gapIterator.next()).rejects.toThrow(/stream skipped seq/) - // Reopening from the established cursor replays both durable events. + // Reopening replaces the window with a complete snapshot containing both durable events. const followAbort = new AbortController() const controlAbort = new AbortController() const followed: FixtureFollowFrame[] = [] const controlled: FixtureControlFrame[] = [] const following = (async () => { - for await (const frame of api.sessionRemote.follow( - sid('fx-alpha'), - followAbort.signal, - resumeCursor, - )) followed.push(frame) + for await (const frame of api.sessionRemote.follow(sid('fx-alpha'), followAbort.signal)) { + followed.push(frame) + } })() const controlling = (async () => { for await (const frame of api.sessionRemote.control(controlAbort.signal)) controlled.push(frame) })() await new Promise(resolve => setTimeout(resolve, 10)) await vi.waitFor(() => { - expect(followed.some(frame => frame.type === 'event' - && JSON.stringify(frame.event.data).includes('静默丢帧'))).toBe(true) - expect(followed.some(frame => frame.type === 'event' - && JSON.stringify(frame.event.data).includes('正常直播'))).toBe(true) + const snapshot = followed.find(frame => frame.type === 'snapshot') + expect(snapshot?.events.some(entry => JSON.stringify(entry.event.data).includes('静默丢帧'))).toBe(true) + expect(snapshot?.events.some(entry => JSON.stringify(entry.event.data).includes('正常直播'))).toBe(true) }) hooks.appendTitle('fx-alpha', 'Fixture 修订标题') hooks.beginModelRetry('fx-alpha') @@ -1497,7 +1443,6 @@ describe('createFixtureApi', () => { hooks.beginModelRetry('fx-alpha') hooks.cancelModelRetryDuringBackoff('fx-alpha') await vi.waitFor(() => { - expect(followed.some(frame => frame.type === 'event' && JSON.stringify(frame.event.data).includes('正常直播'))).toBe(true) expect(followed.some(frame => frame.type === 'event' && (frame.event as { type: string }).type === 'llm/retry')).toBe(true) expect(followed.some(frame => frame.type === 'event' && JSON.stringify(frame.event.data).includes('重试后的完整回复'))).toBe(true) expect(followed.some(frame => frame.type === 'event' diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index 0fb58c8207..0d54a69f01 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-model-selection", - "description": "Model selection: the /model popupSelect over session.models / session.selectModel", + "description": "Model selection over the shared model catalog, Session projection, and session.selectModel", "version": "0.1.1-rc.2", "publishConfig": { "access": "public" @@ -33,6 +33,7 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-commands", "@deepseek-ai/dsh-api-remotes" diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index e53001da89..aa21644e1d 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -107,14 +107,6 @@ export function ModelSelect( load() } - // Mount-time load resolves the trigger label; every open refreshes. - useEffect(() => { - if (available) { - lastActionRef.current = 'load' - load() - } - }, [available, load]) - useEffect(() => { if (!open) return const closeOutside = (event: MouseEvent): void => { @@ -202,13 +194,19 @@ export function ModelSelect( void select(selection).then(settleSelection) } - const modelLabel = currentChoice?.model.name ?? t('trigger.fallback') + const waiting = state.current === null && state.status === 'loading' + const modelLabel = waiting + ? t('trigger.loading') + : currentChoice?.model.name + ?? (state.current === null ? t('trigger.fallback') : `${state.current.provider}/${state.current.model}`) const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}` - const triggerAria = currentChoice === undefined - ? t('trigger.selectAria') - : effortLabel === undefined - ? t('trigger.aria', { model: modelLabel }) - : t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel }) + const triggerAria = waiting + ? t('trigger.loading') + : state.current === null + ? t('trigger.selectAria') + : effortLabel === undefined + ? t('trigger.aria', { model: modelLabel }) + : t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel }) itemRefs.current = [] let itemIndex = 0 const itemRef = () => { diff --git a/packages/client/ui-model-selection/src/client/catalog.ts b/packages/client/ui-model-selection/src/client/catalog.ts new file mode 100644 index 0000000000..b0ec866a9a --- /dev/null +++ b/packages/client/ui-model-selection/src/client/catalog.ts @@ -0,0 +1,89 @@ +/** One Host-generation model catalog shared by every Session selector. */ + +import { + type IApiClient, + type ModelCatalog, +} from '@deepseek-ai/dsh-client-connection/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' + +/** Observable lifecycle of the shared model catalog. */ +export interface ModelCatalogState { + value: ModelCatalog | null + status: 'idle' | 'loading' | 'ready' | 'error' + error: string | null +} + +/** Loads at most one model catalog for the current Host generation. */ +export class ModelCatalogDirectory { + /** Current shared catalog value and load lifecycle. */ + readonly store: SnapshotStore = createSnapshotStore({ + value: null, + status: 'idle', + error: null, + }) + + private generation = 0 + private inflight: Promise | undefined + + /** @param api - shared connection API client. */ + constructor(private readonly api: IApiClient) {} + + /** + * Return the current generation's catalog, sharing its one in-flight load. + * @returns the loaded global catalog. + */ + load(): Promise { + const state = this.store.getSnapshot() + if (state.status === 'ready' && state.value !== null) return Promise.resolve(state.value) + if (this.inflight !== undefined) return this.inflight + const generation = this.generation + this.store.update((draft) => { + draft.status = 'loading' + draft.error = null + }) + const operation = this.api.llm.models({}).then((response) => { + if (!response.result.ok) { + throw new Error(`${response.result.error.code}: ${response.result.error.message}`) + } + if (generation === this.generation) { + this.store.set({ value: response.result.value, status: 'ready', error: null }) + } + return response.result.value + }).catch((error: unknown) => { + if (generation === this.generation) { + this.store.update((draft) => { + draft.status = 'error' + draft.error = error instanceof Error ? error.message : String(error) + }) + } + throw error + }).finally(() => { + if (generation === this.generation && this.inflight === operation) this.inflight = undefined + }) + this.inflight = operation + return operation + } + + /** + * Invalidate the loaded catalog; the next explicit menu read reloads it. + * @param clear - whether values from the previous Host generation must be hidden. + */ + private invalidate(clear = false): void { + this.generation += 1 + this.inflight = undefined + const value = clear ? null : this.store.getSnapshot().value + this.store.set({ value, status: 'idle', error: null }) + } + + /** Invalidate and reload the catalog after a Host-side model input changes. */ + refresh(): void { + this.invalidate() + void this.load().catch(() => { /* the selector exposes the shared error */ }) + } + + /** Clear Host-specific values and load the replacement Host generation. */ + resetGeneration(): void { + this.invalidate(true) + void this.load().catch(() => { /* the selector exposes the shared error */ }) + } +} diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index dc4c4fb392..6ceee704ed 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -1,21 +1,21 @@ /** * Per-session model directory: the ONE state both selection entries share. - * The /model popup and the composer-seat selector load through the same - * controller and submit through the same selectModel call, so the host stays - * the single fact source and the store is one shared echo — a switch made in - * either entry is what the other shows next. + * The /model popup and composer seat combine one shared Host catalog with the + * Session's durable selection projection, then submit through the same + * selectModel call. A switch made in either entry updates this shared state. */ import type { - ModelCatalogFailure, ModelProviderGroup, ModelSelection, SessionModels, + ModelCatalogFailure, ModelProviderGroup, ModelSelection, ModelSelectionProjection, } from '@deepseek-ai/dsh-api-session-controller/types' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { ModelCatalogDirectory } from './catalog.ts' /** Directory snapshot both entries render from. */ export interface ModelDirectoryState { - /** Model selection the host reports for the next assembled step; null before the first load. */ + /** Effective selection: durable next-request projection, then Host default. */ current: ModelSelection | null /** * Whether an adapter serves the current selection's provider, as the host reports @@ -42,55 +42,47 @@ export class ModelDirectory { current: null, routable: null, groups: [], failures: [], status: 'idle', error: null, }) - /** Latest operation wins; an older response never overwrites a newer one. */ + /** Latest selection operation wins; an older response never overwrites a newer one. */ private generation = 0 private disposed = false + private resolved = false + private readonly unsubscribeCatalog: () => void + private readonly unsubscribeSelection: () => void /** * @param sessions - the session wire face (captured from the plugin's root connection). * @param sessionId - the owning session. * @param available - whether this session may use Agent-bound model RPCs. + * @param catalog - Host-generation catalog shared by every Session. + * @param projected - durable model selection projected from Session history. */ constructor( - private readonly sessions: Pick, + private readonly sessions: Pick, private readonly sessionId: SessionId, private readonly available: () => boolean, - ) {} - - /** - * Refresh the advisory directory (both entries call this on open). - * Failure preserves the last good groups and current selection. - * @returns the fresh directory value. - */ - async load(): Promise { - this.assertAvailable() - const generation = ++this.generation - this.store.update((s) => { s.status = 'loading'; s.error = null }) - const result = await this.sessions.models({ sessionId: this.sessionId }) - if (this.disposed || generation !== this.generation) { - if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) - return result.value - } - if (!result.ok) { - this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` }) - throw new Error(`session.models failed: ${result.error.code}: ${result.error.message}`) - } - const { current, routable, groups, failures } = result.value - this.store.update((s) => { - s.current = current - s.routable = routable - s.groups = groups - s.failures = failures - s.status = 'ready' - s.error = null - }) - return result.value + private readonly catalog: ModelCatalogDirectory, + private readonly projected: ObservableSnapshot, + ) { + this.unsubscribeCatalog = catalog.store.subscribe(() => { this.syncInputs() }) + this.unsubscribeSelection = projected.subscribe(() => { this.syncInputs() }) + this.syncInputs() } /** - * Select the complete provider/model/reasoning selection (both entries submit through here). Success - * updates the shared current; failure surfaces on the store and throws so - * each entry's own retry surface engages. + * Ensure the Host generation's shared advisory catalog is loaded. + * @returns the fresh directory value. + */ + async load(): Promise { + this.assertAvailable() + await this.catalog.load() + this.syncInputs() + return this.store.getSnapshot() + } + + /** + * Select the complete provider/model/reasoning selection. The durable + * projection frame updates the shared current; failures surface on the store + * and throw so each entry's own retry surface engages. * @param selection - provider, provider-owned model id, and optional adapter-owned effort. */ async select(selection: ModelSelection): Promise { @@ -113,39 +105,28 @@ export class ModelDirectory { this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` }) throw new Error(`session.selectModel failed: ${result.error.code}: ${result.error.message}`) } - // The Host validated the route before accepting it, so a selection that - // landed is by construction one it can serve. - this.store.update((s) => { - s.current = result.value.selected - s.routable = true - s.status = 'ready' - s.error = null - }) + this.store.update((s) => { s.status = 'ready'; s.error = null }) + this.syncInputs() } /** - * Drop the previous Host generation's projection and repull it. Clearing - * first prevents an unconsumed process-local selection from being displayed - * while the restarted Host has restored the last logged model selection. + * Invalidate an in-flight selection response from the previous Host generation. */ resetConnected(): void { if (this.disposed) return ++this.generation - this.store.update((s) => { - s.current = null - s.routable = null - s.groups = [] - s.failures = [] - s.status = 'idle' - s.error = null + this.store.update((state) => { + if (state.status === 'selecting') state.status = 'idle' + state.error = null }) - if (!this.available()) return - void this.load().catch(() => { /* the next menu open remains the explicit retry surface */ }) + this.syncInputs() } /** Scope teardown: late settlements lose write access to the store. */ dispose(): void { this.disposed = true + this.unsubscribeSelection() + this.unsubscribeCatalog() } private assertAvailable(): void { @@ -153,4 +134,46 @@ export class ModelDirectory { throw new Error('model selection is unavailable for addressed subagent sessions') } } + + private syncInputs(): void { + if (this.disposed) return + const catalog = this.catalog.store.getSnapshot() + const projected = modelSelectionProjection(this.projected.getSnapshot()) + if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) { + if (this.resolved) { + if (catalog.status === 'error') { + this.store.update((state) => { + state.status = 'error' + state.error = catalog.error + }) + } + return + } + this.store.set({ + current: null, + routable: null, + groups: [], + failures: [], + status: catalog.status === 'error' ? 'error' : 'loading', + error: catalog.error, + }) + return + } + const current = projected.next ?? catalog.value.default + this.resolved = true + this.store.set({ + current, + routable: catalog.value.routableProviders.includes(current.provider), + groups: catalog.value.groups, + failures: catalog.value.failures, + status: this.store.getSnapshot().status === 'selecting' + ? 'selecting' + : 'ready', + error: null, + }) + } +} + +function modelSelectionProjection(value: unknown): ModelSelectionProjection | undefined { + return value === undefined ? undefined : value as ModelSelectionProjection } diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index f5fd706fb4..d68e0463d3 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -1,18 +1,17 @@ /** * Model selection plugin, browser half — TWO entries over ONE per-session * directory owned by ModelDirectoryResolver (`ctx.modelDirectories`). The /model popupSelect - * contribution and the composer's named `conversation.input.model` seat both - * load the session's provider-grouped advisory directory (`session.models`) - * and submit through `session.selectModel` via the same directory instance, - * so the host-reported current selection is the single fact both surfaces echo - * — a switch made in either entry is what the other shows next. Failures + * contribution and the composer's named `conversation.input.model` seat share + * one Host-generation `llm.models` catalog, combine it with the Session's + * durable model-selection projection, and submit through `session.selectModel`. + * A switch made in either entry is what the other shows next. Failures * ride each entry's own retry surface (popup shell error/retry; seat menu * inline error) without forking the state. Addressed subagent sessions expose * neither entry because those Agent-bound RPCs would activate persisted * history outside the direct-parent continuation path. */ // Type-only: the carrier types, the forwarded Host-event face and the ctx.remote merge. -import type { ModelSelection, SessionModels } from '@deepseek-ai/dsh-api-session-controller/types' +import type { ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types' import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { CommandUiContract, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client' @@ -48,7 +47,7 @@ function rowId(providerId: string, modelId: string): string { } /** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */ -function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOption[] { +function optionsOf(directory: ModelDirectoryState, t: TranslateNS<'model'>): SelectOption[] { const rows: SelectOption[] = [] for (const group of directory.groups) { for (const model of group.models) { @@ -56,7 +55,9 @@ function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOpt id: rowId(group.id, model.id), label: model.name, detail: model.description !== undefined ? `${group.name} · ${model.description}` : group.name, - ...(directory.current.provider === group.id && directory.current.model === model.id + ...(directory.current !== null + && directory.current.provider === group.id + && directory.current.model === model.id ? { active: true } : {}), }) } diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts index b1e373a57f..3bf0774679 100644 --- a/packages/client/ui-model-selection/src/client/locales.ts +++ b/packages/client/ui-model-selection/src/client/locales.ts @@ -13,6 +13,7 @@ export const zh = { 'command.description': '选择本会话使用的模型', 'option.loadError': '目录加载失败:{message}', 'trigger.fallback': '选择模型', + 'trigger.loading': '正在加载模型…', 'trigger.selectAria': '选择模型', 'trigger.aria': '选择模型,当前 {model}', 'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}', @@ -37,6 +38,7 @@ export const en = { 'command.description': 'Select the model for this conversation', 'option.loadError': 'Catalog failed to load: {message}', 'trigger.fallback': 'Select model', + 'trigger.loading': 'Loading models…', 'trigger.selectAria': 'Select model', 'trigger.aria': 'Select model, current {model}', 'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}', diff --git a/packages/client/ui-model-selection/src/client/service.ts b/packages/client/ui-model-selection/src/client/service.ts index 0e1ce576c1..7e01ecd5a2 100644 --- a/packages/client/ui-model-selection/src/client/service.ts +++ b/packages/client/ui-model-selection/src/client/service.ts @@ -15,7 +15,9 @@ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-session-controller/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { ModelCatalogDirectory } from './catalog.ts' import { ModelDirectory } from './directory.ts' declare module '@deepseek-ai/cordis' { @@ -32,9 +34,10 @@ interface LiveState { /** The `ctx.modelDirectories` session model-selection service. */ export class ModelDirectoryResolver extends Service { - static inject = ['sessions', 'remote', 'remote.session'] + static inject = ['sessions', 'remote', 'remote.session', 'connection'] private readonly live: LiveState = { directories: new Map() } + private readonly catalog: ModelCatalogDirectory /** Localized composer-block copy; this plugin owns the string it raises. */ private readonly blockReason: () => string @@ -46,18 +49,17 @@ export class ModelDirectoryResolver extends Service { constructor(ctx: Context, config: { blockReason: () => string }) { super(ctx, 'modelDirectories') this.blockReason = config.blockReason + const connection = ctx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) throw new Error('ui-model-selection: connection service is unavailable') + this.catalog = new ModelCatalogDirectory(connection.api) + void this.catalog.load().catch(() => { /* selectors expose the shared error */ }) ctx.on('connection/reset', () => { + this.catalog.resetGeneration() for (const directory of this.live.directories.values()) directory.resetConnected() }) - // Either source can change the directory: registry topology commits and - // settings documents that carry provider catalogs or default selection. - const refresh = (): void => { - for (const directory of this.live.directories.values()) { - directory.load().catch(() => undefined) - } - } - ctx.remote.$on('llm/adapters-updated', refresh) - ctx.remote.$on('settings/document-updated', refresh) + ctx.remote.$on('llm/adapters-updated', () => { this.catalog.refresh() }) + ctx.remote.$on('settings/document-updated', () => { this.catalog.refresh() }) + ctx.remote.$on('credentials/reference-updated', () => { this.catalog.refresh() }) } /** @@ -73,10 +75,14 @@ export class ModelDirectoryResolver extends Service { const sessions = this.ctx.sessions const actx = sessions.scope(sessionId) if (actx === undefined) throw new Error(`ui-model-selection: session "${String(sessionId)}" resolved no scope`) + const binding = sessions.binding(sessionId) + if (binding === undefined) throw new Error(`ui-model-selection: session "${String(sessionId)}" resolved no binding`) const directory = new ModelDirectory( this.ctx.remote.session, sessionId, () => sessions.subagentAddress(sessionId) === undefined, + this.catalog, + binding.session.projections.faceOf('modelSelection'), ) live.directories.set(sessionId, directory) // The composer cannot read this plugin (the dependency runs one way), so diff --git a/packages/client/ui-model-selection/src/client/slots.ts b/packages/client/ui-model-selection/src/client/slots.ts index 3924b5a95f..fcc88bbd26 100644 --- a/packages/client/ui-model-selection/src/client/slots.ts +++ b/packages/client/ui-model-selection/src/client/slots.ts @@ -14,7 +14,7 @@ export interface ModelSelectInjected { available: boolean /** The session's shared directory store (same instance the /model popup reads). */ directory: SnapshotStore - /** Refresh the advisory directory (fire-and-forget; errors land on the store). */ + /** Ensure the shared advisory catalog is loaded (errors land on the store). */ load: () => void /** * Select a complete provider/model/reasoning selection. diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 795d2487be..459c972208 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -9,12 +9,13 @@ * Scope disposal drops the directory (HMR safety). */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createScope } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' -import type { ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types' +import type { ModelSelection, ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types' import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client' import type { ModelSelectInjected } from '../src/client/slots.ts' import { apply, inject } from '../src/client/index.ts' @@ -56,30 +57,51 @@ const GROUPS = [{ /** Boot the plugin over fake faces + a stateful fake host (current moves on selectModel). */ async function bench() { const ctx = new Context() - let current: ModelSelection = { provider: 'deepseek-official', model: 'deepseek-v4-flash' } + let defaultSelection: ModelSelection = { provider: 'deepseek-official', model: 'deepseek-v4-flash' } + let selected = defaultSelection const calls = { models: 0, select: 0 } + const projections = new Map>() + // Whether the Host reports an adapter for the current route; the composer + // block follows this, never catalog membership. + let routable = true const sessionRemote = { - models: () => { - calls.models += 1 - return Promise.resolve({ ok: true as const, value: { current, routable, groups: GROUPS, failures: [] } }) - }, - selectModel: (payload: { provider: string; model: string; reasoningEffort?: string }) => { + selectModel: (payload: { sessionId: SessionId; provider: string; model: string; reasoningEffort?: string }) => { calls.select += 1 - current = { + selected = { provider: payload.provider, model: payload.model, ...payload.reasoningEffort === undefined ? {} : { reasoningEffort: payload.reasoningEffort }, } - return Promise.resolve({ ok: true as const, value: { selected: current } }) + projections.get(payload.sessionId)?.set({ lastUsed: null, next: selected }) + return Promise.resolve({ ok: true as const, value: { selected } }) }, } const remote = Object.assign(new TestRemote(ctx), { session: sessionRemote }) ctx.reflect.provide('remote.session', sessionRemote) - // Whether the Host reports an adapter for the current route; the composer - // block follows this, never catalog membership. - let routable = true + ctx.provide('connection', { + api: { + llm: { + models: () => { + calls.models += 1 + return Promise.resolve({ + rpcId: 'model-catalog', + result: { + ok: true as const, + value: { + default: defaultSelection, + routableProviders: routable ? ['deepseek-official'] : [], + groups: GROUPS, + failures: [], + }, + }, + }) + }, + }, + }, + isLoopback: false, + } as never) const blocks = new Map() ctx.provide('conversation', { blocks: { @@ -114,6 +136,17 @@ async function bench() { const addressed = new Set() ctx.provide('sessions', { scope: (id: SessionId) => scopes.get(id), + binding: (id: SessionId) => { + const scope = scopes.get(id) + const projection = projections.get(id) + return scope === undefined || projection === undefined + ? undefined + : { + sessionId: id, + session: { projections: { faceOf: () => projection } }, + ctx: scope, + } + }, subagentAddress: (id: SessionId) => addressed.has(id) ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } : undefined, @@ -122,16 +155,22 @@ async function bench() { await fiber.await() await ctx.plugin(function probe() {}).await() const mint = (key: string) => { - const handle = createScope(ctx, sid(key)) - scopes.set(sid(key), handle.ctx) + const id = sid(key) + const handle = createScope(ctx, id) + scopes.set(id, handle.ctx) + projections.set(id, createSnapshotStore({ + lastUsed: null, + next: null, + })) return handle } return { ctx, fiber, mint, calls, remote, contribution: () => contribution!, seat: () => seats.get('conversation.input.model')!, - hostCurrent: () => current, - setHostCurrent: (selection: ModelSelection) => { current = selection }, + hostCurrent: () => selected, + setHostCurrent: (selection: ModelSelection) => { defaultSelection = selection }, + setProjected: (id: SessionId, value: ModelSelectionProjection) => { projections.get(id)?.set(value) }, address: (id: SessionId) => { addressed.add(id) }, setRoutable: (next: boolean) => { routable = next }, blockOf: (key: string) => blocks.get(sid(key)), @@ -209,9 +248,14 @@ describe('ui-model-selection dual entry', () => { expect(faceA.directory).not.toBe(faceB.directory) // The service face resolves the same instance the seat inject handed out. expect(b.ctx.modelDirectories.directoryFor(sid('a')).store).toBe(faceA.directory) + await Promise.all([ + b.contribution().ui.options(projection('a'), new AbortController().signal), + b.contribution().ui.options(projection('b'), new AbortController().signal), + ]) + expect(b.calls.models).toBe(1) }) - it('drops an unconsumed local selection and restores the Host target after reconnect', async () => { + it('keeps the durable projected selection while the eager catalog reconnects', async () => { const b = await bench() b.mint('s1') const face = b.seat().inject!(sid('s1')) @@ -219,12 +263,40 @@ describe('ui-model-selection dual entry', () => { b.setHostCurrent({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) b.ctx.emit('connection/reset') - expect(face.directory.getSnapshot()).toMatchObject({ current: null, status: 'loading' }) - await Promise.resolve() + expect(face.directory.getSnapshot()).toMatchObject({ + current: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, + status: 'ready', + }) + face.load() + expect(face.directory.getSnapshot()).toMatchObject({ + current: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, + status: 'ready', + }) + }) + + it('keeps the last complete view while a refreshed catalog catches up with projection', async () => { + const b = await bench() + b.mint('s1') + const face = b.seat().inject!(sid('s1')) + face.load() + expect(face.directory.getSnapshot().current?.model).toBe('deepseek-v4-flash') + + b.remote.emit('settings/document-updated', ['llm-deepseek', 1]) + b.setProjected(sid('s1'), { + lastUsed: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + next: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, + }) expect(face.directory.getSnapshot()).toMatchObject({ current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, status: 'ready', }) + + await vi.waitFor(() => { + expect(face.directory.getSnapshot()).toMatchObject({ + current: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, + status: 'ready', + }) + }) }) it('scope disposal drops the directory; a reborn scope gets a fresh one', async () => { @@ -249,19 +321,22 @@ describe('ui-model-selection dual entry', () => { await Promise.resolve() await Promise.resolve() expect(b.blockOf('s1')).toBeUndefined() + expect(b.calls.models).toBe(1) b.setRoutable(false) - b.remote.emit('llm/adapters-updated', []) - await Promise.resolve() - await Promise.resolve() - expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer']) - - // Recovering clears it without a reload of the surface. - b.setRoutable(true) b.remote.emit('settings/document-updated', ['llm-deepseek', 1]) await Promise.resolve() await Promise.resolve() + expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer']) + expect(b.calls.models).toBe(2) + + // Recovering clears it without a reload of the surface. + b.setRoutable(true) + b.remote.emit('llm/adapters-updated', []) + await Promise.resolve() + await Promise.resolve() expect(b.blockOf('s1')).toBeUndefined() + expect(b.calls.models).toBe(3) }) it('never blocks on catalog membership alone', async () => { @@ -286,9 +361,8 @@ describe('ui-model-selection dual entry', () => { b.setRoutable(false) const face = b.seat().inject!(sid('s1')) face.load() - await Promise.resolve() - await Promise.resolve() - expect(b.blockOf('s1')).toBeDefined() + b.remote.emit('llm/adapters-updated', []) + await vi.waitFor(() => { expect(b.blockOf('s1')).toBeDefined() }) await scope.fiber.dispose() expect(b.blockOf('s1')).toBeUndefined() @@ -322,6 +396,6 @@ describe('ui-model-selection dual entry', () => { })).rejects.toThrow(/unavailable for addressed subagent/) b.ctx.emit('connection/reset') await Promise.resolve() - expect(b.calls).toEqual({ models: 0, select: 0 }) + expect(b.calls).toEqual({ models: 2, select: 0 }) }) }) diff --git a/packages/client/ui-model-selection/tests/catalog.client.spec.ts b/packages/client/ui-model-selection/tests/catalog.client.spec.ts new file mode 100644 index 0000000000..95f3d440b4 --- /dev/null +++ b/packages/client/ui-model-selection/tests/catalog.client.spec.ts @@ -0,0 +1,93 @@ +import type { IApiClient, ModelCatalog } from '@deepseek-ai/dsh-client-connection/client' +import { describe, expect, it, vi } from 'vitest' +import { ModelCatalogDirectory } from '../src/client/catalog.ts' + +const catalog = (model: string): ModelCatalog => ({ + default: { provider: 'fixture', model }, + routableProviders: ['fixture'], + groups: [{ id: 'fixture', name: 'Fixture', models: [{ id: model, name: model }] }], + failures: [], +}) + +function directory(models: () => Promise): ModelCatalogDirectory { + return new ModelCatalogDirectory({ llm: { models } } as unknown as IApiClient) +} + +describe('ModelCatalogDirectory', () => { + it('shares one failing request, exposes the RPC error, and permits a retry', async () => { + const models = vi.fn() + .mockResolvedValueOnce({ + result: { ok: false, error: { code: 'unavailable', message: 'catalog offline', details: {} } }, + }) + .mockResolvedValueOnce({ result: { ok: true, value: catalog('recovered') } }) + const subject = directory(models) + + const first = subject.load() + expect(subject.load()).toBe(first) + await expect(first).rejects.toThrow('unavailable: catalog offline') + expect(subject.store.getSnapshot()).toMatchObject({ status: 'error', error: 'unavailable: catalog offline' }) + await expect(subject.load()).resolves.toEqual(catalog('recovered')) + expect(models).toHaveBeenCalledTimes(2) + }) + + it('does not publish a successful result from an invalidated generation', async () => { + const first = Promise.withResolvers() + const second = Promise.withResolvers() + const models = vi.fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + const subject = directory(models) + + const stale = subject.load() + subject.resetGeneration() + first.resolve({ result: { ok: true, value: catalog('stale') } }) + await expect(stale).resolves.toEqual(catalog('stale')) + expect(subject.store.getSnapshot()).toMatchObject({ value: null, status: 'loading' }) + second.resolve({ result: { ok: true, value: catalog('fresh') } }) + await vi.waitFor(() => { + expect(subject.store.getSnapshot()).toMatchObject({ value: catalog('fresh'), status: 'ready' }) + }) + }) + + it('does not publish a failure from an invalidated generation', async () => { + const first = Promise.withResolvers() + const second = Promise.withResolvers() + const models = vi.fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + const subject = directory(models) + + const stale = subject.load() + subject.resetGeneration() + first.reject(new Error('stale failure')) + await expect(stale).rejects.toThrow('stale failure') + expect(subject.store.getSnapshot()).toMatchObject({ value: null, status: 'loading', error: null }) + second.resolve({ result: { ok: true, value: catalog('fresh') } }) + await vi.waitFor(() => { + expect(subject.store.getSnapshot()).toMatchObject({ value: catalog('fresh'), status: 'ready' }) + }) + }) + + it('contains refresh failures while retaining old data and clears it on a failed Host reset', async () => { + const models = vi.fn() + .mockResolvedValueOnce({ result: { ok: true, value: catalog('old') } }) + .mockRejectedValueOnce('refresh failed') + .mockRejectedValueOnce(new Error('reset failed')) + const subject = directory(models) + await subject.load() + + subject.refresh() + await vi.waitFor(() => { + expect(subject.store.getSnapshot()).toEqual({ + value: catalog('old'), status: 'error', error: 'refresh failed', + }) + }) + + subject.resetGeneration() + await vi.waitFor(() => { + expect(subject.store.getSnapshot()).toEqual({ + value: null, status: 'error', error: 'reset failed', + }) + }) + }) +}) diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 166eb6d74e..eefd167c1b 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -112,7 +112,7 @@ describe('ModelSelect reasoning effort', () => { .toEqual(['Default', 'Standard']) }) - it('prompts for a selection when the current model is no longer advertised', () => { + it('shows the durable model id when the catalog has no matching display name', () => { const directory = createSnapshotStore(state({ current: { provider: 'deepseek-official', model: 'removed-model' }, })) @@ -126,15 +126,41 @@ describe('ModelSelect reasoning effort', () => { t={t} />) - const trigger = screen.getByRole('button', { name: '选择模型' }) - expect(trigger.textContent).toContain('选择模型') + const trigger = screen.getByRole('button', { name: '选择模型,当前 deepseek-official/removed-model' }) + expect(trigger.textContent).toContain('deepseek-official/removed-model') fireEvent.click(trigger) expect(screen.queryByRole('menuitem', { name: /推理等级/ })).toBeNull() fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) - expect(screen.queryByText('removed-model')).toBeNull() + expect(screen.queryByRole('menuitemradio', { name: 'removed-model' })).toBeNull() expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy() }) + it('shows loading until the catalog and Session projection are both ready', async () => { + const directory = createSnapshotStore(state({ + current: null, + routable: null, + groups: [], + status: 'loading', + })) + render() + + expect(screen.getByRole('button', { name: '正在加载模型…' }).textContent) + .toContain('正在加载模型…') + directory.set(state()) + await waitFor(() => { + expect(screen.getByRole('button', { + name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High', + })).toBeTruthy() + }) + }) + it('announces a rejected selection as a transient toast and keeps the in-menu strip for loads', async () => { const groups = [{ id: 'deepseek-official', diff --git a/packages/client/ui-model-selection/tsconfig.json b/packages/client/ui-model-selection/tsconfig.json index 7dbfc4369d..de04dc2c92 100644 --- a/packages/client/ui-model-selection/tsconfig.json +++ b/packages/client/ui-model-selection/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../api/remotes/tsconfig.client.json" }, + { + "path": "../connection/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts index c005211567..7f34bbfcca 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -36,6 +36,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'hook/result', 'llm/retry', 'llm/retry-started', + 'model/selection', 'permission/preset', 'plan/mode', 'request/context', From b8dfa8b892373d0832d583344913c79ed82c539e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:07:58 +0800 Subject: [PATCH 06/17] fix(agent-presets): project selection and refresh client catalogs --- apps/web/tests/agent-preset-selection.e2e.ts | 13 ++- packages/client/ui-agent-preset/package.json | 2 + .../src/client/AgentPresetLabel.tsx | 6 +- .../ui-agent-preset/src/client/index.ts | 22 +--- .../ui-agent-preset/src/client/seat-store.ts | 56 +++++----- .../tests/apply.client.spec.ts | 101 ++++++++++++------ .../tests/components.client.spec.tsx | 14 ++- .../tests/settings-store.client.spec.ts | 79 ++++++++++++-- packages/client/ui-agent-preset/tsconfig.json | 3 + .../ui-commands/src/client/directory.ts | 12 +++ .../client/ui-commands/src/client/service.ts | 7 +- .../tests/directory.client.spec.ts | 24 +++++ .../ui-commands/tests/service.client.spec.ts | 4 +- .../ui-input-trigger/src/client/controller.ts | 12 ++- .../tests/service.client.spec.ts | 14 ++- .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 2 +- packages/preset/agent-presets/README.zh.md | 2 +- packages/preset/agent-presets/package.json | 5 +- packages/preset/agent-presets/src/index.ts | 10 +- packages/preset/agent-presets/src/session.ts | 42 +++----- packages/preset/agent-presets/src/types.ts | 10 ++ .../agent-presets/tests/session.spec.ts | 52 +++------ .../agent-presets/tests/settings.spec.ts | 2 + packages/preset/agent-presets/tsconfig.json | 3 + 25 files changed, 333 insertions(+), 168 deletions(-) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 648a3f870e..ea7ee009d5 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -150,9 +150,18 @@ async function livePreset(baseUrl: string): Promise { }), }) const body = await response.json() as { - result: { value?: { items: { sessionId: string; agentPreset?: string }[] } } + result: { + value?: { + items: { + sessionId: string + projections?: { values: { agentPreset?: string | null } } + }[] + } + } } - return body.result.value?.items.find(item => item.sessionId !== SEED_ID)?.agentPreset + const preset = body.result.value?.items.find(item => item.sessionId !== SEED_ID) + ?.projections?.values.agentPreset + return typeof preset === 'string' ? preset : undefined } /** Every option label the trigger menu currently lists. */ diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 4199ec7226..60a91df206 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -53,6 +53,7 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -66,6 +67,7 @@ "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx index 06448f27a1..b40453e94a 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -14,6 +14,7 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli import { IconAgentPresetOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the header actions). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-agent-presets/types' import type { AgentPresetSettingsState } from './settings-store.ts' import { presetDisplayText } from './locales.ts' import css from './AgentPresetLabel.module.css' @@ -42,7 +43,10 @@ export type AgentPresetLabelProps = export function AgentPresetLabel({ sessionId, useSessions, useAgentPresets, load, t, }: AgentPresetLabelProps) { - const preset = useSessions(state => state.byId[sessionId]?.agentPreset) + const preset = useSessions((state) => { + const value = state.byId[sessionId]?.projectionValues?.agentPreset + return typeof value === 'string' ? value : undefined + }) const options = useAgentPresets(state => state.options) useEffect(() => { diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index d40b14ae00..28a50c034f 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -34,7 +34,6 @@ import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx' import { AgentPresetSection } from './AgentPresetSection.tsx' import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx' import { AgentPresetSeatController } from './seat-store.ts' -import type { SeatSessionSummary } from './seat-store.ts' import { AgentPresetSectionController } from './section-store.ts' import { en, zh } from './locales.ts' import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts' @@ -43,7 +42,7 @@ export type { AgentPresetLabelInjected, AgentPresetLabelProps } from './AgentPre export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx' export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx' export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx' -export type { AgentPresetSeatState, SeatSessionSummary } from './seat-store.ts' +export type { AgentPresetSeatState } from './seat-store.ts' export { draftBlocker, type AgentPresetSectionState, type CopyDraft, type PresetRow, type PresetView, } from './section-store.ts' @@ -106,18 +105,9 @@ export function apply(ctx: ClientContext): void { // staged choice belongs to the flow rather than to any one session. ctx.inject(['slots', 'conversation', 'sessions', 'uiWorkspace'], (scope: ClientContext) => { const api = (scope.get('connection') as ConnectionHandle).api - const seat = new AgentPresetSeatController(api, (): SeatSessionSummary | undefined => { + const seat = new AgentPresetSeatController(api, () => { const state = scope.sessions.list.getSnapshot() - const summary = state.current === undefined ? undefined : state.byId[state.current] - return summary === undefined - ? undefined - : { - id: summary.id, - blank: summary.blank, - ...summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset }, - } - }, (sessionId, agentPreset) => { - scope.sessions.noteAgentPreset(sessionId as never, agentPreset) + return state.current === undefined ? undefined : state.byId[state.current] }) const seatInjected = (): AgentPresetSeatInjected => ({ @@ -146,11 +136,6 @@ export function apply(ctx: ClientContext): void { if (ns !== AGENT_PRESET_SETTINGS_NS) return void seat.load() }) - // Every tab folds the committed preset into the shared session row; the - // initiating tab may already have applied the RPC echo, which is idempotent. - const presetSelected = scope.remote.$on('agent-preset/selected', (sessionId, agentPreset) => { - scope.sessions.noteAgentPreset(sessionId, agentPreset) - }) // Authoring writes a FILE, not a setting, so nothing on the wire // announces it — without this the screen that starts the next session // keeps offering the roster as it stood when the chip first loaded, and @@ -183,7 +168,6 @@ export function apply(ctx: ClientContext): void { return () => { stop() settingsMoved() - presetSelected() rosterReaders.delete(readRoster) creatorDraft = undefined chip() diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index 64e71fefb7..b16e803ac5 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -11,8 +11,9 @@ */ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-agent-presets/types' import { messageOf, presetOptions } from './settings-store.ts' import type { AgentPresetOption } from './settings-store.ts' @@ -37,16 +38,6 @@ const INITIAL: AgentPresetSeatState = { options: [], current: '', error: null, busy: false, introduce: false, } -/** One session's identity and whether it has started. */ -export interface SeatSessionSummary { - /** The session the chip would apply its staged choice to. */ - id: SessionId - /** False once a turn has run — applying is refused from then on. */ - blank: boolean - /** The preset the session already runs, when the summary reports one. */ - agentPreset?: string -} - /** Stages the next session's preset and applies it when one appears. */ export class AgentPresetSeatController { /** Chip snapshot the renderer subscribes to. */ @@ -64,13 +55,10 @@ export class AgentPresetSeatController { constructor( private readonly api: Pick, /** The session the hero is about to hand over to, when there is one. */ - private readonly currentSession: () => SeatSessionSummary | undefined, - /** - * Publish an applied switch into the session list, so the header label - * moves with the composition instead of waiting for the next full list - * refresh. Optional: a harness that renders no list omits it. - */ - private readonly onApplied?: (sessionId: string, agentPreset: string) => void, + private readonly currentSession: () => Pick< + SessionSummary, + 'id' | 'blank' | 'projectionValues' + > | undefined, ) {} private set(patch: Partial): void { @@ -90,6 +78,7 @@ export class AgentPresetSeatController { } const { presets } = response.result.value this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '' + const session = this.currentSession() this.set({ options: presetOptions(presets), // Staged pick first, then the composition the current session @@ -98,7 +87,7 @@ export class AgentPresetSeatController { // an applied stage was consumed — the chip mounts (and loads) only // once the flow's session is current, so the reply can arrive after // apply() already composed it. - current: this.staged ?? this.currentSession()?.agentPreset ?? this.fallback, + current: this.staged ?? (session === undefined ? this.fallback : presetOf(session) ?? ''), error: null, }) } catch (error) { @@ -150,10 +139,15 @@ export class AgentPresetSeatController { async apply(): Promise { const staged = this.staged const session = this.currentSession() - if (staged === undefined || session === undefined) return + if (staged === undefined) { + const current = session === undefined ? this.fallback : presetOf(session) ?? '' + if (current !== this.store.getSnapshot().current) this.set({ current }) + return + } + if (session === undefined) return // A started session's history was produced under its own composition; the // host refuses the swap, so the stage is no longer meaningful. - if (!session.blank || session.agentPreset === staged) { + if (!session.blank || presetOf(session) === staged) { this.staged = undefined return } @@ -162,15 +156,29 @@ export class AgentPresetSeatController { const response = await this.api.agentPresets.select({ sessionId: session.id, agentPreset: staged }) this.staged = undefined if (!response.result.ok) { - this.set({ busy: false, error: response.result.error.message, current: this.fallback }) + this.set({ + busy: false, + error: response.result.error.message, + current: presetOf(session) ?? '', + }) return } // Consumed: the next new session opens on the deployment default again. this.set({ busy: false, current: response.result.value.agentPreset }) - this.onApplied?.(session.id, response.result.value.agentPreset) } catch (error) { this.staged = undefined - this.set({ busy: false, error: messageOf(error), current: this.fallback }) + this.set({ + busy: false, + error: messageOf(error), + current: presetOf(session) ?? '', + }) } } } + +function presetOf( + session: Pick | undefined, +): string | undefined { + const value = session?.projectionValues?.agentPreset + return typeof value === 'string' ? value : undefined +} diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index e93570355e..f0215a0341 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -11,6 +11,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { SessionId } from '@deepseek-ai/dsh-session' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client' import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx' @@ -21,6 +22,7 @@ import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx' import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSection.tsx' import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx' +import { AgentPresetSeatController } from '../src/client/seat-store.ts' // These specs assert the shipped Chinese copy. The lane has no jsdom `window`, // so browser-language detection never runs and a fresh LocaleRuntime opens on @@ -155,7 +157,11 @@ function uiWorkspaceDouble() { /** A sessions double whose list can be moved and whose changes are pushed. */ function sessionsDouble(state: { current?: string - byId: Record + byId: Record }) { const listeners = new Set<() => void>() return { @@ -166,12 +172,6 @@ function sessionsDouble(state: { return () => listeners.delete(fn) }, }, - noteAgentPreset: (sessionId: string, agentPreset: string) => { - const summary = state.byId[sessionId] - if (summary === undefined || summary.agentPreset === agentPreset) return - summary.agentPreset = agentPreset - for (const fn of listeners) fn() - }, /** Push a list change the way the runtime's store does. */ notify: () => { for (const fn of listeners) fn() }, } @@ -354,24 +354,6 @@ describe('ui-agent-preset apply', () => { conversation() }) - it('folds a remote preset commit into the shared session row', async () => { - const { ctx, slots, remote } = await bench() - declareRoot(slots) - declareConversation(slots) - ctx.provide('conversation', {} as never) - const state = { - current: 's1', - byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } }, - } - ctx.provide('sessions', sessionsDouble(state) as never) - ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() - - remote.emit('agent-preset/selected', ['s1', 'minimal']) - - expect(state.byId.s1.agentPreset).toBe('minimal') - }) - it('offers a just-authored preset on the new-session chip', async () => { const { ctx, slots } = await bench() declareRoot(slots) @@ -409,7 +391,11 @@ describe('ui-agent-preset apply', () => { ctx.provide('conversation', {} as never) const state: { current?: string - byId: Record + byId: Record } = { byId: {} } const sessions = sessionsDouble(state) ctx.provide('sessions', sessions as never) @@ -424,7 +410,9 @@ describe('ui-agent-preset apply', () => { expect(calls).not.toContain('select:minimal') state.current = 's1' - state.byId['s1'] = { id: 's1', blank: true, agentPreset: 'standard' } + state.byId['s1'] = { + id: 's1', blank: true, projectionValues: { agentPreset: 'standard' }, + } sessions.notify() // Connecting a workspace produced the session; the stage reaches it there. @@ -461,7 +449,9 @@ describe('ui-agent-preset apply', () => { ctx.provide('conversation', {} as never) const state = { current: 's1', - byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } }, + byId: { + s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } }, + }, } const sessions = sessionsDouble(state) ctx.provide('sessions', sessions as never) @@ -543,7 +533,11 @@ describe('ui-agent-preset apply', () => { ctx.provide('conversation', {} as never) const state: { current?: string - byId: Record + byId: Record } = { byId: {} } const sessions = sessionsDouble(state) ctx.provide('sessions', sessions as never) @@ -562,7 +556,9 @@ describe('ui-agent-preset apply', () => { // The chip mounts with the flow's session, so its roster load can land // AFTER the stage was consumed; the session's own composition is what // the display must keep — not the deployment default. - state.byId['s1'] = { id: 's1', blank: true, agentPreset: 'cordis' } + state.byId['s1'] = { + id: 's1', blank: true, projectionValues: { agentPreset: 'cordis' }, + } await seat.load() expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') @@ -581,3 +577,48 @@ describe('ui-agent-preset apply', () => { expect(section.startCreatorDraft).toBeUndefined() }) }) + +describe('AgentPresetSeatController reconciliation', () => { + it('uses the deployment default without a Session and clears it for an uncomposed Session', async () => { + const state: { current?: { id: SessionId; blank: boolean } } = {} + const controller = new AgentPresetSeatController({ + agentPresets: { + list: () => Promise.resolve(ROSTER_ONE), + }, + } as never, () => state.current) + + await controller.load() + await controller.apply() + expect(controller.store.getSnapshot().current).toBe('standard') + + state.current = { id: SessionId('uncomposed'), blank: true } + await controller.apply() + expect(controller.store.getSnapshot().current).toBe('') + }) + + it.each([ + { + name: 'RPC rejection', + select: () => Promise.resolve({ + rpcId: 'r', + result: { ok: false as const, error: { code: 'failed', message: 'selection rejected', details: {} } }, + }), + message: 'selection rejected', + }, + { + name: 'transport failure', + select: () => Promise.reject(new Error('transport failed')), + message: 'transport failed', + }, + ])('restores an empty current value after $name for an uncomposed Session', async ({ select, message }) => { + const controller = new AgentPresetSeatController({ + agentPresets: { select }, + } as never, () => ({ id: SessionId('uncomposed'), blank: true })) + + await controller.select('minimal') + + expect(controller.store.getSnapshot()).toMatchObject({ + busy: false, current: '', error: message, + }) + }) +}) diff --git a/packages/client/ui-agent-preset/tests/components.client.spec.tsx b/packages/client/ui-agent-preset/tests/components.client.spec.tsx index 29ecc33eb5..0339e43341 100644 --- a/packages/client/ui-agent-preset/tests/components.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.client.spec.tsx @@ -71,7 +71,7 @@ function renderSeat(state: Partial = {}) { } function renderLabel( - summary: { blank: boolean; agentPreset?: string } | undefined, + summary: { blank: boolean; projectionValues?: { agentPreset?: string | null } } | undefined, roster: Partial = {}, ) { // The chip and the label read the same roster, metadata included. @@ -367,7 +367,10 @@ describe('the chip introduce cue', () => { describe('the session-header label', () => { it('names the preset the session runs, and never offers a switch', async () => { - const { load } = renderLabel({ blank: false, agentPreset: 'standard' }) + const { load } = renderLabel({ + blank: false, + projectionValues: { agentPreset: 'standard' }, + }) await waitFor(() => { expect(load).toHaveBeenCalledTimes(1) }) // A control here would promise a switch the host refuses outright. @@ -376,13 +379,16 @@ describe('the session-header label', () => { }) it('falls back to the id, and to the generic hint, when metadata is absent', () => { - renderLabel({ blank: true, agentPreset: 'mine' }) + renderLabel({ blank: true, projectionValues: { agentPreset: 'mine' } }) expect(screen.getByTitle(en.headerHint).textContent).toBe('mine') }) it('shows the id until the roster resolves it', () => { - renderLabel({ blank: false, agentPreset: 'standard' }, { options: [] }) + renderLabel({ + blank: false, + projectionValues: { agentPreset: 'standard' }, + }, { options: [] }) // The session's own summary is the authority on which preset it runs; the // roster only supplies the display name, and its arrival is a later frame. diff --git a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts index 3bb310a97e..9d7baa26d1 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts @@ -7,7 +7,9 @@ import { describe, expect, it } from 'vitest' import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf, } from '../src/client/settings-store.ts' @@ -17,7 +19,8 @@ function derivedController(api: IApiClient) { return new AgentPresetSettingsController(api, new SettingsDescribeMirror(api)) } import { AgentPresetSeatController } from '../src/client/seat-store.ts' -import type { SeatSessionSummary } from '../src/client/seat-store.ts' + +type SeatSession = Pick interface Recorded { ns: string; patch: unknown } @@ -245,7 +248,7 @@ describe('the new-session chip controller', () => { /** A chip over a current session the test can move. */ function chip( presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], - current: { id: string; blank: boolean; agentPreset?: string } | undefined, + current: SeatSession | undefined | (() => SeatSession | undefined), options: { writes?: Recorded[]; failSelect?: string; failList?: string; throwOn?: 'list' | 'select' } = {}, ): AgentPresetSeatController { const api = { @@ -265,7 +268,10 @@ describe('the new-session chip controller', () => { }, }, } as unknown as IApiClient - return new AgentPresetSeatController(api, () => current as SeatSessionSummary | undefined) + return new AgentPresetSeatController( + api, + typeof current === 'function' ? current : () => current, + ) } const ROSTER: { id: string; trust: 'system' | 'user'; isDefault: boolean }[] = [ @@ -331,9 +337,32 @@ describe('the new-session chip controller', () => { expect(controller.store.getSnapshot().current).toBe('minimal') }) + it('replaces the default display when an existing blank session arrives after roster load', async () => { + const state: { current?: SeatSession } = {} + const controller = chip([ + { id: 'standard', trust: 'system', isDefault: false }, + { id: 'minimal', trust: 'system', isDefault: true }, + ], () => state.current) + await controller.load() + expect(controller.store.getSnapshot().current).toBe('minimal') + + state.current = { + id: 's1' as SessionId, + blank: true, + projectionValues: { agentPreset: 'standard' }, + } + await controller.apply() + + expect(controller.store.getSnapshot().current).toBe('standard') + }) + it('applies the stage to the blank session the flow lands on', async () => { const writes: Recorded[] = [] - const current = { id: 's1', blank: true, agentPreset: 'standard' } + const current = { + id: 's1' as SessionId, + blank: true, + projectionValues: { agentPreset: 'standard' }, + } const controller = chip(ROSTER, current, { writes }) await controller.load() await controller.select('minimal') @@ -344,7 +373,11 @@ describe('the new-session chip controller', () => { it('spends the stage exactly once', async () => { const writes: Recorded[] = [] - const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { writes }) + const controller = chip(ROSTER, { + id: 's1' as SessionId, + blank: true, + projectionValues: { agentPreset: 'standard' }, + }, { writes }) await controller.load() await controller.select('minimal') @@ -358,7 +391,11 @@ describe('the new-session chip controller', () => { it('drops the stage against a session that already started', async () => { const writes: Recorded[] = [] - const controller = chip(ROSTER, { id: 's1', blank: false, agentPreset: 'standard' }, { writes }) + const controller = chip(ROSTER, { + id: 's1' as SessionId, + blank: false, + projectionValues: { agentPreset: 'standard' }, + }, { writes }) await controller.load() await controller.select('minimal') @@ -369,7 +406,11 @@ describe('the new-session chip controller', () => { it('drops the stage when the session already runs it', async () => { const writes: Recorded[] = [] - const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'minimal' }, { writes }) + const controller = chip(ROSTER, { + id: 's1' as SessionId, + blank: true, + projectionValues: { agentPreset: 'minimal' }, + }, { writes }) await controller.load() await controller.select('minimal') @@ -379,7 +420,14 @@ describe('the new-session chip controller', () => { it('falls back to the default when the host refuses the switch', async () => { const controller = chip( - ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { failSelect: 'already started' }) + ROSTER, + { + id: 's1' as SessionId, + blank: true, + projectionValues: { agentPreset: 'standard' }, + }, + { failSelect: 'already started' }, + ) await controller.load() await controller.select('minimal') @@ -391,7 +439,14 @@ describe('the new-session chip controller', () => { it('falls back to the default when the switch never reaches the host', async () => { const controller = chip( - ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { throwOn: 'select' }) + ROSTER, + { + id: 's1' as SessionId, + blank: true, + projectionValues: { agentPreset: 'standard' }, + }, + { throwOn: 'select' }, + ) await controller.load() await controller.select('minimal') @@ -402,7 +457,11 @@ describe('the new-session chip controller', () => { it('ignores a pick while a switch is in flight', async () => { const writes: Recorded[] = [] - const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { writes }) + const controller = chip(ROSTER, { + id: 's1' as SessionId, + blank: true, + projectionValues: { agentPreset: 'standard' }, + }, { writes }) await controller.load() const first = controller.select('minimal') diff --git a/packages/client/ui-agent-preset/tsconfig.json b/packages/client/ui-agent-preset/tsconfig.json index 0488aba5ee..3aa26af84e 100644 --- a/packages/client/ui-agent-preset/tsconfig.json +++ b/packages/client/ui-agent-preset/tsconfig.json @@ -50,6 +50,9 @@ { "path": "../../api/session-controller/tsconfig.client.json" }, + { + "path": "../../preset/agent-presets" + }, { "path": "../../core/session" } diff --git a/packages/client/ui-commands/src/client/directory.ts b/packages/client/ui-commands/src/client/directory.ts index 04d5eebb3b..10e3df8eab 100644 --- a/packages/client/ui-commands/src/client/directory.ts +++ b/packages/client/ui-commands/src/client/directory.ts @@ -62,6 +62,18 @@ export class CommandDirectory { for (const key of this.entries.keys()) void this.refresh(key) } + /** + * Drop one Session's obsolete composition-specific snapshot and prewarm its replacement. + * @param sessionId - Session whose effective command composition changed. + */ + resetSession(sessionId: SessionId): void { + const entry = this.entry(sessionId) + entry.state = 'cold' + entry.commands = [] + entry.lastError = undefined + void this.refresh(sessionId) + } + /** * Hard reset on reconnect: every entry drops its snapshot (the agent world * may have changed shape across the generation) and prewarms. diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index 3ae9b5d324..bef9f0c3bc 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -156,10 +156,9 @@ export class CommandUiRuntime extends Service implements CommandUiContract { }), 'command: slash source') ctx.remote.$on('commands/change', () => { this.directory.invalidateAll() }) // A preset switch changes which commands one session's agent resolves and - // registers nothing globally, so the registry-wide signal above never - // fires for it: repull that key alone, soft, so the old snapshot serves - // the menu until the new one lands. - ctx.remote.$on('agent-preset/selected', (sessionId) => { void this.directory.refresh(sessionId) }) + // registers nothing globally. Drop that key's old composition before + // prewarming so a newly opened menu waits for the replacement catalog. + ctx.remote.$on('agent-preset/selected', (sessionId) => { this.directory.resetSession(sessionId) }) ctx.on('connection/reset', () => { this.directory.resetConnected() }) } diff --git a/packages/client/ui-commands/tests/directory.client.spec.ts b/packages/client/ui-commands/tests/directory.client.spec.ts index 3f0b1b91df..29fc328fb2 100644 --- a/packages/client/ui-commands/tests/directory.client.spec.ts +++ b/packages/client/ui-commands/tests/directory.client.spec.ts @@ -187,6 +187,30 @@ describe('resetConnected (reconnect hard)', () => { }) }) +describe('resetSession (preset-change hard)', () => { + it('drops and prewarms only the changed Session', async () => { + const { dir, pull, countOf } = bench() + const first = dir.refresh(S1) + const second = dir.refresh(S2) + pull(S1, 0).resolve(CMDS) + pull(S2, 0).resolve(S2_CMDS) + await Promise.all([first, second]) + + dir.resetSession(S1) + expect(dir.status(S1)).toBe('pending') + expect(dir.resolve(S1, 'plan')).toBeUndefined() + expect(dir.status(S2)).toBe('ready') + expect(dir.resolve(S2, 'attach')).toBeDefined() + expect(countOf(S1)).toBe(2) + expect(countOf(S2)).toBe(1) + + pull(S1, 1).resolve([{ name: 'fresh', description: 'new composition' }]) + await Promise.resolve() + await Promise.resolve() + expect(dir.resolve(S1, 'fresh')).toBeDefined() + }) +}) + describe('warm', () => { it('launches a pull from cold, again after failure, and never over pending/ready', async () => { const { dir, pull, countOf } = bench() diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index ffd2946b80..8aa633f158 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -766,7 +766,7 @@ describe('directory invalidation events', () => { expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() }) - it('agent-preset/selected repulls the recomposed session and leaves the others served', async () => { + it('agent-preset/selected drops and repulls the recomposed session while leaving others served', async () => { const rounds = new Map() const { source, warm, remote } = await bench({ commands: (payload) => { @@ -784,6 +784,8 @@ describe('directory invalidation events', () => { // A preset switch changes which commands one session's agent resolves; // every other session keeps the catalog its own composition serves. remote.emit('agent-preset/selected', [sid('s1'), 'minimal']) + expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() + expect(source.matchSpace!(proj('s2'), '/goal')).not.toBeUndefined() await new Promise(resolve => setTimeout(resolve, 0)) expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined() expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index 9a477a91e6..46279cf4fc 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -364,7 +364,17 @@ export class InputTriggerController { /** Wire one source's lexicon invalidation channel into refresh (hookless or roll-less sources never notify). */ private watchLexicon(source: InputTriggerSource, projection: ClientSessionContext): void { if (source.lexicon === undefined || source.subscribeLexicon === undefined) return - this.lexiconOffs.set(source, source.subscribeLexicon(projection, () => { this.refreshLexicon() })) + this.lexiconOffs.set(source, source.subscribeLexicon(projection, () => { + this.refreshLexicon() + const hit = this.hit + if (hit === null || !this.menu.getSnapshot().open || hit.trigger !== source.trigger) return + // Let every source process the same invalidation before rebuilding the + // open menu, so one source cannot contribute its previous catalog. + void Promise.resolve().then(() => { + if (this.disposed || this.hit !== hit || !this.menu.getSnapshot().open) return + this.fetchCandidates(hit, this.deps.roster.sources(hit.trigger)) + }) + })) } /** Launch the candidate fetch for one hit generation, superseding the previous one. */ diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index b936e64c5f..ce68807800 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -623,13 +623,13 @@ describe('lexicon', () => { expect(rolls.has('@')).toBe(false) }) - it('a source lexicon notification republishes the aggregated store', () => { - let roll: readonly string[] | undefined = undefined + it('a source lexicon notification republishes the roll and refreshes an open menu', async () => { + let roll: readonly string[] | undefined = ['old'] let notify: (() => void) | undefined const source: InputTriggerSource = { trigger: '/', name: 'skill', - candidates: () => Promise.resolve([]), + candidates: () => Promise.resolve((roll ?? []).map(name => ({ name }))), onPick: () => undefined, lexicon: () => roll, subscribeLexicon: (_session, listener) => { @@ -638,12 +638,18 @@ describe('lexicon', () => { }, } const { controller } = controllerBench([source]) - expect(controller.lexicon.getSnapshot().size).toBe(0) + expect(controller.lexicon.getSnapshot().get('/')).toEqual(['old']) + controller.track('/', 1, { tier: 'plain' }, 1) + await tick() + expect(controller.menu.getSnapshot().groups[0]?.items).toEqual([{ name: 'old' }]) const seen: number[] = [] controller.lexicon.subscribe(() => { seen.push(controller.lexicon.getSnapshot().size) }) roll = ['commit-helper'] notify?.() + await tick() + await tick() expect(controller.lexicon.getSnapshot().get('/')).toEqual(['commit-helper']) + expect(controller.menu.getSnapshot().groups[0]?.items).toEqual([{ name: 'commit-helper' }]) expect(seen).toEqual([1]) controller.dispose() expect(notify).toBeUndefined() diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index a4f3fed8d6..1ec6f36a14 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/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/preset/agent-presets/README.md -README.md: 3e92f02518c5a36412c9448a26d32958c217f79c -README.zh.md: 4600589749a063df924f9c961cc449506ba2af9f +README.md: 1c943f0d56a720fbb3225371ed8161bfeb07b677 +README.zh.md: 6f348be04092eee87da74490dbcf73831e53215e diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 3e92f02518..1c943f0d56 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -40,7 +40,7 @@ The child records the joined id on its own durable header ([`dsh-subagent`](../. ### Which preset a session runs -The creation header names the preset a session STARTED with; `resolveSessionPreset(session)` names the one it RUNS. They differ whenever a blank session switched, so every reconstruction path — the summary a picker reads, a resume, a fork — resolves rather than reading the header. +The creation header names the preset a session STARTED with; the `agentPreset` Session projection names the one it RUNS. They differ whenever a blank session switched, so every reconstruction path — the summary a picker reads, a resume, a fork — consumes that projection rather than reading the header or folding the log independently. The header stays frozen because it is a creation fact. A switch is an `agent-preset/selected` session event appended after the swap commits, which is what the model-visible ⟺ logged rule requires: the preset decides the tool schemas and prompt sections the model sees, so it has to be reconstructable from the log. The service re-emits that committed fact as the non-scoped cordis event `agent-preset/selected(sessionId, agentPreset)` declared by the client-safe `./types` export, allowing remote consumers to invalidate session-derived state without importing Host runtime types. Reading the header alone would rebuild a switched session under the composition it was created with, replaying history the new tool set cannot act on — the exact hazard the blank-only lock exists to prevent. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 4600589749..6f348be040 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -40,7 +40,7 @@ subagent 的子 agent 通过 `composeFrom()` 加入其父方的常驻组装, ### 会话实际运行的是哪个 preset -创建头部记录的是会话**以什么开始**,`resolveSessionPreset(session)` 给出的才是它**实际运行的**。空白会话一旦切换过,两者就不同,因此所有重建路径——选择器读取的摘要、resume、fork——都走解析,而非直接读头部。 +创建头部记录的是会话**以什么开始**,`agentPreset` Session projection 记录的才是它**实际运行的**。空白会话一旦切换过,两者就不同,因此所有重建路径——选择器读取的摘要、resume、fork——都消费该 projection,而非直接读头部或各自重新归约日志。 头部保持冻结,因为它是创建期事实。切换以 `agent-preset/selected` 会话事件记录,在替换提交之后追加;这正是 model-visible ⟺ logged 规则的要求:preset 决定模型看到的工具 schema 与提示词段落,因此必须能从日志重建。服务会把这项已提交事实重新发为不带 scope 的 cordis 事件 `agent-preset/selected(sessionId, agentPreset)`,其声明位于 client-safe 的 `./types` 出口,使远端消费方无需导入 Host 运行时类型即可让会话派生状态失效。只读头部会让切换过的会话按创建时的组装重建,从而重放新工具集无法执行的历史——这正是「仅空白可切」那道锁要防的危险。 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index a20ab0a87c..15db5b3afd 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", @@ -53,7 +54,8 @@ }, "dependencies": { "js-yaml": "^4.1.0", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/cordis-plugin-include": "workspace:^", @@ -66,6 +68,7 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-settings-file": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 8f44e2003c..83516d5c4d 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -27,6 +27,7 @@ import z from '@deepseek-ai/schemastery' import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' // Type-only: resolves the `agent/created` lifecycle event this service watches. import type {} from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-session-projection' // Type-only: resolves the registry notification emitted after scope reparenting. import type {} from '@deepseek-ai/dsh-tools' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' @@ -36,7 +37,8 @@ import { copyComposition, deleteComposition, readComposition } from './authoring import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts' import { PresetExistsError } from './authoring.ts' import { PresetMountError, UnknownPresetError, type AgentPreset, type Config, type PresetRoot } from './preset.ts' -import type {} from './types.ts' +import { agentPresetProjectionDefinition } from './session.ts' +export type * from './types.ts' /** Settings namespace carrying the user's chosen default preset. */ export const SETTINGS_NAMESPACE = 'agent-presets' @@ -64,7 +66,7 @@ export { copyComposition, deleteComposition, InvalidPresetIdError, PresetExistsError, PresetNotWritableError, readComposition, writableRoot, } from './authoring.ts' -export { resolveSessionPreset, type PresetBearingSession } from './session.ts' +export { agentPresetProjectionDefinition } from './session.ts' export { PresetMountError, UnknownPresetError } from './preset.ts' export type { AgentPreset, Config, PresetRoot, PresetTrust } from './preset.ts' @@ -158,6 +160,10 @@ export class AgentPresets extends Service { }, 'agentPresets.settings()') }) + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register(agentPresetProjectionDefinition) + }) + // Advisory, not fatal: a synchronous `agent/created` listener that throws // VETOES publication, and this service must not, because composing an agent // outside the roster is legal — `recompose` binds exactly such a bare agent diff --git a/packages/preset/agent-presets/src/session.ts b/packages/preset/agent-presets/src/session.ts index ae3edada27..61df969967 100644 --- a/packages/preset/agent-presets/src/session.ts +++ b/packages/preset/agent-presets/src/session.ts @@ -9,11 +9,13 @@ * it is required outright by the repo's model-visible ⟺ logged rule, since the * preset decides the tool schemas and prompt sections the model sees. * - * Reconstruction reads {@link resolveSessionPreset}, never the header alone. + * Reconstruction reads the `agentPreset` Session projection, never the header + * alone. * @module @deepseek-ai/dsh-agent-presets/session */ -import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import { z } from 'zod' declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { @@ -27,28 +29,16 @@ declare module '@deepseek-ai/dsh-session/types' { } } -/** The minimum a caller must supply to resolve a session's preset. */ -export interface PresetBearingSession { - /** The session's creation header. */ - readonly header: SessionHeader - /** The session's event log, oldest first. */ - readonly events: readonly SessionEvent[] -} +const agentPresetSchema = z.union([z.string(), z.null()]) -/** - * The preset a session actually runs, newest selection winning. - * - * The header supplies the creation-time value; every later selection is a - * logged event, so the last one is the answer. Reading the header alone - * rebuilds a switched session under the composition it was created with, not - * the one its history was produced under. - * @param session - the session's header and event log. - * @returns the preset id, or `undefined` when the deployment composes none. - */ -export function resolveSessionPreset(session: PresetBearingSession): string | undefined { - for (let index = session.events.length - 1; index >= 0; index -= 1) { - const event = session.events[index] - if (event?.type === 'agent-preset/selected') return event.data.agentPreset - } - return session.header.agentPreset -} +/** Current Session preset, initialized from its header and advanced by selection events. */ +export const agentPresetProjectionDefinition = { + key: 'agentPreset', + stateSchema: agentPresetSchema, + init: header => header.agentPreset ?? null, + apply: (state, event) => event.type === 'agent-preset/selected' + ? event.data.agentPreset + : state, + wire: { viewSchema: agentPresetSchema, view: state => state }, + stateVersion: 1, +} satisfies ProjectionDefinition<'agentPreset', string | null> diff --git a/packages/preset/agent-presets/src/types.ts b/packages/preset/agent-presets/src/types.ts index 77803355a1..a1e04c3d2f 100644 --- a/packages/preset/agent-presets/src/types.ts +++ b/packages/preset/agent-presets/src/types.ts @@ -1,6 +1,16 @@ /** Client-safe event declarations owned by the agent-preset domain. */ import type { SessionId } from '@deepseek-ai/dsh-session/types' +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + agentPreset: string | null + } + interface SessionProjectionMap { + /** Preset the Session runs, or null when the deployment composes none. */ + agentPreset: string | null + } +} + declare module '@deepseek-ai/cordis' { interface Events { /** diff --git a/packages/preset/agent-presets/tests/session.spec.ts b/packages/preset/agent-presets/tests/session.spec.ts index d87c4d1937..6b6da3d540 100644 --- a/packages/preset/agent-presets/tests/session.spec.ts +++ b/packages/preset/agent-presets/tests/session.spec.ts @@ -1,16 +1,9 @@ -/** - * Which preset a session ran is a question about its LOG, not its header: the - * header records the creation-time choice, and a switch made during the blank - * window is an event. Every reconstruction — the list row, the header label, - * resume, fork — goes through this resolver, so a resolver that read the header - * alone would rebuild a switched session under a composition its own history - * contradicts. - */ +/** The Session projection that records which preset a Session runs. */ import { describe, expect, it } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' -import { resolveSessionPreset } from '../src/session.ts' +import { agentPresetProjectionDefinition } from '../src/session.ts' /** A header carrying the creation-time preset, if any. */ function header(agentPreset?: string): SessionHeader { @@ -28,35 +21,24 @@ function selected(agentPreset: string, seq: number): SessionEvent { return { type: 'agent-preset/selected', seq, time: seq, data: { agentPreset } } } -describe('resolving which preset a session ran', () => { - it('reads the creation-time value when nothing was switched', () => { - expect(resolveSessionPreset({ header: header('standard'), events: [] })).toBe('standard') +describe('agent preset selection projection', () => { + it('starts from the creation header, including no configured preset', () => { + expect(agentPresetProjectionDefinition.init(header('standard'))).toBe('standard') + expect(agentPresetProjectionDefinition.init(header())).toBeNull() }) - it('prefers a logged switch over the header', () => { - // The switch's effect outlives the blank window it was made in: the turns - // that follow run under the newer composition. - expect(resolveSessionPreset({ header: header('standard'), events: [selected('minimal', 0)] })) - .toBe('minimal') - }) + it('starts from the header and keeps the latest selected preset', () => { + const definition = agentPresetProjectionDefinition + let state = definition.init(header('standard')) + expect(state).toBe('standard') - it('takes the last switch when a session was moved twice', () => { - expect(resolveSessionPreset({ - header: header('standard'), - events: [selected('minimal', 0), selected('cordis', 1)], - })).toBe('cordis') - }) + state = definition.apply(state, selected('minimal', 0)) + state = definition.apply(state, { + type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, + }) + state = definition.apply(state, selected('cordis', 2)) - it('finds a switch behind later events', () => { - const later = { type: 'turn/end', seq: 2, time: 2, data: { turn: 1 } } as SessionEvent - - expect(resolveSessionPreset({ header: header(), events: [selected('minimal', 0), later] })) - .toBe('minimal') - }) - - it('reports none when the deployment composes no presets', () => { - // A valid deployment: every session shares the host composition, and no - // surface should invent a preset name for it. - expect(resolveSessionPreset({ header: header(), events: [] })).toBeUndefined() + expect(definition.wire.view(state)).toBe('cordis') + expect(definition.stateSchema.parse(state)).toBe('cordis') }) }) diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index 1c549f7874..9dc9d78d3c 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.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 from '@deepseek-ai/dsh-agent' @@ -43,6 +44,7 @@ async function harness( ctx.loader.builtins.include = Include await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-presets/tsconfig.json index b40cf776ad..f42a158941 100644 --- a/packages/preset/agent-presets/tsconfig.json +++ b/packages/preset/agent-presets/tsconfig.json @@ -27,6 +27,9 @@ { "path": "../../core/session" }, + { + "path": "../../session/session-projection" + }, { "path": "../../core/system-prompt" }, From f5f0448bee40fdc07cf9b0e5552c16d064e9a4fd Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:08:14 +0800 Subject: [PATCH 07/17] refactor(subagent): consume shared session observations --- apps/cli/package.json | 1 + apps/cli/tests/github-webhook-real.e2e.ts | 25 ++- .../subagent-diagnostic.cordis.snapshot.yml | 3 + .../fixtures/subagent-diagnostic-query.ts | 14 ++ apps/cli/tests/web-agent-presets.e2e.ts | 21 +-- apps/cli/tsconfig.json | 3 + apps/web/tests/cordis-tool-round.e2e.ts | 12 ++ .../conversation.expected.md | 4 +- apps/web/tests/reference-composer.e2e.ts | 2 +- apps/web/tests/seeded-history.e2e.ts | 41 ++--- apps/web/tests/smoke-real.e2e.ts | 2 +- apps/web/tests/subagent-conversation.e2e.ts | 32 ++++ .../src/client/skeleton/ConversationRoot.tsx | 11 +- .../src/client/skeleton/InputBar.tsx | 2 +- .../conversation-registry.client.spec.ts | 1 - .../src/client/SubagentHeaderLineage.tsx | 11 -- .../client/ui-subagent/src/client/index.ts | 5 +- .../tests/browser-plugin.client.spec.ts | 1 + .../tests/conversation-ui.client.spec.tsx | 4 +- packages/experimental/agent-team/package.json | 1 + .../agent-team/tests/persistence.spec.ts | 2 + .../agent-team/tests/team.spec.ts | 2 + .../agent-team/tests/test-session-query.ts | 14 ++ .../experimental/tool-agent-team/package.json | 1 + .../tool-agent-team/tests/tool-team.spec.ts | 13 ++ packages/host/apiproxy/src/api-proxy.ts | 83 +++------ packages/host/apiproxy/src/api/index.ts | 2 +- packages/host/apiproxy/src/api/llm.schema.ts | 10 ++ packages/host/apiproxy/src/api/llm.ts | 5 +- .../tests/api-proxy-agent-preset.spec.ts | 38 +++- .../apiproxy/tests/api-proxy-config.spec.ts | 2 + .../tests/api-proxy-skills-cold.spec.ts | 30 ++-- .../tests/api-proxy-subagents.spec.ts | 7 +- .../apiproxy/tests/client-handler.spec.ts | 24 ++- .../host/apiproxy/tests/fetch-carrier.spec.ts | 13 +- packages/subagent/subagent/package.json | 5 + .../subagent/subagent/src/continuation.ts | 34 +++- packages/subagent/subagent/src/index.ts | 25 +-- .../subagent/subagent/src/list-children.ts | 145 +++++++-------- .../tests/continuation-inheritance.spec.ts | 2 + .../subagent/tests/continuation.spec.ts | 20 ++- .../subagent/tests/list-children.spec.ts | 165 ++++++++++++++---- .../subagent/tests/test-session-query.ts | 14 ++ packages/subagent/subagent/tsconfig.json | 3 + .../tool-subagent-control/package.json | 1 + .../tests/list-agents.spec.ts | 2 + .../tests/test-session-query.ts | 14 ++ .../tests/tool-subagent-control.spec.ts | 2 + 48 files changed, 572 insertions(+), 302 deletions(-) create mode 100644 apps/cli/tests/profiles/headless/tests/fixtures/subagent-diagnostic-query.ts create mode 100644 packages/experimental/agent-team/tests/test-session-query.ts create mode 100644 packages/subagent/subagent/tests/test-session-query.ts create mode 100644 packages/subagent/tool-subagent-control/tests/test-session-query.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 6955701667..3d785a12cd 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -124,6 +124,7 @@ "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-settings-file": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/apps/cli/tests/github-webhook-real.e2e.ts b/apps/cli/tests/github-webhook-real.e2e.ts index a3a5434c52..e16aef8b3e 100644 --- a/apps/cli/tests/github-webhook-real.e2e.ts +++ b/apps/cli/tests/github-webhook-real.e2e.ts @@ -28,8 +28,8 @@ interface SessionList { items: Array<{ sessionId: string cwd?: string - agentPreset?: string blank: boolean + projections?: { values: { agentPreset?: string | null } } }> } @@ -222,23 +222,18 @@ async function workspaceBaseline(baseUrl: string): Promise { return frame.value as WorkspaceBaseline } -/** Read the explicit page cut from a fresh Session follow generation. */ -async function sessionCursor(baseUrl: string, sessionId: string): Promise { +/** Read the complete opening page from a fresh Session follow generation. */ +async function history(baseUrl: string, sessionId: string): Promise { const frame = await openingStreamItem( baseUrl, 'session/follow', - { request: { address: { kind: 'session', sessionId } } }, - value => isRecord(value) && value.type === 'opened' && Number.isSafeInteger(value.cursor), + { request: { address: { kind: 'session', sessionId }, maxMessages: 100 } }, + value => isRecord(value) + && value.type === 'snapshot' + && Array.isArray(value.events) + && typeof value.hasMore === 'boolean', ) - return frame.cursor as number -} - -/** Read Session history at the cursor explicitly opened for this page. */ -async function history(baseUrl: string, sessionId: string): Promise { - const throughSeq = await sessionCursor(baseUrl, sessionId) - return remoteRpc(baseUrl, 'session/page', { - request: { address: { kind: 'session', sessionId }, throughSeq, maxMessages: 100 }, - }) + return { events: frame.events as HistoryPage['events'], hasMore: frame.hasMore as boolean } } /** Poll a public observation until it satisfies the test's behavior predicate. */ @@ -379,9 +374,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('GitHub webhook through the real const sessions = await remoteRpc(baseUrl, 'session/list', { _request: {} }) expect(sessions.items.find(session => session.sessionId === sessionId)).toMatchObject({ - agentPreset: 'minimal', blank: false, cwd: canonicalWorkspacePath, + projections: { values: { agentPreset: 'minimal' } }, }) const admitted = await eventually( diff --git a/apps/cli/tests/profiles/headless/subagent-diagnostic.cordis.snapshot.yml b/apps/cli/tests/profiles/headless/subagent-diagnostic.cordis.snapshot.yml index 74dfb67c8e..4d59334e96 100644 --- a/apps/cli/tests/profiles/headless/subagent-diagnostic.cordis.snapshot.yml +++ b/apps/cli/tests/profiles/headless/subagent-diagnostic.cordis.snapshot.yml @@ -10,6 +10,9 @@ root: './.sessions' compression: none +- id: session-query + name: './tests/fixtures/subagent-diagnostic-query.ts' + # file/override both default to their DSH_SNAPSHOT_* env vars. - id: replay name: '@deepseek-ai/dsh-llm-replay' diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/subagent-diagnostic-query.ts b/apps/cli/tests/profiles/headless/tests/fixtures/subagent-diagnostic-query.ts new file mode 100644 index 0000000000..a0bb2f7b0c --- /dev/null +++ b/apps/cli/tests/profiles/headless/tests/fixtures/subagent-diagnostic-query.ts @@ -0,0 +1,14 @@ +/** Exact-read Session query used by the descriptor-less child snapshot. */ + +import SessionQueryEngine from '@deepseek-ai/dsh-session-query' + +/** Search is outside this fixture; inherited corpus and observation reads stay real. */ +export default class SubagentDiagnosticQuery extends SessionQueryEngine { + override searchSessions(): Promise { + return Promise.reject(new Error('session search is unavailable in this fixture')) + } + + override searchEvents(): Promise { + return Promise.reject(new Error('event search is unavailable in this fixture')) + } +} diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 6af58a3a37..f06599f9e5 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -12,7 +12,7 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-tool-subagent/model-selection-settings' -import { resolveSessionPreset, SETTINGS_NAMESPACE, SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets' +import { SETTINGS_NAMESPACE, SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets' import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent' import { CallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-compaction-basic' @@ -629,27 +629,12 @@ describe('a switch survives the session', () => { // The header keeps the creation fact; the log carries what it runs. expect(handle.agent.session.header.agentPreset).toBe('standard') - expect(resolveSessionPreset(handle.agent.session)).toBe('minimal') + expect(ctx.sessionProjections.stateOf(handle.agent.session, 'agentPreset')).toBe('minimal') } finally { await handle.dispose() } }) - it('rebuilds a switched session from the log, not the creation header', () => { - // The exact shape a resume reads back from disk: the header says standard, - // the log records the switch the user made while the session was blank. - const rebuilt = resolveSessionPreset({ - header: { version: 0, id: SessionId('x'), createdAt: 0, agentPreset: 'standard' }, - events: [ - { type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } }, - { type: 'turn/start', seq: 2, time: 0, data: { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } } }, - ] as never, - }) - - // Reading the header alone would compose the creation-time preset over a - // history another one produced — the replay the blank-only lock prevents. - expect(rebuilt).toBe('minimal') - }) }) describe('a forked session', () => { @@ -659,7 +644,7 @@ describe('a forked session', () => { meta: { agentPreset: 'minimal' }, setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) - const inherited = resolveSessionPreset(parent.agent.session) + const inherited = ctx.sessionProjections.stateOf(parent.agent.session, 'agentPreset') ?? undefined const child = await ctx.agents.create({ sessionId: SessionId('preset-fork-child'), meta: { diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 439d26eca8..135838240c 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -65,6 +65,9 @@ { "path": "../../packages/session-query/session-query-sqlite" }, + { + "path": "../../packages/session-query/session-query" + }, { "path": "../../packages/shell/shell-env" }, diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts index 6bf952a8c3..e6e3fe6f5e 100644 --- a/apps/web/tests/cordis-tool-round.e2e.ts +++ b/apps/web/tests/cordis-tool-round.e2e.ts @@ -69,6 +69,8 @@ describe('web e2e: Cordis tools use their owned cards', () => { let page: Page let tripwire: ReturnType const sessionEvents: SessionEvent[] = [] + const modelFrames: string[] = [] + const modelChanges: string[] = [] beforeAll(async () => { scaffold = await launchWebScaffold({ @@ -77,8 +79,17 @@ describe('web e2e: Cordis tools use their owned cards', () => { ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }), }) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + scaffold.ctx.sessionProjections.onChanged((_session, key, value, seq) => { + if (key === 'modelSelection') modelChanges.push(`${String(seq)}:${JSON.stringify(value)}`) + }) browser = await chromium.launch() page = await newEnglishPage(browser) + page.on('websocket', (socket) => { + socket.on('framereceived', (frame) => { + const payload = String(frame.payload) + if (payload.includes('modelSelection')) modelFrames.push(payload) + }) + }) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -169,6 +180,7 @@ describe('web e2e: Cordis tools use their owned cards', () => { it.skipIf(MODE === 'record')('matches the conversation aria golden', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-aria')) + console.log('MODEL_TRACE', { modelChanges, frameCount: modelFrames.length, modelFrames }) await page.locator('[data-conversation-scroll]').evaluate((host) => { host.scrollTop = host.scrollHeight }) await expect.poll( async () => page.getByRole('button', { name: 'Back to bottom', exact: true }).count(), diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index 97e1ead72a..0f4902439c 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -42,8 +42,8 @@ - button "Commands": - img - 'button "Access mode, current: Read Only"': Read Only -- button "Select model": - - text: Select model +- button "Select model, current github-webhook-review-test/reply": + - text: github-webhook-review-test/reply - img - button "Send message" [disabled] - text: 1 turns · 1 steps LLM {{duration}} diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index 13f0468ef4..c6d8bfb60f 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -233,7 +233,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through const group = page.getByRole('treeitem', { name: /Ungrouped/ }) await group.waitFor({ timeout: 15_000 }) if (await group.getAttribute('aria-expanded') !== 'true') await group.click() - const target = page.getByRole('treeitem').filter({ hasText: /^dsh-web-e2e-ws-/ }).first() + const target = page.getByRole('treeitem', { name: /Reference order target/ }) await target.waitFor({ timeout: 15_000 }) await target.click() await page.getByRole('button', { name: /^Session recall\s*Research notes$/ }).waitFor({ timeout: 15_000 }) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index b9ec3d3299..ad4af3b3da 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -228,44 +228,35 @@ describe('web e2e: seeded history renders through cold resume', () => { await recordFixture(scaffold, sessionId, SEED) }, 200_000) - it.skipIf(MODE === 'record')('serves the projections baseline on the real composition tail page', async () => { + it.skipIf(MODE === 'record')('serves the projections baseline on the real composition opening snapshot', async () => { // Composition regression tripwire: the projection registry must be a row // in the SHIPPED cordis.yml — with it absent every domain unit's optional // injection stays silent and this block disappears (no titles/todos on // the web), while fixture-level suites stay green. Assert through the - // real HTTP wire against the booted real host. - const response = await fetch(`${scaffold.baseUrl}/api/session/page`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - type: 'client-request', rpcId: 'seeded-projections', method: 'session/page', - payload: { - args: { request: { - address: { kind: 'session', sessionId: SEED_ID }, - throughSeq: seededThroughSeq, - } }, - }, - }), - }) - expect(response.ok).toBe(true) - const body = await response.json() as { - result: { ok: boolean; value?: { projections?: { asOfSeq: number; values: Record } } } + // production Session Controller against the booted real host. + const controller = new AbortController() + const stream = scaffold.ctx.sessionController.follow({ + address: { kind: 'session', sessionId: SessionId(SEED_ID) }, + }, controller.signal)[Symbol.asyncIterator]() + const first = await stream.next() + controller.abort() + if (first.done || first.value.type !== 'snapshot') { + throw new Error('session follow did not publish its opening snapshot') } - expect(body.result.ok).toBe(true) - const projections = body.result.value?.projections - expect(projections).toBeDefined() - expect(projections?.asOfSeq).toBeGreaterThanOrEqual(0) + expect(first.value.cursor).toBe(seededThroughSeq) + const projections = first.value.projections + expect(projections.asOfSeq).toBe(seededThroughSeq) // The seed carries a session/title event: the title unit is host-plane, so // it folds the detached log and serves the value with nothing composed. - expect(typeof projections?.values.title).toBe('string') + expect(typeof projections.values.title).toBe('string') // `todos` is absent because its unit belongs to the agent preset and this // directly seeded session never composed that preset. History computes // the baseline through the standard projection registry without mounting // an Agent composition as a read side effect. - expect(projections?.values).not.toHaveProperty('todos') + expect(projections.values).not.toHaveProperty('todos') // The session-stats unit is a shipped web-app bundle row: whole-log // turn/step counts ride the same tail block (the stats strip's source). - const sessionStats = projections?.values.sessionStats as { turns: number; steps: number } | undefined + const sessionStats = projections.values.sessionStats as { turns: number; steps: number } | undefined expect(sessionStats).toBeDefined() expect(sessionStats?.turns).toBeGreaterThanOrEqual(1) expect(sessionStats?.steps).toBeGreaterThanOrEqual(sessionStats?.turns ?? 0) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 01c863f5b2..979e375472 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -125,7 +125,7 @@ async function sessionCursor(baseUrl: string, sessionId: string): Promise { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-restore')) + const pattern = '**/api/subagent.list' + let requested = false + let releaseCatalog = (): void => {} + const catalogHeld = new Promise((resolve) => { releaseCatalog = resolve }) + await page.route(pattern, async (route) => { + const response = await route.fetch() + requested = true + await catalogHeld + await route.fulfill({ response }) + }) + + const warningStart = tripwire.warnings.length + try { + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await expect.poll(() => requested, { timeout: 15_000 }).toBe(true) + expect(await page.getByText('This subagent is read-only for now', { exact: true }).count()).toBe(0) + expect(await page.locator('[data-composer-seat]').evaluate(element => + getComputedStyle(element).visibility)).toBe('hidden') + releaseCatalog() + const input = page.getByRole('textbox', { name: 'Message the agent' }) + await input.waitFor({ timeout: 15_000 }) + await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + } finally { + releaseCatalog() + await page.unroute(pattern) + } + }) + it('continues through FIFO follow-up admission and receives the child follow events', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-followup')) const ended = new Promise((resolveEnded, reject) => { diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 458c15e2e9..a3a5942da7 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -80,8 +80,15 @@ export function ConversationRoot({ // The exemption is deliberately open-state-wide, not loading-only: a // summary-blank session is the hero before its open starts (`cold`) and // after one fails (`error`) for the same reason — there is no history. - const settling = sessionId !== undefined && shellPhase === 'blank' && openState === 'loading' - && summaryBlank !== true + // A restored continuable subagent also stays settled until its eagerly + // loaded parent catalog establishes availability. This keeps the composer + // hidden instead of briefly rendering the parent-offline takeover. + const parentAvailabilityPending = session?.subagent?.address.mode === 'continuable' + && session.subagent.parentAvailable === undefined + const settling = sessionId !== undefined && ( + (shellPhase === 'blank' && openState === 'loading' && summaryBlank !== true) + || parentAvailabilityPending + ) const hero = sessionId === undefined || (shellPhase === 'blank' && (openState === 'open' || summaryBlank === true)) const zone: InputZone | undefined = diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 0a137c8010..0ac5ec52b8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -157,7 +157,7 @@ export function InputBar({ // A continuable child without its live parent cannot accept human input, // but its independent Stop below stays available while it runs. const continuable = subagent?.address.mode === 'continuable' - const parentOffline = continuable && !subagent.parentAvailable + const parentOffline = continuable && subagent.parentAvailable !== true // Running input stays free; locked = session removed, the // inert no-workspace state, the machine faces absent (no session), or a // parent-offline continuable child. An owner block also disables input; diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index 3df509dcdf..20a7a0f7aa 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -79,7 +79,6 @@ function fakeSessions(ctx: Context): { sessions: ISessions; binding: SessionBind subagentAddress: () => undefined, setSubagentCatalogOpen: () => {}, refreshSubagents: () => Promise.reject(new Error('unused fake Sessions operation')), - noteAgentPreset: () => {}, clear: () => {}, refresh: () => Promise.reject(new Error('unused fake Sessions operation')), search: () => Promise.reject(new Error('unused fake Sessions operation')), diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index 2660ba3a0f..308a713b9f 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -499,7 +499,6 @@ function CatalogDropdown({ const hoverOpenTimer = useRef | undefined>(undefined) const hoverCloseTimer = useRef | undefined>(undefined) const observedCatalogs = useRef(new Set()) - const requestedInitialCatalog = useRef() const setCatalogOpenRef = useRef(setCatalogOpen) setCatalogOpenRef.current = setCatalogOpen const currentEntry = currentSessionId === undefined @@ -531,16 +530,6 @@ function CatalogDropdown({ } : catalog - useEffect(() => { - if ( - variant !== 'switcher' - || catalog !== undefined - || requestedInitialCatalog.current === rootSessionId - ) return - requestedInitialCatalog.current = rootSessionId - refresh(rootSessionId) - }, [catalog, refresh, rootSessionId, variant]) - const observeCatalog = (parentSessionId: SessionId, next: boolean): void => { if (next) observedCatalogs.current.add(parentSessionId) else observedCatalogs.current.delete(parentSessionId) diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 538eaf33f0..452123427c 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -34,7 +34,10 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc const subagent = owner.session?.subagent if (subagent === undefined || subagent === null) return null if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' } - if (subagent.parentAvailable) return null + // The parent catalog is fetched ahead of the selected Session. Until it + // resolves, leave the normal disabled composer in place instead of briefly + // claiming that the parent is offline. + if (subagent.parentAvailable !== false) return null // A RUNNING parent-offline continuable child keeps the default composer: // its input is disabled there, but the same primary Stop stays available so // the child can be interrupted. Once it stops, this takeover returns. diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index 0fc346f9ee..e3a80b3ee0 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -133,6 +133,7 @@ describe('apply', () => { // One-shot stays read-only even while running: it has no stop action. expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }, true))) .toEqual({ reason: 'one-shot' }) + expect(select(owner({ address }))).toBeNull() expect(select(owner({ address, parentAvailable: true }))).toBeNull() expect(select(owner({ address, parentAvailable: false }))) .toEqual({ reason: 'parent-unavailable' }) diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index c507acfe1c..52f35b241b 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -770,7 +770,7 @@ describe('SubagentHeaderLineage', () => { it.each([ ['ancestor', vi.fn()], ['current', undefined], - ] as const)('refreshes an absent %s switcher catalog without waiting for hover', (_kind, openTitle) => { + ] as const)('keeps an absent %s switcher catalog lazy until interaction', (_kind, openTitle) => { const input = { ...props(undefined, {}, { [CHILD]: { @@ -784,7 +784,7 @@ describe('SubagentHeaderLineage', () => { } render() - expect(input.refresh).toHaveBeenCalledWith(PARENT) + expect(input.refresh).not.toHaveBeenCalled() }) it('keeps a nested title switcher scoped to its direct-parent catalog', () => { diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index b73a8b998d..da61dd441e 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^", "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts index 17102cc22a..46020874ca 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -16,6 +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, { foldTeam, TeamId, TeamMessageId } from '../src/index.ts' import type { TeamMemberSnapshot, TeamMessageSnapshot, TeamTaskSnapshot } from '../src/index.ts' +import { TestSessionQuery } from './test-session-query.ts' const SIGNAL = new AbortController().signal const PERSISTENCE_TEST_TIMEOUT_MS = 15_000 @@ -94,6 +95,7 @@ async function stack( contexts.add(ctx) await mountAgentLoopTestDependencies(ctx) await backend.mount(ctx, root) + await ctx.plugin(TestSessionQuery) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 4df0dd1fac..3a805cea1f 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -16,6 +16,7 @@ import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-a import TeamService, { foldTeam, TeamError, TeamId, TeamMessageId, TeamTaskId } from '../src/index.ts' import { TeamRuntimeLifecycle } from '../src/lifecycle.ts' import type { TeamMemberSnapshot, TeamMessageSnapshot, TeamTaskSnapshot } from '../src/index.ts' +import { TestSessionQuery } from './test-session-query.ts' const SIGNAL = new AbortController().signal const roots: string[] = [] @@ -48,6 +49,7 @@ async function setup( const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-')) roots.push(storageRoot) await ctx.plugin(JsonlSessionPersistence, { root: storageRoot }) + await ctx.plugin(TestSessionQuery) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) diff --git a/packages/experimental/agent-team/tests/test-session-query.ts b/packages/experimental/agent-team/tests/test-session-query.ts new file mode 100644 index 0000000000..903626c082 --- /dev/null +++ b/packages/experimental/agent-team/tests/test-session-query.ts @@ -0,0 +1,14 @@ +/** Minimal concrete Session query for Agent Team continuation tests. */ + +import SessionQueryEngine from '@deepseek-ai/dsh-session-query' + +/** Session query implementation whose search faces are outside these tests. */ +export class TestSessionQuery extends SessionQueryEngine { + override searchSessions(): Promise { + return Promise.reject(new Error('session search is not configured in this test')) + } + + override searchEvents(): Promise { + return Promise.reject(new Error('event search is not configured in this test')) + } +} diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json index 35e8ddcb55..aae5baad20 100644 --- a/packages/experimental/tool-agent-team/package.json +++ b/packages/experimental/tool-agent-team/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^", diff --git a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts index ebc60f92cb..19765b9980 100644 --- a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts +++ b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts @@ -10,6 +10,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import { scopeOf } from '@deepseek-ai/dsh-scope' import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionQueryEngine from '@deepseek-ai/dsh-session-query' import SubagentService from '@deepseek-ai/dsh-subagent' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' @@ -37,6 +38,17 @@ const TOOL_NAMES = [ const roots: string[] = [] let callNumber = 0 +/** Session query implementation whose search faces are outside these tests. */ +class TestSessionQuery extends SessionQueryEngine { + override searchSessions(): Promise { + return Promise.reject(new Error('session search is not configured in this test')) + } + + override searchEvents(): Promise { + return Promise.reject(new Error('event search is not configured in this test')) + } +} + afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) @@ -47,6 +59,7 @@ async function setup(script: ConstructorParameters[0], legac const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-tool-team-')) roots.push(storageRoot) await ctx.plugin(JsonlSessionPersistence, { root: storageRoot }) + await ctx.plugin(TestSessionQuery) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) if (legacyControl) await ctx.plugin(ToolSubagentControl) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index b0fd7abafe..a236782544 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -10,19 +10,18 @@ import type { Agent, ModelSelection } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-presets/types' import type { Session, SessionId } from '@deepseek-ai/dsh-session' import { SubagentError } from '@deepseek-ai/dsh-subagent' -import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' import { isUserInvocable } from '@deepseek-ai/dsh-skill' import { InvalidPresetIdError, PresetExistsError, PresetMountError, - PresetNotWritableError, resolveSessionPreset, UnknownPresetError, + PresetNotWritableError, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' -import type { PresetBearingSession } from '@deepseek-ai/dsh-agent-presets' import type { ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, - SettingsNamespaceView, SubagentAddress, + SettingsNamespaceView, } from './api/index.ts' import type { SessionRequestId } from '@deepseek-ai/dsh-api-session-controller/types' -import { ApiSessionNotFound, buildModelCatalog } from '@deepseek-ai/dsh-api-session-controller' +import { buildModelCatalog } from '@deepseek-ai/dsh-api-session-controller' +import { SessionQueryError } from '@deepseek-ai/dsh-session-query' import { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, flushLiveSessionLog, @@ -199,49 +198,6 @@ function projectionsUnavailableError(): RpcError { } } -/** Verify one address and mode against the complete direct-child catalog. */ -async function catalogChild( - ctx: Context, - address: SubagentAddress, - signal?: AbortSignal, -): Promise<{ - entry?: Extract - error?: RpcError -}> { - const { parentSessionId, childSessionId, mode } = address - try { - const entries = await ctx.subagents.listChildren(parentSessionId, signal) - const entry = entries.find(candidate => candidate.id === childSessionId) - if (entry === undefined || (entry.kind === 'child' && entry.mode !== mode)) { - return { - error: { - code: 'subagent-not-found', - message: `session "${childSessionId}" is not a ${mode} direct child of "${parentSessionId}"`, - details: { parentSessionId, childSessionId }, - }, - } - } - if (entry.kind === 'diagnostic') { - return { - error: { - code: 'subagent-catalog-diagnostic', - message: `subagent "${childSessionId}" is ${entry.reason}`, - details: { parentSessionId, childSessionId, reason: entry.reason }, - }, - } - } - return { entry } - } catch (error: unknown) { - if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) { - return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } } - } - if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') { - return { error: projectionsUnavailableError() } - } - return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } } - } -} - /** * The requested preset differs from the one this session already runs. * @@ -300,14 +256,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro /** Resolve a Session's live or standing preset scope without resuming it. */ async function sessionScopeFor( sessionId: SessionId, - session: PresetBearingSession, + agentPreset: string | undefined, ): Promise { const live = ctx.get('agents')?.get(sessionId) if (live !== undefined) return live const presets = ctx.get('agentPresets') if (presets === undefined) return undefined try { - return await presets.standingKeyFor(resolveSessionPreset(session)) + return await presets.standingKeyFor(agentPreset) } catch { // An unknown or unusable recorded preset falls back to the global registry. return undefined @@ -536,10 +492,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: { parentSessionId }, }) } - const verified = await catalogChild(ctx, { - parentSessionId, childSessionId, mode: 'continuable', - }, signal) - if (verified.error !== undefined) return err(request, verified.error) try { const messageId = await ctx.subagents.followup(parent, childSessionId, content, { source: { @@ -869,15 +821,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // the view scope is the live agent or the preset's standing key. async list(request) { const { sessionId } = request.payload - let session: PresetBearingSession + let cwd: string | undefined + let agentPreset: string | undefined try { - const inspected = await ctx.sessionController.inspect(sessionId) - session = { header: inspected.meta, events: inspected.events } + using observation = await ctx.sessionQuery.observeSession(sessionId) + if (observation.projections === undefined) { + throw new Error('skill catalog requires a projected Session observation') + } + cwd = observation.header.cwd + agentPreset = observation.projections.values.agentPreset ?? undefined } catch (error: unknown) { - if (error instanceof ApiSessionNotFound) { + if (error instanceof SessionQueryError + && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { return err(request, { code: 'session-not-found', - message: error.message, + message: `session "${sessionId}" not found`, details: { sessionId }, }) } @@ -887,12 +845,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - if (session.header.cwd === undefined) { + if (cwd === undefined) { // Every served session records its project at create time; a // cwd-less header is a pre-project legacy log (not served). return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} }) } - const cwd = session.header.cwd // The host registry is layered per scope and serves every session. A // composition may still realm-mount its own registry instead; that // instance is invisible to host contexts, so address it through the @@ -910,7 +867,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } // Resolve the live or recorded preset scope so the catalog matches the // Session composition without resuming its Agent. - const scope = await sessionScopeFor(sessionId, session) + const scope = await sessionScopeFor(sessionId, agentPreset) try { const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable) return ok(request, { @@ -1065,7 +1022,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, async models(request) { - return ok(request, await buildModelCatalog(ctx)) + return ok(request, await buildModelCatalog(ctx, defaults.defaultModelSelection())) }, async discoverModels(request, signal) { diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 6fc92b71e4..4cfcea6418 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -30,7 +30,7 @@ export interface ApiProxy { // ---- Domain interfaces and payload entities ---- export type { - ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelSelection, } from '@deepseek-ai/dsh-api-session-controller/types' export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' diff --git a/packages/host/apiproxy/src/api/llm.schema.ts b/packages/host/apiproxy/src/api/llm.schema.ts index 1fa7a6ceea..ff7c63a822 100644 --- a/packages/host/apiproxy/src/api/llm.schema.ts +++ b/packages/host/apiproxy/src/api/llm.schema.ts @@ -10,6 +10,7 @@ import type { ConfigurableProviderView, DiscoveredModelView } from './llm.ts' import type { ModelCatalogFailure, ModelCatalogModel, + ModelSelection, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, @@ -50,6 +51,13 @@ const modelCatalogFailureSchema = z.object({ message: z.string(), }) satisfies z.ZodType> +/** Complete model selection used as the Host default. */ +const modelSelectionSchema = z.object({ + provider: z.string().min(1), + model: z.string().min(1), + reasoningEffort: z.string().min(1).optional(), +}) satisfies z.ZodType> + /** ConfigurableProviderView row of llm.providers. */ export const configurableProviderViewSchema = z.object({ provider: z.string().min(1), @@ -73,6 +81,8 @@ export const llmModelsRequestSchema = z.object({}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/llm.ts b/packages/host/apiproxy/src/api/llm.ts index 9aef65d856..96b5db874e 100644 --- a/packages/host/apiproxy/src/api/llm.ts +++ b/packages/host/apiproxy/src/api/llm.ts @@ -9,8 +9,7 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' import type { - ModelCatalogFailure, - ModelProviderGroup, + ModelCatalog, } from '@deepseek-ai/dsh-api-session-controller/types' /** Wire view of one configurable provider. */ @@ -48,7 +47,7 @@ export interface LlmApi { * settings surface's models view, needing no session. Per-provider listing * failures ride `failures` without failing the sound groups. */ - models(request: RpcRequest<{}>): Promise> + models(request: RpcRequest<{}>): Promise> /** * Interrogate a provider endpoint the configuration surface is still diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 13000567d3..ce7942cf38 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -10,12 +10,13 @@ import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import { RpcId, type RpcRequest } from '../src/api/rpc.ts' import type { ApiProxy } from '../src/api/index.ts' import { - InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError, + agentPresetProjectionDefinition, InvalidPresetIdError, PresetExistsError, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-agent-presets/types' import { GoalId } from '@deepseek-ai/dsh-goal' import { createApiProxy } from '../src/api-proxy.ts' import { describe, expect, it } from 'vitest' +import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' let nextRpc = 0 function request

(payload: P): RpcRequest

{ @@ -122,6 +123,35 @@ async function harness( await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) if (presets !== undefined) ctx.provide('agentPresets', roster(presets, options.userIds) as never) + ctx.provide('sessionQuery', { + observeSession: (sessionId: SessionId) => { + const session = ctx.sessions.get(sessionId) + if (session === undefined) { + return Promise.reject(new SessionQueryError( + `session "${sessionId}" not found`, + 'SESSION_QUERY_SESSION_NOT_FOUND', + )) + } + let preset = agentPresetProjectionDefinition.init(session.header) + for (const event of session.events) { + preset = agentPresetProjectionDefinition.apply(preset, event) + } + const events = Object.freeze([...session.events]) + const lease = (): SessionObservation => ({ + source: 'live' as const, + header: session.header, + events, + cursor: events.at(-1)?.seq ?? -1, + projections: { + asOfSeq: events.at(-1)?.seq ?? -1, + values: { agentPreset: preset }, + }, + retain: lease, + [Symbol.dispose]: () => {}, + }) + return Promise.resolve(lease()) + }, + } as never) const factory: AgentFactory = { async createAgent(_ownerCtx, options) { @@ -289,7 +319,8 @@ describe('agentPreset.select', () => { const session = ctx.sessions.get(SessionId('sel-log')) if (session === undefined) throw new Error('unreachable') expect(session.header.agentPreset).toBe('standard') - expect(resolveSessionPreset(session)).toBe('minimal') + expect(session.events.findLast(event => event.type === 'agent-preset/selected')?.data) + .toEqual({ agentPreset: 'minimal' }) }) it('serializes two concurrent selects on one session', async () => { @@ -309,7 +340,8 @@ describe('agentPreset.select', () => { const session = ctx.sessions.get(SessionId('sel-race')) if (session === undefined) throw new Error('unreachable') // One winner, and the log agrees with it: the last committed switch. - expect(resolveSessionPreset(session)).toBe('standard') + expect(session.events.findLast(event => event.type === 'agent-preset/selected')?.data) + .toEqual({ agentPreset: 'standard' }) }) it('refuses once the conversation has started', async () => { diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 2916222805..4c09abf433 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -679,6 +679,8 @@ describe('llm domain', () => { ctx.llm.registerAdapter(['broken'], new BrokenCatalogAdapter('Broken', [])) const api = createApiProxy(ctx, DEFAULTS) const value = expectOk(await api.llm.models(request({}))) + expect(value.default).toEqual({ provider: 'p', model: 'm' }) + expect(value.routableProviders).toEqual(['deepseek-official', 'broken']) expect(value.groups).toEqual([{ id: 'deepseek-official', name: 'DeepSeek', diff --git a/packages/host/apiproxy/tests/api-proxy-skills-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-skills-cold.spec.ts index e0ff471a57..9883179ea0 100644 --- a/packages/host/apiproxy/tests/api-proxy-skills-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-skills-cold.spec.ts @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' -import { ApiSessionNotFound } from '@deepseek-ai/dsh-api-session-controller' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import type {} from '@deepseek-ai/dsh-skill' import { describe, expect, it, vi } from 'vitest' import { createApiProxy } from '../src/api-proxy.ts' @@ -14,11 +14,18 @@ describe('skill catalog Session inspection', () => { await ctx.plugin(AgentRegistry) const sessionId = SessionId('cold-skills') const resolveAgent = vi.fn() - const inspect = vi.fn(() => Promise.resolve({ - meta: { version: 0 as const, id: sessionId, createdAt: 1, cwd: '/cold/project' }, + const dispose = vi.fn() + const observeSession = vi.fn(() => Promise.resolve({ + source: 'live', + header: { version: 0 as const, id: sessionId, createdAt: 1, cwd: '/cold/project' }, events: [], - })) - ctx.provide('sessionController', { inspect, resolveAgent } as never) + cursor: -1, + projections: { asOfSeq: -1, values: {} }, + retain: () => { throw new Error('not retained') }, + [Symbol.dispose]: dispose, + } satisfies SessionObservation)) + ctx.provide('sessionQuery', { observeSession } as never) + ctx.provide('sessionController', { resolveAgent } as never) const list = vi.fn(() => Promise.resolve([{ name: 'review', description: 'Review the current change.', @@ -42,7 +49,8 @@ describe('skill catalog Session inspection', () => { }], }, }) - expect(inspect).toHaveBeenCalledWith(sessionId) + expect(observeSession).toHaveBeenCalledWith(sessionId) + expect(dispose).toHaveBeenCalledOnce() expect(resolveAgent).not.toHaveBeenCalled() expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope: undefined }) }) @@ -51,7 +59,10 @@ describe('skill catalog Session inspection', () => { const sessionId = SessionId('missing-skills') for (const fixture of [ { - error: new ApiSessionNotFound('session "missing-skills" not found'), + error: new SessionQueryError( + 'session "missing-skills" not found', + 'SESSION_QUERY_SESSION_NOT_FOUND', + ), code: 'session-not-found', }, { error: new Error('storage offline'), code: 'internal' }, @@ -59,9 +70,8 @@ describe('skill catalog Session inspection', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - ctx.provide('sessionController', { - inspect: () => Promise.reject(fixture.error), - resolveAgent: vi.fn(), + ctx.provide('sessionQuery', { + observeSession: () => Promise.reject(fixture.error), } as never) ctx.provide('skills', { list: vi.fn() } as never) const api = createApiProxy(ctx, { diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index bbdddd878d..0ba2a216b3 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -107,7 +107,7 @@ describe('subagent gateway', () => { .toMatchObject({ ok: true, value: { entries: [{ activity: 'running' }] } }) }) - it('maps the missing projections capability to one wire face on list and prompt', async () => { + it('maps missing catalog projections on list without preflighting prompt delivery', async () => { const listError = () => new SubagentError( 'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', @@ -124,8 +124,9 @@ describe('subagent gateway', () => { const prompt = bench({ listError: listError() }) expect((await prompt.api.subagents.prompt(request({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [], - }), new AbortController().signal)).result).toMatchObject({ ok: false, error: expected }) - expect(prompt.followup).not.toHaveBeenCalled() + }), new AbortController().signal)).result).toMatchObject({ ok: true }) + expect(prompt.listChildren).not.toHaveBeenCalled() + expect(prompt.followup).toHaveBeenCalledOnce() }) it('routes human content through the exact live parent with rpc attribution', async () => { diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 648af08e3b..09509a0782 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -81,7 +81,12 @@ function scriptedApi(overrides: { }, llm: { providers: r => ok(r, { providers: [] }), - models: r => ok(r, { groups: [], failures: [] }), + models: r => ok(r, { + default: { provider: 'test', model: 'test' }, + routableProviders: [], + groups: [], + failures: [], + }), discoverModels: err, ...overrides.llm, }, @@ -439,7 +444,12 @@ describe('config unary surface', () => { }, llm: { providers: record('llm.providers', r => ok(r, { providers: [providerRow] })), - models: record('llm.models', r => ok(r, { groups: [group], failures: [] })), + models: record('llm.models', r => ok(r, { + default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routableProviders: ['deepseek-official'], + groups: [group], + failures: [], + })), discoverModels: record('llm.discoverModels', r => ok(r, { models: [{ id: 'acme-large', contextWindow: 65536 }] })), }, }) @@ -465,7 +475,15 @@ describe('config unary surface', () => { const providers = await c.llm.providers({}) expect(providers.result).toEqual({ ok: true, value: { providers: [providerRow] } }) const models = await c.llm.models({}) - expect(models.result).toEqual({ ok: true, value: { groups: [group], failures: [] } }) + expect(models.result).toEqual({ + ok: true, + value: { + default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routableProviders: ['deepseek-official'], + groups: [group], + failures: [], + }, + }) const discovered = await c.llm.discoverModels({ settingsNs: 'llm-pi-ai', baseURL: 'https://gateway.acme.example/v1', diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index a512edd7ce..f16136eeff 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -140,7 +140,18 @@ function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy { return { rpcId: request.rpcId, result: { ok: true, value: { providers: [] } } } }, async models(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { groups: [], failures: [] } } } + return { + rpcId: request.rpcId, + result: { + ok: true, + value: { + default: { provider: 'test', model: 'test' }, + routableProviders: [], + groups: [], + failures: [], + }, + }, + } }, async discoverModels(request) { return { rpcId: request.rpcId, result: { ok: true, value: { models: [] } } } diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 269268dc6a..293089b84d 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -52,6 +52,7 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", @@ -76,6 +77,9 @@ "@deepseek-ai/dsh-session-projection-cache": { "optional": true }, + "@deepseek-ai/dsh-session-query": { + "optional": true + }, "@deepseek-ai/dsh-jobs": { "optional": true }, @@ -96,6 +100,7 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 2588c1a699..6b50947060 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -35,6 +35,7 @@ import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-ll import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import type { SessionObservation, SessionQueryEngine } from '@deepseek-ai/dsh-session-query' import type { ToolRestriction } from '@deepseek-ai/dsh-tools' import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts' import type { SubagentDescriptorData } from './descriptor.ts' @@ -939,7 +940,7 @@ export class SubagentContinuationManager { } /** - * Cold-resume a persisted child: inspect and authorize its Session, fold the + * Cold-resume a persisted child: retain and authorize its prepared Session, fold the * generic descriptor, create the Activation through `ctx.agents.resume()`, * and submit the waiting turn. This never dispatches through a subagent * provider — the persisted Session already holds the initial prefix and the @@ -951,23 +952,27 @@ export class SubagentContinuationManager { content: ContentBlock[], options: SubagentFollowupOptions, ): Promise { - const persistence = this.requirePersistence() - let loaded: Awaited> + const query = this.requireSessionQuery() + let observation: SessionObservation try { - loaded = await persistence.inspect(childId, options.signal) + observation = await query.observeSession(childId, { + signal: options.signal, + }) } catch (error: unknown) { options.signal.throwIfAborted() throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error }) } - options.signal.throwIfAborted() + using source = observation this.assertAdmitting(parent) // Authorize the persisted header before folding: only the durable child's // exact live direct parent may continue it. - this.authorizeLineage(parent, childId, loaded.meta.parentSession) + this.authorizeLineage(parent, childId, source.header.parentSession) // Fold only the child's own suffix: a fork seed replays the parent's log, // which may carry an ANCESTOR's descriptor when the parent is itself a // continuable child. - const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0)) + const descriptor = foldSubagentDescriptor( + source.events.slice(source.header.seedLength ?? 0), + ) if (descriptor === undefined || descriptor.mode !== 'continuable') { throw new SubagentError( `subagent "${childId}" has no supported continuation state and cannot be resumed; ` @@ -996,7 +1001,7 @@ export class SubagentContinuationManager { if (error instanceof SubagentError) throw error throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error }) } - return this.submitMaterialized(activation, content, options.source, parent, options.signal) + return await this.submitMaterialized(activation, content, options.source, parent, options.signal) } /** @@ -1545,6 +1550,19 @@ export class SubagentContinuationManager { } return persistence } + + /** Resolve the Session query service used for cold child observations. */ + private requireSessionQuery(): SessionQueryEngine { + const query = this.ctx.get('sessionQuery') + if (query === undefined) { + throw new SubagentError( + 'continuable subagents require session query (load @deepseek-ai/dsh-session-query)', + 'CONTINUATION_UNAVAILABLE', + ) + } + return query + } + } export type { SubagentDescriptorData } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 42dca84908..0701ff7b90 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -325,27 +325,16 @@ export class SubagentRuntime extends Service { /** * Enumerate the parent's direct session-backed subagents without loading or - * resuming an Agent and without any query service: the listing merges the live - * session store with optional session persistence (live-preferred) and - * serves each child's durable mode/label from the registered `subagent` - * projection unit down a three-rung ladder — the registry's watermark - * snapshot for a live child; for a cold one, a durable projection-cache - * row when the optional cache serves an own-suffix identity (its `seq` - * gate proves the value postdates the fork seed, where a child's own - * descriptor is immutable once appended), else one persistence inspection - * folded through the registry. The - * projection fold is the single classification authority; per-child - * diagnostics relay a fold that served no identity or a failed inspection, - * never a list-time descriptor parse. Absent persistence, enumeration is - * live-only (a cold child cannot be resumed then either, so its absence is - * capability absence, not an error). This service consults no Agent - * registrations, Activations, or providers. + * resuming an Agent. The Session query service supplies one live-preferred + * corpus and shared point observations; the projection cache supplies + * immutable descriptor hits without opening cold logs. The registered + * `subagent` projection remains the sole mode/label classifier. * - * Every persistence read receives `signal`, and the listing rechecks - * cancellation around each of those awaits. Read rejections that settle + * Every query receives `signal`, and the listing rechecks cancellation + * around each await. Read rejections that settle * after an abort become a stable `SubagentError` with code `CANCELLED`. * @param parentSessionId - parent session whose direct children are listed. - * @param signal - caller-owned cancellation forwarded to persistence reads + * @param signal - caller-owned cancellation forwarded to Session queries * and observed around every read await. * @returns children and per-child diagnostics ordered by `createdAt`, then id. * @throws {@link SubagentError} when the projection registry or the session diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 95c3be8520..ffefa624b8 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -1,13 +1,13 @@ /** * Read-only enumeration of durable subagent children and descendant trees - * straight from the live session store and optional session persistence — no - * query service. Candidates come from one live-preferred corpus; each child's - * mode/label is the registered `subagent` projection unit's value, resolved + * through the Session query service. Candidates come from one live-preferred + * corpus; each child's mode/label is the registered `subagent` projection + * unit's value, resolved * down a three-rung ladder: the registry's watermark cache for a live child, * a durable projection-cache row when it serves an own-suffix identity (the - * seq gate), and one persistence inspection folded through the registry - * otherwise, validated against the enumerated lifecycle. The projection fold - * is the single classification authority — this module parses no descriptor + * seq gate), and one shared Session observation otherwise, validated against + * the enumerated lifecycle. The projection fold is the single classification + * authority — this module parses no descriptor * itself. Absent persistence, enumeration is live-only: a cold child is * unreachable for resume anyway, so its absence is capability absence, not an * error. The module owns no catalog state and does not consult Activation, @@ -17,17 +17,17 @@ */ import type { Context } from '@deepseek-ai/cordis' -import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection' import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache' +import type { SessionObservation, SessionQueryEngine } from '@deepseek-ai/dsh-session-query' import { SubagentError } from './error.ts' import type { SubagentIdentityProjection } from './projection-types.ts' /** - * Concurrent cold inspections per listing; a constant because it bounds one - * read-only scan of local media, not deployment behavior. Should a networked - * persistence backend appear, promote it to a validated `Config` field. + * Concurrent cold observations per explicit catalog listing. Current Session + * persistence providers are local; a networked provider must promote this to + * a validated deployment setting. */ const COLD_READ_CONCURRENCY = 4 @@ -79,8 +79,8 @@ export type SubagentListEntry = * unrecognized-version descriptor — deliberately undistinguished), and * for any candidate whose log makes a registered unit's fold or schema * throw (deterministic data damage, contained per child); `unavailable` - * when the candidate's persistence inspection failed (retried on the - * next listing). `unsupported` is never produced; it remains in the + * when the candidate's Session observation was absent or transiently + * unreadable (retried on the next listing). `unsupported` is never produced; it remains in the * union for consumers that route on it. */ readonly reason: 'corrupt' | 'unsupported' | 'unavailable' @@ -102,7 +102,7 @@ type CorpusRecord = { readonly header: SessionHeader; readonly live: Session | u interface ListingRuntime { readonly projections: SessionProjectionRegistry - readonly persistence: SessionPersistence | undefined + readonly query: SessionQueryEngine readonly cache: SessionProjectionCache | undefined readonly corpus: ReadonlyMap readonly subagentParents: ReadonlySet @@ -120,8 +120,7 @@ interface PositionedCandidate { * 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), - * else one bounded-concurrency persistence inspection folded through the - * registry. + * 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, * optional persistence, and the optional projection cache. @@ -206,29 +205,34 @@ async function prepareListing( ) } assertListingNotCancelled(signal) - const persistence = ctx.get('sessionPersistence') + const query = ctx.get('sessionQuery') + if (query === undefined) { + throw new SubagentError( + 'listing subagents requires the sessionQuery service (load @deepseek-ai/dsh-session-query)', + 'SUBAGENT_CONTROL_QUERY_UNAVAILABLE', + ) + } // Optional acceleration only: an absent cache service just means every // cold candidate takes the authoritative preparation rung, so it carries // no error code and no configuration check. const cache = ctx.get('sessionProjectionCache') - let persistedHeaders: readonly SessionHeader[] = [] - if (persistence !== undefined) { - try { - persistedHeaders = await persistence.list(signal) - } catch (error: unknown) { - // The backend may reject with its own abort failure after observing the - // forwarded signal; cancellation stays a stable subagent failure. - assertListingNotCancelled(signal) - throw error - } + let records: Awaited> + try { + records = await query.listSessions(signal) + } catch (error: unknown) { assertListingNotCancelled(signal) + throw error } + assertListingNotCancelled(signal) // Live-preferred merge without header reconciliation: a live record wins // its id wholesale, exactly as a live-preferred corpus would serve it. const corpus = new Map() - for (const header of persistedHeaders) corpus.set(header.id, { header, live: undefined }) - for (const session of sessions.list()) { - corpus.set(session.header.id, { header: session.header, live: session }) + for (const record of records) { + const live = sessions.get(record.header.id) + corpus.set(record.header.id, { + header: live?.header ?? record.header, + live, + }) } const subagentParents = new Set() for (const record of corpus.values()) { @@ -236,7 +240,7 @@ async function prepareListing( subagentParents.add(record.header.parentSession) } } - return { projections, persistence, cache, corpus, subagentParents } + return { projections, query, cache, corpus, subagentParents } } /** Resolve projection-backed rows for aligned candidates with bounded cold reads. */ @@ -245,7 +249,7 @@ async function resolveCandidateRows( listing: ListingRuntime, signal: AbortSignal | undefined, ): Promise<(SubagentListEntry | undefined)[]> { - const { projections, persistence, cache, subagentParents } = listing + const { projections, query, cache, subagentParents } = listing const rows: (SubagentListEntry | undefined)[] = Array.from({ length: candidates.length }) const coldReads: { index: number; header: SessionHeader }[] = [] candidates.forEach((candidate, index) => { @@ -254,36 +258,33 @@ async function resolveCandidateRows( coldReads.push({ index, header: candidate.header }) return } - // The registry's watermark cache serves the live value with zero log - // reads; a live child without an identity yet is the creation window - // before the establishing provider appends its descriptor. + // Read only the identity unit. A live child without an identity yet is the + // creation window before the establishing provider appends its descriptor. let identity: SubagentIdentityProjection | null | undefined try { - identity = projections.snapshot(candidate.live).values.subagent + identity = projections.snapshot(candidate.live, ['subagent']).values.subagent } catch { - // The snapshot folds EVERY registered unit over this child's log, so - // any unit's fold or schema can reject damaged payloads. That is - // deterministic data damage in this one child; it degrades to one - // corrupt diagnostic instead of failing the whole listing. + // A rejecting identity fold is deterministic data damage in this child; + // contain it as one diagnostic instead of failing the whole listing. rows[index] = { kind: 'diagnostic', id: childId, reason: 'corrupt' } return } // The unit's serializable no-value sentinel is `null`; `undefined` can // only mean the key was dropped at a JSON boundary. Both are no value. - if (identity === undefined || identity === null) return + if (identity === undefined || identity === null + || identity.seq < (candidate.header.seedLength ?? 0)) return rows[index] = childRow(childId, identity, 'running', subagentParents.has(childId)) }) - // Cold candidates exist only when persistence listed them, so the narrow - // re-check is about types, not reachability. - if (persistence !== undefined && coldReads.length > 0) { + // Cold candidates came from the query corpus and are resolved concurrently. + if (coldReads.length > 0) { const queue = [...coldReads] await Promise.all(Array.from( { length: Math.min(COLD_READ_CONCURRENCY, queue.length) }, async () => { for (let job = queue.shift(); job !== undefined; job = queue.shift()) { rows[job.index] = await resolveColdIdentity( - persistence, projections, cache, job.header, + query, cache, job.header, subagentParents.has(job.header.id), signal, ) } @@ -338,16 +339,14 @@ function compareCorpusRecords(a: CorpusRecord, b: CorpusRecord): number { /** * Resolve one cold candidate down the remaining ladder: a durable * projection-cache row when it serves an own-suffix identity (the seq gate), - * otherwise one persistence inspection folded through the projection - * registry (the same detached recipe the API proxy uses for detached session - * projections). A failed inspection is one transient `unavailable` row - * retried on the next listing; an inspection naming another lifecycle, and a + * otherwise one shared Session observation. An absent or transiently failed + * observation is one `unavailable` row retried on the next listing; an observation + * source naming another lifecycle, and a * settled log the fold cannot identify — or that makes any registered unit * throw — are final, so they report `corrupt`. */ async function resolveColdIdentity( - persistence: SessionPersistence, - projections: SessionProjectionRegistry, + query: SessionQueryEngine, cache: SessionProjectionCache | undefined, header: SessionHeader, hasChildren: boolean, @@ -357,7 +356,7 @@ async function resolveColdIdentity( if (cache !== undefined) { let cached: SubagentIdentityProjection | null | undefined try { - cached = cache.cachedSnapshot(header)?.values.subagent + cached = cache.cachedSnapshot(header, ['subagent'])?.values.subagent } catch { // Unlike the preparation fold below, a throwing cache read renders no // verdict: the cache is derived data, so its damage (a poisoned stored @@ -377,32 +376,35 @@ async function resolveColdIdentity( } } assertListingNotCancelled(signal) - let inspected: { meta: SessionHeader; events: readonly SessionEvent[] } + let observation: SessionObservation try { - inspected = await persistence.inspect(childId, signal) - } catch { - // Per-child isolation: the child vanished or its backend read failed — - // one diagnostic row, and the listing itself still succeeds. + observation = await query.observeSession(childId, { + ...(signal === undefined ? {} : { signal }), + }) + } catch (error: unknown) { + // Per-child isolation: durable corruption is stable; absence and backend + // failures remain retryable. Either way, the listing itself still succeeds. assertListingNotCancelled(signal) - return { kind: 'diagnostic', id: childId, reason: 'unavailable' } + return { + kind: 'diagnostic', + id: childId, + reason: sessionQueryCode(error) === 'SESSION_QUERY_CORRUPT_SESSION' + || sessionQueryCode(error) === 'SESSION_QUERY_SOURCE_CONFLICT' + ? 'corrupt' + : 'unavailable', + } } + using ownedObservation = observation assertListingNotCancelled(signal) // A session id names a slot, not a lifecycle: a child deleted and // re-published under another owner between the enumeration and this read // must not leak into the old parent's listing. - if (!sameLifecycle(inspected.meta, header)) { + if (!sameLifecycle(ownedObservation.header, header)) { return { kind: 'diagnostic', id: childId, reason: 'corrupt' } } - let identity: SubagentIdentityProjection | null | undefined - try { - identity = projections.restore({}, inspected.events, 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 - // damage in this one child, contained as its own corrupt diagnostic. - return { kind: 'diagnostic', id: childId, reason: 'corrupt' } - } - if (identity === undefined || identity === null) { + const identity = ownedObservation.projections?.values.subagent + if (identity === undefined || identity === null + || identity.seq < (header.seedLength ?? 0)) { return { kind: 'diagnostic', id: childId, reason: 'corrupt' } } return childRow(childId, identity, 'inactive', hasChildren) @@ -437,6 +439,7 @@ function childRow( /** Immutable header fields that distinguish one session lifecycle from another under the same id. */ const LIFECYCLE_WITNESS_KEYS = [ 'version', 'id', 'createdAt', 'cwd', 'parentSession', 'seedLength', 'delegationDepth', + 'origin', 'agentPreset', ] as const /** Whether an inspected log still belongs to the enumerated lifecycle. */ @@ -450,3 +453,7 @@ function assertListingNotCancelled(signal: AbortSignal | undefined): void { throw new SubagentError('subagent listing was cancelled', 'CANCELLED') } } + +function sessionQueryCode(error: unknown): unknown { + return error instanceof Error && 'code' in error ? error.code : undefined +} diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index 3f2c0b1e31..f37ec63c31 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -24,6 +24,7 @@ import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import ApprovalService, { effectiveApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentRuntime from '../src/index.ts' +import { TestSessionQuery } from './test-session-query.ts' type Script = ConstructorParameters[0] @@ -45,6 +46,7 @@ async function setup(script: Script) { await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: root }) await ctx.plugin(ApprovalService) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(TestSessionQuery) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index d1d40e0ff4..8f38df3f79 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -22,6 +22,7 @@ import SubagentRuntime, { } from '../src/index.ts' import type { SubagentRunEndInfo, SubagentRunInfo } from '../src/index.ts' import * as SubagentInvariant from '../src/invariant.ts' +import { TestSessionQuery } from './test-session-query.ts' type Script = ConstructorParameters[0] @@ -65,7 +66,10 @@ afterEach(async () => { }) /** Boot the full continuable stack: loop, persistence, providers, and subagents. */ -async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } = {}) { +async function setupWith( + adapter: LlmAdapter, + options: { persistence?: boolean; sessionQuery?: boolean } = {}, +) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) let disposePersistence: (() => Promise) | undefined @@ -81,6 +85,7 @@ async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } }) } await ctx.plugin(AgentLoop, { agents: [] }) + if (options.sessionQuery !== false) await ctx.plugin(TestSessionQuery) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) @@ -455,6 +460,7 @@ describe('SubagentRuntime.startContinuable', () => { // afterEach closes it before removing the root (even on a failure path). cleanups.push(async () => { await freshPersistence.dispose() }) await fresh.plugin(AgentLoop, { agents: [] }) + await fresh.plugin(TestSessionQuery) await fresh.plugin(SubagentRuntime) await fresh.plugin(SubagentSpawn, { providerName: 'spawn' }) const freshParent = fresh.agentLoop.create(SessionId('routeless-resume'), {}) @@ -518,6 +524,16 @@ describe('SubagentRuntime.startContinuable', () => { }) describe('SubagentRuntime.followup residency routing', () => { + it('fails a cold follow-up when Session query is unavailable', async () => { + const { ctx, parent } = await setupWith(new MockAdapter([]), { + persistence: false, + sessionQuery: false, + }) + + await expect(followup(ctx, parent, SessionId('cold-without-query'), message('continue'))) + .rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' }) + }) + it('enqueues in the same Activation while it is running, preserving one inbox FIFO', async () => { const releaseFirst = Promise.withResolvers() const adapter = new GatedAdapter([ @@ -669,7 +685,7 @@ describe('SubagentRuntime.followup residency routing', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) const inspectStarted = Promise.withResolvers() - const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect').mockImplementation((_id, signal) => { + const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession').mockImplementation((_id, signal) => { return new Promise((_resolve, reject) => { if (signal === undefined) { reject(new Error('cold inspection must receive the followup signal')) diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index e9a5633189..eb1fa0b1a2 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -9,6 +9,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' @@ -23,6 +24,7 @@ import SubagentRuntime, { import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { TestSessionQuery } from './test-session-query.ts' type Script = ConstructorParameters[0] @@ -51,6 +53,7 @@ async function setup( ctx.provide('storageDomain', facility) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) } + await ctx.plugin(TestSessionQuery) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) @@ -152,10 +155,11 @@ const hostileProjectionDefinition = { } satisfies ProjectionDefinition<'subagentListHostileProbe', { poisoned?: boolean | undefined }> describe('SubagentRuntime.listChildren', () => { - it('lists live children without persistence, query services, or the continuation runtime', async () => { + it('lists live children without persistence or the continuation runtime', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(TestSessionQuery) await ctx.plugin(SubagentRuntime) expect(ctx.get('jobs')).toBeUndefined() expect(ctx.get('agents')).toBeUndefined() @@ -196,6 +200,17 @@ describe('SubagentRuntime.listChildren', () => { ) }) + it('fails loud when the Session query service is not mounted', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SubagentRuntime) + + await expect(ctx.subagents.listChildren(SessionId('no-query-parent'))).rejects.toThrow( + expect.objectContaining({ code: 'SUBAGENT_CONTROL_QUERY_UNAVAILABLE' }) as Error, + ) + }) + it('lists a persisted continuable child as inactive with its durable label', async () => { const { ctx, parent } = await setup([textResponse('done')]) const childId = await startChild(ctx, parent, 'summarize the doc') @@ -301,6 +316,76 @@ describe('SubagentRuntime.listChildren', () => { await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([]) }) + it('contains a live child projection failure as one corrupt diagnostic', async () => { + const { ctx, parent } = await setup([]) + const childId = SessionId('live-projection-failure') + const child = ctx.sessions.create(childId, { + meta: { parentSession: parent.id, origin: 'subagent' }, + }) + child.append('subagent/descriptor', descriptorPayload('broken live child')) + const snapshot = ctx.sessionProjections.snapshot.bind(ctx.sessionProjections) + vi.spyOn(ctx.sessionProjections, 'snapshot').mockImplementation((session, keys) => { + if (session.id === childId) throw new Error('projection failed') + return snapshot(session, keys) + }) + + await expect(ctx.subagents.listChildren(parent.id)).resolves.toContainEqual({ + kind: 'diagnostic', id: childId, reason: 'corrupt', + }) + }) + + it('maps a non-Error cold observation failure to unavailable', async () => { + const { ctx, parent } = await setup([]) + const childId = await authorChild(ctx, '00000000-0000-4000-8000-00000000aa01', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('unreadable child'))) + const observe = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery) + vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation((id, options) => { + if (id === childId) { + return Promise.reject('backend unavailable') // oxlint-disable-line typescript/prefer-promise-reject-errors + } + return observe(id, options) + }) + + await expect(ctx.subagents.listChildren(parent.id)).resolves.toContainEqual({ + kind: 'diagnostic', id: childId, reason: 'unavailable', + }) + }) + + it('releases a cold observation when cancellation lands after its read', async () => { + const { ctx, parent } = await setup([]) + const controller = new AbortController() + const childId = await authorChild(ctx, '00000000-0000-4000-8000-00000000aa02', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('cancelled child'))) + const dispose = vi.fn() + vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation((id) => { + if (id !== childId) throw new Error(`unexpected observation: ${id}`) + controller.abort(new Error('cancelled after observation')) + return Promise.resolve({ + source: 'prepared', + header: { + version: SESSION_FORMAT_VERSION, + id: childId, + createdAt: 1, + parentSession: parent.id, + origin: 'subagent', + }, + events: [], + cursor: -1, + projections: { asOfSeq: -1, values: {} }, + retain: vi.fn(), + [Symbol.dispose]: dispose, + } as unknown as SessionObservation) + }) + + await expect(ctx.subagents.listChildren(parent.id, controller.signal)) + .rejects.toMatchObject({ code: 'CANCELLED' }) + expect(dispose).toHaveBeenCalledOnce() + }) + it('lists a one-shot child with its durable creation label', async () => { const { ctx, parent } = await setup([]) const labeled = await authorChild(ctx, '00000000-0000-4000-8000-00000000ab02', { @@ -428,7 +513,7 @@ describe('SubagentRuntime.listChildren', () => { asOfSeq: 2, values: { subagent: { mode: 'continuable', label: 'cached own', seq: 2 } }, }) - const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') + const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ kind: 'child', id: child, label: 'cached own', mode: 'continuable', activity: 'inactive', hasChildren: false, @@ -458,7 +543,7 @@ describe('SubagentRuntime.listChildren', () => { asOfSeq: 2, values: { subagent: { mode: 'continuable', label: 'ancestor label', seq: 2 } }, }) - const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') + const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ kind: 'child', id: forkChild, label: 'own label', mode: 'continuable', activity: 'inactive', hasChildren: false, @@ -481,12 +566,12 @@ describe('SubagentRuntime.listChildren', () => { parentSession: parent.id, origin: 'subagent', }, childEvents(descriptorPayload('reborn child'))) - const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence) - ctx.sessionPersistence.inspect = async (sessionId, signal) => { + const original = ctx.sessionPersistence.borrowSession.bind(ctx.sessionPersistence) + ctx.sessionPersistence.borrowSession = async (sessionId, signal) => { const result = await original(sessionId, signal) if (sessionId !== reborn) return result // The id was re-published as a different lifecycle after enumeration. - return { ...result, meta: mutate(result.meta) } + return { ...result, inspection: { ...result.inspection, meta: mutate(result.inspection.meta) } } } const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toContainEqual({ kind: 'diagnostic', id: reborn, reason: 'corrupt' }) @@ -504,7 +589,7 @@ describe('SubagentRuntime.listChildren', () => { }, childEvents(descriptorPayload('actually valid'))) // A stale cached sentinel must not out-rank the authoritative re-fold. ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: 0, values: { subagent: null } }) - const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') + const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ kind: 'child', id: healthy, label: 'actually valid', mode: 'continuable', activity: 'inactive', hasChildren: false, @@ -512,7 +597,7 @@ describe('SubagentRuntime.listChildren', () => { expect(inspect).toHaveBeenCalledTimes(1) }) - it('maps a child rejected by persistence inspection to unavailable', async () => { + it('maps a child rejected by persistence validation to corrupt', async () => { const { ctx, parent } = await setup([]) // The surface-eligible user/message lacks its required surfaceOp, so the // first-party inspection rejects before any projection fold can run. @@ -530,7 +615,7 @@ describe('SubagentRuntime.listChildren', () => { { type: 'subagent/descriptor', seq: 2, time: 3, data: descriptorPayload('broken surface') }, ] as SessionEvent[]) const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'unavailable' }]) + expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'corrupt' }]) }) it('diagnoses a malformed descriptor payload as corrupt', async () => { @@ -556,10 +641,10 @@ describe('SubagentRuntime.listChildren', () => { expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'corrupt' }]) }) - it('lists a fork whose seed replays an ancestor descriptor under that identity', async () => { + it('rejects a fork whose only descriptor belongs to its inherited seed', async () => { const { ctx, parent } = await setup([]) - // The last-wins fold serves a seed-replayed ancestor descriptor until the - // child's own descriptor overrides it (known deviation #1 in the design). + // A seed-replayed descriptor predates this child's own suffix and cannot + // identify the fork as a resumable child. const seed = childEvents(descriptorPayload('ancestor label')) const forkChild = await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', { parentSession: parent.id, @@ -567,12 +652,7 @@ describe('SubagentRuntime.listChildren', () => { origin: 'subagent', }, seed) const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toEqual([ - { - kind: 'child', id: forkChild, label: 'ancestor label', mode: 'continuable', - activity: 'inactive', hasChildren: false, - }, - ]) + expect(entries).toEqual([{ kind: 'diagnostic', id: forkChild, reason: 'corrupt' }]) }) it('does not filter by provider availability: children of unmounted providers stay listed', async () => { @@ -613,7 +693,7 @@ describe('SubagentRuntime.listChildren', () => { }) }) - it('contains a foreign unit failure during a live snapshot to that child as corrupt', async () => { + it('does not evaluate an unrelated wire view when exposing a live child identity', async () => { const { ctx, parent } = await setup([]) ctx.sessionProjections.register(hostileProjectionDefinition) const poisonedId = SessionId('live-poisoned-child') @@ -629,7 +709,10 @@ describe('SubagentRuntime.listChildren', () => { healthy.append('turn/start', { turn: 1 }) healthy.append('subagent/descriptor', descriptorPayload('live healthy')) const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toContainEqual({ kind: 'diagnostic', id: poisonedId, reason: 'corrupt' }) + expect(entries).toContainEqual({ + kind: 'child', id: poisonedId, label: 'poison me', mode: 'continuable', + activity: 'running', hasChildren: false, + }) expect(entries).toContainEqual({ kind: 'child', id: healthyId, label: 'live healthy', mode: 'continuable', activity: 'running', hasChildren: false, @@ -652,8 +735,8 @@ describe('SubagentRuntime.listChildren', () => { parentSession: parent.id, origin: 'subagent', }, childEvents(descriptorPayload('flaky storage'))) - const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence) - ctx.sessionPersistence.inspect = (sessionId, signal) => { + const original = ctx.sessionPersistence.borrowSession.bind(ctx.sessionPersistence) + ctx.sessionPersistence.borrowSession = (sessionId, signal) => { if (sessionId === flaky) { return Promise.reject(new Error('backend read failed')) } @@ -669,7 +752,7 @@ describe('SubagentRuntime.listChildren', () => { }) // Nothing is memoized: with the backend healthy again, the next listing // folds the same child to its identity. - ctx.sessionPersistence.inspect = original + ctx.sessionPersistence.borrowSession = original await expect(ctx.subagents.listChildren(parent.id)).resolves.toContainEqual({ kind: 'child', id: flaky, label: 'flaky storage', mode: 'continuable', activity: 'inactive', hasChildren: false, @@ -723,8 +806,8 @@ describe('SubagentRuntime.listChildren', () => { origin: 'subagent', }, childEvents(descriptorPayload('grandchild'))) const inspected: SessionId[] = [] - const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence) - ctx.sessionPersistence.inspect = (sessionId, signal) => { + const original = ctx.sessionPersistence.borrowSession.bind(ctx.sessionPersistence) + ctx.sessionPersistence.borrowSession = (sessionId, signal) => { inspected.push(sessionId) return original(sessionId, signal) } @@ -755,8 +838,8 @@ describe('SubagentRuntime.listChildren', () => { live.append('subagent/descriptor', descriptorPayload('live mixed child')) const inspected: SessionId[] = [] - const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence) - ctx.sessionPersistence.inspect = (sessionId, signal) => { + const original = ctx.sessionPersistence.borrowSession.bind(ctx.sessionPersistence) + ctx.sessionPersistence.borrowSession = (sessionId, signal) => { inspected.push(sessionId) return original(sessionId, signal) } @@ -778,7 +861,7 @@ describe('SubagentRuntime.listChildren', () => { await vi.waitFor(() => { expect(ctx.sessionProjectionCache.cachedSnapshot(header!)?.values.subagent).toBeDefined() }, { timeout: 5_000 }) - const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') + const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ kind: 'child', id: childId, label: 'cached child', mode: 'continuable', activity: 'inactive', hasChildren: false, @@ -797,7 +880,7 @@ describe('SubagentRuntime.listChildren', () => { activity: 'inactive', hasChildren: false, }] // No stored row at all for a foreign child this process never ran. - const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') + const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual(expected) expect(inspect).toHaveBeenCalledTimes(1) // A stored row whose cut predates the descriptor: the subagent key is @@ -814,7 +897,7 @@ describe('SubagentRuntime.listChildren', () => { parentSession: parent.id, origin: 'subagent', }, childEvents(descriptorPayload('uncacheable child'))) - const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') + const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ kind: 'child', id: foreign, label: 'uncacheable child', mode: 'continuable', activity: 'inactive', hasChildren: false, @@ -833,7 +916,7 @@ describe('SubagentRuntime.listChildren', () => { // is derived data, so its failure must not become a verdict. throw new Error('poisoned cache row') } - const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') + const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ kind: 'child', id: recovered, label: 'recovered child', mode: 'continuable', activity: 'inactive', hasChildren: false, @@ -912,7 +995,7 @@ describe('SubagentRuntime.listChildren', () => { }, childEvents(descriptorPayload('cancelled cold read'))) const controller = new AbortController() const entered = Promise.withResolvers() - ctx.sessionPersistence.inspect = (_sessionId, signal) => { + ctx.sessionPersistence.borrowSession = (_sessionId, signal) => { entered.resolve(undefined) return new Promise((_resolve, reject) => { signal?.addEventListener('abort', () => { @@ -935,8 +1018,8 @@ describe('SubagentRuntime.listChildren', () => { origin: 'subagent', }, childEvents(descriptorPayload('cancelled mid-listing'))) const controller = new AbortController() - const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence) - ctx.sessionPersistence.inspect = async (sessionId, signal) => { + const original = ctx.sessionPersistence.borrowSession.bind(ctx.sessionPersistence) + ctx.sessionPersistence.borrowSession = async (sessionId, signal) => { const result = await original(sessionId, signal) controller.abort() return result @@ -954,7 +1037,7 @@ describe('SubagentRuntime.listChildren', () => { origin: 'subagent', }, childEvents(descriptorPayload('aborted behind a failure'))) const controller = new AbortController() - ctx.sessionPersistence.inspect = () => { + ctx.sessionPersistence.borrowSession = () => { // The read fails while the caller aborts: cancellation normalization // must fail the listing rather than return a one-diagnostic success. controller.abort() @@ -1188,11 +1271,17 @@ describe('SubagentRuntime.listDescendants', () => { createdAt: 1, origin: 'subagent', }, childEvents(descriptorPayload('lineage checked'))) - const realInspect = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence) - ctx.sessionPersistence.inspect = async (sessionId, signal) => { + const realInspect = ctx.sessionPersistence.borrowSession.bind(ctx.sessionPersistence) + ctx.sessionPersistence.borrowSession = async (sessionId, signal) => { const inspected = await realInspect(sessionId, signal) // The exact read reports a different durable parent than enumeration did. - return { ...inspected, meta: { ...inspected.meta, parentSession: SessionId('someone-else') } } + return { + ...inspected, + inspection: { + ...inspected.inspection, + meta: { ...inspected.inspection.meta, parentSession: SessionId('someone-else') }, + }, + } } await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([ { kind: 'diagnostic', id: childId, reason: 'corrupt', parentId: parent.id, depth: 1 }, diff --git a/packages/subagent/subagent/tests/test-session-query.ts b/packages/subagent/subagent/tests/test-session-query.ts new file mode 100644 index 0000000000..c4e6cd0357 --- /dev/null +++ b/packages/subagent/subagent/tests/test-session-query.ts @@ -0,0 +1,14 @@ +/** Minimal concrete Session query for tests that exercise only corpus and point reads. */ + +import SessionQueryEngine from '@deepseek-ai/dsh-session-query' + +/** Session query implementation whose search faces are intentionally unavailable. */ +export class TestSessionQuery extends SessionQueryEngine { + override searchSessions(): Promise { + return Promise.reject(new Error('session search is not configured in this test')) + } + + override searchEvents(): Promise { + return Promise.reject(new Error('event search is not configured in this test')) + } +} diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index 584d57cd39..b64e58a6cd 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -47,6 +47,9 @@ { "path": "../../session/session-projection-cache" }, + { + "path": "../../session-query/session-query" + }, { "path": "../../jobs/jobs" }, diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 9d22d4a93b..5dd5d665d6 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts index 4eb8008c3b..cbf3f7761f 100644 --- a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts @@ -17,6 +17,7 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as tool from '../src/list-agents.ts' import { parkParent } from './park-parent.ts' +import { TestSessionQuery } from './test-session-query.ts' /** One scripted response that may wait on a caller-released gate before streaming. */ interface GatedEntry { @@ -57,6 +58,7 @@ async function setupWith(adapter: MockAdapter | GatedAdapter) { const root = mkdtempSync(join(tmpdir(), 'dsh-tool-list-agents-')) roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) + await ctx.plugin(TestSessionQuery) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) diff --git a/packages/subagent/tool-subagent-control/tests/test-session-query.ts b/packages/subagent/tool-subagent-control/tests/test-session-query.ts new file mode 100644 index 0000000000..80bcb017ea --- /dev/null +++ b/packages/subagent/tool-subagent-control/tests/test-session-query.ts @@ -0,0 +1,14 @@ +/** Minimal concrete Session query for continuation and catalog integration tests. */ + +import SessionQueryEngine from '@deepseek-ai/dsh-session-query' + +/** Session query implementation whose search faces are outside these tests. */ +export class TestSessionQuery extends SessionQueryEngine { + override searchSessions(): Promise { + return Promise.reject(new Error('session search is not configured in this test')) + } + + override searchEvents(): Promise { + return Promise.reject(new Error('event search is not configured in this test')) + } +} diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index 0636254841..d31bbdbd46 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -16,6 +16,7 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as tool from '../src/index.ts' import { parkParent } from './park-parent.ts' +import { TestSessionQuery } from './test-session-query.ts' /** One scripted response that may wait on a caller-released gate before streaming. */ interface GatedEntry { @@ -56,6 +57,7 @@ async function setupWith(adapter: MockAdapter | GatedAdapter) { const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-control-')) roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) + await ctx.plugin(TestSessionQuery) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) From d2904a6c0687194b4e946804e554c95544e6eeaf Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:09:25 +0800 Subject: [PATCH 08/17] fixup! refactor(session): open journal streams from snapshots --- .../tests/fake-api.client.ts | 75 ++++++++++--------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index 6c82ab0352..c0a1b1b780 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -12,9 +12,9 @@ import type { SessionControlFrame, SessionFollowFrame, SessionFollowRequest, - SessionModels, SessionPage, SessionPageRequest, + SessionProjectionBaseline, SessionSelectModelRequest, SessionSelectModelValue, } from '@deepseek-ai/dsh-api-session-controller/types' @@ -23,6 +23,7 @@ import type { WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-contro import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { RemoteStream, + RemoteStreamError, type RemoteStreamOptions, } from '@deepseek-ai/dsh-api-gateway/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' @@ -54,12 +55,6 @@ function addressSessionId(address: SessionAddress): SessionId { return address.kind === 'session' ? address.sessionId : address.childSessionId } -function addressKey(address: SessionAddress): string { - return address.kind === 'session' - ? `session:${address.sessionId}` - : `subagent:${address.parentSessionId}:${address.childSessionId}:${address.mode}` -} - export interface Deferred { promise: Promise resolve(value: T): void @@ -128,12 +123,6 @@ export class FakeApiClient implements IApiClient { onSearch: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [], hasMore: false })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) - onModels: (payload: unknown) => Promise> = () => Promise.resolve(ok({ - current: { provider: 'fixture', model: 'fixture' }, - routable: true, - groups: [], - failures: [], - })) onSelectModel: (payload: SessionSelectModelRequest) => Promise> = payload => Promise.resolve(ok({ selected: { @@ -147,7 +136,7 @@ export class FakeApiClient implements IApiClient { onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) onHistory: (payload: { sessionId: SessionId; throughSeq?: number; beforeSeq?: number; maxMessages?: number }) - => Promise> = + => Promise> = () => Promise.resolve(ok({ events: [], hasMore: false })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) @@ -186,7 +175,6 @@ export class FakeApiClient implements IApiClient { private readonly followConns = new Map[]>() private readonly controlConns: ValueStreamConn[] = [] private readonly workspaceConns: ValueStreamConn[] = [] - private readonly openingPages = new Map>>() /** Optional Host opening cursor override for stale-page and reconnect tests. */ followCursor: number | undefined controlBaseline: SessionControlBaseline = { @@ -292,7 +280,12 @@ export class FakeApiClient implements IApiClient { readonly llm: IApiClient['llm'] = { providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))), - models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))), + models: payload => this.record('llm.models', payload, Promise.resolve(ok({ + default: { provider: 'fixture', model: 'fixture' }, + routableProviders: [], + groups: [], + failures: [], + }))), discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))), } @@ -312,7 +305,6 @@ export class FakeApiClient implements IApiClient { return this.remoteResult('session.search', payload, this.onSearch(payload)) }, create: payload => this.remoteResult('session.create', payload, this.onCreate(payload)), - models: payload => this.remoteResult('session.models', payload, this.onModels(payload)), selectModel: payload => this.remoteResult( 'session.selectModel', payload, @@ -412,14 +404,6 @@ export class FakeApiClient implements IApiClient { } private page(request: SessionPageRequest): Promise> { - const key = addressKey(request.address) - if (request.beforeSeq === undefined && request.maxMessages === 50) { - const opening = this.openingPages.get(key) - if (opening !== undefined) { - this.openingPages.delete(key) - return this.fetchPage(request, opening) - } - } return this.fetchPage(request) } @@ -466,25 +450,42 @@ export class FakeApiClient implements IApiClient { ): AsyncGenerator { const sessionId = addressSessionId(request.address) this.followStarts.push(sessionId) - const key = addressKey(request.address) - const initialPage = this.followCursor === undefined - ? this.onHistory({ sessionId, maxMessages: 50 }) - : undefined - if (initialPage !== undefined) this.openingPages.set(key, initialPage) + this.calls.push({ method: 'session.follow', payload: request }) const conns = this.followConns.get(sessionId) ?? [] if (!this.followConns.has(sessionId)) this.followConns.set(sessionId, conns) const stream = this.openValueStream(conns, signal) try { - const page = initialPage === undefined ? undefined : (await initialPage).result - const cursor = this.followCursor - ?? (page?.ok ? page.value.events.at(-1)?.event.seq ?? -1 : -1) - yield { type: 'opened', cursor } + const response = await this.onHistory({ + sessionId, + maxMessages: request.maxMessages ?? 50, + }) + if (!response.result.ok) { + throw new RemoteStreamError( + response.result.error.code, + response.result.error.message, + response.result.error.details, + ) + } + const page = response.result.value + const cursor = this.followCursor ?? page.events.at(-1)?.event.seq ?? -1 + yield { + type: 'snapshot', + header: { + version: 0, + id: sessionId, + createdAt: 0, + ...(request.address.kind === 'subagent' + ? { origin: 'subagent' as const, parentSession: request.address.parentSessionId } + : {}), + }, + cursor, + events: page.events.filter(entry => entry.event.seq <= cursor), + hasMore: page.hasMore, + projections: page.projections ?? { asOfSeq: cursor, values: {} }, + } yield* stream.values } finally { stream.dispose() - if (initialPage !== undefined && this.openingPages.get(key) === initialPage) { - this.openingPages.delete(key) - } } } From 12292924976be6137f837391821e7b53f0404718 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:10:25 +0800 Subject: [PATCH 09/17] docs(session): refresh architecture and generated contracts --- ...sion-history-and-event-transport.i18n.yaml | 4 +- ...-18-session-history-and-event-transport.md | 34 ++- ...-session-history-and-event-transport.zh.md | 34 ++- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 10 +- docs/config-catalog.zh.md | 10 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 14 +- docs/event-producer-consumer.zh.md | 14 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 253 +++++++++--------- docs/module-graph.zh.md | 253 +++++++++--------- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 18 +- docs/persistence-catalog.zh.md | 18 +- docs/subsystems/persistence.i18n.yaml | 4 +- docs/subsystems/persistence.md | 11 + docs/subsystems/persistence.zh.md | 11 + docs/subsystems/session-projection.i18n.yaml | 4 +- docs/subsystems/session-projection.md | 60 ++++- docs/subsystems/session-projection.zh.md | 60 ++++- docs/subsystems/session-query.i18n.yaml | 4 +- docs/subsystems/session-query.md | 8 + docs/subsystems/session-query.zh.md | 8 + docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 11 +- docs/subsystems/session.zh.md | 11 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 25 +- docs/subsystems/subagent.zh.md | 25 +- docs/subsystems/web-client.i18n.yaml | 4 +- docs/subsystems/web-client.md | 4 +- docs/subsystems/web-client.zh.md | 4 +- .../src/client/api-catalog.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 120 +++++---- pnpm-lock.yaml | 33 ++- scripts/gen-cordis-catalog.ts | 4 + 37 files changed, 639 insertions(+), 460 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml index b0b466cfa3..00652d3b09 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.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-18-session-history-and-event-transport.md -2026-08-18-session-history-and-event-transport.md: 206d3d13d1f183b97b02b89644b022367be2ebfd -2026-08-18-session-history-and-event-transport.zh.md: 3d0b5d4ab768ecd3877bfde86822245d0b63ac49 +2026-08-18-session-history-and-event-transport.md: 808565ff7df60b8aa6aa3f18820c1139b8bf5362 +2026-08-18-session-history-and-event-transport.zh.md: 8bd00def4531afa9cdf77ae7f689f2e77908545e diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md index 206d3d13d1..808565ff7d 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -134,7 +134,7 @@ If a page request is canceled with its physical carrier generation, the journal `packages/api/session-controller` provides Host `ctx.sessionController` and the generated `ctx.remote.session` namespace. -It owns Session list, search, create, models, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, page, follow, and control. +It owns Session list, search, create, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, page, follow, and control. The Host-generation model catalog is exposed separately through `llm.models` because it is not Session-specific. The package separates agent, commands, control, history, and list controllers internally, but Session identity resolution, activation policy, subagent ownership, and Remote error projection have one public owner. @@ -148,9 +148,9 @@ Each method explicitly selects a cold inspection, live-only lookup, or resume-ca | Operation | Source or result without a live Agent | Activation rule | |---|---|---| -| `session.list`, `search` | persistence, projection cache, or cold log | Never resumes an Agent | +| `session.list`, `search` | headers and projection cache; a bounded small-log read can resolve uncertain blankness | Never resumes an Agent | | `session.page(address)` | attached Session or persistence log | Never resumes an Agent | -| `session.follow(address)` | cold-read current cursor, then wait for future appends | Neither opening nor waiting resumes an Agent | +| `session.follow(address)` | one live or prepared observation carrying the opening page and projections | Publishes the snapshot first, then promotes an ordinary cold Session once in the background | | `session.control()` | current attached Agents, pending registry, and process-local registries | Baseline and reconnect do not resume an Agent | | `session.attachment`, fork source read | authorized durable Session data | A read does not resume an Agent | | `session.updateQueue`, `cancel` | only the current live Agent | Does not resume vanished state | @@ -159,6 +159,12 @@ Each method explicitly selects a cold inspection, live-only lookup, or resume-ca Reading titles, lists, and projections does not require an Agent. An observation operation cannot inherit resume authority merely because another Remote endpoint uses Agent lookup. +`SessionQuery.observeSession()` chooses an attached Session or borrows one prepared source from `SessionPersistence.borrowSession()`. The persistence preparation cache shares concurrent cold reads and pins the exact unpublished Session until every observation lease is released. An observation computes either all registered projections or none; callers may expose a subset, but no caller creates a partial projection state. + +`session.list` never performs an unbounded cold-log scan. It uses cached projection hints when available and may fully observe only an individually stored artifact within the configured small-log byte limit to distinguish an abandoned blank Session. Missing or unreadable hints keep the row visible with unknown metadata. + +`model/selection` is a required-on-read durable event because it changes the model route used by the next request. Its projection records both the last request selection and a later pending selection; prompt assembly consumes the pending value when the matching `request/header` is committed. + #### Session journal `session.page` returns a history window clipped on message boundaries with contiguous internal sequence numbers. Every request must carry an explicit `throughSeq`; this value comes from the corresponding `session.follow` generation's opening cursor and fixes the read at the same log cut. A tail page without `beforeSeq` must end exactly at `throughSeq`, where `-1` denotes an empty log. `beforeSeq` only selects an older page before that cut and cannot replace the synchronization cursor. `maxMessages` limits user/assistant message count without dropping chunks, tools, or state events between those messages. @@ -167,20 +173,20 @@ The tail page also carries a projection baseline no later than `throughSeq`; old Ordinary Sessions and direct subagents use one `SessionAddress` protocol. A direct-subagent address carries parent Session, child Session, and mode; a cold Host read verifies durable ownership and descriptor rather than authorizing access from the child id alone. -`session.follow` installs `session/event` and `session/created` listeners before checking an attached Session or persistence, then reads the current cursor. +`session.follow` installs `session/event` and `session/created` listeners before observing an attached or prepared Session. -The first follow response is `{ type: 'opened', cursor }`. A generation with `afterSeq` first replays the missing suffix from the authoritative log, then emits commits buffered during the read in sequence order. +The first follow response is a complete `{ type: 'snapshot', header, cursor, events, hasMore, projections }` frame. Every reconnect sends another complete snapshot replacement; the protocol has no `afterSeq`. Events committed during observation remain buffered and are emitted after the snapshot in sequence order. -A cold Session can open history immediately and keep follow waiting. Future events appear only after another explicit command resumes the Agent. +A cold ordinary Session can publish its prepared snapshot immediately. After that first frame, the Controller transfers a retained observation to one background promotion; follow does not wait for activation. Direct-subagent addresses never use this promotion path. -Client `SessionEventStream` extends `RemoteJournalStream` and supplies only `session.follow`, `session.page`, the Session sequence algorithm, and repair requests. The general layer first obtains opening cursor `C`, then calls `session.page({ throughSeq: C })`; entries `C + 1...` received during the read remain in the follow queue, and the page must cover exactly through `C` before the layer merges and publishes a continuous sequence. +Client `SessionEventStream` extends `RemoteJournalStream` and supplies only `session.follow`, `session.page`, the Session sequence algorithm, and repair requests. The general layer validates and publishes the opening snapshot directly. It calls `session.page({ throughSeq })` only for older history or when a later event reveals a sequence gap. ```text -ctx.remote.session.follow(address, afterSeq?) --------| - |[]> SessionEventStream -ctx.remote.session.page(address, throughSeq, pageArgs) -| |-- replace(window) - |-- prepend(history) - `-- append(live entry) +ctx.remote.session.follow(address, pageArgs) ----------------| + snapshot(header, cursor, page, projections), event* |[]> SessionEventStream +ctx.remote.session.page(address, throughSeq, pageArgs) -------| |-- replace(window) + |-- prepend(history) + `-- append(live entry) ``` Each Client Session owns only one current `events: SessionEventStream | undefined`. The read-only `SessionEventSource` gives the materialized event window to Conversation consumers. @@ -328,7 +334,7 @@ Connection tests pin missing, duplicate, and withdrawn generation sources; the r `RemoteSnapshotStream` tests pin exactly one opening snapshot per generation, rejection of an update before a snapshot, rejection of duplicate snapshots, and reconnect replacement. -`RemoteJournalStream` tests pin follow-before-page, opening-overlap removal, contiguous append, historical prepend, reconnect catch-up, gap repair, and one atomic replacement. +`RemoteJournalStream` tests pin snapshot-first opening, contiguous append, historical prepend, reconnect replacement, gap repair, and one atomic replacement. Session Host tests pin cold page/follow without increasing attached Agents, contiguous events reaching a cold follow after an explicit prompt, direct-subagent ownership, message-aligned pagination, and terminal-error projection. @@ -352,7 +358,7 @@ Static checks pin that API Proxy exports no Session/Workspace Host-frame carrier ## Consequences -The browser can read and follow a durable Session while its Agent is stopped. Observation does not implicitly resume execution; only explicitly authorized Session commands create or resume Agents according to their own rules. +The browser can read a durable Session while its Agent is stopped. Opening an ordinary Session publishes the prepared snapshot before one background promotion begins; list, search, page, and other observation-only reads never activate it. Durable logs repair a missing suffix by sequence number and page; Session control and Workspace state converge through opening snapshots; ordinary Remote Events promise no replay. Recovery semantics follow the data kind instead of imitating one another. diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md index 3d0b5d4ab7..8bd00def45 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -134,7 +134,7 @@ repair 期间旧 window 保持可读;page 与期间积累的 live entries 拼 `packages/api/session-controller` 提供 Host `ctx.sessionController` 与生成的 `ctx.remote.session` namespace。 -它拥有 Session list、search、create、models、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、page、follow 与 control。 +它拥有 Session list、search、create、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、page、follow 与 control。Host generation 的 model catalog 通过独立的 `llm.models` 公开,因为它不属于特定 Session。 包内的 agent、commands、control、history 与 list controller 分开实现,但 Session 身份解析、激活策略、subagent ownership 和 Remote 错误投影只有一个公开 owner。 @@ -148,9 +148,9 @@ Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类 | 操作 | 无 live Agent 时的数据来源或结果 | 激活规则 | |---|---|---| -| `session.list`、`search` | persistence、投影缓存或冷日志 | 永不恢复 Agent | +| `session.list`、`search` | header 与投影缓存;可通过有界的小日志读取判断不确定的 blank 状态 | 永不恢复 Agent | | `session.page(address)` | attached Session 或 persistence 日志 | 永不恢复 Agent | -| `session.follow(address)` | 冷读当前 cursor,等待将来的 append | 建联和等待都不恢复 Agent | +| `session.follow(address)` | 一份携带 opening page 与 projection 的 live 或 prepared observation | 先发布 snapshot,再在后台把普通冷 Session 提升一次 | | `session.control()` | 当前 attached Agent、pending registry 与进程内 registry | baseline 与重连不恢复 Agent | | `session.attachment`、fork 源读取 | 已授权的持久 Session 数据 | 读取不恢复 Agent | | `session.updateQueue`、`cancel` | 仅命中当前 live Agent | 不为已消失状态恢复 Agent | @@ -159,6 +159,12 @@ Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类 读取 title、列表和投影不要求 Agent。观察操作不能因为另一个 Remote endpoint 使用了 Agent lookup 而继承其恢复权限。 +`SessionQuery.observeSession()` 选择 attached Session,或从 `SessionPersistence.borrowSession()` 借用 prepared source。Persistence preparation cache 共享并发冷读取,并在所有 observation lease 释放前固定同一个未发布 Session。一次 observation 要么计算所有已注册 projection,要么完全不计算;调用方可以只公开其中一部分,但不会建立只计算部分 projection 的中间状态。 + +`session.list` 不会无界扫描冷日志。它优先使用缓存的 projection hint,仅在独立存储 artifact 不超过配置的小日志字节上限时,才可能完整观察日志以判断不确定的 blank 状态。hint 缺失或不可读时,列表仍保留该行,并把 metadata 视为未知。 + +`model/selection` 是 required-on-read 的持久 event,因为它改变下一次请求使用的 model route。对应 projection 同时记录最近一次 request selection 与之后的 pending selection;prompt assembly 在提交匹配的 `request/header` 时消费 pending value。 + #### Session 日志 `session.page` 返回一段按消息边界裁剪、内部 seq 连续的历史窗口。每个请求必须显式携带 `throughSeq`;该值来自对应 `session.follow` generation 的 opening cursor,并把本次读取固定在同一个日志切点。无 `beforeSeq` 的 tail page 必须精确结束于 `throughSeq`,其中 `-1` 表示空日志;`beforeSeq` 只选择该切点之前的更早页面,不能替代同步 cursor。`maxMessages` 限制 user/assistant 消息数,不丢弃这些消息之间的 chunk、tool 或状态事件。 @@ -167,20 +173,20 @@ tail page 同时携带不晚于 `throughSeq` 的 projection baseline;旧页只 普通 Session 与 direct subagent 使用同一个 `SessionAddress` 协议。direct subagent 地址同时携带父 Session、子 Session 与 mode,Host 冷读时验证持久 ownership 和 descriptor,不能只凭 child id 越权读取。 -`session.follow` 在检查 attached Session 或 persistence 前先安装 `session/event` 与 `session/created` listener,再读取当前 cursor。 +`session.follow` 在观察 attached 或 prepared Session 前先安装 `session/event` 与 `session/created` listener。 -首次 follow 返回 `{ type: 'opened', cursor }`。带 `afterSeq` 的 generation 先从权威日志重放缺失后缀,再按 seq 排出读取期间缓存的 commit。 +首次 follow 返回完整的 `{ type: 'snapshot', header, cursor, events, hasMore, projections }` frame。每次重连都发送另一份完整 snapshot replacement;协议不含 `afterSeq`。观察期间提交的 event 会保留在缓冲区,并在 snapshot 之后按 seq 发出。 -冷 Session 可以立即打开历史并保持 follow 等待。只有另一条显式命令恢复 Agent 后,后续事件才会出现。 +普通冷 Session 可以立即发布 prepared snapshot。首帧之后,Controller 把 retained observation 交给一次后台 promotion;follow 不等待激活。Direct-subagent 地址不会进入该 promotion 路径。 -Client 的 `SessionEventStream` 继承 `RemoteJournalStream`,只提供 `session.follow`、`session.page`、Session seq 算法与 repair request。通用层先取得 opening cursor `C`,再调用 `session.page({ throughSeq: C })`;读取期间收到的 `C + 1...` entries 留在 follow 队列中,page 精确覆盖至 `C` 后才按连续 seq 合并并发布。 +Client 的 `SessionEventStream` 继承 `RemoteJournalStream`,只提供 `session.follow`、`session.page`、Session seq 算法与 repair request。通用层直接校验并发布 opening snapshot;仅在读取更早历史或后续 event 暴露 seq gap 时调用 `session.page({ throughSeq })`。 ```text -ctx.remote.session.follow(address, afterSeq?) --------| - |[]> SessionEventStream -ctx.remote.session.page(address, throughSeq, pageArgs) -| |-- replace(window) - |-- prepend(history) - `-- append(live entry) +ctx.remote.session.follow(address, pageArgs) ----------------| + snapshot(header, cursor, page, projections), event* |[]> SessionEventStream +ctx.remote.session.page(address, throughSeq, pageArgs) -------| |-- replace(window) + |-- prepend(history) + `-- append(live entry) ``` 每个 Client Session 只持有一个当前 `events: SessionEventStream | undefined`。只读 `SessionEventSource` 把已物化 event window 交给 Conversation consumer。 @@ -328,7 +334,7 @@ Connection 测试固定 generation source 缺失、重复注册、撤回、`$eve `RemoteSnapshotStream` 测试固定每 generation 恰好一份 opening snapshot、update-before-snapshot 拒绝、重复 snapshot 拒绝和重连 replacement。 -`RemoteJournalStream` 测试固定 follow-before-page、opening overlap 去重、连续 append、历史 prepend、重连 catch-up、gap repair 与一次性 replacement。 +`RemoteJournalStream` 测试固定 snapshot-first opening、连续 append、历史 prepend、重连 replacement、gap repair 与一次性 replacement。 Session Host 测试固定 cold page/follow 不增加 attached Agent、显式 prompt 后 cold follow 收到连续事件、direct subagent ownership、message-aligned pagination 和终止错误投影。 @@ -352,7 +358,7 @@ Remote Event Client 测试固定实例私有 key、Cordis 注册顺序、Agent C ## 后果 -浏览器可以在 Agent 停止时读取并跟随持久 Session。观察不隐式恢复执行,只有明确获得授权的 Session 命令按各自约定创建或恢复 Agent。 +浏览器可以在 Agent 停止时读取持久 Session。打开普通 Session 时先发布 prepared snapshot,再开始一次后台 promotion;list、search、page 及其他只读 observation 不会激活 Agent。 持久日志用 seq 与 page 修复缺失后缀;Session control 和 Workspace state 用 opening snapshot 收敛;普通 Remote Event 不承诺重放。恢复语义由数据类型决定,不再互相模拟。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 8bf27532bb..802244d347 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: 603f96385b1ab9a804ce2fa17f012343887c929a -config-catalog.zh.md: 97ebe046478eb195a99e5c820684cf24f111da20 +config-catalog.md: 5991c911d33b34db51c12313036322acf94dcaf9 +config-catalog.zh.md: 80b8163fc46bccc9509f51730bfcb7e256834f15 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 603f96385b..5991c911d3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -280,12 +280,12 @@ Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/co ## `@deepseek-ai/dsh-api-session-controller` -Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `typert` · `workspaceRegistry` +Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` ```ts config-catalog /** Session Controller deployment policy. */ export interface Config { - /** Maximum cold Session artifact size read to determine blankness. */ + /** Maximum cold Session artifact size eligible for one full projection observation. */ readonly coldBlankProbeMaxBytes?: number } ``` @@ -1781,7 +1781,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session/session-persistence-jsonl/src/index.ts:60`](../packages/session/session-persistence-jsonl/src/index.ts) +Source: [`packages/session/session-persistence-jsonl/src/index.ts:62`](../packages/session/session-persistence-jsonl/src/index.ts) @@ -1808,7 +1808,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session/session-persistence-sqlite/src/index.ts:37`](../packages/session/session-persistence-sqlite/src/index.ts) +Source: [`packages/session/session-persistence-sqlite/src/index.ts:38`](../packages/session/session-persistence-sqlite/src/index.ts) @@ -1831,7 +1831,7 @@ export interface Config { } ``` -Source: [`packages/session/session-projection-cache/src/index.ts:42`](../packages/session/session-projection-cache/src/index.ts) +Source: [`packages/session/session-projection-cache/src/index.ts:46`](../packages/session/session-projection-cache/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 97ebe04647..80b8163fc4 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -282,12 +282,12 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -需要:`agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `typert` · `workspaceRegistry` +需要:`agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` ```ts config-catalog /** Session Controller deployment policy. */ export interface Config { - /** Maximum cold Session artifact size read to determine blankness. */ + /** Maximum cold Session artifact size eligible for one full projection observation. */ readonly coldBlankProbeMaxBytes?: number } ``` @@ -1783,7 +1783,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -来源:[`packages/session/session-persistence-jsonl/src/index.ts:60`](../packages/session/session-persistence-jsonl/src/index.ts) +来源:[`packages/session/session-persistence-jsonl/src/index.ts:62`](../packages/session/session-persistence-jsonl/src/index.ts) @@ -1810,7 +1810,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -来源:[`packages/session/session-persistence-sqlite/src/index.ts:37`](../packages/session/session-persistence-sqlite/src/index.ts) +来源:[`packages/session/session-persistence-sqlite/src/index.ts:38`](../packages/session/session-persistence-sqlite/src/index.ts) @@ -1833,7 +1833,7 @@ export interface Config { } ``` -来源:[`packages/session/session-projection-cache/src/index.ts:42`](../packages/session/session-projection-cache/src/index.ts) +来源:[`packages/session/session-projection-cache/src/index.ts:46`](../packages/session/session-projection-cache/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 33c536f50a..c87c5ac974 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: 4ab1275e9309e150504d6a3bdd80792bb1a49ea1 -event-producer-consumer.zh.md: 8d8a401bfdccc74d5774ce5ce7ceac2c548f6c72 +event-producer-consumer.md: 586316e90992447d45ce2b0f0d67c306689f95cb +event-producer-consumer.zh.md: 4aebaa2f10e4975df238639a1dac40694f50bed2 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4ab1275e93..586316e909 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,7 +8,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-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:23`](../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:161`](../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:170`](../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) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:292`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:219`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:180`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:444`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:424`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:451`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:430`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:437`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:468`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:475`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -45,7 +45,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:65`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`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) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-telemetry`](../packages/session/session-telemetry), [`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) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 8d8a401bfd..4aebaa2f10 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,7 +10,7 @@ | 事件 | 模式 | 声明位置 | 派发方 | 监听方 | | --- | --- | --- | --- | --- | | `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-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:23`](../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:161`](../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:170`](../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) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:444`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:424`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:451`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:430`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:437`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:468`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:475`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -47,7 +47,7 @@ | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:65`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`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) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-telemetry`](../packages/session/session-telemetry), [`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) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 34ba9c0294..452f44877a 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: ea402f19468fe92455f828a23f3478235903776b -module-graph.zh.md: af3efe50e535791d4f060a5b598ddf52013b465d +module-graph.md: 985636d3889394b912dcc1d68cb9e0a4fc1cb11b +module-graph.zh.md: d352643e21e8d54e523885ea06320610e6951dfe diff --git a/docs/module-graph.md b/docs/module-graph.md index ea402f1946..985636d388 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -889,6 +889,7 @@ flowchart TD pkg_agent_presets --> pkg_invariants pkg_agent_presets --> pkg_scope pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_session_projection pkg_agent_presets --> pkg_settings pkg_agent_presets --> pkg_system_prompt pkg_agent_presets --> pkg_tools @@ -977,26 +978,13 @@ flowchart TD pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions pkg_plugin_package_inventory_deepseek --> pkg_invariants pkg_plugin_package_inventory_deepseek --> pkg_session - pkg_subagent --> pkg_agent - pkg_subagent --> pkg_agent_presets - pkg_subagent --> pkg_brand - pkg_subagent --> pkg_invariants - pkg_subagent --> pkg_jobs - pkg_subagent --> pkg_llm - pkg_subagent --> pkg_sandbox - pkg_subagent --> pkg_sandbox_policy - pkg_subagent --> pkg_scope - pkg_subagent --> pkg_session - pkg_subagent --> pkg_session_persistence - pkg_subagent --> pkg_session_projection - pkg_subagent --> pkg_session_projection_cache - pkg_subagent --> pkg_tools - pkg_subagent --> pkg_user_approval pkg_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm pkg_session_query --> pkg_session pkg_session_query --> pkg_session_persistence + pkg_session_query --> pkg_session_projection + pkg_session_query --> pkg_session_projection_cache pkg_session_query --> pkg_session_title pkg_session_query --> pkg_tool_todo pkg_acp --> pkg_agent @@ -1058,60 +1046,22 @@ flowchart TD pkg_webhook --> pkg_session pkg_webhook --> pkg_session_title pkg_webhook --> pkg_workspace - pkg_subagent_acp --> pkg_agent - pkg_subagent_acp --> pkg_invariants - pkg_subagent_acp --> pkg_llm - pkg_subagent_acp --> pkg_session - pkg_subagent_acp --> pkg_subagent - pkg_subagent_acp --> pkg_subprocess - pkg_subagent_acp --> pkg_timeout - pkg_subagent_claude_code --> pkg_invariants - pkg_subagent_claude_code --> pkg_llm - pkg_subagent_claude_code --> pkg_session - pkg_subagent_claude_code --> pkg_subagent - pkg_subagent_claude_code --> pkg_subprocess - pkg_subagent_claude_code --> pkg_timeout - pkg_subagent_codex --> pkg_invariants - pkg_subagent_codex --> pkg_llm - pkg_subagent_codex --> pkg_session - pkg_subagent_codex --> pkg_subagent - pkg_subagent_codex --> pkg_subprocess - pkg_subagent_codex --> pkg_timeout - pkg_subagent_in_process_driver --> pkg_agent - pkg_subagent_in_process_driver --> pkg_invariants - pkg_subagent_in_process_driver --> pkg_llm - pkg_subagent_in_process_driver --> pkg_session - pkg_subagent_in_process_driver --> pkg_subagent - pkg_subagent_in_process_driver --> pkg_system_prompt - pkg_subagent_in_process_driver --> pkg_tools - pkg_tool_subagent --> pkg_agent - pkg_tool_subagent --> pkg_invariants - pkg_tool_subagent --> pkg_jobs - pkg_tool_subagent --> pkg_llm - pkg_tool_subagent --> pkg_scope - pkg_tool_subagent --> pkg_session - pkg_tool_subagent --> pkg_settings - pkg_tool_subagent --> pkg_subagent - pkg_tool_subagent --> pkg_system_prompt - pkg_tool_subagent --> pkg_tools - pkg_tool_subagent_control --> pkg_invariants - pkg_tool_subagent_control --> pkg_llm - pkg_tool_subagent_control --> pkg_session - pkg_tool_subagent_control --> pkg_subagent - pkg_tool_subagent_control --> pkg_tools - pkg_tool_subagent_report --> pkg_invariants - pkg_tool_subagent_report --> pkg_llm - pkg_tool_subagent_report --> pkg_subagent - pkg_tool_subagent_report --> pkg_system_prompt - pkg_tool_subagent_report --> pkg_tools - pkg_hooks_claude_code --> pkg_agent - pkg_hooks_claude_code --> pkg_hook_protocol - pkg_hooks_claude_code --> pkg_invariants - pkg_hooks_claude_code --> pkg_llm - pkg_hooks_claude_code --> pkg_session - pkg_hooks_claude_code --> pkg_session_persistence - pkg_hooks_claude_code --> pkg_subagent - pkg_hooks_claude_code --> pkg_tools + pkg_subagent --> pkg_agent + pkg_subagent --> pkg_agent_presets + pkg_subagent --> pkg_brand + pkg_subagent --> pkg_invariants + pkg_subagent --> pkg_jobs + pkg_subagent --> pkg_llm + pkg_subagent --> pkg_sandbox + pkg_subagent --> pkg_sandbox_policy + pkg_subagent --> pkg_scope + pkg_subagent --> pkg_session + pkg_subagent --> pkg_session_persistence + pkg_subagent --> pkg_session_projection + pkg_subagent --> pkg_session_projection_cache + pkg_subagent --> pkg_session_query + pkg_subagent --> pkg_tools + pkg_subagent --> pkg_user_approval pkg_session_query_sqlite --> pkg_invariants pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence @@ -1169,6 +1119,74 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_jobs pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tools + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_apiproxy + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_experimental_webworker_runtime --> pkg_invariants + pkg_webhook_github --> pkg_credentials + pkg_webhook_github --> pkg_host_webserver + pkg_webhook_github --> pkg_invariants + pkg_webhook_github --> pkg_session + pkg_webhook_github --> pkg_webhook + pkg_subagent_acp --> pkg_agent + pkg_subagent_acp --> pkg_invariants + pkg_subagent_acp --> pkg_llm + pkg_subagent_acp --> pkg_session + pkg_subagent_acp --> pkg_subagent + pkg_subagent_acp --> pkg_subprocess + pkg_subagent_acp --> pkg_timeout + pkg_subagent_claude_code --> pkg_invariants + pkg_subagent_claude_code --> pkg_llm + pkg_subagent_claude_code --> pkg_session + pkg_subagent_claude_code --> pkg_subagent + pkg_subagent_claude_code --> pkg_subprocess + pkg_subagent_claude_code --> pkg_timeout + pkg_subagent_codex --> pkg_invariants + pkg_subagent_codex --> pkg_llm + pkg_subagent_codex --> pkg_session + pkg_subagent_codex --> pkg_subagent + pkg_subagent_codex --> pkg_subprocess + pkg_subagent_codex --> pkg_timeout + pkg_subagent_in_process_driver --> pkg_agent + pkg_subagent_in_process_driver --> pkg_invariants + pkg_subagent_in_process_driver --> pkg_llm + pkg_subagent_in_process_driver --> pkg_session + pkg_subagent_in_process_driver --> pkg_subagent + pkg_subagent_in_process_driver --> pkg_system_prompt + pkg_subagent_in_process_driver --> pkg_tools + pkg_tool_subagent --> pkg_agent + pkg_tool_subagent --> pkg_invariants + pkg_tool_subagent --> pkg_jobs + pkg_tool_subagent --> pkg_llm + pkg_tool_subagent --> pkg_scope + pkg_tool_subagent --> pkg_session + pkg_tool_subagent --> pkg_settings + pkg_tool_subagent --> pkg_subagent + pkg_tool_subagent --> pkg_system_prompt + pkg_tool_subagent --> pkg_tools + pkg_tool_subagent_control --> pkg_invariants + pkg_tool_subagent_control --> pkg_llm + pkg_tool_subagent_control --> pkg_session + pkg_tool_subagent_control --> pkg_subagent + pkg_tool_subagent_control --> pkg_tools + pkg_tool_subagent_report --> pkg_invariants + pkg_tool_subagent_report --> pkg_llm + pkg_tool_subagent_report --> pkg_subagent + pkg_tool_subagent_report --> pkg_system_prompt + pkg_tool_subagent_report --> pkg_tools + pkg_hooks_claude_code --> pkg_agent + pkg_hooks_claude_code --> pkg_hook_protocol + pkg_hooks_claude_code --> pkg_invariants + pkg_hooks_claude_code --> pkg_llm + pkg_hooks_claude_code --> pkg_session + pkg_hooks_claude_code --> pkg_session_persistence + pkg_hooks_claude_code --> pkg_subagent + pkg_hooks_claude_code --> pkg_tools + pkg_api_gateway --> pkg_brand + pkg_api_gateway --> pkg_client_connection + pkg_api_gateway --> pkg_host_webserver + pkg_api_gateway --> pkg_invariants + pkg_api_gateway --> pkg_typert_registry pkg_experimental_agent_team --> pkg_agent pkg_experimental_agent_team --> pkg_brand pkg_experimental_agent_team --> pkg_invariants @@ -1176,19 +1194,10 @@ flowchart TD pkg_experimental_agent_team --> pkg_session pkg_experimental_agent_team --> pkg_session_persistence pkg_experimental_agent_team --> pkg_subagent - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_apiproxy - pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_invariants pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent - pkg_webhook_github --> pkg_credentials - pkg_webhook_github --> pkg_host_webserver - pkg_webhook_github --> pkg_invariants - pkg_webhook_github --> pkg_session - pkg_webhook_github --> pkg_webhook pkg_tool_ralph --> pkg_agent pkg_tool_ralph --> pkg_invariants pkg_tool_ralph --> pkg_llm @@ -1212,37 +1221,6 @@ flowchart TD pkg_subagent_spawn_in_process --> pkg_invariants pkg_subagent_spawn_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver - pkg_api_gateway --> pkg_brand - pkg_api_gateway --> pkg_client_connection - pkg_api_gateway --> pkg_host_webserver - pkg_api_gateway --> pkg_invariants - pkg_api_gateway --> pkg_typert_registry - pkg_experimental_tool_agent_team --> pkg_agent - pkg_experimental_tool_agent_team --> pkg_experimental_agent_team - pkg_experimental_tool_agent_team --> pkg_invariants - pkg_experimental_tool_agent_team --> pkg_session - pkg_experimental_tool_agent_team --> pkg_system_prompt - pkg_experimental_tool_agent_team --> pkg_tools - pkg_sdk_client --> pkg_invariants - pkg_sdk_client --> pkg_llm - pkg_sdk_client --> pkg_sdk_protocol - pkg_sdk_client --> pkg_session - pkg_sdk_jsonrpc_server --> pkg_agent - pkg_sdk_jsonrpc_server --> pkg_attachment - pkg_sdk_jsonrpc_server --> pkg_invariants - pkg_sdk_jsonrpc_server --> pkg_llm - pkg_sdk_jsonrpc_server --> pkg_llm_deepseek - pkg_sdk_jsonrpc_server --> pkg_scope - pkg_sdk_jsonrpc_server --> pkg_sdk_protocol - pkg_sdk_jsonrpc_server --> pkg_session - pkg_sdk_jsonrpc_server --> pkg_subagent - pkg_subagent_dsh_sdk --> pkg_agent - pkg_subagent_dsh_sdk --> pkg_invariants - pkg_subagent_dsh_sdk --> pkg_llm - pkg_subagent_dsh_sdk --> pkg_sdk_client - pkg_subagent_dsh_sdk --> pkg_session - pkg_subagent_dsh_sdk --> pkg_subagent - pkg_subagent_dsh_sdk --> pkg_subprocess pkg_api_session_controller --> pkg_agent pkg_api_session_controller --> pkg_agent_default_model pkg_api_session_controller --> pkg_agent_presets @@ -1272,6 +1250,32 @@ flowchart TD pkg_api_workspace_controller --> pkg_storage_domain pkg_api_workspace_controller --> pkg_typert_protocol pkg_api_workspace_controller --> pkg_workspace + pkg_experimental_tool_agent_team --> pkg_agent + pkg_experimental_tool_agent_team --> pkg_experimental_agent_team + pkg_experimental_tool_agent_team --> pkg_invariants + pkg_experimental_tool_agent_team --> pkg_session + pkg_experimental_tool_agent_team --> pkg_system_prompt + pkg_experimental_tool_agent_team --> pkg_tools + pkg_sdk_client --> pkg_invariants + pkg_sdk_client --> pkg_llm + pkg_sdk_client --> pkg_sdk_protocol + pkg_sdk_client --> pkg_session + pkg_sdk_jsonrpc_server --> pkg_agent + pkg_sdk_jsonrpc_server --> pkg_attachment + pkg_sdk_jsonrpc_server --> pkg_invariants + pkg_sdk_jsonrpc_server --> pkg_llm + pkg_sdk_jsonrpc_server --> pkg_llm_deepseek + pkg_sdk_jsonrpc_server --> pkg_scope + pkg_sdk_jsonrpc_server --> pkg_sdk_protocol + pkg_sdk_jsonrpc_server --> pkg_session + pkg_sdk_jsonrpc_server --> pkg_subagent + pkg_subagent_dsh_sdk --> pkg_agent + pkg_subagent_dsh_sdk --> pkg_invariants + pkg_subagent_dsh_sdk --> pkg_llm + pkg_subagent_dsh_sdk --> pkg_sdk_client + pkg_subagent_dsh_sdk --> pkg_session + pkg_subagent_dsh_sdk --> pkg_subagent + pkg_subagent_dsh_sdk --> pkg_subprocess pkg_api_remotes --> pkg_agent_presets pkg_api_remotes --> pkg_api_gateway pkg_api_remotes --> pkg_api_session_controller @@ -1383,6 +1387,7 @@ flowchart TD pkg_client_ui_workspace --> pkg_invariants pkg_client_ui_workspace --> pkg_session pkg_client_ui_workspace --> pkg_util_workspace_path + pkg_client_ui_agent_preset --> pkg_agent_presets pkg_client_ui_agent_preset --> pkg_api_remotes pkg_client_ui_agent_preset --> pkg_api_session_controller pkg_client_ui_agent_preset --> pkg_client_connection @@ -1794,7 +1799,7 @@ flowchart TD | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`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), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`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) | @@ -1810,8 +1815,7 @@ flowchart TD | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`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), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | +| [`session-query`](../packages/session-query/session-query) | `session-query` | [`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), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | @@ -1820,6 +1824,15 @@ flowchart TD | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`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), [`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) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`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) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`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-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) | @@ -1828,27 +1841,19 @@ flowchart TD | [`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) | -| [`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) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`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) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`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) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`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) | -| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`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) | | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`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) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`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), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`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), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | -| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | @@ -1862,7 +1867,7 @@ flowchart TD | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`util-workspace-path`](../packages/util/workspace-path) | -| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index af3efe50e5..d352643e21 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -891,6 +891,7 @@ flowchart TD pkg_agent_presets --> pkg_invariants pkg_agent_presets --> pkg_scope pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_session_projection pkg_agent_presets --> pkg_settings pkg_agent_presets --> pkg_system_prompt pkg_agent_presets --> pkg_tools @@ -979,26 +980,13 @@ flowchart TD pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions pkg_plugin_package_inventory_deepseek --> pkg_invariants pkg_plugin_package_inventory_deepseek --> pkg_session - pkg_subagent --> pkg_agent - pkg_subagent --> pkg_agent_presets - pkg_subagent --> pkg_brand - pkg_subagent --> pkg_invariants - pkg_subagent --> pkg_jobs - pkg_subagent --> pkg_llm - pkg_subagent --> pkg_sandbox - pkg_subagent --> pkg_sandbox_policy - pkg_subagent --> pkg_scope - pkg_subagent --> pkg_session - pkg_subagent --> pkg_session_persistence - pkg_subagent --> pkg_session_projection - pkg_subagent --> pkg_session_projection_cache - pkg_subagent --> pkg_tools - pkg_subagent --> pkg_user_approval pkg_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm pkg_session_query --> pkg_session pkg_session_query --> pkg_session_persistence + pkg_session_query --> pkg_session_projection + pkg_session_query --> pkg_session_projection_cache pkg_session_query --> pkg_session_title pkg_session_query --> pkg_tool_todo pkg_acp --> pkg_agent @@ -1060,60 +1048,22 @@ flowchart TD pkg_webhook --> pkg_session pkg_webhook --> pkg_session_title pkg_webhook --> pkg_workspace - pkg_subagent_acp --> pkg_agent - pkg_subagent_acp --> pkg_invariants - pkg_subagent_acp --> pkg_llm - pkg_subagent_acp --> pkg_session - pkg_subagent_acp --> pkg_subagent - pkg_subagent_acp --> pkg_subprocess - pkg_subagent_acp --> pkg_timeout - pkg_subagent_claude_code --> pkg_invariants - pkg_subagent_claude_code --> pkg_llm - pkg_subagent_claude_code --> pkg_session - pkg_subagent_claude_code --> pkg_subagent - pkg_subagent_claude_code --> pkg_subprocess - pkg_subagent_claude_code --> pkg_timeout - pkg_subagent_codex --> pkg_invariants - pkg_subagent_codex --> pkg_llm - pkg_subagent_codex --> pkg_session - pkg_subagent_codex --> pkg_subagent - pkg_subagent_codex --> pkg_subprocess - pkg_subagent_codex --> pkg_timeout - pkg_subagent_in_process_driver --> pkg_agent - pkg_subagent_in_process_driver --> pkg_invariants - pkg_subagent_in_process_driver --> pkg_llm - pkg_subagent_in_process_driver --> pkg_session - pkg_subagent_in_process_driver --> pkg_subagent - pkg_subagent_in_process_driver --> pkg_system_prompt - pkg_subagent_in_process_driver --> pkg_tools - pkg_tool_subagent --> pkg_agent - pkg_tool_subagent --> pkg_invariants - pkg_tool_subagent --> pkg_jobs - pkg_tool_subagent --> pkg_llm - pkg_tool_subagent --> pkg_scope - pkg_tool_subagent --> pkg_session - pkg_tool_subagent --> pkg_settings - pkg_tool_subagent --> pkg_subagent - pkg_tool_subagent --> pkg_system_prompt - pkg_tool_subagent --> pkg_tools - pkg_tool_subagent_control --> pkg_invariants - pkg_tool_subagent_control --> pkg_llm - pkg_tool_subagent_control --> pkg_session - pkg_tool_subagent_control --> pkg_subagent - pkg_tool_subagent_control --> pkg_tools - pkg_tool_subagent_report --> pkg_invariants - pkg_tool_subagent_report --> pkg_llm - pkg_tool_subagent_report --> pkg_subagent - pkg_tool_subagent_report --> pkg_system_prompt - pkg_tool_subagent_report --> pkg_tools - pkg_hooks_claude_code --> pkg_agent - pkg_hooks_claude_code --> pkg_hook_protocol - pkg_hooks_claude_code --> pkg_invariants - pkg_hooks_claude_code --> pkg_llm - pkg_hooks_claude_code --> pkg_session - pkg_hooks_claude_code --> pkg_session_persistence - pkg_hooks_claude_code --> pkg_subagent - pkg_hooks_claude_code --> pkg_tools + pkg_subagent --> pkg_agent + pkg_subagent --> pkg_agent_presets + pkg_subagent --> pkg_brand + pkg_subagent --> pkg_invariants + pkg_subagent --> pkg_jobs + pkg_subagent --> pkg_llm + pkg_subagent --> pkg_sandbox + pkg_subagent --> pkg_sandbox_policy + pkg_subagent --> pkg_scope + pkg_subagent --> pkg_session + pkg_subagent --> pkg_session_persistence + pkg_subagent --> pkg_session_projection + pkg_subagent --> pkg_session_projection_cache + pkg_subagent --> pkg_session_query + pkg_subagent --> pkg_tools + pkg_subagent --> pkg_user_approval pkg_session_query_sqlite --> pkg_invariants pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence @@ -1171,6 +1121,74 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_jobs pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tools + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_apiproxy + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_experimental_webworker_runtime --> pkg_invariants + pkg_webhook_github --> pkg_credentials + pkg_webhook_github --> pkg_host_webserver + pkg_webhook_github --> pkg_invariants + pkg_webhook_github --> pkg_session + pkg_webhook_github --> pkg_webhook + pkg_subagent_acp --> pkg_agent + pkg_subagent_acp --> pkg_invariants + pkg_subagent_acp --> pkg_llm + pkg_subagent_acp --> pkg_session + pkg_subagent_acp --> pkg_subagent + pkg_subagent_acp --> pkg_subprocess + pkg_subagent_acp --> pkg_timeout + pkg_subagent_claude_code --> pkg_invariants + pkg_subagent_claude_code --> pkg_llm + pkg_subagent_claude_code --> pkg_session + pkg_subagent_claude_code --> pkg_subagent + pkg_subagent_claude_code --> pkg_subprocess + pkg_subagent_claude_code --> pkg_timeout + pkg_subagent_codex --> pkg_invariants + pkg_subagent_codex --> pkg_llm + pkg_subagent_codex --> pkg_session + pkg_subagent_codex --> pkg_subagent + pkg_subagent_codex --> pkg_subprocess + pkg_subagent_codex --> pkg_timeout + pkg_subagent_in_process_driver --> pkg_agent + pkg_subagent_in_process_driver --> pkg_invariants + pkg_subagent_in_process_driver --> pkg_llm + pkg_subagent_in_process_driver --> pkg_session + pkg_subagent_in_process_driver --> pkg_subagent + pkg_subagent_in_process_driver --> pkg_system_prompt + pkg_subagent_in_process_driver --> pkg_tools + pkg_tool_subagent --> pkg_agent + pkg_tool_subagent --> pkg_invariants + pkg_tool_subagent --> pkg_jobs + pkg_tool_subagent --> pkg_llm + pkg_tool_subagent --> pkg_scope + pkg_tool_subagent --> pkg_session + pkg_tool_subagent --> pkg_settings + pkg_tool_subagent --> pkg_subagent + pkg_tool_subagent --> pkg_system_prompt + pkg_tool_subagent --> pkg_tools + pkg_tool_subagent_control --> pkg_invariants + pkg_tool_subagent_control --> pkg_llm + pkg_tool_subagent_control --> pkg_session + pkg_tool_subagent_control --> pkg_subagent + pkg_tool_subagent_control --> pkg_tools + pkg_tool_subagent_report --> pkg_invariants + pkg_tool_subagent_report --> pkg_llm + pkg_tool_subagent_report --> pkg_subagent + pkg_tool_subagent_report --> pkg_system_prompt + pkg_tool_subagent_report --> pkg_tools + pkg_hooks_claude_code --> pkg_agent + pkg_hooks_claude_code --> pkg_hook_protocol + pkg_hooks_claude_code --> pkg_invariants + pkg_hooks_claude_code --> pkg_llm + pkg_hooks_claude_code --> pkg_session + pkg_hooks_claude_code --> pkg_session_persistence + pkg_hooks_claude_code --> pkg_subagent + pkg_hooks_claude_code --> pkg_tools + pkg_api_gateway --> pkg_brand + pkg_api_gateway --> pkg_client_connection + pkg_api_gateway --> pkg_host_webserver + pkg_api_gateway --> pkg_invariants + pkg_api_gateway --> pkg_typert_registry pkg_experimental_agent_team --> pkg_agent pkg_experimental_agent_team --> pkg_brand pkg_experimental_agent_team --> pkg_invariants @@ -1178,19 +1196,10 @@ flowchart TD pkg_experimental_agent_team --> pkg_session pkg_experimental_agent_team --> pkg_session_persistence pkg_experimental_agent_team --> pkg_subagent - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_apiproxy - pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_invariants pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent - pkg_webhook_github --> pkg_credentials - pkg_webhook_github --> pkg_host_webserver - pkg_webhook_github --> pkg_invariants - pkg_webhook_github --> pkg_session - pkg_webhook_github --> pkg_webhook pkg_tool_ralph --> pkg_agent pkg_tool_ralph --> pkg_invariants pkg_tool_ralph --> pkg_llm @@ -1214,37 +1223,6 @@ flowchart TD pkg_subagent_spawn_in_process --> pkg_invariants pkg_subagent_spawn_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver - pkg_api_gateway --> pkg_brand - pkg_api_gateway --> pkg_client_connection - pkg_api_gateway --> pkg_host_webserver - pkg_api_gateway --> pkg_invariants - pkg_api_gateway --> pkg_typert_registry - pkg_experimental_tool_agent_team --> pkg_agent - pkg_experimental_tool_agent_team --> pkg_experimental_agent_team - pkg_experimental_tool_agent_team --> pkg_invariants - pkg_experimental_tool_agent_team --> pkg_session - pkg_experimental_tool_agent_team --> pkg_system_prompt - pkg_experimental_tool_agent_team --> pkg_tools - pkg_sdk_client --> pkg_invariants - pkg_sdk_client --> pkg_llm - pkg_sdk_client --> pkg_sdk_protocol - pkg_sdk_client --> pkg_session - pkg_sdk_jsonrpc_server --> pkg_agent - pkg_sdk_jsonrpc_server --> pkg_attachment - pkg_sdk_jsonrpc_server --> pkg_invariants - pkg_sdk_jsonrpc_server --> pkg_llm - pkg_sdk_jsonrpc_server --> pkg_llm_deepseek - pkg_sdk_jsonrpc_server --> pkg_scope - pkg_sdk_jsonrpc_server --> pkg_sdk_protocol - pkg_sdk_jsonrpc_server --> pkg_session - pkg_sdk_jsonrpc_server --> pkg_subagent - pkg_subagent_dsh_sdk --> pkg_agent - pkg_subagent_dsh_sdk --> pkg_invariants - pkg_subagent_dsh_sdk --> pkg_llm - pkg_subagent_dsh_sdk --> pkg_sdk_client - pkg_subagent_dsh_sdk --> pkg_session - pkg_subagent_dsh_sdk --> pkg_subagent - pkg_subagent_dsh_sdk --> pkg_subprocess pkg_api_session_controller --> pkg_agent pkg_api_session_controller --> pkg_agent_default_model pkg_api_session_controller --> pkg_agent_presets @@ -1274,6 +1252,32 @@ flowchart TD pkg_api_workspace_controller --> pkg_storage_domain pkg_api_workspace_controller --> pkg_typert_protocol pkg_api_workspace_controller --> pkg_workspace + pkg_experimental_tool_agent_team --> pkg_agent + pkg_experimental_tool_agent_team --> pkg_experimental_agent_team + pkg_experimental_tool_agent_team --> pkg_invariants + pkg_experimental_tool_agent_team --> pkg_session + pkg_experimental_tool_agent_team --> pkg_system_prompt + pkg_experimental_tool_agent_team --> pkg_tools + pkg_sdk_client --> pkg_invariants + pkg_sdk_client --> pkg_llm + pkg_sdk_client --> pkg_sdk_protocol + pkg_sdk_client --> pkg_session + pkg_sdk_jsonrpc_server --> pkg_agent + pkg_sdk_jsonrpc_server --> pkg_attachment + pkg_sdk_jsonrpc_server --> pkg_invariants + pkg_sdk_jsonrpc_server --> pkg_llm + pkg_sdk_jsonrpc_server --> pkg_llm_deepseek + pkg_sdk_jsonrpc_server --> pkg_scope + pkg_sdk_jsonrpc_server --> pkg_sdk_protocol + pkg_sdk_jsonrpc_server --> pkg_session + pkg_sdk_jsonrpc_server --> pkg_subagent + pkg_subagent_dsh_sdk --> pkg_agent + pkg_subagent_dsh_sdk --> pkg_invariants + pkg_subagent_dsh_sdk --> pkg_llm + pkg_subagent_dsh_sdk --> pkg_sdk_client + pkg_subagent_dsh_sdk --> pkg_session + pkg_subagent_dsh_sdk --> pkg_subagent + pkg_subagent_dsh_sdk --> pkg_subprocess pkg_api_remotes --> pkg_agent_presets pkg_api_remotes --> pkg_api_gateway pkg_api_remotes --> pkg_api_session_controller @@ -1385,6 +1389,7 @@ flowchart TD pkg_client_ui_workspace --> pkg_invariants pkg_client_ui_workspace --> pkg_session pkg_client_ui_workspace --> pkg_util_workspace_path + pkg_client_ui_agent_preset --> pkg_agent_presets pkg_client_ui_agent_preset --> pkg_api_remotes pkg_client_ui_agent_preset --> pkg_api_session_controller pkg_client_ui_agent_preset --> pkg_client_connection @@ -1796,7 +1801,7 @@ flowchart TD | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`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), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`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) | @@ -1812,8 +1817,7 @@ flowchart TD | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`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), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | +| [`session-query`](../packages/session-query/session-query) | `session-query` | [`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), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | @@ -1822,6 +1826,15 @@ flowchart TD | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`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), [`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) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`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) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`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-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) | @@ -1830,27 +1843,19 @@ flowchart TD | [`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) | -| [`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) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`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) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`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) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`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) | -| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`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) | | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`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) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`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), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`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), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | -| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | @@ -1864,7 +1869,7 @@ flowchart TD | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`util-workspace-path`](../packages/util/workspace-path) | -| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index aad7db87f6..30b1894dbe 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-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/persistence-catalog.md -persistence-catalog.md: dd2124520e43e590fc3506b23c533b3e482132cd -persistence-catalog.zh.md: 48c0867f37fc87ce5d29ce04b0b6970fca83648c +persistence-catalog.md: 893ffef71be98afe2356419dcb6ca0d871f26649 +persistence-catalog.zh.md: e34ce2b4b67746f9ce79f3d61add5e7f59e1aa22 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index dd2124520e..893ffef71b 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -133,7 +133,7 @@ Source: [`packages/core/agent/src/types.ts:38`](../packages/core/agent/src/types 'agent-preset/selected': { agentPreset: string } ``` -Source: [`packages/preset/agent-presets/src/session.ts:26`](../packages/preset/agent-presets/src/session.ts) +Source: [`packages/preset/agent-presets/src/session.ts:28`](../packages/preset/agent-presets/src/session.ts) ### `approval/*` @@ -498,6 +498,22 @@ Source: [`packages/llm/llm-retry/src/types.ts:9`](../packages/llm/llm-retry/src/ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src/types.ts) +### `model/*` + + + +#### `model/selection` — log-only + +```ts persistence-catalog +/** + * Complete validated model selection requested for subsequent prompt + * assembly. Log-only: it never enters derived model history. + */ +'model/selection': ModelSelection +``` + +Source: [`packages/api/session-controller/src/types.ts:39`](../packages/api/session-controller/src/types.ts) + ### `permission/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 48c0867f37..e34ce2b4b6 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -135,7 +135,7 @@ export type SessionEvent = { 'agent-preset/selected': { agentPreset: string } ``` -来源:[`packages/preset/agent-presets/src/session.ts:26`](../packages/preset/agent-presets/src/session.ts) +来源:[`packages/preset/agent-presets/src/session.ts:28`](../packages/preset/agent-presets/src/session.ts) ### `approval/*` @@ -500,6 +500,22 @@ export type SessionEvent = { 来源:[`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src/types.ts) +### `model/*` + + + +#### `model/selection` — log-only + +```ts persistence-catalog +/** + * Complete validated model selection requested for subsequent prompt + * assembly. Log-only: it never enters derived model history. + */ +'model/selection': ModelSelection +``` + +来源:[`packages/api/session-controller/src/types.ts:39`](../packages/api/session-controller/src/types.ts) + ### `permission/*` diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 22bd237e38..f85e085da9 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.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/persistence.md -persistence.md: a1bec03a1c5afefa81c713a07bcff2f80e586794 -persistence.zh.md: 2bece66c957d140eacfc364f60527eaa8f20e472 +persistence.md: 098f5798e5313ca97e90e67dce1d67177f003ca7 +persistence.zh.md: d6b3baf7cdb7f1735008e0c1da9740e0b756baff diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index a1bec03a1c..098f5798e5 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -347,6 +347,17 @@ abstract load(id: SessionId): Promise */ abstract inspect(id: SessionId, signal?: AbortSignal): Promise +/** + * Borrow one exact inspection while retaining any reusable prepared source. + * A cold observation must pin the exact prepared Session that a later + * {@link prepare} reserves. Implementations must not degrade this operation + * to a detached {@link inspect} result. + * @param id - persisted session to observe. + * @param signal - optional cancellation for preparation work. + * @returns a disposable immutable observation. + */ +abstract borrowSession(id: SessionId, signal?: AbortSignal): Promise + /** * Read the stored events from `fromSeq` onward — the read-from-seq * primitive for read models that resume from a watermark (e.g. a persisted diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 2bece66c95..d6b3baf7cd 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -347,6 +347,17 @@ abstract load(id: SessionId): Promise */ abstract inspect(id: SessionId, signal?: AbortSignal): Promise +/** + * Borrow one exact inspection while retaining any reusable prepared source. + * A cold observation must pin the exact prepared Session that a later + * {@link prepare} reserves. Implementations must not degrade this operation + * to a detached {@link inspect} result. + * @param id - persisted session to observe. + * @param signal - optional cancellation for preparation work. + * @returns a disposable immutable observation. + */ +abstract borrowSession(id: SessionId, signal?: AbortSignal): Promise + /** * Read the stored events from `fromSeq` onward — the read-from-seq * primitive for read models that resume from a watermark (e.g. a persisted diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 61ab54ba12..e450430401 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: 8614cf3466eff8deb360a7667ff6b4e37da1bf6e -session-projection.zh.md: b9e213b60e0df9de54e4c4805d11e64ca866dad2 +session-projection.md: c66a1c23930d4855add50465414a6b0ac64baab7 +session-projection.zh.md: 56e08754e0504b032b5820d9d7165ac70b0bb501 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 8614cf3466..c66a1c2393 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -28,10 +28,11 @@ interface ProjectionDefinition< /** Validates persisted state before it seeds a fold. */ stateSchema: ZodType /** - * State for the empty log. + * State for the empty log and its immutable Session metadata. + * @param header - immutable metadata for the Session being projected. * @returns the initial state. */ - init(): NoInfer + init(header: SessionHeader): NoInfer /** * Pure transition: previous state + one committed event → next state. A * unit uninterested in an event MUST return the same state reference — an @@ -124,10 +125,23 @@ The persisted projection cache service. Opens the `session_projcache` domain at * paths (the history tail baseline, {@link coldSnapshot}) supersede these * values whenever a session is actually 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 * `undefined` when no usable row exists for this lifecycle. */ -cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined +cachedSnapshot( meta: SessionHeader, keys?: readonly Extract[], ): ProjectionSnapshot | undefined + +/** + * Hydrate projection cells for an already-prepared Session without another + * persistence read. The cache seeds matching rows; the supplied exact log + * advances every unit to the observation cut. No checkpoint is written + * because the logical observation may contain recovery events not yet durable. + * @param session - exact unpublished Session retained by persistence. + * @param meta - observed lifecycle header. + * @param events - exact logical event prefix represented by the observation. + * @returns all projection values at the event cut. + */ +hydratePrepared( session: Session, meta: SessionHeader, events: readonly SessionEvent[], ): ProjectionSnapshot /** * Durably checkpoint one live session NOW (both mandatory points call @@ -154,7 +168,7 @@ async write(session: Session): Promise async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise ``` -Types: [Session](session.md) · [SessionHeader](persistence.md) · [SessionId](core.md) +Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md) · [SessionId](core.md) Source: [`packages/session/session-projection-cache/src/index.ts`](../../packages/session/session-projection-cache/src/index.ts) @@ -192,7 +206,8 @@ register< K extends Exclude void /** - * Read one unit's current host state without computing unrelated views. + * Read one unit's current host state after materializing every registered + * unit at the Session cursor. Unrelated wire views are not produced. * The returned value is live; callers must not mutate it. * @param session - the session whose state is read. * @param key - the registered unit key. @@ -206,9 +221,20 @@ stateOf( session: Session, key: K, ): * Fully synchronous — every value and `asOfSeq` reflect the same log * position. Each value passes its unit's `viewSchema` before leaving. * @param session - the session whose projection values are read. - * @returns the snapshot; `values` is empty when no client-visible unit is registered. + * @param keys - optional client-visible outputs; state materialization remains complete. + * @returns the snapshot; `values` is empty when no selected client-visible unit is registered. */ -snapshot(session: Session): ProjectionSnapshot +snapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot + +/** + * Read only already-materialized client-visible cells without folding history. + * Values may trail the live Session and are therefore hints, not a complete + * baseline. Missing cells are omitted. + * @param session - attached Session whose cached cells are inspected. + * @param keys - optional wire keys to view. + * @returns the lowest common cached cut, or `undefined` when no wire cell exists. + */ +cachedSnapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot | undefined /** * State-level checkpoint of every persisted unit for one session, read @@ -252,9 +278,10 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined * fuller read path refolds it). The zero-I/O rung of the read ladder — * values are as stale as their rows, never wrong. * @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. */ -viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial +viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract[], ): Partial /** * Cold read: fold every persisted unit over a stored log suffix, seeding @@ -274,14 +301,27 @@ viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial * @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 header - immutable metadata for the Session being restored. * @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. */ -restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } +restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, header: SessionHeader, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } + +/** + * Restore an exact cut and install its states on the supplied prepared Session. + * A later publication reuses these cells; ordinary live reads and event drive + * advance any constructor-owned suffix exactly once. + * @param session - exact prepared Session that owns the restored log prefix. + * @param checkpoint - persisted rows for this Session lifecycle. + * @param events - exact events at the observation cut. + * @param baseSeq - first supplied event sequence. + * @returns all projection values at the supplied cut. + */ +hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): ProjectionSnapshot ``` -Types: [Session](session.md) · [SessionEvent](session.md) +Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md) Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index b9e213b60e..56e08754e0 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -28,10 +28,11 @@ interface ProjectionDefinition< /** Validates persisted state before it seeds a fold. */ stateSchema: ZodType /** - * State for the empty log. + * State for the empty log and its immutable Session metadata. + * @param header - immutable metadata for the Session being projected. * @returns the initial state. */ - init(): NoInfer + init(header: SessionHeader): NoInfer /** * Pure transition: previous state + one committed event → next state. A * unit uninterested in an event MUST return the same state reference — an @@ -124,10 +125,23 @@ The persisted projection cache service. Opens the `session_projcache` domain at * paths (the history tail baseline, {@link coldSnapshot}) supersede these * values whenever a session is actually 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 * `undefined` when no usable row exists for this lifecycle. */ -cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined +cachedSnapshot( meta: SessionHeader, keys?: readonly Extract[], ): ProjectionSnapshot | undefined + +/** + * Hydrate projection cells for an already-prepared Session without another + * persistence read. The cache seeds matching rows; the supplied exact log + * advances every unit to the observation cut. No checkpoint is written + * because the logical observation may contain recovery events not yet durable. + * @param session - exact unpublished Session retained by persistence. + * @param meta - observed lifecycle header. + * @param events - exact logical event prefix represented by the observation. + * @returns all projection values at the event cut. + */ +hydratePrepared( session: Session, meta: SessionHeader, events: readonly SessionEvent[], ): ProjectionSnapshot /** * Durably checkpoint one live session NOW (both mandatory points call @@ -154,7 +168,7 @@ async write(session: Session): Promise async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise ``` -Types: [Session](session.zh.md) · [SessionHeader](persistence.zh.md) · [SessionId](core.zh.md) +Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md) · [SessionId](core.zh.md) Source: [`packages/session/session-projection-cache/src/index.ts`](../../packages/session/session-projection-cache/src/index.ts) @@ -192,7 +206,8 @@ register< K extends Exclude void /** - * Read one unit's current host state without computing unrelated views. + * Read one unit's current host state after materializing every registered + * unit at the Session cursor. Unrelated wire views are not produced. * The returned value is live; callers must not mutate it. * @param session - the session whose state is read. * @param key - the registered unit key. @@ -206,9 +221,20 @@ stateOf( session: Session, key: K, ): * Fully synchronous — every value and `asOfSeq` reflect the same log * position. Each value passes its unit's `viewSchema` before leaving. * @param session - the session whose projection values are read. - * @returns the snapshot; `values` is empty when no client-visible unit is registered. + * @param keys - optional client-visible outputs; state materialization remains complete. + * @returns the snapshot; `values` is empty when no selected client-visible unit is registered. */ -snapshot(session: Session): ProjectionSnapshot +snapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot + +/** + * Read only already-materialized client-visible cells without folding history. + * Values may trail the live Session and are therefore hints, not a complete + * baseline. Missing cells are omitted. + * @param session - attached Session whose cached cells are inspected. + * @param keys - optional wire keys to view. + * @returns the lowest common cached cut, or `undefined` when no wire cell exists. + */ +cachedSnapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot | undefined /** * State-level checkpoint of every persisted unit for one session, read @@ -252,9 +278,10 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined * fuller read path refolds it). The zero-I/O rung of the read ladder — * values are as stale as their rows, never wrong. * @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. */ -viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial +viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract[], ): Partial /** * Cold read: fold every persisted unit over a stored log suffix, seeding @@ -274,14 +301,27 @@ viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial * @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 header - immutable metadata for the Session being restored. * @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. */ -restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } +restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, header: SessionHeader, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } + +/** + * Restore an exact cut and install its states on the supplied prepared Session. + * A later publication reuses these cells; ordinary live reads and event drive + * advance any constructor-owned suffix exactly once. + * @param session - exact prepared Session that owns the restored log prefix. + * @param checkpoint - persisted rows for this Session lifecycle. + * @param events - exact events at the observation cut. + * @param baseSeq - first supplied event sequence. + * @returns all projection values at the supplied cut. + */ +hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): ProjectionSnapshot ``` -Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) +Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md) Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) diff --git a/docs/subsystems/session-query.i18n.yaml b/docs/subsystems/session-query.i18n.yaml index ced2ada4d3..93e35627bb 100644 --- a/docs/subsystems/session-query.i18n.yaml +++ b/docs/subsystems/session-query.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-query.md -session-query.md: 40dbd63cb8f0b6922130cb855cc313ebee73150a -session-query.zh.md: 7ccda38af1117b3ab9d2f55fde910c6d338c31f2 +session-query.md: 5f897cfe28983ca3d932291ede904cff237583cc +session-query.zh.md: 7d63210f51fe07710f19434b55436935086a8c29 diff --git a/docs/subsystems/session-query.md b/docs/subsystems/session-query.md index 40dbd63cb8..5f897cfe28 100644 --- a/docs/subsystems/session-query.md +++ b/docs/subsystems/session-query.md @@ -373,6 +373,14 @@ Unified live-preferred session query service. Exact reads, filters, and traces are backend-independent concrete behavior. A backend implements full-text observation, reconciliation, ranking, cursor generations, and query execution on the same `ctx.sessionQuery` service. ```ts cordis-catalog +/** + * Observe one exact live or prepared Session without a persistence listing preflight. + * @param sessionId - logical Session identity. + * @param options - cancellation and projection selection for this read. + * @returns a caller-owned observation lease. + */ +observeSession( sessionId: SessionId, options: SessionObservationOptions = {}, ): Promise + /** * Search the live-preferred logical corpus and group by session. * @param request - query text, metadata filters, page size, and cursor. diff --git a/docs/subsystems/session-query.zh.md b/docs/subsystems/session-query.zh.md index 7ccda38af1..7d63210f51 100644 --- a/docs/subsystems/session-query.zh.md +++ b/docs/subsystems/session-query.zh.md @@ -373,6 +373,14 @@ Unified live-preferred session query service. Exact reads, filters, and traces are backend-independent concrete behavior. A backend implements full-text observation, reconciliation, ranking, cursor generations, and query execution on the same `ctx.sessionQuery` service. ```ts cordis-catalog +/** + * Observe one exact live or prepared Session without a persistence listing preflight. + * @param sessionId - logical Session identity. + * @param options - cancellation and projection selection for this read. + * @returns a caller-owned observation lease. + */ +observeSession( sessionId: SessionId, options: SessionObservationOptions = {}, ): Promise + /** * Search the live-preferred logical corpus and group by session. * @param request - query text, metadata filters, page size, and cursor. diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 339baa81cc..e3d112e468 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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.md -session.md: b7806a4989684be7585d8d42ac215fe1ab1540f0 -session.zh.md: a80a3146b50c4c0fdcf4c3e54e1dc4943eb28642 +session.md: 23b3f8535ac432c297595bdf621cad5cecf717d4 +session.zh.md: ad73efb2d1ec8a2a7df3463518f172103f107296 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index b7806a4989..23b3f8535a 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -636,13 +636,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH */ @Remote('create') create(request: SessionCreateRequest): Promise -/** - * Read model choices after explicitly resuming the addressed Session. - * @param request - Session whose model state is requested. - * @returns the current selection and available model groups. - */ -@Remote('models') models(request: SessionModelsRequest): Promise - /** * Select one Session-local model after explicitly resuming the Session. * @param request - Session identity and requested model selection. @@ -697,7 +690,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH * Read one cold-safe, message-aligned Session history page. * @param request - durable address, backward cursor, and page budget. * @param signal - cancellation for persistence reads. - * @returns one chronological page and optional latest projections. + * @returns one chronological page. */ @Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise @@ -705,7 +698,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH * Follow one Session log from its opening or resume cursor. * @param request - durable address and last committed sequence already held by the caller. * @param signal - cancellation owned by the Remote stream carrier. - * @returns an opened cursor followed by gap-free event frames. + * @returns a complete opening snapshot followed by gap-free event frames. */ @Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index a80a3146b5..ad73efb2d1 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -640,13 +640,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH */ @Remote('create') create(request: SessionCreateRequest): Promise -/** - * Read model choices after explicitly resuming the addressed Session. - * @param request - Session whose model state is requested. - * @returns the current selection and available model groups. - */ -@Remote('models') models(request: SessionModelsRequest): Promise - /** * Select one Session-local model after explicitly resuming the Session. * @param request - Session identity and requested model selection. @@ -701,7 +694,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH * Read one cold-safe, message-aligned Session history page. * @param request - durable address, backward cursor, and page budget. * @param signal - cancellation for persistence reads. - * @returns one chronological page and optional latest projections. + * @returns one chronological page. */ @Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise @@ -709,7 +702,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH * Follow one Session log from its opening or resume cursor. * @param request - durable address and last committed sequence already held by the caller. * @param signal - cancellation owned by the Remote stream carrier. - * @returns an opened cursor followed by gap-free event frames. + * @returns a complete opening snapshot followed by gap-free event frames. */ @Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index cd3f9d99c9..4415be48b3 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.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/subagent.md -subagent.md: 63edef0a4b8d5368ea9d6d82f0ea3ef99ea0bbad -subagent.zh.md: 21b0dfd21dbee5e1d37558d6c02fe7949126d9a9 +subagent.md: 8c26177bb2c534cfe724a3efb3861fb8a303b731 +subagent.zh.md: 9cdf55d5d8c9e8bec8ab93a541f2b80da4655c12 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 63edef0a4b..8c26177bb2 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -606,27 +606,16 @@ async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): P /** * Enumerate the parent's direct session-backed subagents without loading or - * resuming an Agent and without any query service: the listing merges the live - * session store with optional session persistence (live-preferred) and - * serves each child's durable mode/label from the registered `subagent` - * projection unit down a three-rung ladder — the registry's watermark - * snapshot for a live child; for a cold one, a durable projection-cache - * row when the optional cache serves an own-suffix identity (its `seq` - * gate proves the value postdates the fork seed, where a child's own - * descriptor is immutable once appended), else one persistence inspection - * folded through the registry. The - * projection fold is the single classification authority; per-child - * diagnostics relay a fold that served no identity or a failed inspection, - * never a list-time descriptor parse. Absent persistence, enumeration is - * live-only (a cold child cannot be resumed then either, so its absence is - * capability absence, not an error). This service consults no Agent - * registrations, Activations, or providers. + * resuming an Agent. The Session query service supplies one live-preferred + * corpus and shared point observations; the projection cache supplies + * immutable descriptor hits without opening cold logs. The registered + * `subagent` projection remains the sole mode/label classifier. * - * Every persistence read receives `signal`, and the listing rechecks - * cancellation around each of those awaits. Read rejections that settle + * Every query receives `signal`, and the listing rechecks cancellation + * around each await. Read rejections that settle * after an abort become a stable `SubagentError` with code `CANCELLED`. * @param parentSessionId - parent session whose direct children are listed. - * @param signal - caller-owned cancellation forwarded to persistence reads + * @param signal - caller-owned cancellation forwarded to Session queries * and observed around every read await. * @returns children and per-child diagnostics ordered by `createdAt`, then id. * @throws {@link SubagentError} when the projection registry or the session diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 21b0dfd21d..9cdf55d5d8 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -610,27 +610,16 @@ async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): P /** * Enumerate the parent's direct session-backed subagents without loading or - * resuming an Agent and without any query service: the listing merges the live - * session store with optional session persistence (live-preferred) and - * serves each child's durable mode/label from the registered `subagent` - * projection unit down a three-rung ladder — the registry's watermark - * snapshot for a live child; for a cold one, a durable projection-cache - * row when the optional cache serves an own-suffix identity (its `seq` - * gate proves the value postdates the fork seed, where a child's own - * descriptor is immutable once appended), else one persistence inspection - * folded through the registry. The - * projection fold is the single classification authority; per-child - * diagnostics relay a fold that served no identity or a failed inspection, - * never a list-time descriptor parse. Absent persistence, enumeration is - * live-only (a cold child cannot be resumed then either, so its absence is - * capability absence, not an error). This service consults no Agent - * registrations, Activations, or providers. + * resuming an Agent. The Session query service supplies one live-preferred + * corpus and shared point observations; the projection cache supplies + * immutable descriptor hits without opening cold logs. The registered + * `subagent` projection remains the sole mode/label classifier. * - * Every persistence read receives `signal`, and the listing rechecks - * cancellation around each of those awaits. Read rejections that settle + * Every query receives `signal`, and the listing rechecks cancellation + * around each await. Read rejections that settle * after an abort become a stable `SubagentError` with code `CANCELLED`. * @param parentSessionId - parent session whose direct children are listed. - * @param signal - caller-owned cancellation forwarded to persistence reads + * @param signal - caller-owned cancellation forwarded to Session queries * and observed around every read await. * @returns children and per-child diagnostics ordered by `createdAt`, then id. * @throws {@link SubagentError} when the projection registry or the session diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index 3c8e1c99ed..5406522b5b 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.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/web-client.md -web-client.md: 40902c273e2daafb5ea8acf2aefb0ce2a418b3f4 -web-client.zh.md: 79452e73c7ed6ed89df834995291c8f0e44d8258 +web-client.md: a06f6aaf45a482437b0509e65b6ee8332ec35a44 +web-client.zh.md: 09f25bc45369a34ebf32c7a0b2b995c6732c9b29 diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index 40902c273e..a06f6aaf45 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -43,7 +43,7 @@ Each API controller package owns a paired Host and Client face. The Host side ow - `SessionManager` owns the list baseline, live list/control updates, lazy Session instances, queues, projection stores, subagent catalogs, and conflict ordering between pulls and later updates. - Each `Session` owns one contiguous event window, paging, follow, prompt/control state, and the observable snapshot consumed by adapters. -The durable event path opens `follow()` before reading the first page. A page establishes a contiguous window; live events append by sequence; older pages prepend without replacing unrelated objects. A gap or a new physical generation reads a fresh tail through the opening cursor before publishing a replacement. The transient control stream starts every generation with a complete baseline and then applies queue, job, and projection updates. +The durable event path opens `follow()`, whose first frame contains the current header, tail page, cursor, and complete projection baseline. Each physical generation atomically replaces the retained window from that snapshot; live events then append by sequence. `page()` is reserved for older history and gap repair. The transient control stream starts every generation with a complete baseline and then applies queue, job, and projection updates. ### Workspaces @@ -75,7 +75,7 @@ Physical and logical recovery are separate. Gateway mux restores the physical We Recovery follows the data's semantics: -- A durable Session journal resumes from the last accepted sequence and repairs the loaded window against a tail page before accepting later events. +- A durable Session journal replaces its window from every generation's opening snapshot; `page()` supplies older history and repairs any later sequence gap. - Session control and Workspace streams retain the last published value while disconnected, then atomically replace it from a fresh opening baseline. - Ordinary forwarded notifications are not replayed. Stateful domains need a baseline, cursor, or explicit query; scoped waterfalls retain their own request lifetime. diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index 79452e73c7..09f25bc453 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -43,7 +43,7 @@ Connection 拥有 request correlation、`/api` carrier、trust check、Host desc - `SessionManager` 拥有 list baseline、实时 list/control update、惰性 Session instance、queue、projection store、subagent catalog,以及 pull 与后到 update 之间的冲突顺序。 - 每个 `Session` 拥有一段连续 event window、pagination、follow、prompt/control state 与供 adapter 消费的 observable snapshot。 -持久 event 路径会先打开 `follow()`,再读取第一页。page 建立连续窗口;实时 event 按 seq append;旧 page prepend 时不替换无关对象。遇到 gap 或新的物理 generation 时,模型先通过 opening cursor 读取新 tail,再发布 replacement。瞬态 control stream 每代以完整 baseline 开始,随后应用 queue、job 与 projection update。 +持久 event 路径打开 `follow()`,其首帧包含当前 header、tail page、cursor 与完整 projection baseline。每个物理 generation 都根据该 snapshot 原子替换保留窗口,随后按 seq append 实时 event。`page()` 只用于更早历史与 gap repair。瞬态 control stream 每代以完整 baseline 开始,随后应用 queue、job 与 projection update。 ### Workspaces @@ -75,7 +75,7 @@ Connection 拥有 request correlation、`/api` carrier、trust check、Host desc 恢复方式由数据语义决定: -- 持久 Session journal 从最后接受的 seq 继续,并在接受后续 event 前依据 tail page 修复已加载窗口。 +- 持久 Session journal 根据每个 generation 的 opening snapshot 替换窗口;`page()` 提供更早历史并修复后续 seq gap。 - Session control 与 Workspace stream 在断开期间保留最后一次发布的值,再用新的 opening baseline 原子替换。 - 普通 forwarded notification 不会 replay。需要可靠恢复的 stateful domain 必须提供 baseline、cursor 或显式 query;scoped waterfall 保留自身的 request lifetime。 diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 0458ac1704..1635660caf 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -679,7 +679,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionSnapshot', - declaration: 'export interface SessionSnapshot {\n readonly sessionId: SessionId;\n readonly queue: readonly QueuedMessage[];\n readonly running: boolean;\n readonly subagent: {\n readonly address: SubagentAddress;\n readonly parentAvailable: boolean;\n } | null;\n readonly removed: boolean;\n readonly openState: OpenState;\n readonly openError: ClientFailure | null;\n readonly hasMore: boolean;\n readonly loadingOlder: boolean;\n readonly promptError: PromptError | null;\n readonly blank: boolean;\n readonly lastAgentError: string | null;\n readonly promptAttempted: boolean;\n readonly awaitingFirstTurn: boolean;\n}', + declaration: 'export interface SessionSnapshot {\n readonly sessionId: SessionId;\n readonly queue: readonly QueuedMessage[];\n readonly running: boolean;\n readonly subagent: {\n readonly address: SubagentAddress;\n readonly parentAvailable?: boolean;\n } | null;\n readonly removed: boolean;\n readonly openState: OpenState;\n readonly openError: ClientFailure | null;\n readonly hasMore: boolean;\n readonly loadingOlder: boolean;\n readonly promptError: PromptError | null;\n readonly blank: boolean;\n readonly lastAgentError: string | null;\n readonly promptAttempted: boolean;\n readonly awaitingFirstTurn: boolean;\n}', }, { name: 'SessionStandardProps', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 3d5d495cc0..89b1cb6d77 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1243,12 +1243,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'request', description: 'requested identity, location, and Agent preset.' }], returns: 'the Session identity and resolved preset when configured.', }, - { - signature: '@Remote(\'models\') models(request: SessionModelsRequest): Promise', - description: 'Read model choices after explicitly resuming the addressed Session.', - parameters: [{ name: 'request', description: 'Session whose model state is requested.' }], - returns: 'the current selection and available model groups.', - }, { signature: '@Remote(\'selectModel\') selectModel(request: SessionSelectModelRequest): Promise', description: 'Select one Session-local model after explicitly resuming the Session.', @@ -1295,13 +1289,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: '@Remote(\'page\') page(request: SessionPageRequest, signal: AbortSignal): Promise', description: 'Read one cold-safe, message-aligned Session history page.', parameters: [{ name: 'request', description: 'durable address, backward cursor, and page budget.' }, { name: 'signal', description: 'cancellation for persistence reads.' }], - returns: 'one chronological page and optional latest projections.', + returns: 'one chronological page.', }, { signature: '@Remote({ mode: \'stream\' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable', description: 'Follow one Session log from its opening or resume cursor.', parameters: [{ name: 'request', description: 'durable address and last committed sequence already held by the caller.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], - returns: 'an opened cursor followed by gap-free event frames.', + returns: 'a complete opening snapshot followed by gap-free event frames.', }, { signature: '@Remote({ mode: \'stream\' }) control(signal: AbortSignal): AsyncIterable', @@ -1367,6 +1361,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'id', description: 'the persisted session to inspect.' }, { name: 'signal', description: 'optional cancellation for queued and backend read work.' }], returns: 'the validated header and current logical event log.', }, + { + signature: 'abstract borrowSession(id: SessionId, signal?: AbortSignal): Promise', + description: 'Borrow one exact inspection while retaining any reusable prepared source. A cold observation must pin the exact prepared Session that a later prepare reserves. Implementations must not degrade this operation to a detached inspect result.', + parameters: [{ name: 'id', description: 'persisted session to observe.' }, { name: 'signal', description: 'optional cancellation for preparation work.' }], + returns: 'a disposable immutable observation.', + }, { signature: 'abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', description: 'Read the stored events from `fromSeq` onward — the read-from-seq primitive for read models that resume from a watermark (e.g. a persisted projection cache folding only the tail past its checkpoint). Unlike inspect, it is a detached physical suffix read: no preparation cache, torn-tail truncation, synthetic closers, or coordinator-state publication. Only events from the valid contiguous stored prefix are returned, so a torn fragment never reaches the caller. `fromSeq` at or beyond the stored prefix returns an empty event list (never an error). Backends whose medium can seek by seq (SQLite) read only the suffix; sequential media (JSONL, both encodings) still parse the whole artifact and skip forward — the primitive bounds what is RETURNED and refolded, not every backend\'s physical read.', @@ -1393,11 +1393,17 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus two mandatory points — `turn/end` and session disposal (the live-to-cold moment) — and serves the cold-read ladder: cached row, persistence `readFrom` tail, registry `restore`, durable write-back. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write or cold read.', methods: [ { - signature: 'cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined', + 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.', - parameters: [{ name: 'meta', description: 'the listed session\'s header (identity witness; no log read).' }], + 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.', }, + { + signature: 'hydratePrepared( session: Session, meta: SessionHeader, events: readonly SessionEvent[], ): ProjectionSnapshot', + description: 'Hydrate projection cells for an already-prepared Session without another persistence read. The cache seeds matching rows; the supplied exact log advances every unit to the observation cut. No checkpoint is written because the logical observation may contain recovery events not yet durable.', + parameters: [{ name: 'session', description: 'exact unpublished Session retained by persistence.' }, { name: 'meta', description: 'observed lifecycle header.' }, { name: 'events', description: 'exact logical event prefix represented by the observation.' }], + returns: 'all projection values at the event cut.', + }, { signature: 'async write(session: Session): Promise', description: 'Durably checkpoint one live session NOW (both mandatory points call this; tests and carriers may too). The registry cut is snapshotted at this boundary (states are live references), then the whole record is replaced. NOT fail-soft — callers on the fail-soft paths contain it.', @@ -1437,15 +1443,21 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'stateOf( session: Session, key: K, ): SessionProjectionStateMap[K] | undefined', - description: 'Read one unit\'s current host state without computing unrelated views. The returned value is live; callers must not mutate it.', + description: 'Read one unit\'s current host state after materializing every registered unit at the Session cursor. Unrelated wire views are not produced. The returned value is live; callers must not mutate it.', parameters: [{ name: 'session', description: 'the session whose state is read.' }, { name: 'key', description: 'the registered unit key.' }], returns: 'current state, or `undefined` when the key is not registered.', }, { - signature: 'snapshot(session: Session): ProjectionSnapshot', + signature: 'snapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot', description: 'One consistent cut over every registered client-visible unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). Fully synchronous — every value and `asOfSeq` reflect the same log position. Each value passes its unit\'s `viewSchema` before leaving.', - parameters: [{ name: 'session', description: 'the session whose projection values are read.' }], - returns: 'the snapshot; `values` is empty when no client-visible unit is registered.', + parameters: [{ name: 'session', description: 'the session whose projection values are read.' }, { name: 'keys', description: 'optional client-visible outputs; state materialization remains complete.' }], + returns: 'the snapshot; `values` is empty when no selected client-visible unit is registered.', + }, + { + signature: 'cachedSnapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot | undefined', + description: 'Read only already-materialized client-visible cells without folding history. Values may trail the live Session and are therefore hints, not a complete baseline. Missing cells are omitted.', + parameters: [{ name: 'session', description: 'attached Session whose cached cells are inspected.' }, { name: 'keys', description: 'optional wire keys to view.' }], + returns: 'the lowest common cached cut, or `undefined` when no wire cell exists.', }, { signature: 'checkpoint(session: Session): ProjectionCheckpoint', @@ -1460,17 +1472,23 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the seq to hand the persistence `readFrom`, or `undefined` when no unit is registered (no read needed — {@link restore} would serve empty values regardless).', }, { - signature: 'viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial', + 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.', - parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }], + 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.', }, { - signature: 'restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }', + signature: 'restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, header: SessionHeader, ): { 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: 'header', description: 'immutable metadata for the Session being restored.' }], 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.', }, + { + signature: 'hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): ProjectionSnapshot', + description: 'Restore an exact cut and install its states on the supplied prepared Session. A later publication reuses these cells; ordinary live reads and event drive advance any constructor-owned suffix exactly once.', + parameters: [{ name: 'session', description: 'exact prepared Session that owns the restored log prefix.' }, { name: 'checkpoint', description: 'persisted rows for this Session lifecycle.' }, { name: 'events', description: 'exact events at the observation cut.' }, { name: 'baseSeq', description: 'first supplied event sequence.' }], + returns: 'all projection values at the supplied cut.', + }, ], }, { @@ -1478,6 +1496,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Unified live-preferred session query service.', description: 'Unified live-preferred session query service.\n\nExact reads, filters, and traces are backend-independent concrete behavior. A backend implements full-text observation, reconciliation, ranking, cursor generations, and query execution on the same `ctx.sessionQuery` service.', methods: [ + { + signature: 'observeSession( sessionId: SessionId, options: SessionObservationOptions = {}, ): Promise', + description: 'Observe one exact live or prepared Session without a persistence listing preflight.', + parameters: [{ name: 'sessionId', description: 'logical Session identity.' }, { name: 'options', description: 'cancellation and projection selection for this read.' }], + returns: 'a caller-owned observation lease.', + }, { signature: 'abstract searchSessions( request: SessionSearchRequest, exec?: SessionSearchExecContext, ): Promise>', description: 'Search the live-preferred logical corpus and group by session.', @@ -1979,8 +2003,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise', - description: 'Enumerate the parent\'s direct session-backed subagents without loading or resuming an Agent and without any query service: the listing merges the live session store with optional session persistence (live-preferred) and serves each child\'s durable mode/label from the registered `subagent` projection unit down a three-rung ladder — the registry\'s watermark snapshot for a live child; for a cold one, a durable projection-cache row when the optional cache serves an own-suffix identity (its `seq` gate proves the value postdates the fork seed, where a child\'s own descriptor is immutable once appended), else one persistence inspection folded through the registry. The projection fold is the single classification authority; per-child diagnostics relay a fold that served no identity or a failed inspection, never a list-time descriptor parse. Absent persistence, enumeration is live-only (a cold child cannot be resumed then either, so its absence is capability absence, not an error). This service consults no Agent registrations, Activations, or providers.\n\nEvery persistence read receives `signal`, and the listing rechecks cancellation around each of those awaits. Read rejections that settle after an abort become a stable `SubagentError` with code `CANCELLED`.', - parameters: [{ name: 'parentSessionId', description: 'parent session whose direct children are listed.' }, { name: 'signal', description: 'caller-owned cancellation forwarded to persistence reads and observed around every read await.' }], + description: 'Enumerate the parent\'s direct session-backed subagents without loading or resuming an Agent. The Session query service supplies one live-preferred corpus and shared point observations; the projection cache supplies immutable descriptor hits without opening cold logs. The registered `subagent` projection remains the sole mode/label classifier.\n\nEvery query receives `signal`, and the listing rechecks cancellation around each await. Read rejections that settle after an abort become a stable `SubagentError` with code `CANCELLED`.', + parameters: [{ name: 'parentSessionId', description: 'parent session whose direct children are listed.' }, { name: 'signal', description: 'caller-owned cancellation forwarded to Session queries and observed around every read await.' }], returns: 'children and per-child diagnostics ordered by `createdAt`, then id.', throws: ['{@link SubagentError} when the projection registry or the session store is not mounted, or the caller cancels the listing.'], }, @@ -3305,6 +3329,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'BashEnvVariableInfo', declaration: 'export interface BashEnvVariableInfo extends BashEnvVariable {\n contributor: string;\n key: DshEnvironmentKey;\n}', }, + { + name: 'BorrowedSessionSource', + declaration: 'export type BorrowedSessionSource = Disposable & ({\n readonly source: \'prepared\';\n readonly inspection: SessionInspection;\n readonly revision: SessionPersistenceRevision;\n readonly preparedSession: Session;\n} | {\n readonly source: \'live\';\n readonly inspection: SessionInspection;\n});', + }, { name: 'Branded', declaration: 'export type Branded = string & {\n readonly [BRAND]: B;\n};', @@ -4077,14 +4105,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'MessageSourceMap', declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n}', }, - { - name: 'ModelCatalogFailure', - declaration: 'export interface ModelCatalogFailure {\n readonly id: string;\n readonly name: string;\n readonly message: string;\n}', - }, - { - name: 'ModelCatalogModel', - declaration: 'export interface ModelCatalogModel {\n readonly id: string;\n readonly name: string;\n readonly description?: string;\n readonly reasoning?: ModelReasoning;\n}', - }, { name: 'ModelMessageSource', declaration: 'export interface ModelMessageSource extends AssistantProvenance {\n kind: \'model\';\n}', @@ -4097,18 +4117,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ModelModalityMap', declaration: 'export interface ModelModalityMap {\n text: \'text\';\n image: \'image\';\n}', }, - { - name: 'ModelProviderGroup', - declaration: 'export interface ModelProviderGroup {\n readonly id: string;\n readonly name: string;\n readonly models: readonly ModelCatalogModel[];\n}', - }, - { - name: 'ModelReasoning', - declaration: 'export interface ModelReasoning {\n readonly efforts: readonly ModelReasoningEffort[];\n readonly defaultEffort?: string;\n}', - }, - { - name: 'ModelReasoningEffort', - declaration: 'export interface ModelReasoningEffort {\n readonly id: string;\n readonly name: string;\n readonly description?: string;\n}', - }, { name: 'ObjectJsonSchema', declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};', @@ -4183,7 +4191,7 @@ 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(header: SessionHeader): 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: 'ProjectionSnapshot', @@ -4423,7 +4431,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionControlBaseline', - declaration: 'export interface SessionControlBaseline {\n readonly queues: Readonly>;\n readonly jobs: Readonly>;\n readonly projections: Readonly>;\n}', + declaration: 'export interface SessionControlBaseline {\n readonly queues: Readonly>;\n readonly jobs: Readonly>;\n readonly projections: Readonly>;\n}', }, { name: 'SessionControlFrame', @@ -4515,11 +4523,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionFollowFrame', - declaration: 'export type SessionFollowFrame = {\n readonly type: \'opened\';\n readonly cursor: number;\n} | ({\n readonly type: \'event\';\n} & SessionEventEntry);', + declaration: 'export type SessionFollowFrame = {\n readonly type: \'snapshot\';\n readonly header: SessionHeader;\n readonly cursor: number;\n readonly events: readonly SessionEventEntry[];\n readonly hasMore: boolean;\n readonly projections: SessionProjectionBaseline;\n} | ({\n readonly type: \'event\';\n} & SessionEventEntry);', }, { name: 'SessionFollowRequest', - declaration: 'export interface SessionFollowRequest {\n readonly address: SessionAddress;\n readonly afterSeq?: number;\n}', + declaration: 'export interface SessionFollowRequest {\n readonly address: SessionAddress;\n readonly maxMessages?: number;\n}', }, { name: 'SessionForkRequest', @@ -4574,16 +4582,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [ declaration: 'export interface SessionLogSnapshot {\n session: SessionHeader;\n events: SessionEvent[];\n}', }, { - name: 'SessionModels', - declaration: 'export interface SessionModels {\n readonly current: ModelSelection;\n readonly routable: boolean;\n readonly groups: readonly ModelProviderGroup[];\n readonly failures: readonly ModelCatalogFailure[];\n}', + name: 'SessionObservation', + declaration: 'export interface SessionObservation extends Disposable {\n readonly source: \'live\' | \'prepared\';\n readonly header: SessionHeader;\n readonly events: readonly SessionEvent[];\n readonly cursor: number;\n readonly revision?: SessionPersistenceRevision;\n readonly projections?: ProjectionSnapshot;\n retain(): SessionObservation;\n}', }, { - name: 'SessionModelsRequest', - declaration: 'export interface SessionModelsRequest {\n readonly sessionId: SessionId;\n}', + name: 'SessionObservationOptions', + declaration: 'export interface SessionObservationOptions {\n readonly signal?: AbortSignal;\n readonly projectionMode?: \'all\' | \'none\';\n}', }, { name: 'SessionPage', - declaration: 'export interface SessionPage {\n readonly events: readonly SessionEventEntry[];\n readonly hasMore: boolean;\n readonly projections?: SessionProjectionsBlock;\n}', + declaration: 'export interface SessionPage {\n readonly events: readonly SessionEventEntry[];\n readonly hasMore: boolean;\n}', }, { name: 'SessionPageRequest', @@ -4606,12 +4614,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [ declaration: 'export interface SessionPreparationOptions {\n readonly release?: () => void;\n}', }, { - name: 'SessionProjectionMap', - declaration: 'export interface SessionProjectionMap {\n}', + name: 'SessionProjectionBaseline', + declaration: 'export interface SessionProjectionBaseline {\n readonly asOfSeq: number;\n readonly values: SessionProjectionValues;\n}', }, { - name: 'SessionProjectionsBlock', - declaration: 'export interface SessionProjectionsBlock {\n readonly asOfSeq: number;\n readonly values: SessionProjectionValues;\n}', + name: 'SessionProjectionHints', + declaration: 'export interface SessionProjectionHints {\n readonly asOfSeq: number;\n readonly values: SessionProjectionValues;\n}', + }, + { + name: 'SessionProjectionMap', + declaration: 'export interface SessionProjectionMap {\n}', }, { name: 'SessionProjectionStateMap', @@ -4719,7 +4731,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionSummary', - declaration: 'export interface SessionSummary {\n readonly sessionId: SessionId;\n readonly updatedAt: number;\n readonly running: boolean;\n readonly blank: boolean;\n readonly parentSessionId?: SessionId;\n readonly origin?: \'subagent\';\n readonly cwd?: string;\n readonly agentPreset?: string;\n readonly projections?: SessionProjectionsBlock;\n}', + declaration: 'export interface SessionSummary {\n readonly sessionId: SessionId;\n readonly updatedAt: number;\n readonly running: boolean;\n readonly blank: boolean;\n readonly parentSessionId?: SessionId;\n readonly origin?: \'subagent\';\n readonly cwd?: string;\n readonly projections?: SessionProjectionHints;\n}', }, { name: 'SessionSurface', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 823b10216f..2ba800eb73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -418,6 +418,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../packages/session/session-persistence-jsonl + '@deepseek-ai/dsh-session-query': + specifier: workspace:^ + version: link:../../packages/session-query/session-query '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../packages/settings/settings @@ -1702,6 +1705,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -4543,6 +4549,9 @@ importers: '@deepseek-ai/dsh-session-persistence-sqlite': specifier: workspace:^ version: link:../../session/session-persistence-sqlite + '@deepseek-ai/dsh-session-query': + specifier: workspace:^ + version: link:../../session-query/session-query '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -4586,6 +4595,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-query': + specifier: workspace:^ + version: link:../../session-query/session-query '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -6435,6 +6447,9 @@ importers: js-yaml: specifier: ^4.1.0 version: 4.2.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -6469,6 +6484,9 @@ 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-settings': specifier: workspace:^ version: link:../../settings/settings @@ -6800,6 +6818,12 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session/session-persistence + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection + '@deepseek-ai/dsh-session-projection-cache': + specifier: workspace:^ + version: link:../../session/session-projection-cache '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session/session-title @@ -8022,6 +8046,9 @@ importers: '@deepseek-ai/dsh-session-projection-cache': specifier: workspace:^ version: link:../../session/session-projection-cache + '@deepseek-ai/dsh-session-query': + specifier: workspace:^ + version: link:../../session-query/session-query '@deepseek-ai/dsh-storage': specifier: workspace:^ version: link:../../storage/storage @@ -8527,6 +8554,9 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session/session-projection + '@deepseek-ai/dsh-session-query': + specifier: workspace:^ + version: link:../../session-query/session-query '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -8989,9 +9019,6 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-api-session-controller': - specifier: workspace:^ - version: link:../../api/session-controller '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4acfd5b694..2869df322e 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -396,6 +396,7 @@ export const LINK_MAP: Readonly> = { PrepareSessionOptions: 'persistence.md', SessionHeader: 'persistence.md', SessionInspection: 'persistence.md', + BorrowedSessionSource: 'persistence.md', SessionLocation: 'persistence.md', SessionPreparation: 'persistence.md', SessionPersistenceSnapshot: 'persistence.md', @@ -435,6 +436,8 @@ export const LINK_MAP: Readonly> = { SessionEventTraceRequest: 'session-query.md', SessionEventWindow: 'session-query.md', SessionLineageTrace: 'session-query.md', + SessionObservation: 'session-query.md', + SessionObservationOptions: 'session-query.md', SessionRecord: 'session-query.md', SessionResultFilter: 'session-query.md', SessionSearchExecContext: 'session-query.md', @@ -602,6 +605,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ 'Error', 'EntryTree', 'Exclude', + 'Extract', 'Map', 'NonNullable', 'Omit', From 059598de5967faef182ab333bdb875ef86c5d085 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:12:10 +0800 Subject: [PATCH 10/17] fix: c i --- packages/api/session-controller/src/index.ts | 2 +- .../api/session-controller/tests/controller.host.spec.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index af71281853..a3ce201aaf 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -100,7 +100,7 @@ export class SessionController extends TypertRemoteService { ctx.effect(() => async () => { await Promise.allSettled([...this.promotions]) }, 'session-controller.promotions') - this.history = new SessionHistoryController(ctx, observation => { this.promote(observation) }) + this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) this.listState = new ApiSessionList( ctx, config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES, diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index d3785b806a..39a4bf1888 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -168,10 +168,10 @@ describe('SessionController facade', () => { }) as never) const controller = createSessionTestController(ctx, defaults) const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents - const started = Promise.withResolvers() - const release = Promise.withResolvers() + const started = Promise.withResolvers() + const release = Promise.withResolvers() vi.spyOn(agents, 'resolveObservedAgent').mockImplementation(async () => { - started.resolve() + started.resolve(undefined) await release.promise return { agent: { id: sessionId, session: { id: sessionId }, ctx, status: 'idle' } as unknown as Agent, @@ -189,7 +189,7 @@ describe('SessionController facade', () => { await Promise.resolve() expect(disposed).toBe(false) - release.resolve() + release.resolve(undefined) await disposal await expect(waiting).resolves.toMatchObject({ done: true }) }) From 2b60227d08408b092e4355aae41a3d2713cab3ef Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:23:10 +0800 Subject: [PATCH 11/17] docs(session): record observation and projection ownership --- ...nd-projection-owned-client-state.i18n.yaml | 6 + ...tions-and-projection-owned-client-state.md | 201 ++++++++++++++++++ ...ns-and-projection-owned-client-state.zh.md | 201 ++++++++++++++++++ 3 files changed, 408 insertions(+) create mode 100644 .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md create mode 100644 .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md 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 new file mode 100644 index 0000000000..6660f864d6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 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 new file mode 100644 index 0000000000..e47f2fc75e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -0,0 +1,201 @@ +# Agent Note: Session observations and projection-owned client state + +Status: implemented + +English | [中文](2026-08-25-session-observations-and-projection-owned-client-state.zh.md) + +## Problem + +Session-facing consumers needed the same logical data but resolved it independently. List, follow, page, attachment and fork reads, and subagent inspection each chose between an attached Session, persisted metadata, a prepared Session, and projection cache entries. One page visit could therefore materialize the same cold log more than once, and independently assembled header, event, cursor, and projection values could describe different cuts. + +Client features also kept Session-derived facts in several forms. Title had dedicated list and update handling; model selection mixed a Session-specific catalog request with local state; agent preset display could infer a global default before the current Session arrived; and subagent listing scanned or reconstructed identity separately. These mirrors introduced intermediate states in which the UI showed a guessed default, a raw id, or an unavailable state even though the durable Session already determined the answer. + +Unifying only the persistence read would leave those Client mirrors as competing authorities. Unifying only the Client fields would leave each Host endpoint free to obtain a different source cut. The read unit and the derived-state unit therefore need one coordinated ownership rule. + +## Decision + +Exact Session reads use a retained `SessionObservation`, and replayable Session-derived values exposed to the Client use registered projections. Observation owns source selection and one immutable read cut; projection owns derivation from that cut. API layers select what to publish, while Client code consumes finished values and does not reconstruct Session facts from events or duplicate them in domain-specific mirrors. + +### Data flow + +The two ownership rules meet at the observation's projection snapshot. Lightweight listing may stop at cached hints; every exact opening reaches the same observation path and gives the Client a complete replacement baseline. + +```mermaid +flowchart LR + List["list / search"] --> Corpus["SessionQuery corpus"] + Follow["follow"] --> Observe["observeSession"] + Page["page / attachment / fork"] --> Observe + Subagent["subagent list / continuation"] --> Corpus + Subagent --> Observe + Corpus --> Cache["projection cache hints"] + Cache --> ClientList["Client Session list"] + Cache -->|"small miss"| Observe + Observe --> Source{"live or cold"} + Source --> Live["attached Session cut"] + Source --> Borrow["borrowSession"] + Borrow --> Prepared["SessionPreparations.borrow"] + Live --> Mode{"all or none"} + Prepared --> Mode + Mode --> Snapshot["SessionObservation"] + Snapshot --> Opening["follow opening snapshot"] + Snapshot --> Read["page / inspection"] + Opening --> Store["Client projection store"] + Store --> Domain["title / model / preset / subagent"] +``` + +### Observation is the point-read unit + +`SessionQueryEngine.observeSession(sessionId, options)` returns a disposable `SessionObservation` containing one source kind, header, contiguous event prefix, cursor, optional projection snapshot, and the durable revision for a prepared source. An attached Session wins. Otherwise `SessionPersistence.borrowSession()` and `SessionPreparations.borrow()` share and pin one prepared Session, including an in-flight cold load. + +Every owner disposes its observation. `retain()` creates another lease over the same cut, which lets `session.follow` publish a snapshot and then transfer that exact prepared source to background Agent promotion without rereading the log. A live Session that appears during cold resolution wins before publication; a disappeared live source is retried as cold. + +### Source resolution and lifetime + +An observation binds all returned fields to one lifecycle witness. Callers do not combine a header from corpus listing, events from persistence, and projections from a later live Session. The selected header and event prefix produce the cursor and projection snapshot together. + +Live preference is checked both before and after a cold borrow. The second check closes the race in which an Agent attaches while persistence is loading. If persistence itself reports that a live source won but that source has already detached by the time SessionQuery examines it, resolution restarts instead of publishing an unowned reference. + +Persistence absence maps to Session-not-found only after no attached Session exists. Durable corruption, source-identity conflict, cancellation, and operational persistence failure remain distinct `SessionQueryError` outcomes so API owners can preserve their own public error vocabulary without duplicating source detection. + +The observation owns no mutation authority. Its event array is an immutable prefix, and its prepared Session remains unpublished. Promotion is an explicit ownership transfer performed by the Session Controller after it has emitted the opening snapshot; other readers cannot turn an observation into a live Agent. + +Projection work is deliberately `all | none`. `all` computes every registered projection at the observation's event cursor; `none` leaves projection state untouched. There is no per-key preparation state, `projectionKeys` mode, or cached `viewedState`/`viewedValue` layer. A publisher may filter the completed values for an audience, but the underlying observation is never partly projected. + +### Projection execution boundary + +For a live source, `all` reads one synchronous registry snapshot. For a prepared source, the projection cache may seed valid state rows, after which every registered unit advances over the exact remaining event prefix. The resulting client values share one `asOfSeq`. + +Filtering belongs after computation because it changes disclosure, not state. A page authorization check may consume only `subagent`, and a list row may publish only list-relevant values, while both still rely on a complete projected cut when they request projection work. + +The registry owns fold state; each domain owns its `init`, `apply`, `view`, schemas, and `stateVersion`. SessionQuery knows only whether projection work is required. It does not know title, model, preset, subagent, token, image, plan, todo, or goal semantics. + +`view` remains an uncached synchronous conversion over folded state. Its cost is bounded by the registered projection units and is paid at snapshot publication; introducing a second cache would add invalidation states without reducing event replay. + +Corpus listing remains a separate lightweight operation. `listSessions()` returns live-preferred headers without materializing every log. Session list and subagent list first use live projection state or durable projection-cache rows. Session list may take one complete observation for an individually stored artifact within its configured small-log limit when cached metadata cannot establish whether it is blank; a large or unreadable cache miss remains visible with unknown hints. + +`session.follow` publishes a required opening snapshot containing header, cursor, the initial event window, and a complete projection baseline. Reconnect replaces the previous generation from another complete snapshot. `session.page` is reserved for older-history reads and gap repair. Observation-only reads never activate an Agent; only an ordinary follow may retain its prepared observation and request promotion after the opening snapshot has been delivered. + +### Read audiences + +Each public operation chooses one query and projection policy. The choice is part of that operation's behavior rather than a heuristic inside persistence or transport. + +| Operation | Read path | Projection policy | Agent activation | +|---|---|---|---| +| `session.list` | Corpus headers, live state, and cached rows; bounded small-log fallback | Partial hints, or one full small-log observation | Never | +| `session.search` | Corpus authorization plus the configured search provider | None for result listing | Never | +| `session.follow` | One exact observation | All, carried in the opening snapshot | Ordinary cold Session only, after snapshot delivery | +| `session.page` | One exact observation | None, except projection-backed subagent authorization | Never | +| Attachment and fork source | One exact observation | None unless authorization requires it | Never for the source | +| Subagent list and continuation | Corpus plus live/cache/observation resolution | All on a cold fallback; audience consumes identity or inherited values | Never for listing; continuation follows its explicit command semantics | + +### Replayable Client facts are projection-owned + +A Client-visible fact belongs to `SessionProjectionMap` when its value is determined by the Session header or event log and must survive reload, cold access, or reconnect. The rule covers title, list metadata, model selection, agent preset selection, subagent identity, and subagent timing. Their domain packages own pure projection definitions; the Session transport and Client value store remain domain-neutral. + +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 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. + +These distinctions prevent one overloaded `undefined` from representing cache miss, unloaded plugin, and a real domain answer. API types name list data as hints and opening data as a baseline so a consumer cannot assume equivalent completeness merely because both carry projection values. + +### Client merge rules + +| Input | Completeness | Freshness | Meaning of missing key | +|---|---|---|---| +| Session list hints | Partial | Last durable checkpoint or bounded fallback cut | Unknown | +| 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 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. + +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. + +Client-local interaction state also remains local: loading and error status, an open menu, an in-flight selection, and a staged choice for a not-yet-created Session are not replayable Session facts. Once a choice applies to a Session, its durable event and projection become authoritative. + +### Domain applications + +- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing. +- **Model selection.** `model/selection` records a complete provider, model, and optional reasoning effort. `modelSelection` distinguishes the last request's route from a later selection pending consumption by a request header. +- **Agent preset.** The projection initializes from immutable Session metadata and advances on preset-selection events. A missing or `null` value is not replaced with the deployment default for an existing Session. +- **Subagent identity.** The `subagent` unit remains the sole descriptor interpreter. Listing obtains candidates from the shared corpus and resolves values through live state, projection cache, or an observation rather than scanning events itself. +- **Subagent presentation.** Opening projection values establish timing and identity before the Client declares the child interactive or offline, so transport loading does not masquerade as a durable state. + +These migrations remove special-case Client state without making projection own provider catalogs or interaction mechanics. A domain still owns mutations and commands; projection owns only their replayable Session result. + +### Failure and readiness boundaries + +- A list cache miss is not an error and does not hide the row. Unknown hints remain absent until a bounded fallback or exact opening supplies them. +- A projection failure during an exact cold observation makes that observation fail as corrupt Session data; callers do not publish a mixture of successful keys and failed keys. +- A subagent candidate's failed cold observation is isolated to that candidate's diagnostic row; sibling candidates remain usable. +- A catalog load failure is Client-visible catalog state. It does not erase a previously complete catalog during refresh and does not synthesize a Session selection. +- A follow carrier generation is not accepted until its opening snapshot is validated and applied. The previous generation remains visible during reconnect. + +Cancellation stops queued or in-flight cold resolution at documented checkpoints and releases every acquired lease. Cancellation does not convert into not-found, nor may it leave a prepared entry pinned. + +### Ownership matrix + +| Concern | Owner | Non-owner | +|---|---|---| +| Cold materialization and revision checks | Session persistence | API Controller and Client | +| Exact live-preferred read cut | SessionQuery observation | Individual endpoint helpers | +| Fold state and client-value computation | Projection registry and domain unit | SessionQuery and Client | +| Partial list acceleration | Projection cache and list policy | Follow protocol | +| Opening and reconnect replacement | Session follow and journal stream | Session page | +| Per-key value ordering | Client projection store | Domain UI components | +| Provider or preset catalog lifecycle | Its catalog directory | Session projection | +| Rendering and transient interaction state | Domain UI package | Host projection units | + +### Extension rules + +1. Determine whether a new value is a replayable fact of one Session. If it is, define or reuse its durable header/event input before adding a Client field. +2. Register one pure projection unit in the owning domain. Keep fold state and Client view types distinct when their representations differ. +3. Let exact readers request `projectionMode: 'all'`; filter only when constructing an audience-specific response. +4. Let list consumers accept an optional hint. Do not force full corpus hydration merely to avoid an explicit unknown state. +5. Feed the generic Client projection store. Do not add a dedicated reconnect fetch, event reducer, or Session summary mirror for the same fact. +6. Keep non-Session catalogs and ephemeral UI state in their own owners, and define readiness before combining them with a projection value. + +These rules apply to new Session-derived Client state even when a direct event scan appears cheap. Complexity is measured across cold reads, reconnect, multiple tabs, plugin lifetime, and future consumers rather than at the first call site. + +### Relationship to existing decisions + +- [Reusable Session preparation](2026-08-05-session-preparation.md) owns cold materialization, repair, reservation, and publication. Observation adds a shared read lease over that prepared object; it does not move preparation into SessionQuery. +- [Session history and Remote event transport](2026-08-18-session-history-and-event-transport.md) owns stream generations and replacement semantics. This decision supplies the exact snapshot that opens each journal generation. +- [Projection state and Client views](2026-08-19-session-projection-state-and-client-views.md) owns the distinction between Host fold state and Client values. This decision governs where those values are consumed and how partial list hints differ from a complete baseline. +- [Subagent identity projection](2026-08-06-subagent-list-identity-projection.md) continues to own descriptor folding, the serializable `null` sentinel, and the own-suffix sequence check. This decision supersedes only its independent corpus merge and direct cold-inspection path: listing now uses SessionQuery's corpus and observation. +- The broader [session projection and command-log proposal](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) remains proposed for the portions not represented by shipped code. This decision records the shipped observation and Client-ownership subset. + +## Verification + +Persistence and SessionQuery tests pin shared cold loading, cancellation, live-source races, retained observations, disposal, and all-or-none projection calculation. Session Controller and Gateway tests pin snapshot-first opening, replacement reconnect, older-page reads, gap repair, list-cache hints, bounded small-log fallback, and promotion after snapshot delivery. + +Client tests pin higher-sequence-wins projection storage, title updates, model catalog and selection readiness, preset roster refresh and Session-specific selection, and subagent loading without transient offline presentation. Subagent tests pin corpus enumeration, cache and observation fallback, lifecycle witnesses, bounded cold reads, and no Agent activation during listing. + +## Alternatives considered + +**Keep source resolution in each consumer.** Rejected because every caller would continue to implement its own live race, persistence error mapping, preparation lifetime, cancellation, and projection cut, allowing both duplicate work and inconsistent results. + +**Activate an Agent for every exact read.** Rejected because list, history, attachment, search, and subagent inspection are read operations. Activation loads plugins and changes process state, and it has no natural retirement point for pagination or catalog reads. + +**Prepare only requested projection keys.** Rejected because a partially projected Session creates another lifecycle state that every cache, restore, plugin-registration, and caller path must track. Projection units are pure and few; computing all registered units for an exact observation is simpler than maintaining `O(E*k)` partial state instead of `O(E*P)` complete state. + +**Cache each projection's viewed value separately.** Rejected because `view` is a pure synchronous conversion over already folded state. A second `viewReady`/`viewedState`/`viewedValue` cache adds invalidation and plugin-lifetime states without avoiding event folding. + +**Keep dedicated summary fields, RPCs, or Client reducers.** Rejected because each creates a second authority beside the event log and projection registry. It also requires separate baseline, reconnect, and race handling for every domain value. + +**Require complete projections on every list row.** Rejected because listing a large cold corpus would force full-log work before navigation can render. Partial cache hints preserve a cheap list path; consumers already have an explicit unknown state until opening supplies the exact baseline. + +**Render guessed defaults while catalog or projection input is missing.** Rejected because the guess can visibly disagree with the Session and then change after loading. Initial uncertainty renders as loading; refresh retains the last complete value until the replacement is ready. + +## Consequences + +Session consumers share one live-preferred read model and one prepared cold object. Header, events, cursor, and projections belong to the same observation, and ordinary page opening can reuse that object for later promotion. New point-read consumers use SessionQuery instead of composing persistence and registry calls themselves. + +Session-derived Client state has one extension path: record or identify the durable input, register a pure projection unit, and consume its finished value through the generic store. Domain-specific catalogs may remain separate when they are not Session-derived, but they cannot substitute a default for an unknown Session projection. + +The simpler state model accepts bounded extra computation. An exact projected cold observation evaluates every registered unit, and a small cache-missing list artifact may be read in full. Large list rows can remain partially described until opened, so every list consumer must preserve the distinction among unknown, absent capability, and explicit no value. Observation leases also make disposal part of the caller contract; retaining a prepared source without releasing it prevents normal cache retirement. 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 new file mode 100644 index 0000000000..527a4eb6b6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -0,0 +1,201 @@ +# Agent Note: Session observation 与 projection 所有的客户端状态 + +Status: implemented + +[English](2026-08-25-session-observations-and-projection-owned-client-state.md) | 中文 + +## 问题 + +面向 Session 的多个消费方需要相同的逻辑数据,却各自完成解析。list、follow、page、附件/fork 读取和 subagent 检查分别在已挂载 Session、持久化元数据、prepared Session 与 projection cache 之间作选择。因此一次页面访问可能多次物化同一份冷日志,各自拼装的 header、事件、cursor 和 projection 值也可能来自不同的读取切面。 + +客户端功能还以多种形式保存 Session 派生事实。title 有专门的 list 和更新逻辑;模型选择把 Session 专用 catalog 请求与本地状态混在一起;agent preset 展示可能在当前 Session 到达前猜测全局默认值;subagent list 则单独扫描或重建 identity。这些镜像产生了中间状态:即使持久化 Session 已经决定答案,UI 仍会短暂显示猜测出的默认值、原始 id 或不可用状态。 + +仅统一持久化读取,Client 镜像仍会成为互相竞争的真源。仅统一 Client 字段,各 Host 入口仍可能获得不同的数据切面。因此读取单元与派生状态单元需要一条配套的 ownership 规则。 + +## 决策 + +Session 精确读取使用可保留的 `SessionObservation`,向 Client 暴露的可回放 Session 派生值使用已注册 projection。Observation 负责选择数据源和提供一份不可变读取切面;projection 负责从该切面派生状态。API 层只选择要发布的内容,Client 只消费成品值,不从事件重建 Session 事实,也不在各领域镜像中重复保存这些事实。 + +### 数据动线 + +两条 ownership 规则在 observation 的 projection snapshot 处汇合。轻量 list 可以止于 cache hints;每次精确 opening 都进入同一 observation 路径,并向 Client 提供完整 replacement baseline。 + +```mermaid +flowchart LR + List["list / search"] --> Corpus["SessionQuery corpus"] + Follow["follow"] --> Observe["observeSession"] + Page["page / attachment / fork"] --> Observe + Subagent["subagent list / continuation"] --> Corpus + Subagent --> Observe + Corpus --> Cache["projection cache hints"] + Cache --> ClientList["Client Session list"] + Cache -->|"small miss"| Observe + Observe --> Source{"live or cold"} + Source --> Live["attached Session cut"] + Source --> Borrow["borrowSession"] + Borrow --> Prepared["SessionPreparations.borrow"] + Live --> Mode{"all or none"} + Prepared --> Mode + Mode --> Snapshot["SessionObservation"] + Snapshot --> Opening["follow opening snapshot"] + Snapshot --> Read["page / inspection"] + Opening --> Store["Client projection store"] + Store --> Domain["title / model / preset / subagent"] +``` + +### Observation 是 point read 单元 + +`SessionQueryEngine.observeSession(sessionId, options)` 返回可 dispose(资源释放)的 `SessionObservation`,其中包含同一份 source kind、header、连续事件前缀、cursor、可选 projection snapshot,以及 prepared source 的持久化 revision。已挂载 Session 优先;否则 `SessionPersistence.borrowSession()` 与 `SessionPreparations.borrow()` 共享并固定一份 prepared Session,包括尚未完成的冷加载。 + +每个 owner 都会 dispose 自己的 observation。`retain()` 为同一切面创建另一份 lease,使 `session.follow` 能够先发布 snapshot,再把完全相同的 prepared source 转交给后台 Agent promotion,而无需重读日志。冷解析期间出现的 live Session 会在发布前胜出;已经消失的 live source 会按 cold source 重试。 + +### 数据源解析与生命周期 + +一份 observation 把所有返回字段绑定到同一 lifecycle witness。调用方不会把 corpus list 的 header、persistence 的 events 和稍后 live Session 的 projections 拼在一起。选中的 header 与事件前缀共同产生 cursor 和 projection snapshot。 + +系统在 cold borrow 前后都检查 live 优先级。第二次检查封住 persistence 加载期间 Agent 完成 attach 的竞态。如果 persistence 报告由 live source 胜出,但 SessionQuery 检查时该 source 已经 detach,解析会重新开始,而不是发布一份无人持有的引用。 + +只有在不存在已挂载 Session 后,persistence absence 才映射为 Session-not-found。持久数据损坏、source identity 冲突、取消和 persistence 操作失败分别保留不同的 `SessionQueryError`,API owner 因而可以维持自身公开错误词汇,而不用重复数据源判定。 + +Observation 不拥有任何 mutation 权限。其事件数组是不可变前缀,prepared Session 保持未发布。Promotion 是 Session Controller 在 opening snapshot 发出后执行的显式 ownership transfer;其他读方不能把 observation 变成 live Agent。 + +Projection 工作明确只有 `all | none` 两种模式。`all` 在 observation 的事件 cursor 上计算所有已注册 projection;`none` 完全不触碰 projection 状态。系统不存在按 key preparation 的状态、`projectionKeys` 模式或额外的 `viewedState`/`viewedValue` cache。发布方可以按 audience 筛选已完成的值,但底层 observation 不会处于只算完部分 projection 的状态。 + +### Projection 执行边界 + +对于 live source,`all` 读取一份同步 registry snapshot。对于 prepared source,projection cache 可以播种有效 state row,随后每个已注册 unit 在精确的剩余事件前缀上推进。得到的 Client value 共用一个 `asOfSeq`。 + +筛选发生在计算完成之后,因为它改变的是披露内容,而不是状态。Page 鉴权可以只消费 `subagent`,list row 可以只发布 list 相关值;只要它们请求 projection 工作,仍然依赖一份完整 projected cut。 + +Registry 拥有 fold state;各领域拥有自己的 `init`、`apply`、`view`、schema 和 `stateVersion`。SessionQuery 只知道是否需要 projection 工作,不理解 title、model、preset、subagent、token、image、plan、todo 或 goal 的语义。 + +`view` 保持为 folded state 上无 cache 的同步转换。其成本由已注册 projection unit 数量界定,并在 snapshot 发布时支付;引入第二层 cache 只会增加 invalidation 状态,无法减少 event replay。 + +语料库 list 仍是独立的轻量操作。`listSessions()` 返回 live-preferred header,而不物化每份日志。Session list 与 subagent list 先读取 live projection 状态或持久 projection-cache row。当 cache 无法判断 Session 是否为空,且该 Session 拥有的独立产物未超过配置的小日志限制时,Session list 可以执行一次完整 observation;大型或不可读的 cache miss 仍以 hints 未知但 row 可见的方式返回。 + +`session.follow` 发布必需的 opening snapshot,其中包含 header、cursor、首个事件窗口和完整 projection baseline。重连使用另一份完整 snapshot 替换上一 generation。`session.page` 仅用于旧历史读取与 gap repair。只读 observation 不激活 Agent;只有普通 follow 可以保留 prepared observation,并在 opening snapshot 已交付后请求 promotion。 + +### 读取 audience + +每个公开操作选择一组查询与 projection 策略。该选择属于操作本身的行为,而不是 persistence 或 transport 内部的启发式判断。 + +| 操作 | 读取路径 | Projection 策略 | Agent 激活 | +|---|---|---|---| +| `session.list` | Corpus header、live state 和 cached row;有界小日志 fallback | 部分 hints,或一次完整小日志 observation | 从不 | +| `session.search` | Corpus 鉴权加已配置 search provider | 结果列表不计算 | 从不 | +| `session.follow` | 一份精确 observation | 全算,并由 opening snapshot 携带 | 仅普通 cold Session,且在 snapshot 交付后 | +| `session.page` | 一份精确 observation | 不计算,但 projection-backed subagent 鉴权除外 | 从不 | +| Attachment 与 fork source | 一份精确 observation | 鉴权不要求时不计算 | source 从不激活 | +| Subagent list 与 continuation | Corpus 加 live/cache/observation 解析 | cold fallback 全算;audience 只消费 identity 或继承值 | Listing 从不;continuation 遵循显式命令语义 | + +### 可回放的 Client 事实归 projection 所有 + +当一个 Client 可见值由 Session header 或事件日志决定,并且必须在刷新、冷访问或重连后恢复时,它属于 `SessionProjectionMap`。这条规则覆盖 title、list metadata、model selection、agent preset selection、subagent identity 和 subagent timing。各领域包拥有纯 projection definition;Session transport 与 Client value store 不理解具体领域。 + +Projection 的三种交付状态含义不同: + +- Session-list hint 是可选、部分且可能陈旧的数据。key 缺失表示未知,因此 list 消费方不得自行补成空值或部署默认值。 +- Follow opening baseline 是其 cursor 上所有已注册 Client 可见 projection capability 的完整集合。此处缺少 key 表示当前 Host composition 不具备该 capability。 +- 显式 `null` 是领域计算出的无值结果。它不同于 list hint 缺失,并且能够完整通过 JSON transport。 + +这些区别避免由一个重载的 `undefined` 同时表示 cache miss、plugin 未加载和真实领域答案。API 类型把 list 数据命名为 hints,把 opening 数据命名为 baseline,因此消费方不能只因两者都携带 projection value 就假定其完整性相同。 + +### Client 合并规则 + +| 输入 | 完整性 | 新鲜度 | key 缺失的含义 | +|---|---|---|---| +| Session list hints | 部分 | 上次持久 checkpoint 或有界 fallback cut | 未知 | +| 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。 + +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 派生的数据不进入 projection。`llm.models` 拥有当前 Host generation 的 model catalog,`agentPreset.list` 拥有可配置 preset roster。Selector 只在相应 catalog 与 Session 的 `modelSelection` 或 `agentPreset` projection 均就绪后组合两者。刷新时可以保留上一份完整 catalog;第一次获得完整输入前显示 loading,而不是展示猜测的名称或可用性结论。 + +Client 本地交互状态也继续留在本地:loading 和 error 状态、打开的菜单、进行中的选择,以及为尚未创建 Session 暂存的选择都不是可回放 Session 事实。选择一旦应用到 Session,其持久事件与 projection 就成为权威。 + +### 领域应用 + +- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。 +- **Model selection。** `model/selection` 记录完整 provider、model 和可选 reasoning effort。`modelSelection` 区分上一请求使用的 route,以及等待 request header 消费的较晚 selection。 +- **Agent preset。** Projection 从不可变 Session metadata 初始化,并随 preset-selection event 推进。对于现有 Session,缺失或 `null` 值不会替换成部署默认值。 +- **Subagent identity。** `subagent` unit 仍是唯一 descriptor interpreter。Listing 从共享 corpus 获得 candidate,并通过 live state、projection cache 或 observation 解析值,不自行扫描 event。 +- **Subagent presentation。** Opening projection value 在 Client 宣布 child 可交互或离线前建立 timing 与 identity,因此 transport loading 不会伪装成 durable state。 + +这些迁移删除特殊 Client state,但不会让 projection 接管 provider catalog 或交互机制。领域仍拥有 mutation 和 command;projection 只拥有其可回放 Session 结果。 + +### 失败与 readiness 边界 + +- List cache miss 不是错误,也不会隐藏 row。未知 hints 保持缺失,直到有界 fallback 或精确 opening 提供值。 +- 精确 cold observation 中的 projection failure 使整份 observation 按损坏的 Session data 失败;调用方不会发布成功 key 与失败 key 的混合结果。 +- 一个 subagent candidate 的 cold observation 失败只影响该 candidate 的 diagnostic row;sibling candidate 继续可用。 +- Catalog load failure 是 Client 可见的 catalog state。它不会在 refresh 期间清除上一份完整 catalog,也不会合成 Session selection。 +- Follow carrier generation 只有在 opening snapshot 完成校验与应用后才被接受。Reconnect 期间继续显示上一 generation。 + +取消会在文档规定的检查点终止排队中或进行中的 cold resolution,并释放每一份已获得 lease。取消不会变成 not-found,也不能让 prepared entry 保持 pinned。 + +### Ownership 矩阵 + +| 事项 | Owner | 非 owner | +|---|---|---| +| Cold materialization 与 revision 检查 | Session persistence | API Controller 与 Client | +| 精确 live-preferred read cut | SessionQuery observation | 各 endpoint helper | +| Fold state 与 Client-value 计算 | Projection registry 与 domain unit | SessionQuery 与 Client | +| 部分 list acceleration | Projection cache 与 list policy | Follow protocol | +| Opening 与 reconnect replacement | Session follow 与 journal stream | Session page | +| Per-key value ordering | Client projection store | Domain UI component | +| Provider 或 preset catalog lifecycle | 对应 catalog directory | Session projection | +| Rendering 与瞬时 interaction state | Domain UI package | Host projection unit | + +### 扩展规则 + +1. 判断新值是否属于单个 Session 的可回放事实;如果属于,先定义或复用其持久 header/event 输入,再添加 Client 字段。 +2. 在 owning domain 注册一个 pure projection unit。Fold state 与 Client view 表示不同时,分别定义其类型。 +3. 让精确读方请求 `projectionMode: 'all'`;仅在构造 audience-specific response 时筛选。 +4. 让 list 消费方接受 optional hint。不能只为消除显式 unknown state 而强制 hydrate 整个 corpus。 +5. 把值送入通用 Client projection store。不能为同一事实再增加 dedicated reconnect fetch、event reducer 或 Session summary mirror。 +6. 非 Session catalog 与 ephemeral UI state 保留各自 owner,并在和 projection value 组合前定义 readiness。 + +这些规则适用于新的 Session-derived Client state,即使在第一个调用点直接扫描 event 看似廉价。复杂度需要覆盖 cold read、reconnect、多 tab、plugin lifetime 和未来消费方,而不是只看首次实现。 + +### 与既有决策的关系 + +- [可复用 Session preparation](2026-08-05-session-preparation.zh.md)拥有冷物化、修复、reservation 和发布。Observation 在该 prepared object 之上增加共享读取 lease,并未把 preparation 移入 SessionQuery。 +- [Session 历史与 Remote event transport](2026-08-18-session-history-and-event-transport.zh.md)拥有 stream generation 与 replacement 语义。本决策提供每个日志 generation 的精确 opening snapshot。 +- [Projection state 与 Client view](2026-08-19-session-projection-state-and-client-views.zh.md)拥有 Host fold state 和 Client value 的区分。本决策规定这些值在哪里消费,以及部分 list hints 与完整 baseline 的差别。 +- [Subagent identity projection](2026-08-06-subagent-list-identity-projection.zh.md)继续拥有 descriptor folding、可序列化 `null` sentinel 和 own-suffix sequence 检查。本决策只取代其中独立 corpus merge 和直接 cold inspection 路径:listing 改为使用 SessionQuery corpus 和 observation。 +- 更广泛的 [session projection 与 command-log 提案](../../proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)仍为 proposed,其中尚未由已交付代码体现的部分不受影响。本决策记录已经交付的 observation 与 Client ownership 子集。 + +## 验证 + +Persistence 与 SessionQuery 测试固定共享冷加载、取消、live-source race、retained observation、dispose 和 all-or-none projection 计算。Session Controller 与 Gateway 测试固定 snapshot-first opening、replacement reconnect、旧分页读取、gap repair、list-cache hints、小日志有界 fallback,以及 snapshot 交付后的 promotion。 + +Client 测试固定 higher-sequence-wins projection store、title 更新、model catalog 与 selection readiness、preset roster refresh 与 Session 专属选择,以及不会短暂展示离线状态的 subagent loading。Subagent 测试固定 corpus 枚举、cache 与 observation fallback、lifecycle witness、有界冷读,以及 listing 期间不激活 Agent。 + +## 考虑过的替代方案 + +**由各消费方继续解析数据源。** 否决,因为每个调用方都需要重复实现 live race、persistence error mapping、preparation lifetime、cancellation 和 projection cut,既会重复工作,也会产生不一致结果。 + +**每次精确读取都激活 Agent。** 否决,因为 list、history、attachment、search 与 subagent inspection 都是读取操作。Activation 会加载插件并改变进程状态,也没有适合分页或 catalog 读取的自然退出点。 + +**只 prepare 被请求的 projection key。** 否决,因为只完成部分 projection 的 Session 会增加一种生命周期状态,所有 cache、restore、plugin registration 和调用路径都必须追踪它。Projection unit 数量少且为纯函数;精确 observation 计算全部已注册 unit,比为了 `O(E*k)` 而维护部分状态、取代 `O(E*P)` 完整状态更简单。 + +**单独缓存每个 projection 的 viewed value。** 否决,因为 `view` 只是已折叠 state 上的纯同步转换。第二层 `viewReady`/`viewedState`/`viewedValue` cache 会增加 invalidation 和 plugin lifetime 状态,却不能减少 event folding。 + +**保留专用 summary 字段、RPC 或 Client reducer。** 否决,因为每一项都会在 event log 与 projection registry 之外建立第二个真源,还要求每个领域分别实现 baseline、reconnect 和 race handling。 + +**要求每个 list row 都携带完整 projection。** 否决,因为列出大型 cold corpus 时必须先读取完整日志,navigation 才能渲染。部分 cache hints 保留了轻量 list 路径;在 opening 给出精确 baseline 前,消费方已经拥有明确的 unknown 状态。 + +**在 catalog 或 projection 输入缺失时渲染猜测默认值。** 否决,因为猜测可能明显违背 Session,并在加载后发生跳变。初次不确定时显示 loading;刷新时保留上一份完整值,直到替代值就绪。 + +## 后果 + +Session 消费方共享一份 live-preferred read model 和一个 prepared cold object。Header、events、cursor 与 projections 属于同一 observation,普通页面打开还可以为后续 promotion 复用该对象。新的 point-read 消费方使用 SessionQuery,而不再自行拼接 persistence 与 registry 调用。 + +Session 派生 Client 状态只有一条扩展路径:记录或识别持久输入、注册纯 projection unit,再通过通用 store 消费其成品值。不由 Session 派生的领域 catalog 可以独立存在,但不能用默认值替代未知的 Session projection。 + +更简单的状态模型接受有界的额外计算。精确的 projected cold observation 会计算所有已注册 unit,小型且 cache miss 的 list artifact 可能完整读取。大型 list row 在打开前可以保持部分描述,因此每个 list 消费方必须保留 unknown、capability absent 和 explicit no value 的区别。Observation lease 还使 dispose 成为调用方约定的一部分;保留 prepared source 而不释放会阻止正常 cache retirement。 From 08ed5a54a836f1c3ab8de7d45963cc7a791d1038 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:32:55 +0800 Subject: [PATCH 12/17] refactor(webserver): minimize HTTP gzip integration --- ...tree-boot-and-transport-layering.i18n.yaml | 4 +- ...config-tree-boot-and-transport-layering.md | 4 +- ...fig-tree-boot-and-transport-layering.zh.md | 4 +- THIRD_PARTY_NOTICES.md | 6 +- apps/web/tests/scaffold.ts | 7 +- apps/web/tests/shipped-composition.e2e.ts | 10 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 8 +- docs/config-catalog.zh.md | 8 +- docs/subsystems/web-server.i18n.yaml | 4 +- docs/subsystems/web-server.md | 14 +- docs/subsystems/web-server.zh.md | 14 +- packages/bundle/web-app/cordis.patch.yml | 3 + packages/host/webserver/README.i18n.yaml | 4 +- packages/host/webserver/README.md | 4 +- packages/host/webserver/README.zh.md | 4 +- packages/host/webserver/package.json | 8 +- packages/host/webserver/src/index.ts | 95 +++++++++--- .../host/webserver/tests/webserver.spec.ts | 109 +++++++++++++- pnpm-lock.yaml | 142 ++++++++++++++++++ 20 files changed, 408 insertions(+), 48 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index 84409bdd5d..983d460bb0 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: eb30ba84ef293a169931ef6519a9d6d2ea98af7f -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: a3e310a4a5ab8bc6a40ad8d0336cb94e29c1744f +2026-07-24-web-config-tree-boot-and-transport-layering.md: 3d1ccc2a0f71411d496466288934d9038425e7cf +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: c2409e128dfdbd8550bb7052a7e0f67a40fe1d1f diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index eb30ba84ef..3d1ccc2a0f 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -18,7 +18,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) **Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Web gateway consume the same state. -**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `renderIndex` rendering — structured `webserver/index-inject` rows, then raw `tapIndex` transforms in registration order — and `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the boot injection rows, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. +**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `renderIndex` rendering — structured `webserver/index-inject` rows, then raw `tapIndex` transforms in registration order — and `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. Its socket-backed Node HTTP entry may apply configured gzip through maintained middleware without adding a response-writing service method or changing route owners; the Web Worker tunnel carries identity bytes. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the boot injection rows, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. **Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`. @@ -40,3 +40,5 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) | env vars in the mapping table | The same field would gain env/json double sourcing and need an invented precedence | | Unbarriered create-after-prefetch (`arrive()` dedup as safety) | Disproved by a 10–25% boot race: in-flight dedup covers same-package double-fetch, not cross-package synchronous require edges | | json file used directly as loader patches | json keys would couple to yml row structure; profile writers would need cordis knowledge | +| Public response writer plus per-route opt-in | Response coding is Node HTTP policy; exposing it through `ctx.webServer` would make every route owner and test double depend on that policy | +| Hand-written gzip negotiation and stream lifecycle | Maintained middleware already owns negotiation, media-type filtering, header rewriting, backpressure, and threshold behavior | diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index a3e310a4a5..c2409e128d 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -18,7 +18,7 @@ Status: implemented **每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.zh.md)与 Web 网关消费同一份状态。 -**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`renderIndex` 渲染——先结构化 `webserver/index-inject` 行、后原始 `tapIndex` 按注册序应用——与 `port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、启动注入行与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 +**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`renderIndex` 渲染——先结构化 `webserver/index-inject` 行、后原始 `tapIndex` 按注册序应用——与 `port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。其基于 socket 的 Node HTTP 入口可以通过受维护的中间件应用已配置的 gzip,无需新增响应写出服务方法或改变 route 所有者;Web Worker 隧道传递 identity 字节。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、启动注入行与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 **包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`。 @@ -40,3 +40,5 @@ Status: implemented | env 进映射表 | 同一字段将出现 env/json 双源,需再发明优先级 | | create 不等预取(以 `arrive()` 去重为安全依据) | 被 10–25% boot 竞态证伪:在途去重只覆盖同包双拉,不覆盖跨包同步 require 边 | | json 直接当 loader patches 文件 | json 键名将耦合 yml 行结构,profile 编写者要懂 cordis | +| 公开响应写出方法并让每条 route 选择接入 | 响应编码属于 Node HTTP 策略;经 `ctx.webServer` 暴露会让每个 route 所有者与测试替身依赖这项策略 | +| 手写 gzip 协商与流生命周期 | 受维护的中间件已经处理协商、媒体类型筛选、响应头改写、背压与阈值行为 | diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2b2a3019c8..5cea0e012b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -61,6 +61,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | | [`clsx`](https://github.com/lukeed/clsx) | MIT | | [`commander`](https://github.com/tj/commander.js) | MIT | +| [`compression`](https://github.com/expressjs/compression) | MIT | | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | | [`e2b`](https://github.com/e2b-dev/e2b) | MIT | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | @@ -81,6 +82,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT | | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | | [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | +| [`negotiator`](https://github.com/jshttp/negotiator) | MIT | | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | | [`open`](https://github.com/sindresorhus/open) | MIT | @@ -136,8 +138,10 @@ External packages **directly declared** only by repository tooling, test infrast | [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT | | [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | | [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | @@ -153,7 +157,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT | | [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT | | [`dayjs`](https://github.com/iamkun/dayjs) | MIT | -| [`debug`](https://github.com/debug-js/debug) | MIT | +| [`debug`](https://github.com/visionmedia/debug) | MIT | | [`esbuild`](https://github.com/evanw/esbuild) | MIT | | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | | [`execa`](https://github.com/sindresorhus/execa) | MIT | diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index e1ea1f1789..a4e1adc07b 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -492,9 +492,14 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { scaffold = undefined }) -it('assembles the shipped Web catalog, file-reference guidance, retry policy, and confined access default', async () => { +it('assembles the shipped Web transport, catalog, guidance, and defaults', async () => { scaffold = await launchWebScaffold({ deepSeekMissingCredential: true }) const ctx = scaffold.ctx + const index = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}`, { + headers: { 'accept-encoding': 'gzip' }, + }) + expect(index.headers.get('content-encoding')).toBe('gzip') + expect(index.headers.get('vary')).toContain('Accept-Encoding') + await index.body?.cancel() expect(ctx.llm.providerRetryPolicy('deepseek-official')).toMatchInlineSnapshot(` { "initialDelayMs": 500, diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 802244d347..cec89ac365 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: 5991c911d33b34db51c12313036322acf94dcaf9 -config-catalog.zh.md: 80b8163fc46bccc9509f51730bfcb7e256834f15 +config-catalog.md: 19f292ee40861ffa6f8e7fdef4d8717189a2149c +config-catalog.zh.md: 96f9721cae20a1a0ed172349c0189bc47576c75d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5991c911d3..19f292ee40 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -833,12 +833,18 @@ Source: [`packages/host/frontend-static/src/index.ts:28`](../packages/host/front ## `@deepseek-ai/dsh-host-webserver` ```ts config-catalog -/** Gateway config: the listen address. */ +/** Web server listen and response-compression config. */ export interface Config { /** Listen host; the two supported values are loopback and all-interfaces. */ host: '127.0.0.1' | '0.0.0.0' /** Listen port; zero requests an OS-assigned port. */ port: number + /** Response compression for socket-backed HTTP requests. @default 'none' */ + compression?: 'none' | 'gzip' + /** Gzip DEFLATE level from 0 through 9. @default 1 */ + compressionLevel?: number + /** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */ + compressionThresholdBytes?: number } ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 80b8163fc4..96f9721cae 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -835,12 +835,18 @@ export interface Config { ## `@deepseek-ai/dsh-host-webserver` ```ts config-catalog -/** Gateway config: the listen address. */ +/** Web server listen and response-compression config. */ export interface Config { /** Listen host; the two supported values are loopback and all-interfaces. */ host: '127.0.0.1' | '0.0.0.0' /** Listen port; zero requests an OS-assigned port. */ port: number + /** Response compression for socket-backed HTTP requests. @default 'none' */ + compression?: 'none' | 'gzip' + /** Gzip DEFLATE level from 0 through 9. @default 1 */ + compressionLevel?: number + /** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */ + compressionThresholdBytes?: number } ``` diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index 9434d236d4..76eb0f109e 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-server.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/web-server.md -web-server.md: c23a48cd57aeb2127107c0487cb46c9202d11605 -web-server.zh.md: 4097e26ce826067a92de7ba3ec65f790dd6ad1dd +web-server.md: 9e1e88d6c796e457fa6c185c1927b46cca9fcf52 +web-server.zh.md: 4401ccf628360a9571e77ea14ae6c29bd22af151 diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index c23a48cd57..9e1e88d6c7 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -2,7 +2,7 @@ English | [中文](web-server.zh.md) -[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.webServer`, a named-route registry, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. +[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -29,20 +29,26 @@ Match order is fixed: exact table first, then longest matching prefix, then the ## Config ```ts type-equiv -/** Gateway config: the listen address. */ +/** Web server listen and response-compression config. */ interface Config { /** Listen host; the two supported values are loopback and all-interfaces. */ host: '127.0.0.1' | '0.0.0.0' /** Listen port; zero requests an OS-assigned port. */ port: number + /** Response compression for socket-backed HTTP requests. @default 'none' */ + compression?: 'none' | 'gzip' + /** Gzip DEFLATE level from 0 through 9. @default 1 */ + compressionLevel?: number + /** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */ + compressionThresholdBytes?: number } ``` -`host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); there is no TLS, auth, or origin policy, so a non-loopback bind exposes the server to that network. The dist location is an assembly fact of the frontend plugin that claims the seat. +`host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); there is no TLS, auth, or origin policy, so a non-loopback bind exposes the server to that network. `compression` defaults to `none`; the shipped Web bundle selects gzip level 1 with a 1024-byte threshold. The dist location is an assembly fact of the frontend plugin that claims the seat. ## The service -`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0. +`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md). diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index 4097e26ce8..4401ccf628 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -2,7 +2,7 @@ [English](web-server.md) | 中文 -[dsh-host-webserver](../../packages/host/webserver) 是 GUI 宿主的浏览器 HTTP 载体:它是一个提供 `ctx.webServer` 的 `node:http` 插件,包含具名路由注册表、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 +[dsh-host-webserver](../../packages/host/webserver) 是 GUI 宿主的浏览器 HTTP 载体:它是一个提供 `ctx.webServer` 的 `node:http` 插件,包含具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -29,20 +29,26 @@ interface WebRoute { ## 配置 ```ts type-equiv -/** Gateway config: the listen address. */ +/** Web server listen and response-compression config. */ interface Config { /** Listen host; the two supported values are loopback and all-interfaces. */ host: '127.0.0.1' | '0.0.0.0' /** Listen port; zero requests an OS-assigned port. */ port: number + /** Response compression for socket-backed HTTP requests. @default 'none' */ + compression?: 'none' | 'gzip' + /** Gzip DEFLATE level from 0 through 9. @default 1 */ + compressionLevel?: number + /** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */ + compressionThresholdBytes?: number } ``` -`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(刻意的网络暴露);没有 TLS、认证或 origin 策略,因此绑定到非回环地址会把服务器暴露给该网络。dist 位置是认领席位的前端插件的组装事实。 +`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(刻意的网络暴露);没有 TLS、认证或 origin 策略,因此绑定到非回环地址会把服务器暴露给该网络。`compression` 默认为 `none`;随附的 Web 组合选择 gzip level 1 和 1024 字节阈值。dist 位置是认领席位的前端插件的组装事实。 ## 服务 -`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。 +`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。 处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.zh.md) 中。 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 18a0d3911a..f20a79290d 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -133,6 +133,9 @@ config: host: !!js ctx.webStartup.host ?? '127.0.0.1' port: !!js ctx.webStartup.port ?? 3080 + compression: gzip + compressionLevel: 1 + compressionThresholdBytes: 1024 # Web glue owned by this bundle: resolves the built frontend dist (an # assembly fact of dsh-web-app, never user config), mounts the diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 8a6b93bb12..9b127a934f 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/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/host/webserver/README.md -README.md: c6abc503222fc8bf60d4b6c940eeb1f7910cc9aa -README.zh.md: 430488869c98a86ff669e12acfaee86bae7aa8a3 +README.md: 61b18d377cae432895a58ed8c6a80cfdee886d24 +README.zh.md: 7daee31433b8cba1f2d03b21ed5707328c7c7d61 diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index c6abc50322..61b18d377c 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -2,7 +2,9 @@ English | [中文](README.zh.md) -Web HTTP and upgrade-route registration plugin (default-exported `WebServer`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.webServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-host-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. Index startup inputs are structured rows: `collectIndexInjections()` gathers a fresh `IndexInjection` table over one `webserver/index-inject` emit per call, and `renderIndex(html)` renders the rows into an index.html body before applying the raw `tapIndex(transform)` transforms in registration order (`applyIndexTaps(html)`, the escape hatch for markup no row expresses); `script-preload` rows render advisory classic-script preload links. The fallback handler calls `renderIndex` on every index response, and a static deployment ships the same rows over its boot payload. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics. +Web HTTP and upgrade-route registration plugin (default-exported `WebServer`, config `{host, port, compression?, compressionLevel?, compressionThresholdBytes?}`): a `node:http` server that listens on activation and provides `ctx.webServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-host-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. Index startup inputs are structured rows: `collectIndexInjections()` gathers a fresh `IndexInjection` table over one `webserver/index-inject` emit per call, and `renderIndex(html)` renders the rows into an index.html body before applying the raw `tapIndex(transform)` transforms in registration order (`applyIndexTaps(html)`, the escape hatch for markup no row expresses); `script-preload` rows render advisory classic-script preload links. The fallback handler calls `renderIndex` on every index response, and a static deployment ships the same rows over its boot payload. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics. + +`compression: 'gzip'` wraps socket-backed HTTP responses without changing route APIs. The client must prefer gzip and the media type must be compressible; known response lengths below `compressionThresholdBytes` stay identity, while unknown-length streams are eligible immediately. `compressionLevel` controls DEFLATE effort. Existing content encodings, `Cache-Control: no-transform`, range responses, SSE, ZIP, and the packaged `.gz` Worker image stay unmodified. The shipped Web bundle enables level 1 with a 1024-byte threshold; other compositions default to `compression: 'none'`. The Web Worker tunnel does not carry the browser-managed `Accept-Encoding` header, so its synthetic responses remain identity bytes. The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). This server serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge. This package never prints; the URL line belongs to the shell. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 430488869c..7daee31433 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -Web HTTP 与 upgrade route 注册插件(默认导出 `WebServer`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler,处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-host-frontend-static`](../frontend-static/README.zh.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。index 的启动输入是结构化行:`collectIndexInjections()` 每次调用经一次 `webserver/index-inject` emit 现收一张全新的 `IndexInjection` 表,`renderIndex(html)` 先把行渲染进 index.html 响应体,再按注册顺序应用原始的 `tapIndex(transform)` 转换(`applyIndexTaps(html)`,行无法表达的标记的逃生口);`script-preload` 行渲染为 classic script 的提示性预加载链接。fallback handler 在每次 index 响应时调用 `renderIndex`,静态部署则把同一批行经 boot 载荷下发。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。 +Web HTTP 与 upgrade route 注册插件(默认导出 `WebServer`,配置为 `{host, port, compression?, compressionLevel?, compressionThresholdBytes?}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler,处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-host-frontend-static`](../frontend-static/README.zh.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。index 的启动输入是结构化行:`collectIndexInjections()` 每次调用经一次 `webserver/index-inject` emit 现收一张全新的 `IndexInjection` 表,`renderIndex(html)` 先把行渲染进 index.html 响应体,再按注册顺序应用原始的 `tapIndex(transform)` 转换(`applyIndexTaps(html)`,行无法表达的标记的逃生口);`script-preload` 行渲染为 classic script 的提示性预加载链接。fallback handler 在每次 index 响应时调用 `renderIndex`,静态部署则把同一批行经 boot 载荷下发。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。 + +`compression: 'gzip'` 会包装基于 socket 的 HTTP 响应,而不改变 route API。客户端必须偏好 gzip,且媒体类型必须可压缩;已知长度低于 `compressionThresholdBytes` 的响应保持 identity,未知长度的 stream 则直接具备压缩资格。`compressionLevel` 控制 DEFLATE 强度。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持原样。随附的 Web 组合启用 level 1 和 1024 字节阈值;其他组合默认使用 `compression: 'none'`。Web Worker 隧道不携带浏览器管理的 `Accept-Encoding` 请求头,因此其合成响应仍为 identity 字节。 该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认安全姿态)和 `0.0.0.0`(有意向网络开放)。该服务器只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。该包从不打印内容;URL 行属于 shell。 diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index a8739fddc0..e3fc2bb6d9 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -36,10 +36,14 @@ "@deepseek-ai/dsh-invariants": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "compression": "^1.8.1", + "negotiator": "^1.0.0" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/compression": "^1.8.1", + "@types/negotiator": "^0.6.5" } } diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 2257e79623..bc41100de9 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -1,11 +1,9 @@ /** - * @deepseek-ai/dsh-host-webserver — Web route-registration plugin: a node:http - * server plus the `webServer` service (HTTP and upgrade route registries, the - * structured index injection table with raw transform taps behind it, and the - * single fallback seat for everything no route claims). Knows no harness concepts and serves no files; the composing - * application's frontend plugin owns dist serving through the fallback hook. - * Web shape only — Electron loads dist over file:// and carries fetch over an - * IPC bridge. This package never prints: the URL line belongs to the shell. + * @deepseek-ai/dsh-host-webserver — node:http route registration with optional + * gzip, index injection, and one fallback seat. It knows no harness concepts + * and serves no files; the composing application owns dist serving. Electron + * uses file:// plus IPC instead, and this package never prints the URL. + * Route handlers retain direct response ownership. */ import { createServer } from 'node:http' @@ -14,6 +12,8 @@ import type { AddressInfo } from 'node:net' import type { Duplex } from 'node:stream' import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import compressionMiddleware from 'compression' +import Negotiator from 'negotiator' import { renderIndexInjections, type IndexInjection } from './injections.ts' export { renderIndexInjections } from './injections.ts' @@ -55,12 +55,63 @@ export interface WebUpgradeRoute { handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise } -/** Gateway config: the listen address. */ +/** Web server listen and response-compression config. */ export interface Config { /** Listen host; the two supported values are loopback and all-interfaces. */ host: '127.0.0.1' | '0.0.0.0' /** Listen port; zero requests an OS-assigned port. */ port: number + /** Response compression for socket-backed HTTP requests. @default 'none' */ + compression?: 'none' | 'gzip' + /** Gzip DEFLATE level from 0 through 9. @default 1 */ + compressionLevel?: number + /** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */ + compressionThresholdBytes?: number +} + +const DEFAULT_COMPRESSION = 'none' as const +const DEFAULT_COMPRESSION_LEVEL = 1 +const DEFAULT_COMPRESSION_THRESHOLD_BYTES = 1024 + +interface ResolvedConfig extends Config { + compression: 'none' | 'gzip' + compressionLevel: number + compressionThresholdBytes: number +} + +type NodeMiddleware = ( + req: IncomingMessage, + res: ServerResponse, + next: () => void, +) => void + +function createGzipMiddleware(config: ResolvedConfig): NodeMiddleware { + // `compression` is typed for Express, but its runtime uses only the + // node:http request and response members supplied here. + const middleware = compressionMiddleware({ + level: config.compressionLevel, + threshold: config.compressionThresholdBytes, + filter(request, response) { + if (response.getHeader('content-range') !== undefined) return false + const contentType = response.getHeader('content-type') + if (typeof contentType === 'string' && contentType.toLowerCase().startsWith('text/event-stream')) return false + return compressionMiddleware.filter(request, response) + }, + }) as unknown as NodeMiddleware + + return (req, res, next) => { + // The Web Worker tunnel has no socket and transfers identity bytes. + if ((res as { socket?: unknown }).socket === undefined) { + next() + return + } + const encoding = new Negotiator(req).encoding(['gzip', 'identity']) + const gzipRequest = Object.create(req) as IncomingMessage + Object.defineProperty(gzipRequest, 'headers', { + value: { ...req.headers, 'accept-encoding': encoding === 'gzip' ? 'gzip' : 'identity' }, + }) + middleware(gzipRequest, res, next) + } } /** @@ -74,6 +125,9 @@ export class WebServer extends Service { static Config: z = z.object({ host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(), port: z.natural().max(65535).required(), + compression: z.union([z.const('none'), z.const('gzip')]).default(DEFAULT_COMPRESSION), + compressionLevel: z.number().step(1).min(0).max(9).default(DEFAULT_COMPRESSION_LEVEL), + compressionThresholdBytes: z.natural().default(DEFAULT_COMPRESSION_THRESHOLD_BYTES), }) private readonly exact = new Map() @@ -84,9 +138,12 @@ export class WebServer extends Service { private fallback: WebRoute['handler'] | undefined private server!: Server private listenedPort!: number + private readonly gzip: NodeMiddleware | undefined constructor(ctx: Context, private config: Config) { super(ctx, 'webServer') + const resolved = config as ResolvedConfig + this.gzip = resolved.compression === 'gzip' ? createGzipMiddleware(resolved) : undefined } /** The listening port (the OS-assigned value when config.port is 0). */ @@ -183,15 +240,19 @@ export class WebServer extends Service { // client dropping mid-body). Per-request failures log and answer 400 — // never a process exit. this.server = createServer((req, res) => { - handle(req, res).catch((err: unknown) => { - this.ctx.logger.warn(err instanceof Error ? err : new Error(String(err))) - if (res.headersSent) { - res.destroy() - return - } - res.writeHead(400) - res.end() - }) + const next = (): void => { + void handle(req, res).catch((err: unknown) => { + this.ctx.logger.warn(err instanceof Error ? err : new Error(String(err))) + if (res.headersSent) { + res.destroy() + return + } + res.writeHead(400) + res.end() + }) + } + if (this.gzip === undefined) next() + else this.gzip(req, res, next) }) this.server.on('upgrade', (req, socket, head) => { const onError = (error: Error): void => { diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 198716d778..b7ac506b17 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -28,7 +28,7 @@ afterEach(async () => { }) /** Write a cordis.yml with one webserver row, then boot it through the real Loader. */ -async function loadComposition(port = 0): Promise { +async function loadComposition(port = 0, gzip = false): Promise { root = await mkdtemp(join(tmpdir(), 'dsh-webserver-loader-')) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ @@ -36,6 +36,13 @@ async function loadComposition(port = 0): Promise { ' config:', " host: '127.0.0.1'", ` port: ${String(port)}`, + ...(gzip + ? [ + ' compression: gzip', + ' compressionLevel: 1', + ' compressionThresholdBytes: 16', + ] + : []), '', ].join('\n')) @@ -62,9 +69,13 @@ async function loadComposition(port = 0): Promise { } /** GET (by default) one path against the running server; returns status plus a body prefix. */ -async function request(port: number, path: string, init?: RequestInit): Promise<{ status: number; body: string }> { +async function request( + port: number, + path: string, + init?: RequestInit, +): Promise<{ status: number; body: string; headers: Headers }> { const response = await fetch(`http://127.0.0.1:${String(port)}${path}`, init) - return { status: response.status, body: (await response.text()).slice(0, 80) } + return { status: response.status, body: (await response.text()).slice(0, 80), headers: response.headers } } /** Open one raw upgrade request and return after the handler writes its response. */ @@ -86,6 +97,98 @@ async function upgrade(port: number, path: string): Promise { + it('applies gzip only to eligible socket-backed HTTP responses', { timeout: 60_000 }, async () => { + expect(HttpServer.Config({ host: '127.0.0.1', port: 0 })).toEqual({ + host: '127.0.0.1', + port: 0, + compression: 'none', + compressionLevel: 1, + compressionThresholdBytes: 1024, + }) + expect(() => HttpServer.Config({ + host: '127.0.0.1', port: 0, compressionLevel: 10, + })).toThrow() + + const loaded = await loadComposition(0, true) + const server = loaded.webServer + const body = 'compressible response '.repeat(8) + server.register({ + kind: 'exact', + path: '/text', + handler: (_req, res) => { + res.writeHead(200, { + 'content-type': 'text/plain; charset=utf-8', + 'content-length': String(Buffer.byteLength(body)), + }) + res.end(body) + }, + }) + server.register({ + kind: 'exact', + path: '/stream', + handler: (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.write(body.slice(0, 40)) + res.end(body.slice(40)) + }, + }) + server.register({ + kind: 'exact', + path: '/small', + handler: (_req, res) => { + res.writeHead(200, { 'content-type': 'text/plain', 'content-length': '5' }) + res.end('small') + }, + }) + server.register({ + kind: 'exact', + path: '/events', + handler: (_req, res) => { + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.end(body) + }, + }) + server.register({ + kind: 'exact', + path: '/archive', + handler: (_req, res) => { + res.writeHead(200, { 'content-type': 'application/gzip' }) + res.end(body) + }, + }) + server.register({ + kind: 'exact', + path: '/range', + handler: (_req, res) => { + res.writeHead(206, { 'content-type': 'text/plain', 'content-range': 'bytes 0-15/160' }) + res.end(body.slice(0, 16)) + }, + }) + + const compressed = await request(server.port, '/text', { headers: { 'accept-encoding': 'br, gzip, deflate' } }) + expect(compressed).toMatchObject({ status: 200, body: body.slice(0, 80) }) + expect(compressed.headers.get('content-encoding')).toBe('gzip') + expect(compressed.headers.get('content-length')).toBeNull() + expect(compressed.headers.get('vary')).toBe('Accept-Encoding') + const streamed = await request(server.port, '/stream', { headers: { 'accept-encoding': 'gzip' } }) + expect(streamed).toMatchObject({ body: body.slice(0, 80) }) + expect(streamed.headers.get('content-encoding')).toBe('gzip') + expect((await request(server.port, '/small', { headers: { 'accept-encoding': 'gzip' } })) + .headers.get('content-encoding')).toBeNull() + + const identity = await request(server.port, '/text', { + headers: { 'accept-encoding': 'gzip;q=0.5, identity;q=1' }, + }) + expect(identity.headers.get('content-encoding')).toBeNull() + expect(identity.headers.get('vary')).toBe('Accept-Encoding') + expect((await request(server.port, '/events', { headers: { 'accept-encoding': 'gzip' } })) + .headers.get('content-encoding')).toBeNull() + expect((await request(server.port, '/archive', { headers: { 'accept-encoding': 'gzip' } })) + .headers.get('content-encoding')).toBeNull() + expect((await request(server.port, '/range', { headers: { 'accept-encoding': 'gzip' } })) + .headers.get('content-encoding')).toBeNull() + }) + // Real-Loader composition resolves workspace packages through tsx at test // time; first resolution after the host/client program split is slow enough // to trip the default 5s budget on cold caches. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ba800eb73..0e75d16a06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5720,6 +5720,12 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + compression: + specifier: ^1.8.1 + version: 1.8.1 + negotiator: + specifier: ^1.0.0 + version: 1.0.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -5727,6 +5733,12 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@types/compression': + specifier: ^1.8.1 + version: 1.8.1 + '@types/negotiator': + specifier: ^0.6.5 + version: 0.6.5 packages/identity/anonymous-user-id: devDependencies: @@ -12540,9 +12552,18 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/compression@1.8.1': + resolution: {integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -12648,12 +12669,21 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@5.1.3': + resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} @@ -12684,6 +12714,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/negotiator@0.6.5': + resolution: {integrity: sha512-MPOlB48mfWhoUlynY0ga7CFsXIPcH6vGPkjzXMn2p+4PH1QUyn2KPtw0hrLLmO6SaX4zse3X6h2x/083vveAlA==} + '@types/node@22.20.0': resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} @@ -12699,6 +12732,12 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -12713,6 +12752,12 @@ packages: '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/spdx-expression-parse@4.0.0': resolution: {integrity: sha512-odQzy87phelGS4inXOzjmusx4hoCVD0IbxUANxHzVkmTzMRTNnUPoq1urIl7S1qf09KcDWKLFIftPmLtgbsAHA==} @@ -13199,6 +13244,14 @@ packages: compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -13421,6 +13474,14 @@ packages: dayjs@1.11.21: resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -14589,6 +14650,9 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -14600,6 +14664,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -14698,6 +14766,10 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -17817,11 +17889,25 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.20.0 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/compression@1.8.1': + dependencies: + '@types/express': 5.0.6 + '@types/node': 22.20.0 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.20.0 + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -17949,12 +18035,27 @@ snapshots: '@types/estree@1.0.9': {} + '@types/express-serve-static-core@5.1.3': + dependencies: + '@types/node': 22.20.0 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.3 + '@types/serve-static': 2.2.0 + '@types/geojson@7946.0.16': {} '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 + '@types/http-errors@2.0.5': {} + '@types/js-yaml@4.0.9': {} '@types/jsdom@28.0.3': @@ -17985,6 +18086,8 @@ snapshots: '@types/ms@2.1.0': {} + '@types/negotiator@0.6.5': {} + '@types/node@22.20.0': dependencies: undici-types: 6.21.0 @@ -18001,6 +18104,10 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + '@types/react-dom@18.3.7(@types/react@18.3.31)': dependencies: '@types/react': 18.3.31 @@ -18016,6 +18123,15 @@ snapshots: '@types/retry@0.12.0': {} + '@types/send@1.2.1': + dependencies: + '@types/node': 22.20.0 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.20.0 + '@types/spdx-expression-parse@4.0.0': {} '@types/tough-cookie@4.0.5': {} @@ -18511,6 +18627,22 @@ snapshots: compare-versions@6.1.1: {} + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -18752,6 +18884,10 @@ snapshots: dayjs@1.11.21: {} + debug@2.6.9: + dependencies: + ms: 2.0.0 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -20206,12 +20342,16 @@ snapshots: mri@1.2.0: {} + ms@2.0.0: {} + ms@2.1.3: {} nanoid@3.3.12: {} natural-compare@1.4.0: {} + negotiator@0.6.4: {} + negotiator@1.0.0: {} node-addon-api@7.1.1: {} @@ -20297,6 +20437,8 @@ snapshots: dependencies: ee-first: 1.1.1 + on-headers@1.1.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 From 83463aa89627575b205a438c521458bddd9df771 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:43:18 +0800 Subject: [PATCH 13/17] feat(client): use bounded plugin combo URLs --- ...7-23-client-plugin-loading-model.i18n.yaml | 4 +- .../2026-07-23-client-plugin-loading-model.md | 20 +- ...26-07-23-client-plugin-loading-model.zh.md | 20 +- ...ient-shells-and-dynamic-packages.i18n.yaml | 4 +- ...8-15-client-shells-and-dynamic-packages.md | 10 +- ...5-client-shells-and-dynamic-packages.zh.md | 10 +- apps/web/tests/assembled-boot.ts | 13 +- apps/web/tests/settings-chrome.e2e.ts | 2 +- apps/web/tests/smoke-real.e2e.ts | 27 ++- docs/subsystems/client-modules.i18n.yaml | 4 +- docs/subsystems/client-modules.md | 24 +- docs/subsystems/client-modules.zh.md | 24 +- packages/client/hmr/README.i18n.yaml | 4 +- packages/client/hmr/README.md | 4 +- packages/client/hmr/README.zh.md | 4 +- packages/client/hmr/src/client/index.ts | 2 +- .../client/hmr/tests/node-half.client.spec.ts | 2 +- packages/client/modules/README.i18n.yaml | 4 +- packages/client/modules/README.md | 8 +- packages/client/modules/README.zh.md | 8 +- .../client/modules/src/client/manifest.ts | 38 ++-- packages/client/modules/src/client/system.ts | 13 +- packages/client/modules/src/index.ts | 208 +++++++++++------- .../modules/tests/loader.client.spec.ts | 64 ++++-- .../modules/tests/node-half.client.spec.ts | 110 ++++++--- 25 files changed, 388 insertions(+), 243 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index c42ed69bb7..7dbf4eec85 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.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-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: dfa9f34276f20ffa99541db1544539d693313a2f -2026-07-23-client-plugin-loading-model.zh.md: 68fe9b912c60aceb2ecea315ed0121f9f96c1ecf +2026-07-23-client-plugin-loading-model.md: 027fd07ca9a46f13807802912c6cbd9e76cd8e6e +2026-07-23-client-plugin-loading-model.zh.md: e4a6bdf3050987761475fad03b8999b0005568d4 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index dfa9f34276..027fd07ca9 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -28,7 +28,7 @@ The first-generation client loader (`createClientLoader`) hand-wrote both layers The [client shell layering note](2026-08-15-client-shells-and-dynamic-packages.md) defines the current static and dynamic package sets and the import rules between them. The loading machinery treats every `dsh.client` package as a host-graph row with one ordinary `lib/client.js` factory bundle. Its declaration carries Cordis `inject` edges, synchronous module-table `external` requests, and the optional `immediately` prefetch mark; the composing app owns only the mounted roster. -The web kernel remains framework-free and imports no dynamic package value. Modules is itself a dynamic row, but the host parser delivers its factory before the Vite main module. The HTML-installed `__ModuleLoader__` facade uses that factory to construct the module system when the kernel calls `create()`. Every other dynamic row arrives through the application batch; static React, Cordis, and UI library identities come from the shell seed. +The web kernel remains framework-free and imports no dynamic package value. Modules is itself a dynamic row, but the host parser delivers its factory before the Vite main module. The HTML-installed `__ModuleLoader__` facade uses that factory to construct the module system when the kernel calls `create()`. Every other dynamic row belongs to an application combo script; static React, Cordis, and UI library identities come from the shell seed. ### One module system, one plugin governor @@ -38,13 +38,13 @@ The browser mirrors the host's division of labor. `dsh-client-modules` (`ClientM The vendored Loader consumes the module system through its `internal` contract — the only call site is `tree.import` — and owns everything entry-shaped: entry creation, fiber activation through cordis service waiting (PENDING until injected services exist, cascading when a service is provided), update/refresh, teardown. The governance code is byte-identical to the host side, per vendor policy. Browserization is compile-time mapping in the shell's vite config: a `node:module` stub alias plus `process.*` defines make `ModuleLoader.fromInternal()` return undefined — exactly the empty slot the shell fills. The module system mounts as `ctx.modules`. -### Batched external-script arrival and source maps +### Combo external-script arrival and source maps -The Host snapshots every built plugin artifact and concatenates its factory registration into one of two same-origin classic scripts. The parser-blocking `bootstrap` batch contains the modules row; the HTML preloads the `application` batch containing every other graph row while bootstrap executes. The module system keys in-flight transport by batch URL, so concurrent row arrivals execute one application script. Successful settlement still requires each requested row's factory id to exist in the module table, and registration does not run the factory, so the side-effect boundary remains first materialization. +The Host snapshots every built plugin artifact and partitions each scheduling phase's ordered rows into one or more same-origin classic scripts. It greedily fills each group while the longer map-form request URL remains within 3 KiB, preserving graph order and allowing another request instead of emitting an oversized URL. Each script is addressed by its package resources, for example `/plugins/??/client.js,/client.js&rev=`. The `bootstrap` and `application` values are scheduling phases in the graph, not URL components: HTML preloads every application URL before executing every parser-blocking bootstrap URL. The module system keys in-flight transport by combo URL, so concurrent row arrivals within one group execute one script. Successful settlement still requires each requested row's factory id to exist in the module table, and registration does not run the factory, so the side-effect boundary remains first materialization. -The shared tsdown preset emits `client.js.map` for every plugin and rewrites first-party source paths into the browser-resolvable repository shape `/packages///src/...`. The production Client pass consumes `lib/types`; the preset supplies each tsc map to Rolldown and fills `sourcesContent` from the original files, so the final map reaches TypeScript/TSX instead of stopping at emitted JavaScript. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged. Batch generation strips each local `sourceMappingURL`, records its generated-line offset, resolves every source against the original per-plugin map URL, and emits one indexed Source Map v3 file whose sections embed the available plugin maps. The Vite shell also emits source maps, letting shell code and batched or individually reloaded plugins map stacks and performance profiles back to TypeScript/TSX. +The shared tsdown preset emits `client.js.map` for every plugin and rewrites first-party source paths into the browser-resolvable repository form `/packages///src/...`. The production Client pass consumes `lib/types`; the preset supplies each tsc map to Rolldown and fills `sourcesContent` from the original files, so the final map reaches TypeScript/TSX instead of stopping at emitted JavaScript. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged. Combo generation strips each local `sourceMappingURL`, records its generated-line offset, resolves every source against the original per-plugin map URL, and emits an Indexed Source Map v3 whose sections embed the available plugin maps. A missing component map leaves that script range unmapped without suppressing the combined map. The absolute map URL mirrors the script resource list by changing every `client.js` suffix to `client.js.map`, so `/plugins/??/client.js,/client.js&rev=` points to `/plugins/??/client.js.map,/client.js.map&rev=`. One resource follows the same rule and still produces an indexed map with one section. The Vite shell also emits source maps, letting shell code and combo-loaded plugins map stacks and performance profiles back to TypeScript/TSX. -The graph retains each row's revisioned individual URL for HMR and adds content-addressed descriptors for the two startup batches. Initial row revisions are opaque process nonces rather than content hashes; they keep an exceptional initial individual request immutable without hashing every plugin at startup. After the watcher observes one artifact change, `rebuilt(id)` hashes only that bundle and map and publishes the resulting revision. Versioned scripts and maps use immutable caching. The Host serves snapshotted bytes only when the requested revision matches; stale or missing revisions return 404 instead of aliasing newer bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id. +The graph retains each row's revisioned one-resource combo URL for HMR and adds a content-addressed descriptor for every startup combo request; several descriptors may carry the same scheduling phase. Initial row revisions are opaque process nonces rather than content hashes; they keep the snapshotted one-resource response immutable without hashing every plugin at startup. After the watcher observes one artifact change, `rebuilt(id)` hashes only that bundle and map and publishes the resulting revision. Startup combo revisions cover the combined script inputs and indexed map. Versioned scripts and maps use immutable caching. The Host serves only exact generated URLs; stale revisions and unadvertised resource lists return 404 instead of aliasing different bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id. ### The loading flow, end to end @@ -54,11 +54,11 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the 1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)). 2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dsh.client` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber. -3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; batch revisions hash the generated script plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the bundle route and contributes structured index-injection rows. +3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; startup combo revisions hash the combined script inputs plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows. Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted. -**Phase one — the module face.** The injected HTML installs `window.__ModuleLoader__` in queue mode, starts preloading the application batch, executes the bootstrap batch as one blocking classic script, assigns `window.__DSH_BOOT__`, and then starts the Vite main module. The kernel calls the facade's `create()` with the raw graph and shell seeds. The facade removes and materializes the modules registration with a bootstrap `require` that rejects every external, then calls its `createClientModuleSystem` export. The modules bundle parses the graph, constructs the system, memoizes its own exports, retains the instance in its module closure, and switches the same facade to live registration. The kernel then prefetches every `immediately` row in parallel. Their shared application URL executes once and registers every remaining factory without materializing it. A prefetch failure is swallowed here because phase two's import retries and owns the loud failure. `immediately` remains a registration barrier, not a package identity. +**Phase one — the module face.** The injected HTML installs `window.__ModuleLoader__` in queue mode, starts preloading every application combo URL, executes every bootstrap combo URL as a blocking classic script, assigns `window.__DSH_BOOT__`, and then starts the Vite main module. The kernel calls the facade's `create()` with the raw graph and shell seeds. The facade removes and materializes the modules registration with a bootstrap `require` that rejects every external, then calls its `createClientModuleSystem` export. The modules bundle parses the graph, constructs the system, memoizes its own exports, retains the instance in its module closure, and switches the same facade to live registration. The kernel then prefetches every `immediately` row in parallel. Rows in the same application combo share its execution; separate combos load independently when an immediate row, a requested dependency, or ordinary entry import reaches them. A prefetch failure is swallowed here because phase two's import retries and owns the loud failure. `immediately` remains a registration barrier, not a package identity. **Phase two — the plugin face.** @@ -76,8 +76,8 @@ How does a rebuilt bundle become a reload signal? The hmr node half observes it On the browser side, the driver reloads one plugin per frame, serialized: -1. `invalidate` — drop the stale factory and record, and bind the rebuilt frame's revision to that row's individual URL. A live factory would make the next step a no-op. -2. `prefetch` — load the individual external script and register the fresh factory, while the old fiber still serves. The initial batch never executes again. +1. `invalidate` — drop the stale factory and record, and bind the rebuilt frame's revision to that row's one-resource combo URL. A live factory would make the next step a no-op. +2. `prefetch` — load that one-resource external script and register the fresh factory while the old fiber still serves. The initial multi-resource script never executes again. 3. `registry.delete` — before touching the fiber. A bare fiber dispose trips the vendored Loader's self-dispose branch, which would disable the entry permanently. 4. Drain the old fiber's disposers. 5. Remove owned `