Merge codex/invariant-service-seam into codex/invariant-service-review-fixes

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml
#	scripts/test-invariants.spec.ts
#	scripts/test-invariants.ts
This commit is contained in:
Tianyi Cui
2026-07-21 00:47:02 +08:00
51 changed files with 3912 additions and 13 deletions

View File

@@ -9,6 +9,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| Group | Role | Release expectation |
|---|---|---|
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |

View File

@@ -250,6 +250,44 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'goals',
summary: 'Goal service (`ctx.goals`) backed exclusively by the owning session log.',
methods: [
{
signature: 'get(agent: Agent): GoalView | undefined',
jsDoc: '/**\n * Read the current goal for one exact live agent.\n * @param agent - owning live agent.\n * @returns a fresh view or `undefined` when no goal is current.\n * @throws {@link GoalError} when the agent is not the registry\'s live instance.\n */',
},
{
signature: 'create(agent: Agent, request: CreateGoalRequest): GoalView',
jsDoc: '/**\n * Create and arm a goal. A completed goal may be replaced; every other\n * current phase must be cleared or resumed instead.\n * @param agent - owning live agent.\n * @param request - objective and optional round cap.\n * @returns the created live view.\n */',
},
{
signature: 'edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView',
jsDoc: '/**\n * Edit objective and/or round cap without changing phase.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param request - at least one replacement field.\n * @returns the edited view.\n */',
},
{
signature: 'pause(agent: Agent, ref: GoalRef): GoalView',
jsDoc: '/**\n * Pause an active goal and disarm automatic continuation.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the paused view.\n */',
},
{
signature: 'resume(agent: Agent, ref: GoalRef): GoalView',
jsDoc: '/**\n * Resume and arm a stopped goal, or rearm an active goal after a\n * session-start edge, while its round budget still has capacity.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the active view.\n */',
},
{
signature: 'complete(agent: Agent, ref: GoalRef): GoalView',
jsDoc: '/**\n * Mark a current non-complete goal complete and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the completed view.\n */',
},
{
signature: 'block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView',
jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param reason - policy-owned stable code and human-readable explanation.\n * @returns the blocked view with its durable reason.\n */',
},
{
signature: 'clear(agent: Agent, ref: GoalRef): GoalRef',
jsDoc: '/**\n * Clear the current goal while retaining a durable tombstone and history.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the tombstone ref whose revision is one past the cleared snapshot.\n */',
},
],
},
{
key: 'invariants',
summary: 'Package-owned invariant registry with global and regex-based selection.',
@@ -779,6 +817,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * Single-slot decision for the next {@link FileSystem.writeText}. Calling\n * `next()` yields the bare provider\'s unconditional write; the first listener\n * that returns an intent owns the decision rather than composing with peers.\n * @param target - the resolved target about to be written.\n * @param actor - the opaque tool-execution context the decider keys off.\n * @mode waterfall\n */',
summary: 'Single-slot decision for the next FileSystem.writeText.',
},
{
name: 'goal/changed',
mode: 'emit',
signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped<Agent>, agent: Agent, change: GoalChanged): void',
jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching context event is\n * already appended or queued in that agent\'s active tool-batch FIFO.\n * Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - agent whose session owns the goal.\n * @param change - fresh current projection or clear tombstone.\n * @mode emit\n */',
summary: 'Goal mutation accepted by one live agent.',
},
{
name: 'llm/stream',
mode: 'waterfall',
@@ -1105,6 +1150,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'CreateAgentOptions',
declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
},
{
name: 'CreateGoalRequest',
declaration: 'export interface CreateGoalRequest {\n readonly objective: string;\n readonly maxGoalRounds?: number;\n}',
},
{
name: 'CreateSessionOptions',
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}',
@@ -1125,6 +1174,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'DshEnvironmentKey',
declaration: 'export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`;',
},
{
name: 'EditGoalRequest',
declaration: 'export interface EditGoalRequest {\n readonly objective?: string;\n readonly maxGoalRounds?: number;\n}',
},
{
name: 'EpochHeader',
declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n}',
@@ -1197,6 +1250,34 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'GenericResultView',
declaration: 'export interface GenericResultView {\n card: \'generic\';\n title?: string;\n content?: ContentBlock[];\n}',
},
{
name: 'GoalActivation',
declaration: 'export type GoalActivation = \'armed\' | \'disarmed\';',
},
{
name: 'GoalBlockReason',
declaration: 'export interface GoalBlockReason {\n readonly code: string;\n readonly message: string;\n}',
},
{
name: 'GoalId',
declaration: 'export type GoalId = Branded<\'GoalId\'>;',
},
{
name: 'GoalPhase',
declaration: 'export type GoalPhase = \'active\' | \'paused\' | \'blocked\' | \'complete\';',
},
{
name: 'GoalRef',
declaration: 'export interface GoalRef {\n readonly id: GoalId;\n readonly revision: number;\n}',
},
{
name: 'GoalSnapshot',
declaration: 'export interface GoalSnapshot extends GoalRef {\n readonly objective: string;\n readonly phase: GoalPhase;\n readonly blockedReason?: GoalBlockReason;\n readonly maxGoalRounds: number;\n}',
},
{
name: 'GoalView',
declaration: 'export interface GoalView extends GoalSnapshot {\n readonly roundsStarted: number;\n readonly createdAt: number;\n readonly updatedAt: number;\n readonly activation: GoalActivation;\n}',
},
{
name: 'HookContext',
declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n}',

View File

@@ -24,6 +24,7 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
'agent/turn-continuation': args => args[0],
'agent/turn-stop': args => args[0],
'approval/request': args => (args[0] as Record<string, unknown>)['agent'],
'goal/changed': args => args[0],
'session/created': null,
'session/disposed': null,
'session/event': null,

View File

@@ -47,6 +47,7 @@ describe('scoped-dispatch invariants', () => {
['agent/turn-continuation', [agent, 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' })]],
['agent/turn-stop', [agent, 1]],
['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]],
['goal/changed', [agent, { operation: 'create', ref: { id: 'goal-a', revision: 1 } }]],
['system-prompt/assemble', [[], { scope: agent }]],
['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]],
['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],

9
packages/goal/README.md Normal file
View File

@@ -0,0 +1,9 @@
# goal/ — persisted same-session goals
The goal family owns durable objective state independently of the model-facing tools and continuation policy that consume it.
| Package | Role | ctx key |
|---|---|---|
| `goal/` | Event-sourced goal lifecycle, replay fold, compare-and-set mutations, and process-local activation | `ctx.goals` |
Goal state is part of the owning session log. Consumers depend on `dsh-goal`, not on the concrete agent loop; continuation behavior belongs in a separate plugin on the public agent seams.

View File

@@ -0,0 +1,56 @@
# @deepseek-ai/dsh-goal
Event-sourced same-session goal state. The service retains one current completion objective in an agent's existing session while keeping permission to continue as process-local activation. The [goal-domain Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the design rationale; the [goal type catalog](../../../docs/core-data-structures/goal.md) records the literal data shapes.
## Config
```yaml
- id: goal
name: '@deepseek-ai/dsh-goal'
config:
defaultMaxGoalRounds: 256
```
`defaultMaxGoalRounds` must be a positive safe integer. `create()` materializes this deployment default internally before committing a goal; a request-level value overrides it.
## Service contract
`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). Creation default resolution is an internal implementation step, not an additional public verb.
At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation.
Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The `context/message` content projected verbatim to the model, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward.
Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. Reentrant append observers see each accepted mutation exactly once, and incremental replay retains its cursor at the first corrupt event. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained.
Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. Session resume and fork therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation.
The separately published `./invariant` companion maintains an independent fold of each attached session. It rejects malformed goal metadata, source or model-visible content drift, discontinuous revisions, illegal lifecycle transitions, timestamp regressions, and non-sequential admitted rounds before the candidate event enters the durable log.
## Extension points
Policy plugins call the service verbs and react to the scoped `goal/changed` event. A continuation consumer admits rounds as `user/message` events with `GoalMessageSource`; ordinary human turns never increment `roundsStarted`. Consumers use the `Agent` interface and events rather than importing `dsh-agent-loop`.
## Model Experience
### Goal-state mutation
#### What the model sees
Each mutation is one raw user-role context block. A snapshot is rendered as `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. The descriptive XML delimiter follows this repository's existing `<workspace_context>` convention and [Anthropic's published XML-tag prompting guidance](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags); it is public model-experience prior art, not a claim about any provider's proprietary training corpus.
#### Token effect
Every retained mutation adds one full snapshot to derived history until compaction shadows it. Full snapshots make each record independently inspectable but repeat the objective and lifecycle fields.
#### KV Cache effect
Append-only within an epoch: each mutation follows the reusable request prefix and preceding history. Compaction may replace the derived-history suffix and move the reusable boundary.
## Known Limitations and Deferred Work
- **State, not scheduling** — this package does not decide when an armed goal continues, retry abnormal failures, or cancel an active turn; those policies belong to agent-seam consumers.
- **Round-count budget only** — `maxGoalRounds` does not meter tokens, currency, wall time, or provider quotas.
- **No independent evaluator** — the caller that records completion or blocking is authoritative; evaluator-backed certification is deferred to a separate policy layer.
- **One current goal** — parallel objectives and a separate goal database are intentionally absent; history remains available in the session log after replacement or clear.
- **Trusted in-process producers** — a plugin with direct `Session` access can append counterfeit goal metadata. Strict replay detects malformed or inconsistent records and leaves goal access failed at that record until the log is repaired; this is integrity detection, not plugin isolation.

View File

@@ -0,0 +1,51 @@
{
"name": "@deepseek-ai/dsh-goal",
"description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.17.2"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,377 @@
/** Pure replay fold and strict decoder for durable goal changes. */
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { renderGoalChange } from './render.ts'
import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts'
import type {
FoldedGoal,
GoalBlockReason,
GoalChangeMeta,
GoalClearChangeMeta,
GoalMessageSource,
GoalOperation,
GoalPhase,
GoalRef,
GoalSnapshot,
GoalSnapshotChangeMeta,
} from './types.ts'
type ContextMessageEvent = Extract<SessionEvent, { type: 'context/message' }>
const SNAPSHOT_OPERATIONS: ReadonlySet<Exclude<GoalOperation, 'clear'>> = new Set([
'create',
'edit',
'pause',
'resume',
'complete',
'block',
])
const PHASES: ReadonlySet<GoalPhase> = new Set(['active', 'paused', 'blocked', 'complete'])
/** Mutable accumulator kept private to the pure fold. */
export interface GoalFoldState {
goal: GoalSnapshot | undefined
roundsStarted: number
createdAt: number | undefined
updatedAt: number | undefined
lastRef: GoalRef | undefined
seenGoalIds: Set<GoalSnapshot['id']>
}
/**
* Build an empty replay accumulator.
* @returns mutable state with no current goal or prior ref.
*/
export function emptyGoalFoldState(): GoalFoldState {
return {
goal: undefined,
roundsStarted: 0,
createdAt: undefined,
updatedAt: undefined,
lastRef: undefined,
seenGoalIds: new Set(),
}
}
/** Whether a value is a JSON record rather than an array. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Require one positive safe integer. */
function positiveInteger(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
throw new Error(`goal change ${field} must be a positive safe integer`)
}
return value
}
/** Require one non-negative safe integer. */
function nonNegativeInteger(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
throw new Error(`goal change ${field} must be a non-negative safe integer`)
}
return value
}
/** Decode one canonical blocker explanation. */
function decodeBlockReason(value: unknown): GoalBlockReason {
if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'code,message') {
throw new Error('goal change goal.blockedReason has an invalid shape')
}
if (typeof value['code'] !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value['code'])) {
throw new Error('goal change goal.blockedReason.code must be lower-kebab-case')
}
if (typeof value['message'] !== 'string' || value['message'].trim().length === 0
|| value['message'] !== value['message'].trim()) {
throw new Error('goal change goal.blockedReason.message must be non-empty and normalized')
}
return { code: value['code'], message: value['message'] }
}
/** Decode and validate one snapshot. */
function decodeSnapshot(value: unknown): GoalSnapshot {
if (!isRecord(value)) throw new Error('goal change goal must be a record')
if (typeof value['id'] !== 'string' || value['id'].length === 0) {
throw new Error('goal change goal.id must be a non-empty string')
}
if (typeof value['objective'] !== 'string' || value['objective'].trim().length === 0
|| value['objective'] !== value['objective'].trim()) {
throw new Error('goal change goal.objective must be non-empty and normalized')
}
if (typeof value['phase'] !== 'string' || !PHASES.has(value['phase'] as GoalPhase)) {
throw new Error('goal change goal.phase is invalid')
}
const phase = value['phase'] as GoalPhase
const expectedKeys = phase === 'blocked'
? 'blockedReason,id,maxGoalRounds,objective,phase,revision'
: 'id,maxGoalRounds,objective,phase,revision'
if (Object.keys(value).sort().join(',') !== expectedKeys) {
throw new Error('goal change goal has an invalid shape')
}
return {
id: GoalId(value['id']),
revision: positiveInteger(value['revision'], 'goal.revision'),
objective: value['objective'],
phase,
maxGoalRounds: positiveInteger(value['maxGoalRounds'], 'goal.maxGoalRounds'),
...phase === 'blocked' ? { blockedReason: decodeBlockReason(value['blockedReason']) } : {},
}
}
/** Decode and validate one ref. */
function decodeRef(value: unknown): GoalRef {
if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'id,revision') {
throw new Error('goal clear tombstone has an invalid shape')
}
if (typeof value['id'] !== 'string' || value['id'].length === 0) {
throw new Error('goal clear tombstone id must be a non-empty string')
}
return { id: GoalId(value['id']), revision: positiveInteger(value['revision'], 'cleared.revision') }
}
/**
* Decode metadata that declares itself as a goal change. Unrelated metadata
* returns `undefined`; malformed goal metadata fails replay loudly.
* @param value - context-message metadata.
* @returns validated goal change or `undefined` for another metadata kind.
*/
export function decodeGoalChange(value: unknown): GoalChangeMeta | undefined {
if (!isRecord(value) || value['kind'] !== 'goal/change') return undefined
if (value['version'] !== GOAL_CHANGE_VERSION) {
throw new Error(`unsupported goal change version ${String(value['version'])}`)
}
if (value['operation'] === 'clear') {
const allowed = ['cleared', 'clearedAt', 'kind', 'operation', 'version']
if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) {
throw new Error('goal clear change has an invalid shape')
}
return {
kind: 'goal/change',
version: GOAL_CHANGE_VERSION,
operation: 'clear',
cleared: decodeRef(value['cleared']),
clearedAt: nonNegativeInteger(value['clearedAt'], 'clearedAt'),
} satisfies GoalClearChangeMeta
}
if (typeof value['operation'] !== 'string'
|| !SNAPSHOT_OPERATIONS.has(value['operation'] as Exclude<GoalOperation, 'clear'>)) {
throw new Error('goal change operation is invalid')
}
const allowed = ['createdAt', 'goal', 'kind', 'operation', 'roundsStarted', 'updatedAt', 'version']
if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) {
throw new Error('goal snapshot change has an invalid shape')
}
const createdAt = nonNegativeInteger(value['createdAt'], 'createdAt')
const updatedAt = nonNegativeInteger(value['updatedAt'], 'updatedAt')
if (updatedAt < createdAt) throw new Error('goal change updatedAt cannot precede createdAt')
return {
kind: 'goal/change',
version: GOAL_CHANGE_VERSION,
operation: value['operation'] as Exclude<GoalOperation, 'clear'>,
goal: decodeSnapshot(value['goal']),
roundsStarted: nonNegativeInteger(value['roundsStarted'], 'roundsStarted'),
createdAt,
updatedAt,
} satisfies GoalSnapshotChangeMeta
}
/** Narrow model attribution to a valid goal source. */
function goalSource(source: MessageSource): GoalMessageSource | undefined {
if (source.kind !== 'goal') return undefined
if (typeof source.goalId !== 'string' || source.goalId.length === 0
|| !Number.isSafeInteger(source.revision) || source.revision < 1
|| !Number.isSafeInteger(source.round) || source.round < 0) {
throw new Error('goal message source is invalid')
}
return source
}
/** Require two snapshots to retain fields that only `edit` may replace. */
function requireSameDefinition(current: GoalSnapshot, next: GoalSnapshot, operation: GoalOperation): void {
if (next.objective !== current.objective || next.maxGoalRounds !== current.maxGoalRounds) {
throw new Error(`goal ${operation} cannot change objective or maxGoalRounds`)
}
}
/** Require one exact next revision of the current goal. */
function requireNextRevision(current: GoalSnapshot, next: GoalRef, operation: GoalOperation): void {
if (next.id !== current.id || next.revision !== current.revision + 1) {
throw new Error(`goal ${operation} must advance the current goal by one revision`)
}
}
/** Validate one non-create snapshot operation against the preceding projection. */
function validateSnapshotTransition(
state: GoalFoldState,
change: GoalSnapshotChangeMeta,
current: GoalSnapshot,
): void {
const next = change.goal
requireNextRevision(current, next, change.operation)
/* v8 ignore next -- a current goal established by this fold always has an updatedAt */
if (state.updatedAt === undefined) throw new Error('current goal fold lacks updatedAt')
if (change.createdAt !== state.createdAt
|| change.updatedAt < state.updatedAt
|| change.roundsStarted !== state.roundsStarted) {
throw new Error(`goal ${change.operation} does not preserve the current counters and timestamps`)
}
switch (change.operation) {
case 'edit':
if (next.phase !== current.phase
|| JSON.stringify(next.blockedReason) !== JSON.stringify(current.blockedReason)) {
throw new Error('goal edit cannot change phase or blocked reason')
}
break
case 'pause':
requireSameDefinition(current, next, change.operation)
if (current.phase !== 'active' || next.phase !== 'paused') throw new Error('goal pause has an invalid phase transition')
break
case 'resume': {
requireSameDefinition(current, next, change.operation)
const resumable: ReadonlySet<GoalPhase> = new Set([
'active',
'paused',
'blocked',
])
if (!resumable.has(current.phase) || next.phase !== 'active' || state.roundsStarted >= next.maxGoalRounds) {
throw new Error('goal resume has an invalid phase transition or exhausted round budget')
}
break
}
case 'complete':
requireSameDefinition(current, next, change.operation)
if (current.phase === 'complete' || next.phase !== 'complete') throw new Error('goal complete has an invalid phase transition')
break
case 'block':
requireSameDefinition(current, next, change.operation)
if (current.phase !== 'active' || next.phase !== 'blocked') throw new Error('goal block has an invalid phase transition')
break
/* v8 ignore start -- the caller excludes create and GoalOperation is closed; these arms retain fail-loud exhaustiveness */
case 'create':
throw new Error('goal create cannot be validated as a current-goal transition')
default:
change.operation satisfies never
throw new Error('unknown goal snapshot operation')
/* v8 ignore stop */
}
}
/**
* Return the revision identity carried by a snapshot or tombstone.
* @param change - decoded goal mutation.
* @returns stable identity used to reconcile a deferred change with its log event.
*/
export function goalChangeRef(change: GoalChangeMeta): GoalRef {
return change.operation === 'clear' ? change.cleared : change.goal
}
/**
* Validate and apply one decoded change to a mutable accumulator.
* @param state - preceding durable goal projection.
* @param change - decoded full snapshot or clear tombstone.
*/
export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): void {
const ref = goalChangeRef(change)
if (change.operation === 'clear') {
const current = state.goal
if (current === undefined) throw new Error('goal clear requires a current goal')
requireNextRevision(current, change.cleared, change.operation)
/* v8 ignore next -- a current goal established by this fold always has an updatedAt */
if (state.updatedAt === undefined) throw new Error('current goal fold lacks updatedAt')
if (change.clearedAt < state.updatedAt) {
throw new Error('goal clear timestamp cannot precede the current goal update')
}
state.goal = undefined
state.roundsStarted = 0
state.createdAt = undefined
state.updatedAt = undefined
state.lastRef = ref
return
}
if (change.operation === 'create') {
if (change.goal.revision !== 1 || change.goal.phase !== 'active' || change.roundsStarted !== 0
|| (state.goal !== undefined && state.goal.phase !== 'complete')
|| state.seenGoalIds.has(change.goal.id)) {
throw new Error('goal create requires a fresh active revision-one goal with zero rounds')
}
state.seenGoalIds.add(change.goal.id)
} else {
const current = state.goal
if (current === undefined) throw new Error(`goal ${change.operation} requires a current goal`)
validateSnapshotTransition(state, change, current)
}
state.goal = change.goal
state.roundsStarted = change.roundsStarted
state.createdAt = change.createdAt
state.updatedAt = change.updatedAt
state.lastRef = ref
}
/**
* Decode and verify one model-visible goal context event without folding it.
* @param event - context event whose metadata and rendered content must agree.
* @returns validated change or `undefined` for an unrelated context event.
*/
export function decodeGoalEvent(event: ContextMessageEvent): GoalChangeMeta | undefined {
const change = decodeGoalChange(event.data.meta)
const source = goalSource(event.data.source)
if (change === undefined) {
if (source !== undefined) throw new Error(`goal source at session event ${event.seq} lacks goal change metadata`)
return undefined
}
const ref = goalChangeRef(change)
if (source === undefined || source.goalId !== ref.id || source.revision !== ref.revision || source.round !== 0) {
throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`)
}
if (JSON.stringify(event.data.content) !== JSON.stringify(renderGoalChange(change))) {
throw new Error(`goal change at session event ${event.seq} has mismatched model-visible content`)
}
return change
}
/**
* Apply one session event and return its goal change, when present.
* @param state - mutable fold accumulator.
* @param event - next event in sequence order.
* @returns decoded change for pending-overlay reconciliation.
*/
export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalChangeMeta | undefined {
if (event.type === 'context/message') {
const change = decodeGoalEvent(event)
if (change === undefined) return undefined
applyGoalChange(state, change)
return change
}
if (event.type === 'user/message') {
const source = goalSource(event.data.source)
if (source !== undefined) {
const current = state.goal
if (current === undefined || current.phase !== 'active' || source.goalId !== current.id
|| source.revision !== current.revision || source.round !== state.roundsStarted + 1
|| source.round > current.maxGoalRounds) {
throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`)
}
state.roundsStarted = source.round
}
}
return undefined
}
/**
* Fold current goal state from a contiguous session event log.
* @param events - session events in sequence order.
* @returns a fresh durable projection; activation is deliberately absent.
*/
export function foldGoal(events: readonly SessionEvent[]): FoldedGoal {
const state = emptyGoalFoldState()
for (const event of events) applyGoalEvent(state, event)
return {
...state.goal === undefined ? {} : { goal: { ...state.goal } },
roundsStarted: state.roundsStarted,
...state.createdAt === undefined ? {} : { createdAt: state.createdAt },
...state.updatedAt === undefined ? {} : { updatedAt: state.updatedAt },
...state.lastRef === undefined ? {} : { lastRef: { ...state.lastRef } },
}
}

View File

@@ -0,0 +1,529 @@
/**
* Same-session goal domain: event-sourced state, compare-and-set mutations,
* and process-local continuation activation.
* @module @deepseek-ai/dsh-goal
*/
import { randomUUID } from 'node:crypto'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue, Session } from '@deepseek-ai/dsh-session'
import {
applyGoalChange,
applyGoalEvent,
decodeGoalEvent,
emptyGoalFoldState,
goalChangeRef,
} from './fold.ts'
import type { GoalFoldState } from './fold.ts'
import { renderGoalChange } from './render.ts'
import {
GOAL_CHANGE_VERSION,
GoalError,
GoalId,
} from './runtime.ts'
import type {
CreateGoalRequest,
EditGoalRequest,
GoalActivation,
GoalBlockReason,
GoalChangeMeta,
GoalChanged,
GoalClearChangeMeta,
GoalOperation,
GoalPhase,
GoalRef,
GoalSnapshot,
GoalSnapshotChangeMeta,
GoalView,
} from './types.ts'
export * from './types.ts'
export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts'
export { decodeGoalChange, foldGoal, goalChangeRef } from './fold.ts'
export { renderGoalChange } from './render.ts'
declare module 'cordis' {
interface Context {
goals: GoalService
}
}
/** Deployment defaults for goal creation. */
export interface Config {
/** Total rounds used when a create request omits its own cap. */
defaultMaxGoalRounds?: number
}
/** Resolved defaults. */
export interface ResolvedConfig {
/** Validated positive safe-integer default round cap. */
defaultMaxGoalRounds: number
}
/** One accepted mutation waiting to enter or be observed in the session log. */
interface PendingGoalChange {
readonly change: GoalChangeMeta
readonly activation: GoalActivation
applied: boolean
}
/** Process-local cache plus mutations waiting in the active tool-batch FIFO. */
interface GoalCache {
readonly state: GoalFoldState
activation: GoalActivation
observedSeq: number
readonly pending: PendingGoalChange[]
}
/** Validated create input with every deployment default materialized. */
interface ResolvedCreateGoal {
readonly objective: string
readonly maxGoalRounds: number
}
/** Validate a caller-visible positive safe-integer round cap. */
function resolveMaxGoalRounds(value: number): number {
if (!Number.isSafeInteger(value) || value < 1) {
throw new GoalError('maxGoalRounds must be a positive safe integer', 'GOAL_INVALID_MAX_ROUNDS')
}
return value
}
/** Validate and normalize an objective at the domain boundary. */
function resolveObjective(value: string): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new GoalError('goal objective must be a non-empty string', 'GOAL_INVALID_OBJECTIVE')
}
return value.trim()
}
/** Materialize deployment defaults and validate one create request. */
function resolveCreateGoal(request: CreateGoalRequest, defaultMaxGoalRounds: number): ResolvedCreateGoal {
return {
objective: resolveObjective(request.objective),
maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds ?? defaultMaxGoalRounds),
}
}
/** Validate and detach one policy-owned blocker explanation. */
function resolveBlockReason(reason: unknown): GoalBlockReason {
const record = typeof reason === 'object' && reason !== null && !Array.isArray(reason)
? reason as Record<string, unknown>
: undefined
const code = record?.['code']
const message = record?.['message']
if (typeof code !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(code)
|| typeof message !== 'string' || message.trim().length === 0) {
throw new GoalError(
'goal block reason requires a lower-kebab-case code and a non-empty message',
'GOAL_INVALID_BLOCK_REASON',
)
}
return { code, message: message.trim() }
}
/** Compare the complete canonical payloads used for deferred reconciliation. */
function sameChange(left: GoalChangeMeta, right: GoalChangeMeta): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
/** Goal service (`ctx.goals`) backed exclusively by the owning session log. */
export class GoalService extends Service {
static inject = ['agents']
static Config: z<Config> = z.object({
defaultMaxGoalRounds: z.number().default(256),
})
private readonly resolved: ResolvedConfig
private readonly caches = new WeakMap<Session, GoalCache>()
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'goals')
this.resolved = {
defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256),
}
ctx.on('agent/session-start', (agent) => {
this.cache(agent.session).activation = 'disarmed'
})
}
/**
* Read the current goal for one exact live agent.
* @param agent - owning live agent.
* @returns a fresh view or `undefined` when no goal is current.
* @throws {@link GoalError} when the agent is not the registry's live instance.
*/
get(agent: Agent): GoalView | undefined {
this.assertLive(agent)
const cache = this.cache(agent.session)
this.sync(agent.session, cache)
return this.view(cache)
}
/**
* Create and arm a goal. A completed goal may be replaced; every other
* current phase must be cleared or resumed instead.
* @param agent - owning live agent.
* @param request - objective and optional round cap.
* @returns the created live view.
*/
create(agent: Agent, request: CreateGoalRequest): GoalView {
const spec = resolveCreateGoal(request, this.resolved.defaultMaxGoalRounds)
const cache = this.prepareMutation(agent)
const current = cache.state.goal
if (current !== undefined && current.phase !== 'complete') {
throw new GoalError(`goal "${current.id}" already exists with phase "${current.phase}"`, 'GOAL_ALREADY_EXISTS')
}
const now = Date.now()
const goal: GoalSnapshot = {
id: GoalId(`goal-${randomUUID()}`),
revision: 1,
objective: spec.objective,
phase: 'active',
maxGoalRounds: spec.maxGoalRounds,
}
return this.commitSnapshot(agent, cache, 'create', goal, 0, now, now, 'armed')
}
/**
* Edit objective and/or round cap without changing phase.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @param request - at least one replacement field.
* @returns the edited view.
*/
edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView {
const cache = this.prepareMutation(agent)
const current = this.expectCurrent(cache, ref)
if (request.objective === undefined && request.maxGoalRounds === undefined) {
throw new GoalError('goal edit requires objective and/or maxGoalRounds', 'GOAL_INVALID_EDIT')
}
const goal: GoalSnapshot = {
...current,
revision: current.revision + 1,
...request.objective === undefined ? {} : { objective: resolveObjective(request.objective) },
...request.maxGoalRounds === undefined ? {} : { maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds) },
}
return this.commitCurrent(agent, cache, 'edit', goal, cache.activation)
}
/**
* Pause an active goal and disarm automatic continuation.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the paused view.
*/
pause(agent: Agent, ref: GoalRef): GoalView {
return this.transition(agent, ref, 'pause', ['active'], 'paused', 'disarmed')
}
/**
* Resume and arm a stopped goal, or rearm an active goal after a
* session-start edge, while its round budget still has capacity.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the active view.
*/
resume(agent: Agent, ref: GoalRef): GoalView {
const cache = this.prepareMutation(agent)
const current = this.expectCurrent(cache, ref)
const resumable: readonly GoalPhase[] = ['active', 'paused', 'blocked']
if (!resumable.includes(current.phase)) {
throw this.transitionError(current, 'resume', resumable)
}
if (current.phase === 'active' && cache.activation === 'armed') {
throw new GoalError(`goal "${current.id}" is already active and armed`, 'GOAL_INVALID_TRANSITION')
}
if (cache.state.roundsStarted >= current.maxGoalRounds) {
throw new GoalError(
`goal "${current.id}" exhausted ${current.maxGoalRounds} goal rounds; increase maxGoalRounds before resuming`,
'GOAL_INVALID_TRANSITION',
)
}
return this.commitCurrent(agent, cache, 'resume', this.withPhase(current, 'active'), 'armed')
}
/**
* Mark a current non-complete goal complete and disarm it.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the completed view.
*/
complete(agent: Agent, ref: GoalRef): GoalView {
return this.transition(
agent,
ref,
'complete',
['active', 'paused', 'blocked'],
'complete',
'disarmed',
)
}
/**
* Mark an active goal blocked and disarm it.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @param reason - policy-owned stable code and human-readable explanation.
* @returns the blocked view with its durable reason.
*/
block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView {
const cache = this.prepareMutation(agent)
const current = this.expectCurrent(cache, ref)
if (current.phase !== 'active') {
throw this.transitionError(current, 'block', ['active'])
}
return this.commitCurrent(
agent,
cache,
'block',
{ ...this.withPhase(current, 'blocked'), blockedReason: resolveBlockReason(reason) },
'disarmed',
)
}
/**
* Clear the current goal while retaining a durable tombstone and history.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the tombstone ref whose revision is one past the cleared snapshot.
*/
clear(agent: Agent, ref: GoalRef): GoalRef {
const cache = this.prepareMutation(agent)
const current = this.expectCurrent(cache, ref)
const tombstone: GoalRef = { id: current.id, revision: current.revision + 1 }
const change: GoalClearChangeMeta = {
kind: 'goal/change',
version: GOAL_CHANGE_VERSION,
operation: 'clear',
cleared: tombstone,
clearedAt: this.nextMutationTime(cache),
}
this.commit(agent, cache, change, 'disarmed')
return { ...tombstone }
}
/** Resolve and validate the cache used by a mutation. */
private prepareMutation(agent: Agent): GoalCache {
this.assertLive(agent)
const cache = this.cache(agent.session)
this.sync(agent.session, cache)
return cache
}
/** Reject stale or missing current-state refs. */
private expectCurrent(cache: GoalCache, ref: GoalRef): GoalSnapshot {
const current = cache.state.goal
if (current === undefined) throw new GoalError('no current goal', 'GOAL_NOT_FOUND')
if (ref.id !== current.id || ref.revision !== current.revision) {
throw new GoalError(
`stale goal ref "${ref.id}" revision ${ref.revision}; current is "${current.id}" revision ${current.revision}`,
'GOAL_STALE_REVISION',
)
}
return current
}
/** Enforce exact live-agent identity rather than trusting a matching id. */
private assertLive(agent: Agent): void {
if (this.ctx.agents.get(agent.id) !== agent || agent.status === 'disposed') {
throw new GoalError(`agent "${agent.id}" is not live in this registry`, 'GOAL_AGENT_NOT_LIVE')
}
}
/** Return the per-session cache, folding a seed once with activation disarmed. */
private cache(session: Session): GoalCache {
let cache = this.caches.get(session)
if (cache !== undefined) return cache
const state = emptyGoalFoldState()
for (const event of session.events) applyGoalEvent(state, event)
cache = {
state,
activation: 'disarmed',
observedSeq: session.seq,
pending: [],
}
this.caches.set(session, cache)
return cache
}
/** Incrementally observe durable events without losing deferred mutations. */
private sync(session: Session, cache: GoalCache): void {
for (const event of session.events.slice(cache.observedSeq)) {
if (event.type === 'context/message') {
const change = decodeGoalEvent(event)
if (change !== undefined) {
const pending = cache.pending[0]
if (pending !== undefined && sameChange(pending.change, change)) {
if (!pending.applied) {
applyGoalChange(cache.state, change)
cache.activation = pending.activation
pending.applied = true
}
cache.pending.shift()
cache.observedSeq += 1
continue
}
}
}
applyGoalEvent(cache.state, event)
cache.observedSeq += 1
}
}
/** Build a new revision with one replacement phase. */
private withPhase(current: GoalSnapshot, phase: GoalPhase): GoalSnapshot {
return {
id: current.id,
revision: current.revision + 1,
objective: current.objective,
phase,
maxGoalRounds: current.maxGoalRounds,
}
}
/** Shared validated phase transition. */
private transition(
agent: Agent,
ref: GoalRef,
operation: Exclude<GoalOperation, 'create' | 'edit' | 'clear'>,
allowed: readonly GoalPhase[],
phase: GoalPhase,
activation: GoalActivation,
): GoalView {
const cache = this.prepareMutation(agent)
const current = this.expectCurrent(cache, ref)
if (!allowed.includes(current.phase)) throw this.transitionError(current, operation, allowed)
return this.commitCurrent(agent, cache, operation, this.withPhase(current, phase), activation)
}
/** Render a stable invalid-transition error. */
private transitionError(current: GoalSnapshot, operation: GoalOperation, allowed: readonly GoalPhase[]): GoalError {
return new GoalError(
`cannot ${operation} goal "${current.id}" from phase "${current.phase}"; expected ${allowed.join(' or ')}`,
'GOAL_INVALID_TRANSITION',
)
}
/** Commit a mutation that retains the current goal's derived counters/times. */
private commitCurrent(
agent: Agent,
cache: GoalCache,
operation: Exclude<GoalOperation, 'create' | 'clear'>,
goal: GoalSnapshot,
activation: GoalActivation,
): GoalView {
const createdAt = cache.state.createdAt
/* v8 ignore next -- strict replay and every snapshot commit set createdAt whenever a current goal exists */
if (createdAt === undefined) throw new Error('current goal cache lacks createdAt')
return this.commitSnapshot(
agent,
cache,
operation,
goal,
cache.state.roundsStarted,
createdAt,
this.nextMutationTime(cache),
activation,
)
}
/** Clamp a current goal's next timestamp across backward wall-clock movement. */
private nextMutationTime(cache: GoalCache): number {
const updatedAt = cache.state.updatedAt
/* v8 ignore next -- strict replay and every snapshot commit set updatedAt whenever a current goal exists */
if (updatedAt === undefined) throw new Error('current goal cache lacks updatedAt')
return Math.max(Date.now(), updatedAt)
}
/** Build and commit one full-snapshot mutation. */
private commitSnapshot(
agent: Agent,
cache: GoalCache,
operation: Exclude<GoalOperation, 'clear'>,
goal: GoalSnapshot,
roundsStarted: number,
createdAt: number,
updatedAt: number,
activation: GoalActivation,
): GoalView {
const change: GoalSnapshotChangeMeta = {
kind: 'goal/change',
version: GOAL_CHANGE_VERSION,
operation,
goal,
roundsStarted,
createdAt,
updatedAt,
}
this.commit(agent, cache, change, activation)
const view = this.view(cache)
/* v8 ignore next -- applyGoalChange installs the snapshot immediately before this read */
if (view === undefined) throw new Error('snapshot commit cleared the goal unexpectedly')
return view
}
/** Accept one mutation into the agent log/FIFO, cache, and live event stream. */
private commit(agent: Agent, cache: GoalCache, change: GoalChangeMeta, activation: GoalActivation): void {
const ref = goalChangeRef(change)
// snapshotJsonValue preserves its input type for callers that already have
// a JsonValue; this interface is structurally JSON but intentionally has no
// index signature, so narrow the validated output at this boundary.
const meta = snapshotJsonValue(change) as JsonValue | undefined
/* v8 ignore next -- validated goal changes contain only finite JSON primitives and records */
if (meta === undefined) throw new Error('goal change is not losslessly JSON-serializable')
const pending: PendingGoalChange = { change, activation, applied: false }
cache.pending.push(pending)
try {
agent.inject(renderGoalChange(change), {
source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0 },
meta,
})
} catch (error: unknown) {
const index = cache.pending.indexOf(pending)
/* v8 ignore next -- a committed goal append cannot reject after its contained observers run */
if (index < 0) throw new Error('goal injection failed after its pending mutation was reconciled', { cause: error })
cache.pending.splice(index, 1)
throw error
}
if (!pending.applied) {
applyGoalChange(cache.state, change)
cache.activation = activation
pending.applied = true
}
this.sync(agent.session, cache)
const goal = this.view(cache)
const notification: GoalChanged = {
operation: change.operation,
ref: { ...ref },
...goal === undefined ? {} : { goal },
}
agentEvents(this.ctx, agent).emit('goal/changed', notification)
}
/** Build a detached current view. */
private view(cache: GoalCache): GoalView | undefined {
const goal = cache.state.goal
const createdAt = cache.state.createdAt
const updatedAt = cache.state.updatedAt
if (goal === undefined) return undefined
/* v8 ignore next 3 -- strict replay and snapshot commits establish both timestamps with every current goal */
if (createdAt === undefined || updatedAt === undefined) {
throw new Error(`goal "${goal.id}" cache lacks timestamps`)
}
return {
...goal,
roundsStarted: cache.state.roundsStarted,
createdAt,
updatedAt,
activation: cache.activation,
}
}
}
export default GoalService

View File

@@ -0,0 +1,79 @@
/** Package-owned durable goal-stream invariants. @module @deepseek-ai/dsh-goal/invariant */
import type { Context } from 'cordis'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { applyGoalEvent, emptyGoalFoldState } from './fold.ts'
import type { GoalFoldState } from './fold.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-goal'
/** Cordis companion plugin name. */
export const name = 'goal-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** Copy the independent fold before validating one candidate event. */
function cloneState(state: GoalFoldState): GoalFoldState {
return {
goal: state.goal,
roundsStarted: state.roundsStarted,
createdAt: state.createdAt,
updatedAt: state.updatedAt,
lastRef: state.lastRef,
seenGoalIds: new Set(state.seenGoalIds),
}
}
/** Apply one event through the strict goal decoder and attribute failures. */
function applyChecked(state: GoalFoldState, event: SessionEvent, fail: InvariantFailure): void {
try {
applyGoalEvent(state, event)
} catch (error) {
/* v8 ignore next -- the strict goal decoder throws Error instances */
const message = error instanceof Error ? error.message : String(error)
fail(`session event ${event.seq} violates the durable goal stream: ${message}`)
}
}
/** Install an independent incremental fold over every attached session. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
const states = new WeakMap<Session, GoalFoldState>()
const staged = new WeakMap<SessionEvent, { session: Session; state: GoalFoldState }>()
const seed = (session: Session): GoalFoldState => {
const state = emptyGoalFoldState()
for (const event of session.events) applyChecked(state, event, fail)
states.set(session, state)
return state
}
/* v8 ignore next -- session/event always follows list() or session/created seeding */
const stateFor = (session: Session): GoalFoldState => states.get(session) ?? seed(session)
for (const session of ctx.sessions.list()) seed(session)
ctx.on('session/created', (session) => { seed(session) }, { global: true })
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
const state = cloneState(stateFor(session))
applyChecked(state, event, fail)
staged.set(event, { session, state })
}, { global: true })
ctx.on('session/event', (session, event) => {
const candidate = staged.get(event)
/* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */
if (candidate === undefined || candidate.session !== session) {
return fail('session/event reached publication without matching goal-fold validation')
}
staged.delete(event)
states.set(session, candidate.state)
}, { global: true })
}, { inject: ['sessions'] })
/**
* Register the goal-stream invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -0,0 +1,21 @@
/** Model-visible rendering for durable goal mutations. */
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { GoalChangeMeta } from './types.ts'
/**
* Render a complete goal snapshot or clear tombstone without hidden prose.
* @param change - durable goal change metadata.
* @returns the single context block logged and projected verbatim for model reconstruction.
*/
export function renderGoalChange(change: GoalChangeMeta): ContentBlock[] {
const payload = change.operation === 'clear'
? { cleared: change.cleared, clearedAt: change.clearedAt }
: {
goal: change.goal,
roundsStarted: change.roundsStarted,
createdAt: change.createdAt,
updatedAt: change.updatedAt,
}
return [{ type: 'text', text: `<goal_state>${JSON.stringify(payload)}</goal_state>` }]
}

View File

@@ -0,0 +1,29 @@
/** Runtime constructors and protocol constants for the goal domain. */
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { GoalErrorCode, GoalId as GoalIdType } from './types.ts'
/** Version of the goal change metadata embedded in `context/message`. */
export const GOAL_CHANGE_VERSION = 1
/**
* Brand a string as a goal id.
* @param id - raw goal identifier.
* @returns the same string with the compile-time brand.
*/
export function GoalId(id: string): GoalIdType {
return id as GoalIdType
}
/** Error returned by the goal domain boundary. */
export class GoalError extends HarnessError {
/**
* @param message - human-readable rejection reason.
* @param code - stable machine-routable classification.
*/
// Keep the constructor to narrow HarnessError's string code at this boundary.
// eslint-disable-next-line @typescript-eslint/no-useless-constructor -- type-only narrowing
constructor(message: string, code: GoalErrorCode) {
super(message, code)
}
}

View File

@@ -0,0 +1,169 @@
/**
* Durable and live vocabulary for one same-session goal.
* @module @deepseek-ai/dsh-goal/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { Agent } from '@deepseek-ai/dsh-agent'
/** Identifies one goal across its durable revisions. */
export type GoalId = Branded<'GoalId'>
/** Compare-and-set identity for one exact goal revision. */
export interface GoalRef {
/** Stable goal identity. */
readonly id: GoalId
/** Positive revision; every durable mutation increments it. */
readonly revision: number
}
/** Durable continuation phase. Activation is process-local and separate. */
export type GoalPhase =
| 'active'
| 'paused'
| 'blocked'
| 'complete'
/** Machine-routable and human-readable explanation for a blocked goal. */
export interface GoalBlockReason {
/** Stable lower-kebab-case classification chosen by the blocking policy. */
readonly code: string
/** Non-empty explanation shown to humans and models. */
readonly message: string
}
/** Full durable state written by every non-clear goal mutation. */
export interface GoalSnapshot extends GoalRef {
/** Human-requested completion objective. */
readonly objective: string
/** Durable lifecycle phase. */
readonly phase: GoalPhase
/** Present exactly while `phase` is `blocked`. */
readonly blockedReason?: GoalBlockReason
/** Total admitted goal-round cap. */
readonly maxGoalRounds: number
}
/** Whether this live process may automatically continue an active goal. */
export type GoalActivation = 'armed' | 'disarmed'
/** Current goal projection, including values derived from the session log. */
export interface GoalView extends GoalSnapshot {
/** Highest admitted round number for this goal. */
readonly roundsStarted: number
/** Epoch milliseconds of the create mutation. */
readonly createdAt: number
/** Epoch milliseconds of the latest mutation. */
readonly updatedAt: number
/** Process-local continuation eligibility; never persisted. */
readonly activation: GoalActivation
}
/** Goal state-changing verbs recorded in the durable change metadata. */
export type GoalOperation =
| 'create'
| 'edit'
| 'pause'
| 'resume'
| 'complete'
| 'block'
| 'clear'
/** Full-snapshot goal mutation retained in a model-visible context event. */
export interface GoalSnapshotChangeMeta {
readonly kind: 'goal/change'
readonly version: 1
readonly operation: Exclude<GoalOperation, 'clear'>
readonly goal: GoalSnapshot
readonly roundsStarted: number
readonly createdAt: number
readonly updatedAt: number
}
/** Tombstone retained when the current goal is cleared. */
export interface GoalClearChangeMeta {
readonly kind: 'goal/change'
readonly version: 1
readonly operation: 'clear'
readonly cleared: GoalRef
readonly clearedAt: number
}
/** Durable metadata union carried by a goal-owned `context/message`. */
export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
/** Message attribution for durable goal state and continuation rounds. */
export interface GoalMessageSource {
readonly kind: 'goal'
readonly goalId: GoalId
readonly revision: number
/** Zero for state changes; positive for admitted continuation rounds. */
readonly round: number
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
goal: GoalMessageSource
}
}
/** Pure replay fold of durable goal facts. */
export interface FoldedGoal {
/** Current goal, absent after a clear or before the first create. */
readonly goal?: GoalSnapshot
/** Highest admitted round for the current goal. */
readonly roundsStarted: number
/** Current goal creation time, absent without a current goal. */
readonly createdAt?: number
/** Current goal mutation time, absent without a current goal. */
readonly updatedAt?: number
/** Latest mutation ref, including a clear tombstone. */
readonly lastRef?: GoalRef
}
/** Input whose omitted round cap is resolved by the service configuration. */
export interface CreateGoalRequest {
readonly objective: string
readonly maxGoalRounds?: number
}
/** Fields changed by an edit; at least one must be present. */
export interface EditGoalRequest {
readonly objective?: string
readonly maxGoalRounds?: number
}
/** Live notification after one goal mutation has been accepted for logging. */
export interface GoalChanged {
readonly operation: GoalOperation
readonly ref: GoalRef
/** Absent for a clear tombstone. */
readonly goal?: GoalView
}
/** Stable error codes for rejected goal reads and mutations. */
export type GoalErrorCode =
| 'GOAL_AGENT_NOT_LIVE'
| 'GOAL_NOT_FOUND'
| 'GOAL_ALREADY_EXISTS'
| 'GOAL_STALE_REVISION'
| 'GOAL_INVALID_OBJECTIVE'
| 'GOAL_INVALID_MAX_ROUNDS'
| 'GOAL_INVALID_BLOCK_REASON'
| 'GOAL_INVALID_EDIT'
| 'GOAL_INVALID_TRANSITION'
declare module 'cordis' {
interface Events {
/**
* Goal mutation accepted by one live agent. The matching context event is
* already appended or queued in that agent's active tool-batch FIFO.
* Listener failures are contained.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @param agent - agent whose session owns the goal.
* @param change - fresh current projection or clear tombstone.
* @mode emit
*/
'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, agent: Agent, change: GoalChanged): void
}
}

View File

@@ -0,0 +1,75 @@
import { readFile, readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { decodeGoalChange, renderGoalChange } from '@deepseek-ai/dsh-goal'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
const binScript = fileURLToPath(new URL('../../../examples/cli-demo/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL(
'../../../../examples/headless-agent/tests/fixtures/goal-domain/cordis.yml',
import.meta.url,
))
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
async function jsonlFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true })
const paths = await Promise.all(entries.map(async (entry) => {
const path = join(dir, entry.name)
if (entry.isDirectory()) return jsonlFiles(path)
return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
}))
return paths.flat()
}
describe('goal domain through a real cordis.yml and headless process', () => {
it('persists the Loader-mounted snapshot without starting a goal round', async () => {
let events: SessionEvent[] = []
const { stdout, stderr } = await runLoaderSmoke({
label: 'goal-domain',
tempDirPrefix: 'goal-domain-e2e-',
binScript,
configPath,
binArgs: ['--config', configPath, '--output-format', 'json', 'prove the persisted goal domain'],
tsconfigPath: repoTsconfig,
inspect: async (cwd) => {
const logs = await jsonlFiles(join(cwd, '.sessions'))
expect(logs).toHaveLength(1)
const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
},
})
expect(stderr).toBe('')
const result = JSON.parse(stdout) as Record<string, unknown>
expect(result).toMatchObject({
type: 'result',
success: true,
})
expect(result['result']).toBeTypeOf('string')
expect(result['result']).toContain('CLI tool round trip complete')
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(1)
const contexts = events.filter(event => event.type === 'context/message'
&& event.data.source.kind === 'goal')
expect(contexts).toHaveLength(1)
const context = contexts[0]
if (context?.type !== 'context/message') throw new Error('expected goal context event')
const change = decodeGoalChange(context.data.meta)
if (change === undefined) throw new Error('expected durable goal change')
expect(change).toMatchObject({
operation: 'create',
roundsStarted: 0,
goal: {
revision: 1,
objective: 'Prove the composed goal survives in the session log',
phase: 'active',
maxGoalRounds: 7,
},
})
expect(context.data.content).toEqual(renderGoalChange(change))
expect(JSON.stringify(context)).not.toContain('activation')
expect(events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'goal')).toHaveLength(0)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -0,0 +1,851 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
import { HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import GoalService, {
GoalError,
GoalId,
decodeGoalChange,
foldGoal,
renderGoalChange,
} from '@deepseek-ai/dsh-goal'
import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
interface DeferredInjection {
content: ContentBlock[]
options: InjectOptions | undefined
}
interface StubAgent {
agent: Agent
session: Session
deferred: DeferredInjection[]
setDeferred(value: boolean): void
setStatus(value: AgentStatus): void
drain(): void
}
/** Number the next balanced one-shot injection turn. */
function nextTurn(session: Session): number {
return session.events.reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1
}
/** Mirror the public Agent.inject idle/open-turn contract for domain tests. */
function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void {
const source: MessageSource = options?.source ?? { kind: 'user' }
const context = {
content,
source,
...options?.meta === undefined ? {} : { meta: options.meta },
}
const last = session.events.at(-1)
const open = last !== undefined && last.type !== 'turn/end'
if (open) {
session.append('context/message', context, { surfaceOp: 'append' })
return
}
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('context/message', context, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
/** Build a registry-compatible agent around one concrete session. */
function stubAgentForSession(session: Session): StubAgent {
const id = session.id
const deferred: DeferredInjection[] = []
let shouldDefer = false
let status: AgentStatus = 'idle'
const agent: Agent = {
id,
options: {},
session,
ctx: new Context(),
get status() { return status },
send() {},
steer() {},
inject(content, options) {
if (shouldDefer) deferred.push({ content, options })
else appendInjection(session, content, options)
},
cancel() {},
whenIdle() { return Promise.resolve() },
}
return {
agent,
session,
deferred,
setDeferred(value) { shouldDefer = value },
setStatus(value) { status = value },
drain() {
shouldDefer = false
for (const injection of deferred.splice(0)) appendInjection(session, injection.content, injection.options)
},
}
}
/** Build a registry-compatible agent with controllable context deferral. */
function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[]): StubAgent {
return stubAgentForSession(new Session(SessionId(rawId), seed))
}
async function harness(config: { defaultMaxGoalRounds?: number } = {}) {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService, config)
const stub = stubAgent(`goal-test-${Math.random()}`)
ctx.agents.register(stub.agent)
return { ctx, ...stub }
}
/** Append one admitted goal round as a balanced user-message turn. */
function appendRound(session: Session, ref: GoalRef, round: number): void {
const source = { kind: 'goal', goalId: ref.id, revision: ref.revision, round } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'message', source } })
session.append('user/message', { content: [{ type: 'text', text: `round ${round}` }], source }, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
describe('GoalService creation and replay', () => {
it('applies the configured default and writes one balanced verbatim context snapshot', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_700_000_000_000)
const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 })
const seen: string[] = []
ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) })
const goal = ctx.goals.create(agent, { objective: ' finish the feature ' })
expect(goal).toMatchObject({
objective: 'finish the feature',
phase: 'active',
revision: 1,
maxGoalRounds: 17,
roundsStarted: 0,
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_000_000,
activation: 'armed',
})
expect(goal.id).toMatch(/^goal-/)
expect(seen).toEqual(['create'])
expect(session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end'])
const context = session.events[1]
expect(context?.type).toBe('context/message')
if (context?.type !== 'context/message') throw new Error('expected goal context')
expect(context.data.source).toEqual({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 })
const change = decodeGoalChange(context.data.meta)
if (change === undefined) throw new Error('expected decoded goal change')
expect(change).toMatchObject({ operation: 'create', goal: { id: goal.id } })
expect(context.data.content).toEqual(renderGoalChange(change))
expect(session.deriveMessages()).toEqual([{ role: 'user', content: context.data.content }])
expect(foldGoal(session.events)).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 })
vi.useRealTimers()
})
it('uses 256 rounds by default and validates create input inside create', async () => {
const { ctx, agent } = await harness()
expect(() => ctx.goals.create(agent, { objective: ' ' })).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_OBJECTIVE',
}))
expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 0 })).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_MAX_ROUNDS',
}))
expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 1.5 })).toThrow(GoalError)
expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 1.5 })).toThrow(HarnessError)
expect(() => ctx.goals.create(agent, {
objective: 'x', maxGoalRounds: Number.MAX_SAFE_INTEGER + 1,
})).toThrow(GoalError)
expect(ctx.goals.create(agent, { objective: 'x' }).maxGoalRounds).toBe(256)
})
it('also resolves the default when constructed directly without Cordis config normalization', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const goals = new GoalService(ctx)
const stub = stubAgent('goal-direct-construction')
ctx.agents.register(stub.agent)
expect(goals.create(stub.agent, { objective: 'direct' })).toMatchObject({
objective: 'direct', maxGoalRounds: 256,
})
})
it('rejects invalid direct configuration', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await expect(ctx.plugin(GoalService, { defaultMaxGoalRounds: -1 })).rejects.toThrow(expect.objectContaining({
code: 'GOAL_INVALID_MAX_ROUNDS',
}))
})
it('restores a seeded goal and rounds with activation disarmed', async () => {
const first = await harness()
const created = first.ctx.goals.create(first.agent, { objective: 'seed me', maxGoalRounds: 9 })
appendRound(first.session, created, 1)
appendRound(first.session, created, 2)
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const resumed = stubAgent('seeded-goal', first.session.events)
ctx.agents.register(resumed.agent)
expect(ctx.goals.get(resumed.agent)).toMatchObject({
id: created.id,
roundsStarted: 2,
activation: 'disarmed',
})
})
it('inherits the completed-turn goal prefix through SessionStore.fork with child activation disarmed', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent')))
ctx.agents.register(parent.agent)
const goal = ctx.goals.create(parent.agent, { objective: 'inherit through fork', maxGoalRounds: 5 })
appendRound(parent.session, goal, 1)
const child = stubAgentForSession(ctx.sessions.fork(parent.session))
ctx.agents.register(child.agent)
expect(ctx.goals.get(child.agent)).toMatchObject({
id: goal.id,
objective: goal.objective,
roundsStarted: 1,
activation: 'disarmed',
})
expect(child.session.header.parentSession).toBe(parent.session.id)
expect(child.session.header.seedLength).toBe(parent.session.seq)
})
it('disarms live activation on every session-start edge', async () => {
const { ctx, agent, session } = await harness()
let goal = ctx.goals.create(agent, { objective: 'stay stopped after resume' })
expect(goal.activation).toBe('armed')
agentEvents(ctx, agent).emit('agent/session-start', 'resume')
expect(ctx.goals.get(agent)?.activation).toBe('disarmed')
goal = ctx.goals.resume(agent, goal)
expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 2 })
expect(() => foldGoal(session.events)).not.toThrow()
})
it('removes the service and its session-start listener with the providing fiber', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const fiber = await ctx.plugin(GoalService)
const first = ctx.goals
const stub = stubAgent('goal-hmr')
ctx.agents.register(stub.agent)
const goal = first.create(stub.agent, { objective: 'survive service reload' })
await fiber.dispose()
expect(ctx.get('goals')).toBeUndefined()
agentEvents(ctx, stub.agent).emit('agent/session-start', 'resume')
expect(first.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'armed' })
await ctx.plugin(GoalService)
expect(ctx.goals).not.toBe(first)
expect(ctx.goals.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'disarmed' })
})
it('requires the exact live registry instance for reads and mutations', async () => {
const { ctx, agent } = await harness()
const impostor = { ...agent, session: new Session(agent.id) }
expect(() => ctx.goals.get(impostor)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' }))
expect(() => ctx.goals.create(impostor, { objective: 'no' })).toThrow(expect.objectContaining({
code: 'GOAL_AGENT_NOT_LIVE',
}))
})
it('rejects a disposed live object even before registry teardown', async () => {
const test = await harness()
test.setStatus('disposed')
expect(() => test.ctx.goals.get(test.agent)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' }))
})
})
describe('GoalService mutations', () => {
it('edits with compare-and-set revisions and rejects empty edits', async () => {
const { ctx, agent } = await harness()
const created = ctx.goals.create(agent, { objective: 'old', maxGoalRounds: 4 })
expect(() => ctx.goals.edit(agent, created, {})).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_EDIT' }))
const objective = ctx.goals.edit(agent, created, { objective: ' new ' })
expect(objective).toMatchObject({ objective: 'new', maxGoalRounds: 4, revision: 2, activation: 'armed' })
expect(() => ctx.goals.edit(agent, created, { maxGoalRounds: 8 })).toThrow(expect.objectContaining({
code: 'GOAL_STALE_REVISION',
}))
const cap = ctx.goals.edit(agent, objective, { maxGoalRounds: 8 })
expect(cap).toMatchObject({ objective: 'new', maxGoalRounds: 8, revision: 3 })
expect(() => ctx.goals.edit(agent, cap, { objective: ' ' })).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_OBJECTIVE',
}))
})
it('supports pause, resume, block, and completion transitions', async () => {
const { ctx, agent } = await harness()
let goal = ctx.goals.create(agent, { objective: 'lifecycle' })
goal = ctx.goals.pause(agent, goal)
expect(goal).toMatchObject({ phase: 'paused', activation: 'disarmed', revision: 2 })
goal = ctx.goals.resume(agent, goal)
expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 3 })
goal = ctx.goals.block(agent, goal, { code: 'needs-input', message: 'A choice is required.' })
expect(goal).toMatchObject({
phase: 'blocked',
blockedReason: { code: 'needs-input', message: 'A choice is required.' },
activation: 'disarmed',
})
goal = ctx.goals.resume(agent, goal)
goal = ctx.goals.pause(agent, goal)
goal = ctx.goals.complete(agent, goal)
expect(goal).toMatchObject({ phase: 'complete', activation: 'disarmed' })
expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
})
it('allows completion from every stopped phase and replacement only after completion', async () => {
const phases = ['paused', 'blocked'] as const
for (const phase of phases) {
const { ctx, agent } = await harness()
let goal = ctx.goals.create(agent, { objective: phase })
goal = phase === 'paused'
? ctx.goals.pause(agent, goal)
: ctx.goals.block(agent, goal, { code: 'test-blocker', message: 'Blocked for the test.' })
const complete = ctx.goals.complete(agent, goal)
const replacement = ctx.goals.create(agent, { objective: `after ${phase}` })
expect(complete.phase).toBe('complete')
expect(replacement.id).not.toBe(complete.id)
expect(replacement.revision).toBe(1)
}
})
it('rejects replacement and invalid phase transitions while a resumable goal exists', async () => {
const { ctx, agent } = await harness()
const goal = ctx.goals.create(agent, { objective: 'still active' })
expect(() => ctx.goals.create(agent, { objective: 'replacement' })).toThrow(expect.objectContaining({
code: 'GOAL_ALREADY_EXISTS',
}))
expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
const paused = ctx.goals.pause(agent, goal)
expect(() => ctx.goals.pause(agent, paused)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
expect(() => ctx.goals.block(agent, paused, {
code: 'test-blocker', message: 'Blocked for the test.',
})).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_TRANSITION',
}))
})
it('records canonical blocker reasons and enforces the round cap on resume', async () => {
const { ctx, agent, session } = await harness()
let goal = ctx.goals.create(agent, { objective: 'bounded', maxGoalRounds: 2 })
for (const reason of [null, [], { code: 1, message: 'invalid code' }, { code: 'round-limit', message: 1 }]) {
expect(() => ctx.goals.block(agent, goal, reason as never)).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_BLOCK_REASON',
}))
}
expect(() => ctx.goals.block(agent, goal, {
code: 'Not Canonical', message: 'invalid code',
})).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_BLOCK_REASON' }))
expect(() => ctx.goals.block(agent, goal, {
code: 'round-limit', message: ' ',
})).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_BLOCK_REASON' }))
appendRound(session, goal, 1)
expect(ctx.goals.get(agent)?.roundsStarted).toBe(1)
appendRound(session, goal, 2)
goal = ctx.goals.block(agent, goal, { code: 'round-limit', message: ' Goal round limit reached. ' })
expect(goal).toMatchObject({
phase: 'blocked',
blockedReason: { code: 'round-limit', message: 'Goal round limit reached.' },
roundsStarted: 2,
activation: 'disarmed',
})
expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
goal = ctx.goals.edit(agent, goal, { maxGoalRounds: 3 })
expect(goal.blockedReason).toEqual({ code: 'round-limit', message: 'Goal round limit reached.' })
goal = ctx.goals.resume(agent, goal)
expect(goal).toMatchObject({ phase: 'active', maxGoalRounds: 3, activation: 'armed' })
expect(goal.blockedReason).toBeUndefined()
appendRound(session, goal, 3)
goal = ctx.goals.block(agent, goal, { code: 'round-limit', message: 'Goal round limit reached.' })
expect(ctx.goals.complete(agent, goal).phase).toBe('complete')
})
it('clears through a revisioned tombstone and permits a fresh goal', async () => {
const { ctx, agent, session } = await harness()
const goal = ctx.goals.create(agent, { objective: 'temporary' })
const tombstone = ctx.goals.clear(agent, goal)
expect(tombstone).toEqual({ id: goal.id, revision: 2 })
expect(ctx.goals.get(agent)).toBeUndefined()
expect(foldGoal(session.events)).toEqual({ roundsStarted: 0, lastRef: tombstone })
expect(() => ctx.goals.clear(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_NOT_FOUND' }))
const next = ctx.goals.create(agent, { objective: 'fresh' })
expect(next.id).not.toBe(goal.id)
})
it('keeps per-goal mutation timestamps monotonic when the wall clock moves backward', async () => {
vi.useFakeTimers()
vi.setSystemTime(100)
const { ctx, agent, session } = await harness()
let goal = ctx.goals.create(agent, { objective: 'monotonic time' })
vi.setSystemTime(90)
goal = ctx.goals.pause(agent, goal)
expect(goal.updatedAt).toBe(100)
vi.setSystemTime(80)
ctx.goals.clear(agent, goal)
const clear = session.events
.filter(event => event.type === 'context/message')
.map(event => decodeGoalChange(event.data.meta))
.at(-1)
expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 })
expect(() => foldGoal(session.events)).not.toThrow()
vi.useRealTimers()
})
it('contains goal notification failures and preserves later listeners', async () => {
const { ctx, agent } = await harness()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const seen: string[] = []
ctx.on('goal/changed', () => { throw new Error('broken observer') })
ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) })
expect(ctx.goals.create(agent, { objective: 'notify' }).phase).toBe('active')
expect(seen).toEqual(['create'])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer'))
})
it('preserves multiple pending revisions until deferred injections enter the log', async () => {
const test = await harness()
const { ctx, agent, session, deferred } = test
test.setDeferred(true)
let goal = ctx.goals.create(agent, { objective: 'deferred', maxGoalRounds: 5 })
goal = ctx.goals.edit(agent, goal, { objective: 'deferred edit' })
goal = ctx.goals.pause(agent, goal)
expect(goal).toMatchObject({ revision: 3, phase: 'paused', activation: 'disarmed' })
expect(deferred).toHaveLength(3)
expect(session.events).toHaveLength(0)
appendInjection(session, [{ type: 'text', text: 'unrelated' }], { source: { kind: 'plugin', plugin: 'test' } })
expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' })
test.drain()
expect(deferred).toHaveLength(0)
expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' })
expect(foldGoal(session.events)).toMatchObject({ goal: { revision: 3, phase: 'paused' } })
})
it('publishes a mutation consistently to a reentrant session observer', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer')))
ctx.agents.register(stub.agent)
let observed: ReturnType<GoalService['get']>
ctx.on('session/event', (session, event) => {
if (session === stub.session && event.type === 'context/message') observed = ctx.goals.get(stub.agent)
})
const created = ctx.goals.create(stub.agent, { objective: 'publish once' })
expect(observed).toEqual(created)
expect(ctx.goals.get(stub.agent)).toEqual(created)
expect(foldGoal(stub.session.events)).toMatchObject({ goal: { id: created.id, revision: 1 } })
})
it('rolls back a pending mutation when injection rejects before append', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const stub = stubAgent('goal-rejected-injection')
const append = stub.agent.inject.bind(stub.agent)
let reject = true
stub.agent.inject = (content, options) => {
if (reject) throw new Error('injection rejected')
append(content, options)
}
ctx.agents.register(stub.agent)
expect(() => ctx.goals.create(stub.agent, { objective: 'first attempt' })).toThrow('injection rejected')
reject = false
expect(ctx.goals.create(stub.agent, { objective: 'second attempt' })).toMatchObject({
objective: 'second attempt',
revision: 1,
})
})
it('rejects deferred goal mutations that enter the log out of FIFO order', async () => {
const test = await harness()
test.setDeferred(true)
const created = test.ctx.goals.create(test.agent, { objective: 'ordered' })
test.ctx.goals.edit(test.agent, created, { objective: 'ordered edit' })
const second = test.deferred[1]
if (second === undefined) throw new Error('expected a second deferred goal mutation')
appendInjection(test.session, second.content, second.options)
expect(() => test.ctx.goals.get(test.agent)).toThrow('advance the current goal')
})
it('observes a valid goal snapshot appended after an empty cache was established', async () => {
const { ctx, agent, session } = await harness()
expect(ctx.goals.get(agent)).toBeUndefined()
const change: GoalSnapshotChangeMeta = {
kind: 'goal/change',
version: 1,
operation: 'create',
goal: {
id: GoalId('goal-external'),
revision: 1,
objective: 'observe external append',
phase: 'active',
maxGoalRounds: 4,
},
roundsStarted: 0,
createdAt: 12,
updatedAt: 12,
}
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('context/message', {
content: renderGoalChange(change), source, meta: change as never,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
expect(ctx.goals.get(agent)).toMatchObject({
id: change.goal.id,
objective: change.goal.objective,
activation: 'disarmed',
})
})
it('reports the same corrupt unseen event after committing its valid prefix', async () => {
const { ctx, agent, session } = await harness()
expect(ctx.goals.get(agent)).toBeUndefined()
const change: GoalSnapshotChangeMeta = {
kind: 'goal/change',
version: 1,
operation: 'create',
goal: {
id: GoalId('goal-valid-prefix'),
revision: 1,
objective: 'valid prefix',
phase: 'active',
maxGoalRounds: 4,
},
roundsStarted: 0,
createdAt: 12,
updatedAt: 12,
}
appendInjection(session, renderGoalChange(change), {
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 },
meta: change as never,
})
appendInjection(session, [{ type: 'text', text: 'corrupt' }], {
source: { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 },
meta: { ...change, operation: 'edit', extra: true } as never,
})
expect(() => ctx.goals.get(agent)).toThrow('invalid shape')
expect(() => ctx.goals.get(agent)).toThrow('invalid shape')
})
})
describe('goal replay validation', () => {
function snapshotChange(overrides: Partial<GoalSnapshotChangeMeta> = {}): GoalSnapshotChangeMeta {
return {
kind: 'goal/change',
version: 1,
operation: 'create',
goal: {
id: GoalId('goal-validation'),
revision: 1,
objective: 'validate',
phase: 'active',
maxGoalRounds: 2,
},
roundsStarted: 0,
createdAt: 10,
updatedAt: 10,
...overrides,
}
}
function appendChange(
session: Session,
change: GoalChangeMeta,
overrides: { content?: ContentBlock[]; source?: MessageSource } = {},
): void {
const source = overrides.source ?? {
kind: 'goal',
goalId: change.operation === 'clear' ? change.cleared.id : change.goal.id,
revision: change.operation === 'clear' ? change.cleared.revision : change.goal.revision,
round: 0,
}
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('context/message', {
content: overrides.content ?? renderGoalChange(change),
source,
meta: change as never,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
function oneChange(change: GoalChangeMeta, overrides: { content?: ContentBlock[]; source?: MessageSource } = {}) {
const session = new Session(SessionId(`validation-${Math.random()}`))
appendChange(session, change, overrides)
return session.events
}
function mutation(
current: GoalSnapshotChangeMeta,
operation: Exclude<GoalSnapshotChangeMeta['operation'], 'create'>,
phase: GoalSnapshotChangeMeta['goal']['phase'],
overrides: Partial<GoalSnapshotChangeMeta> = {},
): GoalSnapshotChangeMeta {
return {
...current,
operation,
goal: {
id: current.goal.id,
revision: current.goal.revision + 1,
objective: current.goal.objective,
phase,
...phase === 'blocked'
? { blockedReason: { code: 'test-blocker', message: 'Blocked for replay validation.' } }
: {},
maxGoalRounds: current.goal.maxGoalRounds,
},
updatedAt: current.updatedAt + 1,
...overrides,
}
}
function foldPair(first: GoalSnapshotChangeMeta, second: GoalChangeMeta): ReturnType<typeof foldGoal> {
const session = new Session(SessionId(`validation-pair-${Math.random()}`))
appendChange(session, first)
appendChange(session, second)
return foldGoal(session.events)
}
it('ignores unrelated metadata and non-goal round sources', () => {
expect(decodeGoalChange(undefined)).toBeUndefined()
expect(decodeGoalChange({ kind: 'other' })).toBeUndefined()
const session = new Session(SessionId('unrelated'))
appendInjection(session, [{ type: 'text', text: 'other' }], {
source: { kind: 'plugin', plugin: 'test' },
meta: { kind: 'other' },
})
expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 })
const source = { kind: 'plugin', plugin: 'ordinary-user-message' } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'message', source } })
session.append('user/message', { content: [{ type: 'text', text: 'ordinary' }], source }, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 })
})
it('rejects rounds attributed to another goal', () => {
const change = snapshotChange()
const session = new Session(SessionId('other-goal-round'), oneChange(change))
appendRound(session, { id: GoalId('goal-other'), revision: 1 }, 1)
expect(() => foldGoal(session.events)).toThrow('not the next admitted round')
})
it('rejects unsupported versions, operations, and top-level shapes', () => {
expect(() => decodeGoalChange({ ...snapshotChange(), version: 2 })).toThrow('unsupported goal change version')
expect(() => decodeGoalChange({ ...snapshotChange(), operation: 'explode' })).toThrow('operation is invalid')
expect(() => decodeGoalChange({ ...snapshotChange(), extra: true })).toThrow('snapshot change has an invalid shape')
expect(() => decodeGoalChange({
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'x', revision: 2 }, clearedAt: 1, extra: true,
})).toThrow('clear change has an invalid shape')
})
it('rejects invalid create and missing-current mutation sequences', () => {
const base = snapshotChange()
const invalidCreates: GoalSnapshotChangeMeta[] = [
{ ...base, goal: { ...base.goal, revision: 2 } },
{ ...base, goal: { ...base.goal, phase: 'paused' } },
{ ...base, roundsStarted: 1 },
]
for (const change of invalidCreates) expect(() => foldGoal(oneChange(change))).toThrow('goal create requires')
const edit = mutation(base, 'edit', 'active')
expect(() => foldGoal(oneChange(edit))).toThrow('requires a current goal')
const clear: GoalChangeMeta = {
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 12,
}
expect(() => foldGoal(oneChange(clear))).toThrow('clear requires a current goal')
const secondCreate = snapshotChange({
goal: { ...base.goal, id: GoalId('goal-second') },
createdAt: 20,
updatedAt: 20,
})
expect(() => foldPair(base, secondCreate)).toThrow('goal create requires')
})
it('rejects stale identity, counters, timestamps, and definition changes', () => {
const base = snapshotChange()
const invalid: GoalSnapshotChangeMeta[] = [
mutation(base, 'edit', 'active', { goal: { ...base.goal, id: GoalId('goal-wrong'), revision: 2 } }),
mutation(base, 'edit', 'active', { goal: { ...base.goal, revision: 3 } }),
mutation(base, 'edit', 'active', { createdAt: 11 }),
mutation(base, 'edit', 'active', { updatedAt: 9 }),
mutation(base, 'edit', 'active', { roundsStarted: 1 }),
mutation(base, 'pause', 'paused', {
goal: { ...base.goal, revision: 2, phase: 'paused', objective: 'changed illegally' },
}),
mutation(base, 'pause', 'paused', {
goal: { ...base.goal, revision: 2, phase: 'paused', maxGoalRounds: 3 },
}),
]
for (const change of invalid) expect(() => foldPair(base, change)).toThrow()
})
it('rejects invalid replayed lifecycle phase transitions', () => {
const base = snapshotChange()
const invalid: GoalSnapshotChangeMeta[] = [
mutation(base, 'edit', 'paused'),
mutation(base, 'pause', 'active'),
mutation(base, 'resume', 'paused'),
mutation(base, 'complete', 'active'),
mutation(base, 'block', 'active'),
]
for (const change of invalid) expect(() => foldPair(base, change)).toThrow()
const paused = mutation(base, 'pause', 'paused')
const exhausted = mutation(paused, 'resume', 'active', {
roundsStarted: 2,
goal: { ...paused.goal, revision: 3, phase: 'active', maxGoalRounds: 2 },
})
const session = new Session(SessionId('exhausted-resume'))
appendChange(session, base)
appendRound(session, base.goal, 1)
appendRound(session, base.goal, 2)
appendChange(session, { ...paused, roundsStarted: 2 })
appendChange(session, exhausted)
expect(() => foldGoal(session.events)).toThrow('exhausted round budget')
})
it('rejects invalid clear continuity and goal id reuse', () => {
const base = snapshotChange()
const staleClear: GoalChangeMeta = {
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 3 }, clearedAt: 11,
}
expect(() => foldPair(base, staleClear)).toThrow('advance the current goal')
const earlyClear: GoalChangeMeta = {
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 9,
}
expect(() => foldPair(base, earlyClear)).toThrow('timestamp cannot precede')
const complete = mutation(base, 'complete', 'complete')
const sameCurrentId = snapshotChange({
goal: { ...base.goal, revision: 1 },
createdAt: 20,
updatedAt: 20,
})
const completedSession = new Session(SessionId('reuse-complete'))
appendChange(completedSession, base)
appendChange(completedSession, complete)
appendChange(completedSession, sameCurrentId)
expect(() => foldGoal(completedSession.events)).toThrow('fresh active revision-one')
const second = snapshotChange({
goal: { ...base.goal, id: GoalId('goal-second') },
createdAt: 20,
updatedAt: 20,
})
const secondComplete = mutation(second, 'complete', 'complete')
const nonAdjacentReuse = new Session(SessionId('reuse-non-adjacent'))
appendChange(nonAdjacentReuse, base)
appendChange(nonAdjacentReuse, complete)
appendChange(nonAdjacentReuse, second)
appendChange(nonAdjacentReuse, secondComplete)
appendChange(nonAdjacentReuse, { ...sameCurrentId, createdAt: 30, updatedAt: 30 })
expect(() => foldGoal(nonAdjacentReuse.events)).toThrow('fresh active revision-one')
const clear: GoalChangeMeta = {
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 11,
}
const clearedSession = new Session(SessionId('reuse-clear'))
appendChange(clearedSession, base)
appendChange(clearedSession, clear)
appendChange(clearedSession, sameCurrentId)
expect(() => foldGoal(clearedSession.events)).toThrow('fresh active revision-one')
})
it('rejects goal-source context without matching durable metadata', () => {
const session = new Session(SessionId('goal-source-without-meta'))
const source = { kind: 'goal', goalId: GoalId('goal-missing-meta'), revision: 1, round: 0 } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('context/message', {
content: [{ type: 'text', text: 'missing' }], source,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
expect(() => foldGoal(session.events)).toThrow('lacks goal change metadata')
})
it('rejects malformed snapshots, refs, counters, and timestamps', () => {
const base = snapshotChange()
const badSnapshots: unknown[] = [
null,
{ ...base.goal, extra: true },
{ ...base.goal, id: '' },
{ ...base.goal, objective: ' ' },
{ ...base.goal, objective: ' padded ' },
{ ...base.goal, phase: 'unknown' },
{ ...base.goal, blockedReason: { code: 'unexpected', message: 'Only blocked goals have reasons.' } },
{ ...base.goal, phase: 'blocked' },
{ ...base.goal, phase: 'blocked', blockedReason: null },
{ ...base.goal, phase: 'blocked', blockedReason: { code: 'test-blocker', message: 'Valid.', extra: true } },
{ ...base.goal, phase: 'blocked', blockedReason: { code: 'NOT_CANONICAL', message: 'Bad code.' } },
{ ...base.goal, phase: 'blocked', blockedReason: { code: 'test-blocker', message: ' padded ' } },
{ ...base.goal, revision: 0 },
{ ...base.goal, maxGoalRounds: -1 },
]
for (const goal of badSnapshots) expect(() => decodeGoalChange({ ...base, goal })).toThrow()
expect(() => decodeGoalChange({ ...base, roundsStarted: -1 })).toThrow('roundsStarted')
expect(() => decodeGoalChange({ ...base, createdAt: -1 })).toThrow('createdAt')
expect(() => decodeGoalChange({ ...base, updatedAt: 9 })).toThrow('cannot precede')
expect(() => decodeGoalChange({
kind: 'goal/change', version: 1, operation: 'clear', cleared: null, clearedAt: 1,
})).toThrow('tombstone')
expect(() => decodeGoalChange({
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: '', revision: 1 }, clearedAt: 1,
})).toThrow('non-empty')
expect(() => decodeGoalChange({
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'x', revision: 0 }, clearedAt: 1,
})).toThrow('positive safe integer')
})
it('rejects source and content drift from the durable metadata', () => {
const change = snapshotChange()
expect(() => foldGoal(oneChange(change, { source: { kind: 'plugin', plugin: 'wrong' } }))).toThrow('mismatched source')
expect(() => foldGoal(oneChange(change, {
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: -1 },
}))).toThrow('source is invalid')
expect(() => foldGoal(oneChange(change, { content: [{ type: 'text', text: 'wrong' }] }))).toThrow('model-visible content')
})
it('folds a clear tombstone after a snapshot', () => {
const change = snapshotChange()
const session = new Session(SessionId('fold-clear'), oneChange(change))
const clear: GoalChangeMeta = {
kind: 'goal/change',
version: 1,
operation: 'clear',
cleared: { id: change.goal.id, revision: 2 },
clearedAt: 20,
}
const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('context/message', {
content: renderGoalChange(clear), source, meta: clear as never,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
expect(foldGoal(session.events)).toEqual({
roundsStarted: 0,
lastRef: { id: change.goal.id, revision: 2 },
})
})
})

View File

@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import {
GoalId,
renderGoalChange,
type GoalSnapshotChangeMeta,
} from '@deepseek-ai/dsh-goal'
import * as GoalInvariantCompanion from '@deepseek-ai/dsh-goal/invariant'
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
const change: GoalSnapshotChangeMeta = {
kind: 'goal/change',
version: 1,
operation: 'create',
goal: {
id: GoalId('goal-invariant'),
revision: 1,
objective: 'check the stream',
phase: 'active',
maxGoalRounds: 2,
},
roundsStarted: 0,
createdAt: 1,
updatedAt: 1,
}
const changeSource = {
kind: 'goal',
goalId: change.goal.id,
revision: change.goal.revision,
round: 0,
} as const
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(GoalInvariantCompanion)
return ctx
}
describe('goal stream invariants', () => {
it('accepts canonical goal snapshots and sequential admitted rounds', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('goal-invariant-valid'))
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
session.append('context/message', {
content: renderGoalChange(change),
source: changeSource,
meta: change as never,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', {
turn: 2,
trigger: {
kind: 'message',
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 },
},
})
expect(() => {
session.append('user/message', {
content: [{ type: 'text', text: 'continue' }],
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 },
}, { surfaceOp: 'append' })
}).not.toThrow()
})
it('rejects model-visible drift before committing it and keeps the fold reusable', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('goal-invariant-invalid'))
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
expect(() => {
session.append('context/message', {
content: [{ type: 'text', text: 'counterfeit' }],
source: changeSource,
meta: change as never,
}, { surfaceOp: 'append' })
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-goal',
}))
expect(session.seq).toBe(1)
expect(() => {
session.append('context/message', {
content: renderGoalChange(change),
source: changeSource,
meta: change as never,
}, { surfaceOp: 'append' })
}).not.toThrow()
})
it('reconstructs an existing durable goal before checking later rounds', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('goal-invariant-late-load'))
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
session.append('context/message', {
content: renderGoalChange(change),
source: changeSource,
meta: change as never,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(GoalInvariantCompanion)
session.append('turn/start', {
turn: 2,
trigger: {
kind: 'message',
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 },
},
})
expect(() => {
session.append('user/message', {
content: [{ type: 'text', text: 'continue after load' }],
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 },
}, { surfaceOp: 'append' })
}).not.toThrow()
})
})

View File

@@ -0,0 +1,39 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../core/scope"
},
{
"path": "../../core/agent"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -38,6 +38,7 @@ The current executable companions protect these relationships:
| `dsh-llm`, `dsh-llm-retry`, `dsh-tools`, `dsh-system-prompt` | Stream grammar, durable retry position and bounds, tool-pipeline stages and frozen results, and authoritative prompt-assembly data. |
| `dsh-compact`, `dsh-hook-protocol`, `dsh-sandbox-policy` | Durable compaction and hook pairing, compaction metadata, and sandbox-mode vocabulary. |
| `dsh-fs`, `dsh-subagent`, `dsh-workflow` | Filesystem event identity, provider/child pairing, and workflow/agent lifecycle identity. |
| `dsh-goal` | Durable goal source/content agreement, revision and lifecycle transitions, timestamps, and sequential admitted rounds. |
| `dsh-permission`, `dsh-user-approval` | Active-preset references and approval asked/decided audit pairing. |
| `dsh-tasks`, `dsh-tool-todo` | Task snapshot lifecycle/ownership fields and durable whole-list todo structure. |
| `dsh-time-context` | Durable clock readings agree with the session's open turn and next pre-step position and elapsed baseline; rendered time parses and does not postdate its event. |