Просмотр исходного кода

fix(session-projection): close migration coverage gaps

_Kerman 2 недель назад
Родитель
Сommit
cd18de61d8

+ 21 - 0
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 {

+ 6 - 1
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)
   })

+ 19 - 0
packages/experimental/agent-team/tests/team.spec.ts

@@ -126,6 +126,25 @@ async function waitRunning(ctx: Context, id: SessionId): Promise<Agent> {
 }
 
 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',

+ 1 - 0
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

+ 8 - 0
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 () => {

+ 1 - 0
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
 }
 

+ 1 - 0
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
 }
 

+ 4 - 1
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
   }
 
   /**

+ 16 - 0
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')

+ 15 - 0
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)

+ 20 - 5
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()
   })
 })