From cd18de61d8a2bf8db174d9f4819dffc055e19f00 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 15:14:14 +0800 Subject: [PATCH] fix(session-projection): close migration coverage gaps --- .../tests/agent-instructions.spec.ts | 21 ++++++++++++++++ .../tests/projection-events.spec.ts | 7 +++++- .../agent-team/tests/team.spec.ts | 19 ++++++++++++++ packages/goal/goal/src/index.ts | 1 + packages/goal/goal/tests/projection.spec.ts | 8 ++++++ packages/hooks/hooks-claude-code/src/index.ts | 1 + packages/hooks/hooks-codex/src/index.ts | 1 + .../permission-presets/src/index.ts | 5 +++- .../preset/agent-presets/tests/remote.spec.ts | 16 ++++++++++++ .../session-query/tests/observation.spec.ts | 15 +++++++++++ .../session-title/tests/projection.spec.ts | 25 +++++++++++++++---- 11 files changed, 112 insertions(+), 7 deletions(-) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 40e532ca22..5c9c3e7f2f 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -1008,6 +1008,27 @@ describe('workspace context request injection', () => { expect(workspaceContext.inject).toEqual(['sessionProjections']) }) + it('rejects a file-touch projection when the turn boundary unit is absent', async () => { + const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + const exec = stubToolExecution({ + callId: ToolCallId('missing-turn-boundary'), + name: 'read', + arguments: { file_path: 'file.txt' }, + agent: stubAgent('/virtual/repo'), + signal: testToolSignal, + }) + + expect(() => { + ctx.emit('tools/result', exec, { + content: [{ type: 'text', text: 'ok' }], + isError: false, + value: null, + }) + }).toThrow('agent-instructions requires the turnBoundary session projection') + }) + it('does not inject baseline context when no filesystem provider is present', async () => { const ctx = new Context() try { diff --git a/packages/experimental/agent-team/tests/projection-events.spec.ts b/packages/experimental/agent-team/tests/projection-events.spec.ts index a572fc74b1..3cd6b6103c 100644 --- a/packages/experimental/agent-team/tests/projection-events.spec.ts +++ b/packages/experimental/agent-team/tests/projection-events.spec.ts @@ -303,7 +303,12 @@ describe('Agent Teams projection events', () => { teamId: TEAM, task: task(), }, 0) - const state = project(ROOT, [invalid]) + const later = event('team/task', { + version: 1, + teamId: TEAM, + task: task(), + }, 1) + const state = project(ROOT, [invalid, later]) expect(state.failure).toMatch(/unsupported Agent Teams event version 2/) expect(isEmptyState(state)).toBe(true) }) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index c6c7ebfb31..f39f8e100d 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -126,6 +126,25 @@ async function waitRunning(ctx: Context, id: SessionId): Promise { } describe('Team identity and provisioning', () => { + it('rejects missing and failed authoritative Team projections', async () => { + const first = await setup([]) + const journal = teamInternals(first.ctx).journal + const stateOf = first.ctx.sessionProjections.stateOf.bind(first.ctx.sessionProjections) + const stateOfSpy = vi.spyOn(first.ctx.sessionProjections, 'stateOf').mockImplementation((session, key) => ( + key === 'team' ? undefined : stateOf(session, key) + )) + expect(() => journal.state(first.lead)).toThrow('Agent Teams projection is not registered') + stateOfSpy.mockRestore() + + const second = await setup([]) + second.lead.session.append('team/task', { + version: 2, + teamId: TeamId(second.lead.id), + } as never) + expect(() => teamInternals(second.ctx).journal.state(second.lead)) + .toThrow('unsupported Agent Teams event version 2') + }) + it('rejects deployment limits that are not positive safe integers', async () => { const fields = [ 'maxMembers', diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index c95ce32943..b5181f534f 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -583,6 +583,7 @@ export class GoalService extends TypertRemoteService { runtime.pendingActivation = { seq: agent.session.seq, activation } try { const event = agent.session.append('goal/change', change) + /* v8 ignore next -- Session.append returns the event committed at the pre-append seq. */ if (runtime.pendingActivation.seq === event.seq) runtime.activation = activation } finally { runtime.pendingActivation = undefined diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index d9ab29008c..f9ef917584 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -167,6 +167,7 @@ describe('goal projection unit', () => { current: { ...current, roundsStarted: current.goal.maxGoalRounds + 1 }, }).success).toBe(false) const empty = goalProjectionDefinition.init() + expect(goalProjectionDefinition.stateSchema.parse(empty)).toEqual(empty) expect(applyGoalProjection(empty, user)).toBe(empty) const admittedRound = { type: 'user/message', seq: 1, time: 2, @@ -209,6 +210,13 @@ describe('goal projection unit', () => { // A declared goal/change record with a foreign payload kind is an owned-stream failure. const foreignKind = { type: 'goal/change', seq: 4, time: 5, data: { kind: 'not-a-goal-change' } } as never expect(applyGoalProjection(state, foreignKind).failure).toMatch(/invalid kind/) + + const missingTimestamps = { + ...state, + current: { ...current, createdAt: undefined, updatedAt: undefined }, + } as never + expect(applyGoalProjection(missingTimestamps, admittedRound).failure) + .toMatch(/current goal fold lacks timestamps/) }) it('fails host goal access when the projection retained a replay failure', async () => { diff --git a/packages/hooks/hooks-claude-code/src/index.ts b/packages/hooks/hooks-claude-code/src/index.ts index e1b15f7ef1..4dfa3a58f1 100644 --- a/packages/hooks/hooks-claude-code/src/index.ts +++ b/packages/hooks/hooks-claude-code/src/index.ts @@ -310,6 +310,7 @@ const SUBAGENT_TYPE = 'general-purpose' /** The last open turn number in the agent's log, or 0 without an agent. */ function lastTurn(ctx: Context, agent: Agent | undefined): number { if (!agent) return 0 + /* v8 ignore next -- agent-present hook points run inside AgentLoop, which owns this projection. */ return ctx.sessionProjections.stateOf(agent.session, 'turnBoundary')?.lastTurn ?? 0 } diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 799f54f65e..b8f8027c88 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -279,6 +279,7 @@ export function apply(ctx: Context, config: Config): void { /* jscpd:ignore-start */ function lastTurn(ctx: Context, agent: Agent | undefined): number { if (!agent) return 0 + /* v8 ignore next -- agent-present hook points run inside AgentLoop, which owns this projection. */ return ctx.sessionProjections.stateOf(agent.session, 'turnBoundary')?.lastTurn ?? 0 } diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts index 67a9401018..607d71c5d2 100644 --- a/packages/interaction/permission-presets/src/index.ts +++ b/packages/interaction/permission-presets/src/index.ts @@ -265,7 +265,10 @@ export class PermissionPresetService extends Service { } private knobs(session: Session): KnobState { - return this.ctx.sessionProjections.stateOf(session, 'permissions') ?? EMPTY_KNOBS + const state = this.ctx.sessionProjections.stateOf(session, 'permissions') + /* v8 ignore next -- this service registers the hard-required projection before exposing reads. */ + if (state === undefined) throw new Error('permission: permissions session projection is not registered') + return state } /** diff --git a/packages/preset/agent-presets/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts index 406ef3f528..7492efbe48 100644 --- a/packages/preset/agent-presets/tests/remote.spec.ts +++ b/packages/preset/agent-presets/tests/remote.spec.ts @@ -363,6 +363,22 @@ describe('switching one session\'s composition', () => { expect(recordedPreset(agent)).toEqual({ agentPreset: 'minimal' }) }) + it('fails loudly when a composition omits the turn boundary projection', async () => { + const ctx = await harness() + const agent = await agentOn(ctx, 'sel-no-turn-boundary', 'standard') + const stateOf = ctx.sessionProjections.stateOf.bind(ctx.sessionProjections) + vi.spyOn(ctx.sessionProjections, 'stateOf').mockImplementation((session, key) => ( + key === 'turnBoundary' ? undefined : stateOf(session, key) + )) + + const failure = await remoteFailure(ctx.agentPresets.select(agent, 'minimal')) + + expect(failure).toMatchObject({ + code: 'internal', + message: expect.stringContaining('select requires the turnBoundary session projection') as string, + }) + }) + it('serializes two concurrent switches on one session', async () => { const ctx = await harness() const agent = await agentOn(ctx, 'sel-race', 'standard') diff --git a/packages/session-query/session-query/tests/observation.spec.ts b/packages/session-query/session-query/tests/observation.spec.ts index 91bd1e8bc9..7dc856ee29 100644 --- a/packages/session-query/session-query/tests/observation.spec.ts +++ b/packages/session-query/session-query/tests/observation.spec.ts @@ -89,6 +89,21 @@ describe('SessionObservationReader', () => { await ctx.fiber.dispose() }) + it('returns a prepared observation without projections when no registry is mounted', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const meta = header('prepared-without-projections') + ctx.provide('sessionPersistence', { + borrowSession: () => Promise.resolve(preparedSource(meta)), + } as never) + + using observed = await new SessionObservationReader(ctx).read(meta.id) + + expect(observed.source).toBe('prepared') + expect(observed.projections).toBeUndefined() + await ctx.fiber.dispose() + }) + it('reference-counts prepared leases and rejects retention after disposal', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/session/session-title/tests/projection.spec.ts b/packages/session/session-title/tests/projection.spec.ts index 2847ca6e88..d73a142942 100644 --- a/packages/session/session-title/tests/projection.spec.ts +++ b/packages/session/session-title/tests/projection.spec.ts @@ -85,12 +85,27 @@ describe('title projection unit', () => { const checkpoint = ctx.sessionProjections.checkpoint(session) const row = checkpoint.titleInput expect(row).toBeDefined() - const malformed = { - ...checkpoint, - titleInput: { ...row!, val: { first: null, count: 1, lastSeq: null } }, + const invalidStates = [ + { first: null, count: 1, lastSeq: null }, + { first: { seq: 1, text: 'first' }, count: 1, lastSeq: null }, + { first: { seq: 1, text: 'first' }, count: 0, lastSeq: 1 }, + { first: { seq: 2, text: 'first' }, count: 1, lastSeq: 1 }, + ] + for (const state of invalidStates) { + const malformed = { + ...checkpoint, + titleInput: { ...row!, val: state }, + } + expect(() => ctx.sessionProjections.restore(malformed, [], 0, session.header)) + .toThrow(/title input state must pair its count with first and last message seqs/) } - expect(() => ctx.sessionProjections.restore(malformed, [], 0, session.header)) - .toThrow(/title input state must pair its count with first and last message seqs/) + expect(() => ctx.sessionProjections.restore({ + ...checkpoint, + titleInput: { + ...row!, + val: { first: { seq: 1, text: 'first' }, count: 1, lastSeq: 1 }, + }, + }, [], 0, session.header)).not.toThrow() }) })