feat(goal): split the pure type outlet and register the 'goal' projection unit
dsh-goal/types becomes the client-safe pure outlet (tool-todo dual-outlet shape): GoalId/GoalRef/GoalPhase/GoalBlockReason/GoalSnapshot, the new GoalProjection payload, and the SessionProjectionMap 'goal' key merge — zero host imports. Host-coupled vocabulary (GoalView/activation, change metas, message source, folds, GoalError codes, the scoped goal/changed event) moves to src/domain.ts, re-exported from the package root. ./client re-exports the outlet for client aggregates. GoalService registers the projection unit under ctx.inject(['sessionProjections']): applyGoalProjection is a projection-grade last-wins fold — plain-JSON state, same-reference return on non-goal or malformed events (a throwing apply would tear down the registry drive; strict validation stays with the write side and foldGoal). Activation is process-local and deliberately absent from the projection value.
This commit is contained in:
@@ -15,12 +15,21 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client.d.ts",
|
||||
"default": "./lib/types/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -28,6 +37,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-projection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
@@ -36,10 +46,12 @@
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.17.2"
|
||||
"schemastery": "^3.17.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Client-namespace projection of the goal domain: a pure re-export of the
|
||||
* package's types outlet. Client code imports ONLY the client namespace
|
||||
* (repo discipline), so `./client` projects the same single-source content
|
||||
* `./types` serves to host consumers — zero duplication.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-goal/client
|
||||
*/
|
||||
|
||||
export type * from './types.ts'
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Host-side vocabulary of the goal domain: live views, durable change
|
||||
* payloads, message attribution, replay folds, and the scoped `goal/changed`
|
||||
* event. Split from ./types.ts (the pure client-safe outlet) because these
|
||||
* declarations pull dsh-agent, dsh-llm, and cordis into the program — the
|
||||
* one-program-per-side layout forbids that on client aggregates.
|
||||
* @module @deepseek-ai/dsh-goal
|
||||
*/
|
||||
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { GoalId, GoalRef, GoalSnapshot } from './types.ts'
|
||||
|
||||
/** 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 source change. */
|
||||
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 change union carried by a goal-owned round-zero message source. */
|
||||
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
|
||||
/** Complete durable mutation carried only by round-zero state-change messages. */
|
||||
readonly change?: GoalChangeMeta
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,15 @@ 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 { GoalBlockReason, GoalPhase, GoalRef, GoalSnapshot } from './types.ts'
|
||||
import type {
|
||||
FoldedGoal,
|
||||
GoalBlockReason,
|
||||
GoalChangeMeta,
|
||||
GoalClearChangeMeta,
|
||||
GoalMessageSource,
|
||||
GoalOperation,
|
||||
GoalPhase,
|
||||
GoalRef,
|
||||
GoalSnapshot,
|
||||
GoalSnapshotChangeMeta,
|
||||
} from './types.ts'
|
||||
} from './domain.ts'
|
||||
|
||||
type UserMessageEvent = Extract<SessionEvent, { type: 'user/message' }>
|
||||
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { z as zod } from 'zod'
|
||||
import type { ZodType } from 'zod'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
// Type-only: resolves ctx.sessionProjections for the optional unit child.
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
import {
|
||||
applyGoalChange,
|
||||
applyGoalEvent,
|
||||
@@ -25,23 +29,31 @@ import {
|
||||
GoalError,
|
||||
GoalId,
|
||||
} from './runtime.ts'
|
||||
import type {
|
||||
GoalBlockReason,
|
||||
GoalPhase,
|
||||
GoalProjection,
|
||||
GoalRef,
|
||||
GoalSnapshot,
|
||||
} from './types.ts'
|
||||
import type {
|
||||
CreateGoalRequest,
|
||||
EditGoalRequest,
|
||||
GoalActivation,
|
||||
GoalBlockReason,
|
||||
GoalChangeMeta,
|
||||
GoalChanged,
|
||||
GoalClearChangeMeta,
|
||||
GoalOperation,
|
||||
GoalPhase,
|
||||
GoalRef,
|
||||
GoalSnapshot,
|
||||
GoalSnapshotChangeMeta,
|
||||
GoalView,
|
||||
} from './types.ts'
|
||||
} from './domain.ts'
|
||||
|
||||
export * from './types.ts'
|
||||
// The pure payload outlet (./types.ts, ONE home of the `goal` projection-key
|
||||
// declaration) re-exported onto the package root keeps the module edge in
|
||||
// the emitted index.d.ts, so aggregate programs consuming the declarations
|
||||
// still receive the SessionProjectionMap merge.
|
||||
export type * from './types.ts'
|
||||
export type * from './domain.ts'
|
||||
export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts'
|
||||
export { decodeGoalChange, foldGoal, goalChangeRef } from './fold.ts'
|
||||
export { renderGoalChange } from './render.ts'
|
||||
@@ -52,6 +64,52 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Wire payload schema of the `goal` projection (whole current goal or pre-create/cleared null). */
|
||||
const goalProjectionSchema: ZodType<GoalProjection | null> = zod.union([
|
||||
zod.object({
|
||||
goal: zod.object({
|
||||
id: zod.string().min(1),
|
||||
revision: zod.number().int().positive(),
|
||||
objective: zod.string().min(1),
|
||||
phase: zod.union([zod.literal('active'), zod.literal('paused'), zod.literal('blocked'), zod.literal('complete')]),
|
||||
blockedReason: zod.object({ code: zod.string(), message: zod.string() }).optional(),
|
||||
maxGoalRounds: zod.number().int().positive(),
|
||||
}),
|
||||
roundsStarted: zod.number().int().nonnegative(),
|
||||
createdAt: zod.number(),
|
||||
updatedAt: zod.number(),
|
||||
}),
|
||||
zod.null(),
|
||||
]) as ZodType<GoalProjection | null>
|
||||
|
||||
/**
|
||||
* Light last-wins fold of the `goal` projection unit. Unlike the strict
|
||||
* replay fold (fold.ts: transition validation, fail-loud on malformed
|
||||
* changes, Set-typed state), this transition is projection-grade: the state
|
||||
* is plain JSON (persisted-cache precondition), any non-goal or malformed
|
||||
* event returns the same reference (the registry's Object.is gate — the
|
||||
* title/todos posture), and correctness of the written change is the write
|
||||
* side's job (GoalService validated it before appending; the package
|
||||
* invariant rejects a violating stream fail-loud where it is installed).
|
||||
* @param state - the projection covering all prior events.
|
||||
* @param event - the next committed session event.
|
||||
* @returns the next projection (same reference when the event is not a goal change).
|
||||
*/
|
||||
export function applyGoalProjection(state: GoalProjection | null, event: SessionEvent): GoalProjection | null {
|
||||
if (event.type !== 'user/message') return state
|
||||
const source = event.data.source
|
||||
if (source.kind !== 'goal' || source.round !== 0) return state
|
||||
const change = source.change
|
||||
if (change === undefined || change.kind !== 'goal/change') return state
|
||||
if (change.operation === 'clear') return null
|
||||
return {
|
||||
goal: change.goal,
|
||||
roundsStarted: change.roundsStarted,
|
||||
createdAt: change.createdAt,
|
||||
updatedAt: change.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
/** Deployment defaults for goal creation. */
|
||||
export interface Config {
|
||||
/** Total rounds used when a create request omits its own cap. */
|
||||
@@ -150,6 +208,19 @@ export class GoalService extends Service {
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
this.cache(agent.session).activation = 'disarmed'
|
||||
})
|
||||
// The `goal` projection unit: last-wins fold of goal/change whole values
|
||||
// (see applyGoalProjection). The unit child activates only when a
|
||||
// projection registry is composed (headless assemblies stay unaffected).
|
||||
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
||||
projectionCtx.sessionProjections.register<'goal', GoalProjection | null>({
|
||||
key: 'goal',
|
||||
schema: goalProjectionSchema,
|
||||
init: () => null,
|
||||
apply: applyGoalProjection,
|
||||
view: state => state,
|
||||
stateVersion: 1,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Model-visible rendering for durable goal mutations. */
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { GoalChangeMeta } from './types.ts'
|
||||
import type { GoalChangeMeta } from './domain.ts'
|
||||
|
||||
/**
|
||||
* Render a complete goal snapshot or clear tombstone without hidden prose.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** 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'
|
||||
import type { GoalId as GoalIdType } from './types.ts'
|
||||
import type { GoalErrorCode } from './domain.ts'
|
||||
|
||||
/** Version of the goal change embedded in a round-zero message source. */
|
||||
export const GOAL_CHANGE_VERSION = 1
|
||||
|
||||
+24
-115
@@ -1,10 +1,16 @@
|
||||
/**
|
||||
* Durable and live vocabulary for one same-session goal.
|
||||
* Pure types of the goal domain: the ONE home of the `goal` projection-key
|
||||
* declaration plus the durable payload vocabulary it carries, free of this
|
||||
* package's host-side imports (cordis events, dsh-agent, dsh-llm, the
|
||||
* service). Two namespace projections serve it — `./types` for host
|
||||
* consumers, `./client` (the browser half-entry's re-export) for client
|
||||
* aggregates — with zero content duplication. Host-coupled domain
|
||||
* vocabulary (message sources, events, fold shapes) lives in ./domain.ts.
|
||||
*
|
||||
* @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'>
|
||||
@@ -44,128 +50,31 @@ export interface GoalSnapshot extends GoalRef {
|
||||
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 {
|
||||
/**
|
||||
* The `goal` projection value: the current durable goal with its replay
|
||||
* counters, exactly as the latest `goal/change` source carried them.
|
||||
* Activation is process-local (never persisted) and deliberately absent —
|
||||
* the projection reflects durable phase only.
|
||||
*/
|
||||
export interface GoalProjection {
|
||||
/** Current durable goal snapshot (the CAS ref for mutations rides on it). */
|
||||
readonly goal: 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 source change. */
|
||||
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 change union carried by a goal-owned round-zero message source. */
|
||||
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
|
||||
/** Complete durable mutation carried only by round-zero state-change messages. */
|
||||
readonly change?: GoalChangeMeta
|
||||
}
|
||||
|
||||
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 {
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
/**
|
||||
* 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
|
||||
* The session's current goal (the latest `goal/change` whole value), or
|
||||
* `null` before the first create and after a clear tombstone.
|
||||
* Whole-value rule: every goal change carries the complete post-change
|
||||
* state, so the fold is last-wins.
|
||||
*/
|
||||
'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, agent: Agent, change: GoalChanged): void
|
||||
goal: GoalProjection | null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* The `goal` projection unit: mounting GoalService beside the registry
|
||||
* serves the current whole goal on the history tail page with a consistent
|
||||
* asOfSeq; before the first create the value is null; a clear tombstone
|
||||
* returns it to null; a composition without the goal service has no `goal`
|
||||
* key; unmounting drops it (HMR safety). Malformed goal-shaped events are
|
||||
* ignored fail-soft (same-reference return) — strict replay validation
|
||||
* belongs to the write side and foldGoal, never the projection drive.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import GoalService, { applyGoalProjection } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef } from '@deepseek-ai/dsh-goal'
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
session: Session
|
||||
agent: Agent
|
||||
tailValues(): Record<string, unknown>
|
||||
tailAsOfSeq(): number
|
||||
}
|
||||
|
||||
/** Register a minimal registry-compatible live agent over a store session. */
|
||||
function liveAgent(ctx: Context, session: Session): Agent {
|
||||
const status: AgentStatus = 'idle'
|
||||
const agent: Agent = {
|
||||
id: session.id,
|
||||
options: {},
|
||||
session,
|
||||
ctx,
|
||||
get status() { return status },
|
||||
get acceptsNextStep() { return false },
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input: UserMessage) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
},
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
}
|
||||
|
||||
async function harness(withGoal: boolean): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
if (withGoal) await ctx.plugin(GoalService)
|
||||
const session = ctx.sessions.create()
|
||||
const agent = liveAgent(ctx, session)
|
||||
return {
|
||||
ctx,
|
||||
session,
|
||||
agent,
|
||||
tailValues: () => ctx.sessionProjections.snapshot(session).values as Record<string, unknown>,
|
||||
tailAsOfSeq: () => ctx.sessionProjections.snapshot(session).asOfSeq,
|
||||
}
|
||||
}
|
||||
|
||||
/** One paginable message so the tail is non-degenerate. */
|
||||
function seedMessage(session: Session): void {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
describe('goal projection unit', () => {
|
||||
it('serves null before the first create', async () => {
|
||||
const bench = await harness(true)
|
||||
seedMessage(bench.session)
|
||||
expect(bench.tailValues()).toEqual({ goal: null })
|
||||
expect(bench.tailAsOfSeq()).toBe(bench.session.seq - 1)
|
||||
})
|
||||
|
||||
it('serves the whole current goal after create and tracks mutations last-wins', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_700_000_000_000)
|
||||
try {
|
||||
const bench = await harness(true)
|
||||
seedMessage(bench.session)
|
||||
const created = bench.ctx.goals.create(bench.agent, { objective: 'ship the goal bar' })
|
||||
const afterCreate = bench.tailValues().goal
|
||||
expect(afterCreate).toMatchObject({
|
||||
goal: { id: created.id, revision: 1, objective: 'ship the goal bar', phase: 'active' },
|
||||
roundsStarted: 0,
|
||||
})
|
||||
|
||||
const ref: GoalRef = { id: created.id, revision: created.revision }
|
||||
const paused = bench.ctx.goals.pause(bench.agent, ref)
|
||||
expect(bench.tailValues().goal).toMatchObject({
|
||||
goal: { revision: paused.revision, phase: 'paused' },
|
||||
})
|
||||
expect(bench.tailAsOfSeq()).toBe(bench.session.seq - 1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns to null after a clear tombstone', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_700_000_000_000)
|
||||
try {
|
||||
const bench = await harness(true)
|
||||
seedMessage(bench.session)
|
||||
const created = bench.ctx.goals.create(bench.agent, { objective: 'temporary' })
|
||||
expect(bench.tailValues().goal).not.toBeNull()
|
||||
bench.ctx.goals.clear(bench.agent, { id: created.id, revision: created.revision })
|
||||
expect(bench.tailValues().goal).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores non-goal and malformed goal-shaped events fail-soft (same reference)', () => {
|
||||
// The package invariant rejects a violating stream loudly wherever it is
|
||||
// installed — the unit itself must never throw on the projection drive
|
||||
// (a throwing apply would tear down every registered unit's drive), so
|
||||
// its transition is exercised directly as the pure function it is.
|
||||
const user = { type: 'user/message', seq: 0, time: 1, data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
source: { kind: 'user' },
|
||||
}) } as never
|
||||
expect(applyGoalProjection(null, user)).toBeNull()
|
||||
|
||||
const malformed = { type: 'user/message', seq: 1, time: 2, data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'broken' }],
|
||||
source: { kind: 'goal', goalId: 'g-broken', revision: 1, round: 0 } as never,
|
||||
}) } as never
|
||||
const state = { goal: { id: 'g1', revision: 1, objective: 'x', phase: 'active', maxGoalRounds: 4 }, roundsStarted: 0, createdAt: 1, updatedAt: 1 } as never
|
||||
// Same-reference return: the registry's Object.is gate sees no change.
|
||||
expect(applyGoalProjection(state, malformed)).toBe(state)
|
||||
expect(applyGoalProjection(null, malformed)).toBeNull()
|
||||
})
|
||||
|
||||
it('has no goal key when the goal service is not composed', async () => {
|
||||
const bench = await harness(false)
|
||||
seedMessage(bench.session)
|
||||
expect('goal' in (bench.tailValues() ?? {})).toBe(false)
|
||||
})
|
||||
|
||||
it('drops the key when the goal fiber unloads (HMR safety)', async () => {
|
||||
const bench = await harness(false)
|
||||
seedMessage(bench.session)
|
||||
const fiber = await bench.ctx.plugin(GoalService)
|
||||
expect(bench.tailValues()).toEqual({ goal: null })
|
||||
await fiber.dispose()
|
||||
expect('goal' in (bench.tailValues() ?? {})).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,9 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Generated
+9
@@ -2419,6 +2419,9 @@ importers:
|
||||
schemastery:
|
||||
specifier: ^3.17.2
|
||||
version: 3.18.0
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
@@ -2441,6 +2444,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-session-projection':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-projection/session-projection
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
@@ -2670,6 +2676,9 @@ importers:
|
||||
'@deepseek-ai/dsh-commands':
|
||||
specifier: workspace:^
|
||||
version: link:../../ui/commands
|
||||
'@deepseek-ai/dsh-goal':
|
||||
specifier: workspace:^
|
||||
version: link:../../goal/goal
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
"@deepseek-ai/dsh-tool-todo/client": ["./packages/todo/tool-todo/src/client.ts"],
|
||||
"@deepseek-ai/dsh-session-title/types": ["./packages/session-title/session-title/src/types.ts"],
|
||||
"@deepseek-ai/dsh-session-title/client": ["./packages/session-title/session-title/src/client.ts"],
|
||||
"@deepseek-ai/dsh-goal/types": ["./packages/goal/goal/src/types.ts"],
|
||||
"@deepseek-ai/dsh-goal/client": ["./packages/goal/goal/src/client.ts"],
|
||||
"@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"],
|
||||
"@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"],
|
||||
"@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"],
|
||||
|
||||
Reference in New Issue
Block a user