mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
refactor(todo): own todo event vocabulary
This commit is contained in:
@@ -56,6 +56,7 @@
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -68,6 +69,7 @@
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-atta
|
||||
import type {
|
||||
SessionEvent,
|
||||
SessionId,
|
||||
TodoItem,
|
||||
} from '@deepseek-ai/dsh-session/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
|
||||
// Type-only: the brand constructor is host-side; the fixture casts at its
|
||||
// wire-fabrication boundary (the schema layer's one-cast-point posture).
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../todo/tool-todo"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -83,6 +84,7 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
|
||||
import type {
|
||||
RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
{
|
||||
"path": "../../session/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../todo/tool-todo"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
@@ -147,7 +147,6 @@ function validateEvent(
|
||||
case 'session/end-seed':
|
||||
// Unconstrained: an unbalanced seed legally puts it inside an open turn.
|
||||
break
|
||||
case 'todo/write':
|
||||
case 'request/header':
|
||||
case 'request/context': {
|
||||
if (trace.openTurn === null) {
|
||||
|
||||
@@ -176,23 +176,6 @@ export interface TurnEndReasonMap {
|
||||
/** The union over {@link TurnEndReasonMap} — why a turn ended; plugins extend it by merging variants into the map. */
|
||||
export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
|
||||
|
||||
/**
|
||||
* One entry in an agent's todo list — the unit of the `todo/write`
|
||||
* {@link SessionEventMap} event's whole-list snapshot.
|
||||
*
|
||||
* Deliberately minimal: a human-readable `content` line and a three-state
|
||||
* `status`. No id, priority, or `activeForm` — the list is replaced wholesale
|
||||
* on every write (last-write-wins), so entries need no stable identity. The
|
||||
* three statuses describe the complete portable lifecycle needed by model and
|
||||
* UI consumers.
|
||||
*/
|
||||
export interface TodoItem {
|
||||
/** What this task is — a short imperative line shown in the UI. */
|
||||
content: string
|
||||
/** Lifecycle state. `in_progress` marks a task being worked now; parallel work may mark several. */
|
||||
status: 'pending' | 'in_progress' | 'completed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Logged request state outside derived history: call config, system prompt, and
|
||||
* tools. The latest full `request/header` snapshot reconstructs it; canonical
|
||||
@@ -299,8 +282,6 @@ export interface SessionEventMap {
|
||||
error?: { name: string; code: string }
|
||||
meta?: JsonValue
|
||||
}
|
||||
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
|
||||
'todo/write': { todos: TodoItem[] }
|
||||
/**
|
||||
* Full header for the next request, appended inside its step before dispatch.
|
||||
* It is log-only; the latest snapshot reconstructs the request header.
|
||||
|
||||
@@ -141,7 +141,6 @@ describe('session-log invariants', () => {
|
||||
const enclosed = (await setup()).ctx.sessions.create()
|
||||
enclosed.append('turn/start', { turn: 1 })
|
||||
enclosed.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => enclosed.append('todo/write', { todos: [] })).not.toThrow()
|
||||
expect(() => enclosed.append('request/header', {
|
||||
header: { config: { provider: 'mock', model: 'mock' } },
|
||||
reason: 'initial',
|
||||
|
||||
@@ -146,7 +146,9 @@ describe('Session.requestContext', () => {
|
||||
it('advances incrementally across appends and skips unrelated events', () => {
|
||||
const session = Session.create(SessionId('incremental-capacity'), seedWith(CAPACITY))
|
||||
expect(session.requestContext()).toEqual(CAPACITY)
|
||||
session.append('todo/write', { todos: [] })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'unrelated' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(session.requestContext()).toEqual(CAPACITY)
|
||||
session.append('request/context', { ...CAPACITY, model: 'next', contextWindow: 64_000 })
|
||||
expect(session.requestContext()).toEqual({ provider: 'mock', model: 'next', contextWindow: 64_000 })
|
||||
@@ -158,7 +160,9 @@ describe('Session.requestContext', () => {
|
||||
const session = Session.create(SessionId('batched-capacity'), seedWith(CAPACITY))
|
||||
expect(session.requestContext()).toEqual(CAPACITY)
|
||||
session.append('request/context', { ...CAPACITY, contextWindow: 200_000 })
|
||||
session.append('todo/write', { todos: [] })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'unrelated' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('request/context', { ...CAPACITY, contextWindow: 300_000 })
|
||||
expect(session.requestContext()?.contextWindow).toBe(300_000)
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import SessionStore, {
|
||||
SessionId,
|
||||
snapshotSessionEvent,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
|
||||
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface } from '@deepseek-ai/dsh-session'
|
||||
|
||||
describe('Session', () => {
|
||||
it('exposes one stable readonly surface view', () => {
|
||||
@@ -787,7 +787,7 @@ describe('Session', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const event = session.append('todo/write', data as never)
|
||||
const event = session.append('request/context', data as never)
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(event.data).toEqual({ value: 'accepted' })
|
||||
@@ -914,14 +914,14 @@ describe('Session', () => {
|
||||
expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError)
|
||||
|
||||
const appended = Session.create(SessionId('append-frozen'))
|
||||
const appendedEvent = appended.append('todo/write', {
|
||||
todos: [{ content: 'first', status: 'pending' }],
|
||||
})
|
||||
const appendedEvent = appended.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'first' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(Object.isFrozen(appendedEvent)).toBe(true)
|
||||
expect(Object.isFrozen(appendedEvent.data)).toBe(true)
|
||||
expect(Object.isFrozen(appendedEvent.data.todos)).toBe(true)
|
||||
expect(Object.isFrozen(appendedEvent.data.todos[0])).toBe(true)
|
||||
expect(() => { appendedEvent.data.todos[0]!.content = 'mutated' }).toThrow(TypeError)
|
||||
expect(Object.isFrozen(appendedEvent.data.content)).toBe(true)
|
||||
expect(Object.isFrozen(appendedEvent.data.content[0])).toBe(true)
|
||||
expect(() => { (appendedEvent.data.content[0] as { text: string }).text = 'mutated' }).toThrow(TypeError)
|
||||
})
|
||||
|
||||
it('iteratively freezes deeply nested restored event data', () => {
|
||||
@@ -1531,7 +1531,7 @@ describe('SessionStore', () => {
|
||||
const session = ctx.sessions.create(SessionId('reentrant-observer'))
|
||||
const heard: SessionEvent[] = []
|
||||
ctx.on('session/event', (observedSession) => {
|
||||
observedSession.append('todo/write', { todos: [] })
|
||||
observedSession.append('request/context', { provider: 'mock', model: 'mock' })
|
||||
})
|
||||
ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
|
||||
|
||||
@@ -1663,68 +1663,3 @@ describe('SessionStore', () => {
|
||||
expect(heard).toEqual([session])
|
||||
})
|
||||
})
|
||||
|
||||
describe('todo/write event', () => {
|
||||
it('appends the whole-list snapshot and isolates the log from later mutation', () => {
|
||||
const session = Session.create(SessionId('t1'))
|
||||
const todos: TodoItem[] = [
|
||||
{ content: 'plan the work', status: 'in_progress' },
|
||||
{ content: 'write the code', status: 'pending' },
|
||||
]
|
||||
session.append('todo/write', { todos })
|
||||
|
||||
const event = session.events.findLast(e => e.type === 'todo/write')!
|
||||
expect(event.type).toBe('todo/write')
|
||||
expect(event.data.todos).toEqual(todos)
|
||||
|
||||
// The append snapshots its input: mutating the caller's array afterward must
|
||||
// not change what the log holds (the durable-source-of-truth contract).
|
||||
todos.push({ content: 'sneak in', status: 'pending' })
|
||||
todos[0]!.status = 'completed'
|
||||
expect(event.data.todos).toEqual([
|
||||
{ content: 'plan the work', status: 'in_progress' },
|
||||
{ content: 'write the code', status: 'pending' },
|
||||
])
|
||||
})
|
||||
|
||||
it('is last-write-wins: the current list is the most recent todo/write', () => {
|
||||
const session = Session.create(SessionId('t2'))
|
||||
session.append('todo/write', { todos: [{ content: 'first', status: 'pending' }] })
|
||||
session.append('todo/write', { todos: [
|
||||
{ content: 'first', status: 'completed' },
|
||||
{ content: 'second', status: 'in_progress' },
|
||||
] })
|
||||
|
||||
const current = session.events.findLast(e => e.type === 'todo/write')!.data.todos
|
||||
expect(current).toEqual([
|
||||
{ content: 'first', status: 'completed' },
|
||||
{ content: 'second', status: 'in_progress' },
|
||||
])
|
||||
})
|
||||
|
||||
it('is NOT a surface event: it produces no derived message and joins no surface node', () => {
|
||||
const session = Session.create(SessionId('t3'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const before = session.deriveMessages().length
|
||||
session.append('todo/write', { todos: [{ content: 'a task', status: 'pending' }] })
|
||||
// The todo event must not add a message to the derived history…
|
||||
expect(session.deriveMessages()).toHaveLength(before)
|
||||
// …and must not appear on the ordered surface.
|
||||
expect(session.surface.nodes).not.toContain(session.seq - 1)
|
||||
})
|
||||
|
||||
it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
|
||||
const original = Session.create(SessionId('t4'))
|
||||
original.append('turn/start', { turn: 1 })
|
||||
original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
|
||||
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// Seeding a non-surface event with no surfaceOp must not throw.
|
||||
const replayed = Session.create(SessionId('t4-replay'), [...original.events])
|
||||
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
|
||||
.toEqual([{ content: 'only', status: 'completed' }])
|
||||
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
|
||||
expect(replayed.firstLiveSeq).toBe(original.seq)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('contextBreakdown session projection', () => {
|
||||
header: { config: CONFIG, system: 'You are terse.', tools: TOOLS },
|
||||
reason: 'change',
|
||||
})
|
||||
session.append('todo/write', { todos: [] })
|
||||
session.append('session/end-seed', {})
|
||||
expect(changed).not.toContain('contextBreakdown')
|
||||
|
||||
// A system-less, tool-less envelope prices back to zero.
|
||||
@@ -216,7 +216,7 @@ describe('contextBreakdown session projection', () => {
|
||||
expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price')
|
||||
// A claim expires after one intervening event, so replacement delta is zero.
|
||||
let expired = definition.apply(state, meter(1, 3, 8))
|
||||
expired = definition.apply(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent)
|
||||
expired = definition.apply(expired, { type: 'session/end-seed', seq: 9, time: 0, data: {} })
|
||||
expect(definition.wire.view(definition.apply(expired, replace(1, 3))).messageTokens)
|
||||
.toBe(definition.wire.view(state).messageTokens)
|
||||
// The armed claim prices exactly the next event's matching replacement.
|
||||
|
||||
@@ -357,7 +357,7 @@ describe('contextPressure session projection', () => {
|
||||
const changed: string[] = []
|
||||
ctx.sessionProjections.onChanged((_session, key) => { changed.push(key) })
|
||||
|
||||
session.append('todo/write', { todos: [] })
|
||||
session.append('session/end-seed', {})
|
||||
expect(changed).not.toContain('contextPressure')
|
||||
// A repeated capacity record for the same window is also a no-op.
|
||||
recordContext(session, 'small', 64_000)
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
@@ -51,6 +52,7 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/** First-party semantic text extraction for session-query consumers. */
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
// Type-only: includes the first-party todo event consumed below.
|
||||
import type {} from '@deepseek-ai/dsh-tool-todo'
|
||||
|
||||
/**
|
||||
* Extract searchable semantic text from one first-party session event.
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../session/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../todo/tool-todo"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-persistence"
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ import z from '@deepseek-ai/schemastery'
|
||||
import { z as zod } from 'zod'
|
||||
import type { ZodType } from 'zod'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session'
|
||||
import type { TodoItem } from './types.ts'
|
||||
// Type-only: resolves ctx.sessionProjections for the optional unit child.
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
// The `todos` projection-key declaration lives in src/types.ts (its one home);
|
||||
|
||||
@@ -39,20 +39,34 @@ function validateTodos(value: unknown, fail: InvariantFailure): void {
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||
/** Validate the package-owned event fields and ignore unrelated events. */
|
||||
function validateEvent(event: SessionEvent, fail: InvariantFailure): void {
|
||||
if (event.type === 'todo/write') validateTodos(event.data.todos, fail)
|
||||
/** Whether the committed log prefix ends inside an open turn. */
|
||||
function hasOpenTurn(events: readonly SessionEvent[]): boolean {
|
||||
let open = false
|
||||
for (const event of events) {
|
||||
if (event.type === 'turn/start') open = true
|
||||
if (event.type === 'turn/end') open = false
|
||||
}
|
||||
return open
|
||||
}
|
||||
|
||||
/** Validate one package-owned event against its payload and committed session prefix. */
|
||||
function validateEvent(session: Session, event: SessionEvent, fail: InvariantFailure): void {
|
||||
if (event.type !== 'todo/write') return
|
||||
validateTodos(event.data.todos, fail)
|
||||
if (!hasOpenTurn(session.events.slice(0, event.seq))) {
|
||||
fail('todo/write appended outside any open turn')
|
||||
}
|
||||
}
|
||||
|
||||
/** Install validation for loaded and newly appended whole-list todo snapshots. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
for (const session of ctx.sessions.list()) {
|
||||
for (const event of session.events) validateEvent(event, fail)
|
||||
for (const event of session.events) validateEvent(session, event, fail)
|
||||
}
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const event = (args as [Session, SessionEvent])[1]
|
||||
validateEvent(event, fail)
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
validateEvent(session, event, fail)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
@@ -8,9 +8,29 @@
|
||||
* @module @deepseek-ai/dsh-tool-todo/types
|
||||
*/
|
||||
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
/**
|
||||
* One entry in an agent's todo list — the unit of the `todo/write`
|
||||
* whole-list snapshot declared by this package.
|
||||
*
|
||||
* Deliberately minimal: a human-readable `content` line and a three-state
|
||||
* `status`. No id, priority, or `activeForm` — the list is replaced wholesale
|
||||
* on every write (last-write-wins), so entries need no stable identity. The
|
||||
* three statuses describe the complete portable lifecycle needed by model and
|
||||
* UI consumers.
|
||||
*/
|
||||
export interface TodoItem {
|
||||
/** What this task is — a short imperative line shown in the UI. */
|
||||
content: string
|
||||
/** Lifecycle state. `in_progress` marks a task being worked now; parallel work may mark several. */
|
||||
status: 'pending' | 'in_progress' | 'completed'
|
||||
}
|
||||
|
||||
export type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
declare module '@deepseek-ai/dsh-session/types' {
|
||||
interface SessionEventMap {
|
||||
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
|
||||
'todo/write': { todos: TodoItem[] }
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionStateMap {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
import * as TodoInvariant from '@deepseek-ai/dsh-tool-todo/invariant'
|
||||
@@ -14,10 +14,6 @@ async function setup(): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function event(todos: unknown): SessionEvent {
|
||||
return { type: 'todo/write', seq: 0, time: 0, data: { todos } } as SessionEvent
|
||||
}
|
||||
|
||||
describe('todo snapshot invariants', () => {
|
||||
it('accepts historical and live parallel snapshots under the single-active tool policy', async () => {
|
||||
const todos = [
|
||||
@@ -30,11 +26,13 @@ describe('todo snapshot invariants', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(ToolTodo, { allowParallelInProgress: false })
|
||||
ctx.sessions.create().append('todo/write', { todos: [...todos] })
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('todo/write', { todos: [...todos] })
|
||||
await ctx.plugin(InvariantRegistry, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(TodoInvariant).then(() => undefined)).resolves.toBeUndefined()
|
||||
expect(() => { ctx.emit('session/event', {} as Session, event(todos)) }).not.toThrow()
|
||||
expect(() => { session.append('todo/write', { todos: [...todos] }) }).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -49,23 +47,46 @@ describe('todo snapshot invariants', () => {
|
||||
[[{ content: 'task', status: 'paused' }], /unknown status/],
|
||||
])('rejects an incoherent durable todo snapshot', async (todos, message) => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit('session/event', {} as Session, event(todos)) }).toThrow(message)
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1 })
|
||||
expect(() => { session.append('todo/write', { todos } as never) }).toThrow(message)
|
||||
})
|
||||
|
||||
it('ignores unrelated dispatches and session events', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
expect(() => {
|
||||
ctx.emit('tools/change')
|
||||
ctx.emit('session/event', {} as Session, {
|
||||
type: 'turn/start', seq: 0, time: 0, data: { turn: 1 },
|
||||
})
|
||||
session.append('turn/start', { turn: 1 })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a live snapshot outside an open turn before it enters the log', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const before = [...session.events]
|
||||
|
||||
expect(() => session.append('todo/write', { todos: [] })).toThrow(/outside any open turn/)
|
||||
expect(session.events).toEqual(before)
|
||||
})
|
||||
|
||||
it('rejects an existing snapshot outside an open turn on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.sessions.create().append('todo/write', { todos: [] })
|
||||
await ctx.plugin(InvariantRegistry, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(TodoInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('rejects an invalid existing snapshot on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.sessions.create().append('todo/write', {
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('todo/write', {
|
||||
todos: [
|
||||
{ content: 'duplicate', status: 'pending' },
|
||||
{ content: 'duplicate', status: 'completed' },
|
||||
|
||||
@@ -13,7 +13,8 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session, TodoItem } from '@deepseek-ai/dsh-session'
|
||||
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 SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
@@ -83,6 +84,7 @@ describe('todos projection provider', () => {
|
||||
{ content: 'a', status: 'completed' },
|
||||
{ content: 'b', status: 'in_progress' },
|
||||
]
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('todo/write', { todos: first })
|
||||
session.append('todo/write', { todos: second })
|
||||
const projections = await bench.tailProjections()
|
||||
@@ -96,10 +98,11 @@ describe('todos projection provider', () => {
|
||||
const session = bench.session
|
||||
seedMessage(session)
|
||||
const list: TodoItem[] = [{ content: 'done', status: 'completed' }]
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('todo/write', { todos: list })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect((await bench.tailProjections())?.values.todos).toEqual(list)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/start', { turn: 2 })
|
||||
const cleared = await bench.tailProjections()
|
||||
expect(cleared?.values.todos).toBeNull()
|
||||
expect(cleared?.asOfSeq).toBe(session.seq - 1)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo'
|
||||
import { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import * as tool from '../src/index.ts'
|
||||
@@ -233,3 +233,68 @@ describe('dsh-tool-todo', () => {
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('todo/write event', () => {
|
||||
it('appends the whole-list snapshot and isolates the log from later mutation', () => {
|
||||
const session = Session.create(SessionId('t1'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const todos: TodoItem[] = [
|
||||
{ content: 'plan the work', status: 'in_progress' },
|
||||
{ content: 'write the code', status: 'pending' },
|
||||
]
|
||||
session.append('todo/write', { todos })
|
||||
|
||||
const event = session.events.findLast(e => e.type === 'todo/write')!
|
||||
expect(event.type).toBe('todo/write')
|
||||
expect(event.data.todos).toEqual(todos)
|
||||
|
||||
todos.push({ content: 'sneak in', status: 'pending' })
|
||||
todos[0]!.status = 'completed'
|
||||
expect(event.data.todos).toEqual([
|
||||
{ content: 'plan the work', status: 'in_progress' },
|
||||
{ content: 'write the code', status: 'pending' },
|
||||
])
|
||||
})
|
||||
|
||||
it('is last-write-wins: the current list is the most recent todo/write', () => {
|
||||
const session = Session.create(SessionId('t2'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('todo/write', { todos: [{ content: 'first', status: 'pending' }] })
|
||||
session.append('todo/write', { todos: [
|
||||
{ content: 'first', status: 'completed' },
|
||||
{ content: 'second', status: 'in_progress' },
|
||||
] })
|
||||
|
||||
const current = session.events.findLast(e => e.type === 'todo/write')!.data.todos
|
||||
expect(current).toEqual([
|
||||
{ content: 'first', status: 'completed' },
|
||||
{ content: 'second', status: 'in_progress' },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not add a derived message or surface node', () => {
|
||||
const session = Session.create(SessionId('t3'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const before = session.deriveMessages().length
|
||||
session.append('todo/write', { todos: [{ content: 'a task', status: 'pending' }] })
|
||||
|
||||
expect(session.deriveMessages()).toHaveLength(before)
|
||||
expect(session.surface.nodes).not.toContain(session.seq - 1)
|
||||
})
|
||||
|
||||
it('round-trips through a seeded replay identically without surface metadata', () => {
|
||||
const original = Session.create(SessionId('t4'))
|
||||
original.append('turn/start', { turn: 1 })
|
||||
original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
|
||||
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const replayed = Session.create(SessionId('t4-replay'), [...original.events])
|
||||
|
||||
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
|
||||
.toEqual([{ content: 'only', status: 'completed' }])
|
||||
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
|
||||
expect(replayed.firstLiveSeq).toBe(original.seq)
|
||||
})
|
||||
})
|
||||
|
||||
Generated
+9
@@ -1565,6 +1565,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-tool-todo':
|
||||
specifier: workspace:^
|
||||
version: link:../../todo/tool-todo
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
@@ -1706,6 +1709,9 @@ importers:
|
||||
'@deepseek-ai/dsh-timeout':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/timeout
|
||||
'@deepseek-ai/dsh-tool-todo':
|
||||
specifier: workspace:^
|
||||
version: link:../../todo/tool-todo
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
@@ -6403,6 +6409,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session-title':
|
||||
specifier: workspace:^
|
||||
version: link:../../session/session-title
|
||||
'@deepseek-ai/dsh-tool-todo':
|
||||
specifier: workspace:^
|
||||
version: link:../../todo/tool-todo
|
||||
|
||||
packages/session-query/session-query-sqlite:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user