fix(session-title): preserve agent turn outcomes
This commit is contained in:
@@ -11,6 +11,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`.
|
||||
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject.
|
||||
- `ctx.sessions.appendOutOfBand(session, type, data, trigger)` accepts only plugin event types opted into `OutOfBandSessionEventMap`. It appends directly inside an open turn; otherwise it atomically opens a zero-step plugin turn, appends, closes, and flushes. A target failure still closes and flushes the synthetic turn, and detach is deferred until the sequence settles.
|
||||
- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest turn boundary because a later injection or plugin-owned zero-step turn has its own outcome.
|
||||
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
|
||||
- `ctx.sessions.get(id: SessionId): Session | undefined`
|
||||
- `ctx.sessions.list(): Session[]`
|
||||
|
||||
@@ -27,6 +27,27 @@ export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from '
|
||||
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||
|
||||
/**
|
||||
* Find the latest closed message-triggered turn, excluding injection and
|
||||
* plugin-owned zero-step turns.
|
||||
* @param events - session events, or an owned suffix, to inspect.
|
||||
* @returns the latest matching turn end, or `undefined`.
|
||||
*/
|
||||
export function findLastMessageTurnEnd(
|
||||
events: readonly SessionEvent[],
|
||||
): SessionEvent<'turn/end'> | undefined {
|
||||
const messageTurns = new Set<number>()
|
||||
let latest: SessionEvent<'turn/end'> | undefined
|
||||
for (const event of events) {
|
||||
if (event.type === 'turn/start') {
|
||||
if (event.data.trigger.kind === 'message') messageTurns.add(event.data.turn)
|
||||
continue
|
||||
}
|
||||
if (event.type === 'turn/end' && messageTurns.delete(event.data.turn)) latest = event
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
sessions: SessionStore
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, {
|
||||
findLastMessageTurnEnd,
|
||||
SESSION_FORMAT_VERSION,
|
||||
Session,
|
||||
SessionEvent,
|
||||
SessionId,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
|
||||
|
||||
describe('Session', () => {
|
||||
@@ -48,6 +54,42 @@ describe('Session', () => {
|
||||
expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
|
||||
})
|
||||
|
||||
it('finds the latest message-turn outcome past later non-message turns', () => {
|
||||
const session = new Session(SessionId('message-turn-outcome'))
|
||||
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } },
|
||||
})
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'before' }],
|
||||
source: { kind: 'plugin', plugin: 'before' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
|
||||
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'bounded prompt' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const messageEnd = session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } })
|
||||
session.append('turn/start', {
|
||||
turn: 3,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } },
|
||||
})
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'after' }],
|
||||
source: { kind: 'plugin', plugin: 'after' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
|
||||
|
||||
expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd)
|
||||
})
|
||||
|
||||
it('renders context and steering messages as plain user content', () => {
|
||||
const session = new Session(SessionId('s2'))
|
||||
session.append('context/message', {
|
||||
|
||||
Reference in New Issue
Block a user