From c991f76cef1edca45f22dc6af897d24b491bec8e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:31:30 +0800 Subject: [PATCH] feat(goal): split the pure type outlet and register the 'goal' projection unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/goal/goal/package.json | 14 +- packages/goal/goal/src/client.ts | 10 ++ packages/goal/goal/src/domain.ts | 137 +++++++++++++++++ packages/goal/goal/src/fold.ts | 7 +- packages/goal/goal/src/index.ts | 85 ++++++++++- packages/goal/goal/src/render.ts | 2 +- packages/goal/goal/src/runtime.ts | 3 +- packages/goal/goal/src/types.ts | 139 +++-------------- packages/goal/goal/tests/projection.spec.ts | 161 ++++++++++++++++++++ packages/goal/goal/tsconfig.json | 3 + pnpm-lock.yaml | 9 ++ tsconfig.base.json | 2 + 12 files changed, 442 insertions(+), 130 deletions(-) create mode 100644 packages/goal/goal/src/client.ts create mode 100644 packages/goal/goal/src/domain.ts create mode 100644 packages/goal/goal/tests/projection.spec.ts diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index 2427ccfa5e..eaef4e6ad2 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -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:^", diff --git a/packages/goal/goal/src/client.ts b/packages/goal/goal/src/client.ts new file mode 100644 index 0000000000..ffe3de29b7 --- /dev/null +++ b/packages/goal/goal/src/client.ts @@ -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' diff --git a/packages/goal/goal/src/domain.ts b/packages/goal/goal/src/domain.ts new file mode 100644 index 0000000000..d7fb53e685 --- /dev/null +++ b/packages/goal/goal/src/domain.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 + 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, change: GoalChanged): void + } +} diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts index cfb8994748..2ea83029cf 100644 --- a/packages/goal/goal/src/fold.ts +++ b/packages/goal/goal/src/fold.ts @@ -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 diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 7024b2f640..6ae30c2715 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -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 = 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 + +/** + * 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, + }) + }) } /** diff --git a/packages/goal/goal/src/render.ts b/packages/goal/goal/src/render.ts index d7348473db..c269276d25 100644 --- a/packages/goal/goal/src/render.ts +++ b/packages/goal/goal/src/render.ts @@ -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. diff --git a/packages/goal/goal/src/runtime.ts b/packages/goal/goal/src/runtime.ts index 16a9abff72..7c10fa95cd 100644 --- a/packages/goal/goal/src/runtime.ts +++ b/packages/goal/goal/src/runtime.ts @@ -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 diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts index c9cc941870..89d85ca5b6 100644 --- a/packages/goal/goal/src/types.ts +++ b/packages/goal/goal/src/types.ts @@ -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 - 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, change: GoalChanged): void + goal: GoalProjection | null } } diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts new file mode 100644 index 0000000000..b80bcec4dd --- /dev/null +++ b/packages/goal/goal/tests/projection.spec.ts @@ -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 + 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 { + 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, + 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) + }) +}) diff --git a/packages/goal/goal/tsconfig.json b/packages/goal/goal/tsconfig.json index a06b59ed0e..9663f894fe 100644 --- a/packages/goal/goal/tsconfig.json +++ b/packages/goal/goal/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../core/agent" }, + { + "path": "../../session-projection/session-projection" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5309a6838..205fb87968 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/tsconfig.base.json b/tsconfig.base.json index 460e298089..009dd60fbd 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"],