Explorar o código

fix(session): ignore balanced no-step turns

_Kerman hai 1 mes
pai
achega
8ed6a22940

+ 4 - 4
docs/cordis-catalog/events.md

@@ -535,7 +535,7 @@ Creation announcement during session publication. A synchronous throw vetoes and
 
 Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md)
 
-Source: [`packages/core/session/src/index.ts:61`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:71`](../../packages/core/session/src/index.ts)
 
 ### `session/disposed` — emit
 
@@ -556,7 +556,7 @@ Emitted once when an announced session leaves the store, including publication r
 
 Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md)
 
-Source: [`packages/core/session/src/index.ts:71`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:81`](../../packages/core/session/src/index.ts)
 
 ### `session/event` — emit
 
@@ -579,7 +579,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before
 
 Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md)
 
-Source: [`packages/core/session/src/index.ts:83`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:93`](../../packages/core/session/src/index.ts)
 
 ### `session/flush` — parallel
 
@@ -599,7 +599,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await
 
 Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md)
 
-Source: [`packages/core/session/src/index.ts:92`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:102`](../../packages/core/session/src/index.ts)
 
 ## `settings/*`
 

+ 1 - 1
docs/cordis-catalog/services.md

@@ -1682,7 +1682,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
 
 Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md)
 
-Source: [`packages/core/session/src/index.ts:728`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:738`](../../packages/core/session/src/index.ts)
 
 ## `ctx.sessionTitle` — `SessionTitleService`
 

+ 4 - 4
docs/event-producer-consumer.md

@@ -30,10 +30,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:141`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:69`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
-| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
+| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
 | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |

+ 13 - 3
packages/core/session/src/index.ts

@@ -31,14 +31,24 @@ export { foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurface
 export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
 
 /**
- * Find the latest completed model turn in an event sequence.
+ * Find the latest closed turn that entered at least one model step, ignoring
+ * balanced no-step turns produced by rejection, empty input, or cancellation.
  * @param events - session events, or an owned suffix, to inspect.
- * @returns the latest turn end, or `undefined`.
+ * @returns the latest matching turn end, or `undefined`.
  */
 export function findLastMessageTurnEnd(
   events: readonly SessionEvent[],
 ): SessionEvent<'turn/end'> | undefined {
-  return events.findLast(event => event.type === 'turn/end')
+  const steppedTurns = new Set<number>()
+  let latest: SessionEvent<'turn/end'> | undefined
+  for (const event of events) {
+    if (event.type === 'step/start') {
+      steppedTurns.add(event.data.turn)
+      continue
+    }
+    if (event.type === 'turn/end' && steppedTurns.delete(event.data.turn)) latest = event
+  }
+  return latest
 }
 
 declare module 'cordis' {

+ 17 - 0
packages/core/session/tests/session.spec.ts

@@ -6,11 +6,28 @@ import SessionStore, {
   Session,
   SessionEvent,
   SessionId,
+  findLastMessageTurnEnd,
   snapshotSessionEvent,
 } from '@deepseek-ai/dsh-session'
 import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
 
 describe('Session', () => {
+  it('finds the latest closed turn that entered a model step', () => {
+    const session = new Session(SessionId('last-message-turn'))
+    session.append('turn/start', { turn: 1 })
+    session.append('turn/end', { turn: 1, reason: { kind: 'blocked' } })
+
+    expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
+
+    session.append('turn/start', { turn: 2 })
+    session.append('step/start', { turn: 2, step: 1 })
+    session.append('step/end', { turn: 2, step: 1 })
+    session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } })
+
+    expect(findLastMessageTurnEnd(session.events)?.data)
+      .toEqual({ turn: 2, reason: { kind: 'max-tokens' } })
+  })
+
   it('exposes one stable readonly surface view', () => {
     const session = new Session(SessionId('surface-view'))
     const surface = session.surface