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:
imccyu
2026-07-28 21:31:30 +08:00
parent 1c2923c84f
commit c991f76cef
12 changed files with 442 additions and 130 deletions
+13 -1
View File
@@ -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:^",
+10
View File
@@ -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'
+137
View File
@@ -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
}
}
+2 -5
View File
@@ -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' }>
+78 -7
View File
@@ -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 -1
View File
@@ -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.
+2 -1
View File
@@ -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
View File
@@ -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
}
}
+161
View File
@@ -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)
})
})
+3
View File
@@ -32,6 +32,9 @@
{
"path": "../../core/agent"
},
{
"path": "../../session-projection/session-projection"
},
{
"path": "../../support/invariants"
}
+9
View File
@@ -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
+2
View File
@@ -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"],