refactor(goal): persist state with domain events

This commit is contained in:
_Kerman
2026-07-31 22:52:18 +08:00
parent b6cf9298e3
commit 1a09174987
48 changed files with 354 additions and 650 deletions

View File

@@ -901,13 +901,9 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
if (!Object.hasOwn(values, 'title')) return []
return [{ type: 'session/projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }]
}
// Goal fold: inserting a round-zero goal change durably advances the unit;
// later admission of the same message must not advance it again.
if (type === 'agent/inbox/spliced') {
const inserted = (event as unknown as { data: { inserted: UserMessage[] } }).data.inserted
if (inserted.some(message => goalChangeOf(message) !== undefined)) {
return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }]
}
// The goal domain's own durable change advances its projection.
if (type === 'goal/change') {
return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }]
}
// Standing-plan fold: writes replace the list; turn/start clears it (null).
if (type === 'todo/write' || type === 'turn/start') {
@@ -1140,7 +1136,7 @@ interface FxGoalProjection {
updatedAt: number
}
/** One durable goal change riding a round-zero goal-sourced inbox insertion. */
/** One durable goal change. */
type FxGoalChange =
| { kind: 'goal/change'; version: 1; operation: 'clear'; cleared: { id: string; revision: number }; clearedAt: number }
| {
@@ -1153,14 +1149,6 @@ type FxGoalChange =
updatedAt: number
}
/** Decode a fixture goal change from its durable inbox message. */
function goalChangeOf(message: UserMessage): FxGoalChange | undefined {
const source = message.source as unknown as { kind?: string; round?: number; change?: FxGoalChange }
if (source.kind !== 'goal' || source.round !== 0) return undefined
const change = source.change
return change?.kind === 'goal/change' ? change : undefined
}
/**
* Current goal projection over the full log (host parallel: the GoalService
* unit's last-wins fold of goal/change whole values; clear returns null).
@@ -1169,18 +1157,12 @@ function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null {
for (let i = log.length - 1; i >= 0; i--) {
const event = log[i] as unknown as {
type: string
data?: { inserted?: UserMessage[] }
data?: FxGoalChange
} | undefined
if (event === undefined || event.type !== 'agent/inbox/spliced') continue
const inserted = event.data?.inserted ?? []
for (let j = inserted.length - 1; j >= 0; j--) {
const message = inserted[j]
if (message === undefined) continue
const change = goalChangeOf(message)
if (change === undefined) continue
if (change.operation === 'clear') return null
return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
}
if (event === undefined || event.type !== 'goal/change' || event.data === undefined) continue
const change = event.data
if (change.operation === 'clear') return null
return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
}
return null
}
@@ -1419,33 +1401,12 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
for (const frame of projectionFramesOf(id, log, event)) emitMux(frame)
}
/** Append one goal/change as its round-zero goal-sourced inbox insertion (host GoalService parallel). */
/** Append one durable goal/change (host GoalService parallel). */
const appendGoalChange = (id: SessionId, change: FxGoalChange): FxGoalProjection => {
const ref = change.operation === 'clear' ? change.cleared : change.goal
const payload = change.operation === 'clear'
? { cleared: change.cleared, clearedAt: change.clearedAt }
: { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
const log = logOf(id)
const pendingNextStep = log.reduce((count, event) => {
const inboxEvent = event as unknown as {
type: string
data: { target: string; removedCount?: number; inserted: UserMessage[] }
}
if (inboxEvent.type !== 'agent/inbox/spliced' || inboxEvent.data.target !== 'next-step') return count
return count - (inboxEvent.data.removedCount ?? 0) + inboxEvent.data.inserted.length
}, 0)
append(id, {
type: 'agent/inbox/spliced',
data: {
target: 'next-step',
start: pendingNextStep,
inserted: [
userMessage(
text(`<goal_state>${JSON.stringify(payload)}</goal_state>`),
{ kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change } as unknown as MessageSource,
),
],
},
type: 'goal/change',
data: change,
})
return backscanGoal(log) as FxGoalProjection
}

View File

@@ -960,23 +960,12 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
const goalEvents = goalHistory.result.value.events.map(entry => entry.event as unknown as {
type: string
data: {
target?: string
start?: number
operation?: string
source?: { kind?: string; round?: number }
inserted?: Array<{ source?: { kind?: string; round?: number; change?: { operation?: string } } }>
}
})
const goalSplices = goalEvents.filter(event => event.type === 'agent/inbox/spliced'
&& event.data.inserted?.some(message => message.source?.kind === 'goal' && message.source.round === 0) === true)
expect(goalSplices.map(event => ({ target: event.data.target, start: event.data.start }))).toEqual([
{ target: 'next-step', start: 0 },
{ target: 'next-step', start: 1 },
{ target: 'next-step', start: 2 },
{ target: 'next-step', start: 3 },
{ target: 'next-step', start: 4 },
{ target: 'next-step', start: 5 },
])
expect(goalSplices.map(event => event.data.inserted?.[0]?.source?.change?.operation))
const goalChanges = goalEvents.filter(event => event.type === 'goal/change')
expect(goalChanges.map(event => event.data.operation))
.toEqual(['create', 'edit', 'pause', 'resume', 'complete', 'clear'])
expect(goalEvents.some(event => event.type === 'user/message'
&& event.data.source?.kind === 'goal' && event.data.source.round === 0)).toBe(false)

View File

@@ -1328,7 +1328,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
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 message has\n * already committed through a durable inbox insertion; later admission or\n * discard does not change that fact. 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 */',
jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching `goal/change`\n * session event has already committed. 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.',
},
{

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/goal/command-goal/README.md
README.md: 47f81a5ae303d3587c0af1a26407f0f1f0ba0d88
README.zh.md: f5d22fa4889da8b7a1e2ac73e78ebe76714b8d42
README.md: 485a41abb22297f8018dceba258ec32692a28500
README.zh.md: 819f635b5c7c7f94109c1f076e7ed84b22ad96dc

View File

@@ -17,7 +17,7 @@ Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin r
Control words are case-insensitive only when they occupy the complete input. Every other non-empty suffix is an objective, so `/goal pause after verification` creates that literal objective. The goal domain trims and validates objectives. Because the generic command plane has no modal editor or confirmation primitive, `edit` takes its replacement inline and an unfinished replacement returns a direct error instructing the user to edit or clear.
Expected domain rejections become stable direct command errors without exposing branded ids or revisions. Unexpected implementation failures still reject dispatch so adapters can report them as command failures. Generic command text and output remain live UI state; `dsh-goal` persists every accepted mutation through a durable inbox insertion and independently queues its model-facing context.
Expected domain rejections become stable direct command errors without exposing branded ids or revisions. Unexpected implementation failures still reject dispatch so adapters can report them as command failures. Generic command text and output remain live UI state; `dsh-goal` persists every accepted mutation through its own durable `goal/change` event.
## Composition
@@ -40,15 +40,15 @@ The TUI app enables the complete persisted-goal stack and this command by defaul
#### What the model sees
The slash input and direct status/error output are absent from model requests. An accepted mutation queues the goal domain's raw `<goal_state>` snapshot or clear tombstone; the model sees it only if a later pre-step admits that context. The mutation remains durable if the queued message is discarded, and presentation text is never logged.
The slash input, mutation, and direct status/error output are absent from model requests. The goal domain records the mutation as `goal/change`; an enabled same-session driver may expose the resulting state in a later continuation prompt. Presentation text is never logged.
#### Token effect
Reading status or receiving a direct command error adds no model tokens. An admitted mutation context adds the goal domain's retained full snapshot, while one discarded before admission adds none; an enabled same-session driver may add later goal-round prompts.
Reading status, mutating a goal, or receiving a direct command error adds no model tokens. An enabled same-session driver may add later goal-round prompts.
#### KV Cache effect
Command discovery and direct output do not affect the cache. An admitted mutation context appends after the reusable history prefix; later compaction may replace the derived-history suffix.
Command discovery, mutations, and direct output do not affect the cache. Later continuation prompts follow the driver's ordinary request history.
## Known Limitations and Deferred Work

View File

@@ -17,7 +17,7 @@
只有控制词占据完整输入时才不区分大小写。其他任何非空后缀都属于目标,因此 `/goal pause after verification` 会创建该字面目标。goal 领域会去除目标首尾空白并进行验证。由于通用命令平面没有模态编辑器或确认原语,`edit` 会内联接收替换内容;若试图替换未完成的 goal则直接返回错误提示用户执行 edit 或 clear。
可预期的领域拒绝会变成稳定的直接命令错误,不公开带品牌类型的 id 或 revision。意外实现失败仍会 reject 分发,使适配器能将其报告为命令失败。通用命令文本和输出仍属于实时 UI 状态;`dsh-goal` 通过持久 inbox 插入项持久化每项已接受变更,并单独将其面向模型的上下文排队
可预期的领域拒绝会变成稳定的直接命令错误,不公开带品牌类型的 id 或 revision。意外实现失败仍会 reject 分发,使适配器能将其报告为命令失败。通用命令文本和输出仍属于实时 UI 状态;`dsh-goal` 通过自有的持久 `goal/change` 事件记录每项已接受变更
## 组合
@@ -40,15 +40,15 @@ TUI 应用默认启用完整的持久 goal 栈和此命令。ACPAgent Client
#### 模型看到的内容
斜杠输入直接状态/错误输出不会进入模型请求。已接受的变更会将 goal 领域的原始 `<goal_state>` 快照或 clear tombstone 排队;只有后续 pre-step 准入该上下文时,模型才会看到它。如果已排队的消息被丢弃,变更仍然持久;呈现文本绝不会记录到日志中。
斜杠输入、变更以及直接状态/错误输出不会进入模型请求。Goal 领域把变更记录为 `goal/change`;已启用的同会话驱动器可以在后续继续执行提示词中暴露结果状态。呈现文本绝不会记录到日志中。
#### Token 影响
读取状态或收到直接命令错误不会增加模型 token。获准的变更上下文会增加 goal 领域保留的完整快照,准入前被丢弃的上下文则不会增加;已启用的同会话驱动器可能增加后续 Goal Round 提示词。
读取状态、变更 goal 或收到直接命令错误不会增加模型 token。已启用的同会话驱动器可能增加后续 Goal Round 提示词。
#### KV Cache 影响
命令发现与直接输出不会影响缓存。获准的变更上下文会追加到可复用历史前缀之后;后续压缩可能替换派生历史后缀
命令发现、变更与直接输出不会影响缓存。后续继续执行提示词遵循驱动器的普通请求历史
## 已知限制与暂缓事项

View File

@@ -126,7 +126,7 @@ describe('/goal human command', () => {
expect(created.text).toContain('Rounds: 0/256')
expect(created.text).toContain('Activation: armed')
expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release')
expect(domainEvents(test.session).map(event => event.type)).toEqual(['agent/inbox/spliced'])
expect(domainEvents(test.session).map(event => event.type)).toEqual(['goal/change'])
const count = domainEvents(test.session).length
await expect(run(test, ' replacement')).resolves.toEqual({

View File

@@ -295,8 +295,8 @@ describe('same-session goal driving', () => {
expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' })
expect(test.adapter.requests).toHaveLength(0)
// No admitted continuation round (positive round); goal state changes
// (round zero) are expected in the log.
// No admitted continuation round reached the model; goal state changes are
// represented by their own durable event.
expect(test.agent.session.events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'goal' && event.data.source.round > 0)).toBe(false)
})

View File

@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import {
GoalId,
renderGoalChange,
type GoalSnapshotChangeMeta,
type GoalView,
} from '@deepseek-ai/dsh-goal'
@@ -28,29 +27,12 @@ const change: GoalSnapshotChangeMeta = {
updatedAt: 1,
}
const changeSource = {
kind: 'goal',
goalId: change.goal.id,
revision: change.goal.revision,
round: 0,
change,
} as const
function view(roundsStarted: number): GoalView {
return { ...change.goal, roundsStarted, createdAt: 1, updatedAt: 1, activation: 'armed' }
}
function appendChange(session: Session): void {
const message = createUserMessage({
content: renderGoalChange(change),
source: changeSource,
})
session.append('agent/inbox/spliced', {
target: 'next-step', start: 0, inserted: [message],
})
session.append('turn/start', { turn: 1 })
session.append('user/message', message, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('goal/change', change)
}
function appendRound(session: Session, turn: number, content = renderGoalRoundPrompt(view(turn - 2), turn - 1)): void {
@@ -93,7 +75,9 @@ describe('goal-session prompt invariants', () => {
}), { surfaceOp: 'append' })
session.append('turn/end', { turn: 4, reason: { kind: 'completed' } })
const stateSource = { ...changeSource, round: 0 } as const
const stateSource = {
kind: 'goal', goalId: change.goal.id, revision: change.goal.revision, round: 0,
} as never
session.append('turn/start', { turn: 5 })
expect(() => {
session.append('user/message', createUserMessage({
@@ -132,12 +116,7 @@ describe('goal-session prompt invariants', () => {
it('attributes an invalid durable prefix during late loading', async () => {
const { ctx, session } = await mount(true)
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'counterfeit goal state' }],
source: changeSource,
}), { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('goal/change', { ...change, extra: true } as never)
appendRound(session, 2)
await ctx.plugin(InvariantService, { enabled: true })

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/goal/goal/README.md
README.md: f72efe2306f11dfa5f30ac927bb1b900f691a7be
README.zh.md: 4ca86a6c1aea228e069fbf25b22ec248297fae03
README.md: caf01b3d2a088281749a73b78b839d60ac041316
README.zh.md: de92b8b9d7757f80511fe128644738ebe2458af1

View File

@@ -21,13 +21,13 @@ Event-sourced same-session goal state. The service retains one current completio
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 mutation passes a complete versioned snapshot through `agent.inject()`; clear uses a revisioned tombstone. The mutation commits when injection records the message in the durable `agent/inbox/spliced` insertion, even if that context remains queued and never reaches the model. Removing or discarding the queued message does not roll back the mutation. If the same message is later admitted as a model-visible `user/message`, replay verifies that its id, content, and typed `{ kind: 'goal', change }` source agree with the insertion without applying the mutation again.
Every mutation appends a durable `goal/change` event carrying the complete post-mutation snapshot; clear uses a revisioned tombstone. Goal state therefore does not depend on inbox placement, claim, admission, or discard. The session log is the only durable authority.
Strict replay derives mutations only from inbox insertions and rejects malformed shapes, reused message ids with different changes, source/content drift on admission, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential admitted goal rounds. Positive rounds advance only on admitted `user/message` events. Mutation timestamps clamp against the preceding goal update when wall time moves backward. Reentrant insertion observers see each accepted mutation exactly once, incremental replay retains its cursor at the first corrupt event, and `goal/changed` fires after injection succeeds with listener failures contained.
Strict replay derives lifecycle mutations only from `goal/change` and rejects malformed shapes, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential admitted goal rounds. Positive rounds advance only on admitted goal-sourced `user/message` events. Mutation timestamps clamp against the preceding goal update when wall time moves backward. Incremental replay retains its cursor at the first corrupt event, and `goal/changed` fires after the durable event commits with listener failures contained.
Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. A continuation driver also calls `disarm()` before unload or after durability uncertainty. Session resume, fork, and driver replacement 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 source changes, duplicate-id drift between insertion and admission, model-visible content drift, discontinuous revisions, illegal lifecycle transitions, timestamp regressions, and non-sequential admitted rounds before the candidate event enters the durable log.
The separately published `./invariant` companion maintains an independent fold of each attached session. It rejects malformed goal changes, discontinuous revisions, illegal lifecycle transitions, timestamp regressions, and non-sequential admitted rounds before the candidate event enters the durable log.
## Extension points
@@ -39,15 +39,15 @@ Policy plugins call the service verbs and react to the scoped `goal/changed` eve
#### What the model sees
Each mutation queues one raw user-role context block. If admitted, a snapshot is rendered as `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`; a clear renders the tombstone id/revision and `clearedAt`. The mutation remains durable if the queued context is discarded before admission, and there is no hidden state summary outside the session 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.
Goal mutations do not inject model context. Goal tools return the current state, and a continuation consumer may render the objective and round state when it schedules model work. A future always-visible goal context belongs in a separate context plugin rather than the persistence path.
#### Token effect
An admitted mutation adds one full snapshot to derived history until compaction shadows it; an insertion discarded before admission costs no model tokens. Full snapshots make each admitted record independently inspectable but repeat the objective and lifecycle fields.
Goal mutation events add no model tokens by themselves. Tool results and scheduled continuation prompts account for their own visible state.
#### KV Cache effect
Append-only within an epoch after admission: each visible mutation follows the reusable request prefix and preceding history. Compaction may replace the derived-history suffix and move the reusable boundary.
There is no KV-cache effect until another component exposes goal state in model-visible input.
## Known Limitations and Deferred Work
@@ -55,4 +55,4 @@ Append-only within an epoch after admission: each visible mutation follows the r
- **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 source data. 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.
- **Trusted in-process producers** — a plugin with direct `Session` access can append counterfeit `goal/change` data. 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

@@ -21,13 +21,13 @@
最多只有一个当前目标。创建操作会生成 revision 为 1、phase 为 active 的目标并启用续行。未完成的目标必须编辑、转换或清除;已完成目标可以由拥有全局未使用过的 id 的目标替换。编辑会保留 phase、blocker reason 与 activation。暂停、完成、阻塞和清除都会停用续行。阻塞会记录策略自有的 lower-kebab-case 代码和规范化的自由文本说明;提供方限制、配置预算、执行错误与请求人工输入都使用这一种持久 phase不会扩增生命周期状态。只有配置的 Round 上限仍有剩余容量时resume 才接受已停止 phase 或 phase 为 active 但已停用续行的目标;它会清除原 blocker reason。phase 为 active 且已启用续行的目标会拒绝冗余操作。
每次变更都会通过 `agent.inject()` 传递完整的版本化快照clear 使用带 revision 的 tombstone。注入将消息记录到持久 `agent/inbox/spliced` 插入项时,变更即已提交,即使该上下文仍在队列中且从未抵达模型也是如此。移除或丢弃已排队的消息不会回滚变更。如果同一消息随后获准成为模型可见的 `user/message`,回放会验证其 id、内容和带类型的 `{ kind: 'goal', change }` 来源与插入项一致,而不会再次应用变更
每次变更都会追加持久的 `goal/change` 事件其中携带变更后的完整快照clear 使用带 revision 的 tombstone。因此goal 状态不依赖 inbox 放置、领取、准入或丢弃。会话日志是唯一的持久权威
严格回放只从 inbox 插入项派生变更,并拒绝形状错误、以不同变更复用消息 id、准入时的来源内容漂移、不连续 revision、非法生命周期转换、每目标时间戳非单调以及不连续的已准入 Goal Round。只有获准`user/message` 事件会推进正数 Round。挂钟时间倒退时变更时间戳会限制在不早于上一次目标更新的值。可重入插入观察者会且只会看到每项已接受变更一次;增量回放会把游标保留在第一个损坏事件处;`goal/changed` 会在注入成功后触发,监听器失败会被隔离处理。
严格回放只从 `goal/change` 派生生命周期变更,并拒绝形状错误、不连续 revision、非法生命周期转换、每目标时间戳非单调以及不连续的已准入 Goal Round。只有来源为 goal 且已准入`user/message` 事件会推进正数 Round。挂钟时间倒退时变更时间戳会限制在不早于上一次目标更新的值。增量回放会把游标保留在第一个损坏事件处`goal/changed` 会在持久事件提交后触发,监听器失败会被隔离处理。
续行启用状态绝不持久化。新缓存与每次触发 `agent/session-start` 时都会停用续行,即使回放找到了持久 phase 为 active 的目标。续行驱动器在卸载前或持久性不确定后也会调用 `disarm()`。因此会话恢复、fork 与驱动器替换会保留目标、phase、revision 和已准入 Round 数量,却不会启动工作;之后必须通过显式 resume 变更重新启用续行。
单独发布的 `./invariant` 配套模块会为每个已挂接会话维护独立折叠。它会在候选事件进入持久日志前拒绝格式错误的 goal 来源变更、相同 id 在插入与准入之间的变更漂移、模型可见内容漂移、不连续 revision、非法生命周期转换、时间戳回退以及不连续的已准入 Round。
单独发布的 `./invariant` 配套模块会为每个已挂接会话维护独立折叠。它会在候选事件进入持久日志前拒绝格式错误的 goal 变更、不连续 revision、非法生命周期转换、时间戳回退以及不连续的已准入 Round。
## 扩展点
@@ -39,15 +39,15 @@
#### 模型看到的内容
每项变更都会将一个原始用户角色上下文块排队。获准后,快照渲染为 `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`clear 会渲染 tombstone idrevision 与 `clearedAt`。如果排队的上下文在准入前被丢弃,变更仍然持久;会话日志外不存在隐藏状态摘要。这种描述性 XML 分隔符遵循仓库已有的 `<workspace_context>` 约定和 [Anthropic 发布的 XML 标签提示词指南](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags);它是公开的模型体验先例,并非关于任何提供方专有训练语料的声明
Goal 变更不会注入模型上下文。Goal 工具返回当前状态;继续执行消费方可以在调度模型工作时渲染目标描述与 Round 状态。未来如果需要始终可见的 goal 上下文,应由独立上下文插件实现,而不是放在持久化路径中
#### Token 影响
获准的变更会向派生历史增加一份完整快照直到压缩compaction将其遮蔽准入前被丢弃的插入项不消耗模型 token。完整快照让每条获准记录都能独立检查但会重复目标和生命周期字段
Goal 变更事件本身不增加模型 token。工具结果与已调度的继续执行提示词分别计入其自身暴露的状态
#### KV Cache 影响
准入后在一个 epoch 内仅追加:每项可见变更都位于可复用请求前缀和既有历史之后。压缩可能替换派生历史后缀,并移动可复用边界
在其他组件把 goal 状态暴露为模型可见输入之前,不会影响 KV Cache
## 已知限制与暂缓事项
@@ -55,4 +55,4 @@
- **只有 Round 数量预算**`maxGoalRounds` 不计量 token、货币、挂钟时间或提供方配额。
- **没有独立评估器**:记录完成或阻塞的调用方拥有最终决定权;由评估器支持的认证暂缓到独立策略层。
- **只有一个当前目标**:系统有意不支持并行目标或独立目标数据库;替换或清除后,历史仍可在会话日志中读取。
- **信任进程内生产方**:能直接访问 `Session` 的插件可以追加伪造的 goal 来源数据。严格回放会检测格式错误或不一致的记录,并使 goal 访问从该记录起失败,直到日志修复;这是完整性检测,不是插件隔离。
- **信任进程内生产方**:能直接访问 `Session` 的插件可以追加伪造的 `goal/change` 数据。严格回放会检测格式错误或不一致的记录,并使 goal 访问从该记录起失败,直到日志修复;这是完整性检测,不是插件隔离。

View File

@@ -35,7 +35,7 @@ export type GoalOperation =
| 'block'
| 'clear'
/** Full-snapshot goal mutation committed by an injected inbox message. */
/** Full-snapshot goal mutation committed by a durable `goal/change` event. */
export interface GoalSnapshotChangeMeta {
readonly kind: 'goal/change'
readonly version: 1
@@ -55,18 +55,16 @@ export interface GoalClearChangeMeta {
readonly clearedAt: number
}
/** Durable change union carried by a goal-owned round-zero message source. */
/** Durable change union carried by the goal domain's own session event. */
export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
/** Message attribution for durable goal state and continuation rounds. */
/** Message attribution for admitted continuation rounds. */
export interface GoalMessageSource {
readonly kind: 'goal'
readonly goalId: GoalId
readonly revision: number
/** Zero for state changes; positive for admitted continuation rounds. */
/** Positive admitted continuation round. */
readonly round: number
/** Complete durable mutation carried only by round-zero state-change messages. */
readonly change?: GoalChangeMeta
}
declare module '@deepseek-ai/dsh-llm' {
@@ -75,6 +73,15 @@ declare module '@deepseek-ai/dsh-llm' {
}
}
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* Complete post-mutation goal state or clear tombstone.
*/
'goal/change': GoalChangeMeta
}
}
/** Pure replay fold of durable goal facts. */
export interface FoldedGoal {
/** Current goal, absent after a clear or before the first create. */
@@ -101,7 +108,7 @@ export interface EditGoalRequest {
readonly maxGoalRounds?: number
}
/** Live notification after one goal mutation commits through inbox insertion. */
/** Live notification after one durable goal mutation commits. */
export interface GoalChanged {
readonly operation: GoalOperation
readonly ref: GoalRef
@@ -124,9 +131,8 @@ export type GoalErrorCode =
declare module 'cordis' {
interface Events {
/**
* Goal mutation accepted by one live agent. The matching message has
* already committed through a durable inbox insertion; later admission or
* discard does not change that fact. Listener failures are contained.
* Goal mutation accepted by one live agent. The matching `goal/change`
* session event has already committed. 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.

View File

@@ -2,8 +2,6 @@
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { UserMessage } 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 {
@@ -33,7 +31,6 @@ export interface GoalFoldState {
updatedAt: number | undefined
lastRef: GoalRef | undefined
seenGoalIds: Set<GoalSnapshot['id']>
insertedChangeMessages: Map<UserMessage['id'], GoalChangeMeta>
}
/**
@@ -48,7 +45,6 @@ export function emptyGoalFoldState(): GoalFoldState {
updatedAt: undefined,
lastRef: undefined,
seenGoalIds: new Set(),
insertedChangeMessages: new Map(),
}
}
@@ -180,7 +176,7 @@ 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) {
|| !Number.isSafeInteger(source.round) || source.round < 1) {
throw new Error('goal message source is invalid')
}
return source
@@ -307,81 +303,22 @@ export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): v
state.lastRef = ref
}
/**
* Decode and verify one goal state message without folding it. A goal state
* message has a round-zero goal source carrying the complete change; any other
* message returns `undefined`. Attribution and rendered-body drift fail loudly.
* @param message - inserted or admitted message to decode.
* @param location - event location included in replay failures.
* @returns validated change, or `undefined` when the message is not a goal state change.
*/
function decodeGoalMessage(message: UserMessage, location: string): GoalChangeMeta | undefined {
const source = goalSource(message.source)
if (source === undefined) {
const [block] = message.content
if (block?.type === 'text' && block.text.startsWith('<goal_state>')) {
throw new Error(`goal change at ${location} has mismatched source attribution`)
}
return undefined
}
if (source.round !== 0) return undefined
const change = decodeGoalChange(source.change)
if (change === undefined) throw new Error(`goal change at ${location} lacks source change data`)
const ref = goalChangeRef(change)
if (source.goalId !== ref.id || source.revision !== ref.revision) {
throw new Error(`goal change at ${location} has mismatched source attribution`)
}
if (JSON.stringify(message.content) !== JSON.stringify(renderGoalChange(change))) {
throw new Error(`goal change at ${location} has mismatched model-visible content`)
}
return change
}
/**
* Apply one session event to the strict durable goal fold.
* @param state - mutable fold accumulator.
* @param event - next event in sequence order.
*/
export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): void {
if (event.type === 'agent/inbox/spliced') {
for (const message of event.data.inserted) {
const location = `session event ${event.seq}`
const change = decodeGoalMessage(message, location)
if (change === undefined) continue
const inserted = state.insertedChangeMessages.get(message.id)
if (inserted !== undefined) {
if (JSON.stringify(inserted) !== JSON.stringify(change)) {
throw new Error(`goal change at ${location} reuses a message id with different change data`)
}
continue
}
applyGoalChange(state, change)
state.insertedChangeMessages.set(message.id, change)
}
if (event.type === 'goal/change') {
const change = decodeGoalChange(event.data)
/* v8 ignore next -- the event's declared payload always identifies itself as a goal change. */
if (change === undefined) throw new Error(`goal change at session event ${event.seq} has an invalid kind`)
applyGoalChange(state, change)
return
}
if (event.type === 'user/message') {
const inserted = state.insertedChangeMessages.get(event.data.id)
const change = decodeGoalMessage(event.data, `session event ${event.seq}`)
if (inserted !== undefined && change !== undefined) {
if (JSON.stringify(inserted) !== JSON.stringify(change)) {
throw new Error(`goal change at session event ${event.seq} differs from its inbox insertion`)
}
return
}
if (change !== undefined) {
throw new Error(`goal change at session event ${event.seq} was not committed by an inbox insertion`)
}
const source = goalSource(event.data.source)
if (source === undefined) return
// A goal-sourced message without a change must be a positive-round
// admitted continuation prompt; round zero owes a durable source change.
/* v8 ignore next 3 -- decodeGoalMessage returns the change or fails loud for every
round-zero goal source, so only positive rounds reach here; the guard keeps
replay fail-loud against a decoder change */
if (source.round === 0) {
throw new Error(`goal source at session event ${event.seq} lacks goal change data`)
}
const current = state.goal
if (current === undefined || current.phase !== 'active' || source.goalId !== current.id
|| source.revision !== current.revision || source.round !== state.roundsStarted + 1

View File

@@ -11,17 +11,16 @@ 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, SessionEvent, UserMessage } 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 {
applyGoalEvent,
decodeGoalChange,
emptyGoalFoldState,
goalChangeRef,
} from './fold.ts'
import type { GoalFoldState } from './fold.ts'
import { renderGoalChange } from './render.ts'
import {
GOAL_CHANGE_VERSION,
GoalError,
@@ -54,7 +53,6 @@ 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'
declare module 'cordis' {
interface Context {
@@ -80,12 +78,6 @@ const goalProjectionSchema: ZodType<GoalProjection | null> = zod.union([
zod.null(),
]) as ZodType<GoalProjection | null>
/** Plain-JSON projection accumulator retaining duplicate-change identity. */
type GoalProjectionState = readonly [
value: GoalProjection | null,
insertedChangeMessageIds: readonly string[],
]
/**
* Light last-wins fold of the `goal` projection unit. Unlike the strict
* replay fold (fold.ts: transition validation, fail-loud on malformed
@@ -99,31 +91,23 @@ type GoalProjectionState = readonly [
* @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: GoalProjectionState, event: SessionEvent): GoalProjectionState {
if (event.type !== 'agent/inbox/spliced') return state
let projection = state[0]
let insertedChangeMessageIds: string[] | undefined
const seen = new Set(state[1])
for (const message of event.data.inserted) {
const source = message.source
const change = source.kind === 'goal' && source.round === 0 ? source.change : undefined
// oxlint-disable-next-line typescript/no-unnecessary-condition -- durable-boundary guard
if (seen.has(message.id) || change === undefined || change.kind !== 'goal/change') continue
seen.add(message.id)
insertedChangeMessageIds ??= [...state[1]]
insertedChangeMessageIds.push(message.id)
projection = change.operation === 'clear'
? null
: {
goal: change.goal,
roundsStarted: change.roundsStarted,
createdAt: change.createdAt,
updatedAt: change.updatedAt,
}
export function applyGoalProjection(state: GoalProjection | null, event: SessionEvent): GoalProjection | null {
if (event.type !== 'goal/change') return state
let change: GoalChangeMeta | undefined
try {
change = decodeGoalChange(event.data)
} catch (_invalidPersistedGoalChange) {
return state
}
return insertedChangeMessageIds === undefined
? state
: [projection, insertedChangeMessageIds]
if (change === undefined) return state
return change.operation === 'clear'
? null
: {
goal: change.goal,
roundsStarted: change.roundsStarted,
createdAt: change.createdAt,
updatedAt: change.updatedAt,
}
}
/** Deployment defaults for goal creation. */
@@ -138,12 +122,12 @@ export interface ResolvedConfig {
defaultMaxGoalRounds: number
}
/** Process-local cache plus activation intent crossing the synchronous injection boundary. */
/** Process-local cache plus activation intent crossing the synchronous append boundary. */
interface GoalCache {
readonly state: GoalFoldState
activation: GoalActivation
observedSeq: number
readonly pendingActivations: Map<UserMessage['id'], GoalActivation>
pendingActivation: { readonly seq: number; readonly activation: GoalActivation } | undefined
}
/** Validated create input with every deployment default materialized. */
@@ -216,13 +200,13 @@ export class GoalService extends Service {
// (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', GoalProjectionState>({
projectionCtx.sessionProjections.register<'goal', GoalProjection | null>({
key: 'goal',
schema: goalProjectionSchema,
init: () => [null, []],
init: () => null,
apply: applyGoalProjection,
view: state => state[0],
stateVersion: 3,
view: state => state,
stateVersion: 4,
})
})
}
@@ -436,7 +420,7 @@ export class GoalService extends Service {
state,
activation: 'disarmed',
observedSeq: session.seq,
pendingActivations: new Map(),
pendingActivation: undefined,
}
this.caches.set(session, cache)
return cache
@@ -445,14 +429,11 @@ export class GoalService extends Service {
/** Incrementally observe durable events and reconcile local activation intent. */
private sync(session: Session, cache: GoalCache): void {
for (const event of session.events.slice(cache.observedSeq)) {
const newGoalMessages = event.type === 'agent/inbox/spliced'
? event.data.inserted.filter(message => message.source.kind === 'goal'
&& message.source.round === 0 && !cache.state.insertedChangeMessages.has(message.id))
: []
applyGoalEvent(cache.state, event)
for (const message of newGoalMessages) {
cache.activation = cache.pendingActivations.get(message.id) ?? 'disarmed'
cache.pendingActivations.delete(message.id)
if (event.type === 'goal/change') {
cache.activation = cache.pendingActivation?.seq === event.seq
? cache.pendingActivation.activation
: 'disarmed'
}
cache.observedSeq += 1
}
@@ -545,28 +526,20 @@ export class GoalService extends Service {
}
this.commit(agent, cache, change, activation)
const view = this.view(cache)
/* v8 ignore next -- the durable inbox insertion installs the snapshot before this read */
/* v8 ignore next -- the durable goal event installs the snapshot before this read */
if (view === undefined) throw new Error('snapshot commit cleared the goal unexpectedly')
return view
}
/** Accept one mutation into the agent injection queue, cache, and live event stream. */
/** Commit one mutation into the goal log, cache, and live event stream. */
private commit(agent: Agent, cache: GoalCache, change: GoalChangeMeta, activation: GoalActivation): void {
const ref = goalChangeRef(change)
const message = createUserMessage({
content: renderGoalChange(change),
source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change },
})
cache.pendingActivations.set(message.id, activation)
cache.pendingActivation = { seq: agent.session.seq, activation }
try {
agent.inject(message)
} catch (error: unknown) {
cache.pendingActivations.delete(message.id)
throw error
}
this.sync(agent.session, cache)
if (cache.pendingActivations.delete(message.id)) {
throw new Error('goal injection returned without a durable inbox insertion')
agent.session.append('goal/change', change)
this.sync(agent.session, cache)
} finally {
cache.pendingActivation = undefined
}
const goal = this.view(cache)
const notification: GoalChanged = {

View File

@@ -22,7 +22,6 @@ function cloneState(state: GoalFoldState): GoalFoldState {
updatedAt: state.updatedAt,
lastRef: state.lastRef,
seenGoalIds: new Set(state.seenGoalIds),
insertedChangeMessages: new Map(state.insertedChangeMessages),
}
}

View File

@@ -1,21 +0,0 @@
/** Model-visible rendering for durable goal mutations. */
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { GoalChangeMeta } from './domain.ts'
/**
* Render a complete goal snapshot or clear tombstone without hidden prose.
* @param change - durable goal change carried by the message source.
* @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

@@ -52,7 +52,7 @@ export interface GoalSnapshot extends GoalRef {
/**
* The `goal` projection value: the current durable goal with its replay
* counters, exactly as the latest `goal/change` source carried them.
* counters, exactly as the latest `goal/change` event carried them.
* Activation is process-local (never persisted) and deliberately absent —
* the projection reflects durable phase only.
*/

View File

@@ -3,7 +3,7 @@ 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 { decodeGoalChange } 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))
@@ -49,14 +49,11 @@ describe('goal domain through a real cordis.yml and headless process', () => {
expect(result['output']).toContain('CLI tool round trip complete')
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(1)
const contexts = events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'goal')
expect(contexts).toHaveLength(1)
const context = contexts[0]
if (context?.type !== 'user/message') throw new Error('expected goal context event')
const change = context.data.source.kind === 'goal'
? decodeGoalChange(context.data.source.change)
: undefined
const changes = events.filter(event => event.type === 'goal/change')
expect(changes).toHaveLength(1)
const context = changes[0]
if (context?.type !== 'goal/change') throw new Error('expected goal change event')
const change = decodeGoalChange(context.data)
if (change === undefined) throw new Error('expected durable goal change')
expect(change).toMatchObject({
operation: 'create',
@@ -68,10 +65,9 @@ describe('goal domain through a real cordis.yml and headless process', () => {
maxGoalRounds: 7,
},
})
expect(context.data.content).toEqual(renderGoalChange(change))
expect(JSON.stringify(context)).not.toContain('activation')
// No admitted continuation round ran (the snapshot mounts without starting
// a round); the round-zero state change from create is expected above.
// No admitted continuation round ran; the goal change itself is independent
// from model-visible user messages.
expect(events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'goal' && event.data.source.round > 0)).toHaveLength(0)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)

View File

@@ -2,14 +2,13 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage, freezeMessage, HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm'
import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, type UserMessage } 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'
@@ -81,7 +80,7 @@ function appendRound(session: Session, ref: GoalRef, round: number): void {
}
describe('GoalService creation and replay', () => {
it('applies the configured default and writes one verbatim context snapshot', async () => {
it('applies the configured default and writes one durable goal change', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_700_000_000_000)
const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 })
@@ -102,17 +101,14 @@ describe('GoalService creation and replay', () => {
})
expect(goal.id).toMatch(/^goal-/)
expect(seen).toEqual(['create'])
expect(session.events.map(event => event.type)).toEqual(['agent/inbox/spliced'])
expect(session.events.map(event => event.type)).toEqual(['goal/change'])
const context = session.events[0]
expect(context?.type).toBe('agent/inbox/spliced')
if (context?.type !== 'agent/inbox/spliced') throw new Error('expected queued goal context')
const message = context.data.inserted[0]
if (message === undefined) throw new Error('expected inserted goal context')
expect(message.source).toMatchObject({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 })
const change = message.source.kind === 'goal' ? decodeGoalChange(message.source.change) : undefined
expect(context?.type).toBe('goal/change')
if (context?.type !== 'goal/change') throw new Error('expected durable goal change')
const change = decodeGoalChange(context.data)
if (change === undefined) throw new Error('expected decoded goal change')
expect(change).toMatchObject({ operation: 'create', goal: { id: goal.id } })
expect(message.content).toEqual(renderGoalChange(change))
expect(agent.inbox.nextStep).toEqual([])
expect(session.deriveMessages()).toEqual([])
expect(foldGoal(session.events)).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 })
vi.useRealTimers()
@@ -377,12 +373,8 @@ describe('GoalService mutations', () => {
vi.setSystemTime(80)
ctx.goals.clear(agent, goal)
const clear = session.events
.filter(event => event.type === 'agent/inbox/spliced')
.flatMap(event => event.type === 'agent/inbox/spliced' ? event.data.inserted : [])
.filter(message => message.source.kind === 'goal')
.map(message => message.source.kind === 'goal'
? decodeGoalChange(message.source.change)
: undefined)
.filter(event => event.type === 'goal/change')
.map(event => event.type === 'goal/change' ? decodeGoalChange(event.data) : undefined)
.at(-1)
expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 })
expect(() => foldGoal(session.events)).not.toThrow()
@@ -400,14 +392,14 @@ describe('GoalService mutations', () => {
expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer'))
})
it('commits consecutive revisions through synchronous inbox insertions', async () => {
it('commits consecutive revisions through durable goal events', async () => {
const { ctx, agent, session } = await harness()
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(session.events.map(event => event.type)).toEqual([
'agent/inbox/spliced', 'agent/inbox/spliced', 'agent/inbox/spliced',
'goal/change', 'goal/change', 'goal/change',
])
expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' })
expect(foldGoal(session.events)).toMatchObject({ goal: { revision: 3, phase: 'paused' } })
@@ -422,8 +414,7 @@ describe('GoalService mutations', () => {
ctx.agents.register(stub.agent)
let observed: ReturnType<GoalService['get']>
ctx.on('session/event', (session, event) => {
if (session === stub.session && event.type === 'agent/inbox/spliced'
&& event.data.inserted.some(message => message.source.kind === 'goal')) observed = ctx.goals.get(stub.agent)
if (session === stub.session && event.type === 'goal/change') observed = ctx.goals.get(stub.agent)
})
const created = ctx.goals.create(stub.agent, { objective: 'publish once' })
@@ -433,40 +424,20 @@ describe('GoalService mutations', () => {
expect(foldGoal(stub.session.events)).toMatchObject({ goal: { id: created.id, revision: 1 } })
})
it('rolls back a pending mutation when injection rejects before append', async () => {
it('does not delegate goal persistence to agent injection', 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 = (input) => {
if (reject) throw new Error('injection rejected')
append(input)
}
const stub = stubAgent('goal-independent-injection')
stub.agent.inject = () => { throw new Error('injection must not be called') }
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',
expect(ctx.goals.create(stub.agent, { objective: 'persist directly' })).toMatchObject({
objective: 'persist directly',
revision: 1,
})
})
it('rejects an inject implementation that returns before durable insertion', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const stub = stubAgent('goal-missing-insertion')
const inject = stub.agent.inject.bind(stub.agent)
stub.agent.inject = () => {}
ctx.agents.register(stub.agent)
expect(() => ctx.goals.create(stub.agent, { objective: 'missing' }))
.toThrow('without a durable inbox insertion')
stub.agent.inject = inject
expect(ctx.goals.create(stub.agent, { objective: 'committed' })).toMatchObject({ revision: 1 })
expect(stub.agent.inbox.nextStep).toEqual([])
expect(stub.session.events.map(event => event.type)).toEqual(['goal/change'])
})
it('observes a valid goal snapshot appended after an empty cache was established', async () => {
@@ -487,10 +458,7 @@ describe('GoalService mutations', () => {
createdAt: 12,
updatedAt: 12,
}
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change } as const
appendInjection(session, createUserMessage({
content: renderGoalChange(change), source,
}))
session.append('goal/change', change)
expect(ctx.goals.get(agent)).toMatchObject({
id: change.goal.id,
@@ -517,17 +485,8 @@ describe('GoalService mutations', () => {
createdAt: 12,
updatedAt: 12,
}
appendInjection(session, createUserMessage({
content: renderGoalChange(change),
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change },
}))
appendInjection(session, createUserMessage({
content: [{ type: 'text', text: 'corrupt' }],
source: {
kind: 'goal', goalId: change.goal.id, revision: 2, round: 0,
change: { ...change, operation: 'edit', extra: true } as never,
},
}))
session.append('goal/change', change)
session.append('goal/change', { ...change, operation: 'edit', extra: true } as never)
expect(() => ctx.goals.get(agent)).toThrow('invalid shape')
expect(() => ctx.goals.get(agent)).toThrow('invalid shape')
@@ -554,32 +513,13 @@ describe('goal replay validation', () => {
}
}
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,
change,
}
const message = createUserMessage({
content: overrides.content ?? renderGoalChange(change),
source,
})
appendInjection(session, message)
const turn = nextTurn(session)
session.append('turn/start', { turn })
session.append('user/message', message, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
function appendChange(session: Session, change: GoalChangeMeta): void {
session.append('goal/change', change)
}
function oneChange(change: GoalChangeMeta, overrides: { content?: ContentBlock[]; source?: MessageSource } = {}) {
function oneChange(change: GoalChangeMeta) {
const session = new Session(SessionId(`validation-${Math.random()}`))
appendChange(session, change, overrides)
appendChange(session, change)
return session.events
}
@@ -607,74 +547,21 @@ describe('goal replay validation', () => {
}
}
it('commits queued changes before admission and verifies the admitted copy without applying it twice', () => {
it('keeps durable goal state independent from inbox changes', () => {
const change = snapshotChange()
const message = createUserMessage({
content: renderGoalChange(change),
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change },
})
const session = new Session(SessionId('queued-change'))
appendInjection(session, message)
const session = new Session(SessionId('inbox-independent-change'))
appendChange(session, change)
expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } })
const message = createUserMessage({
content: [{ type: 'text', text: 'unrelated pending context' }],
source: { kind: 'plugin', plugin: 'test' },
})
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
expect(inbox.remove('next-step', message.id)).toBe(true)
inbox.append('next-step', message)
session.append('turn/start', { turn: 1 })
session.append('user/message', message, { surfaceOp: 'append' })
expect(inbox.remove('next-step', message.id)).toBe(true)
expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } })
})
it('rejects an admitted change without its inbox insertion', () => {
const change = snapshotChange()
const message = createUserMessage({
content: renderGoalChange(change),
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change },
})
const session = new Session(SessionId('orphan-admitted-change'))
session.append('user/message', message, { surfaceOp: 'append' })
expect(() => foldGoal(session.events)).toThrow('was not committed by an inbox insertion')
})
it('allows an ordinary admission rewrite but rejects changed goal data under an inserted message id', () => {
const change = snapshotChange()
const message = createUserMessage({
content: renderGoalChange(change),
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change },
})
const drift = new Session(SessionId('admitted-change-drift'))
appendInjection(drift, message)
drift.append('user/message', freezeMessage({
...message,
content: [{ type: 'text', text: 'rewritten as ordinary context' }],
source: { kind: 'plugin', plugin: 'changed-after-claim' },
}), { surfaceOp: 'append' })
expect(foldGoal(drift.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } })
const edit = mutation(change, 'edit', 'active')
const changedAdmission = new Session(SessionId('changed-admitted-goal'))
appendInjection(changedAdmission, message)
changedAdmission.append('user/message', freezeMessage({
...message,
content: renderGoalChange(edit),
source: { kind: 'goal', goalId: edit.goal.id, revision: 2, round: 0, change: edit },
}), { surfaceOp: 'append' })
expect(() => foldGoal(changedAdmission.events)).toThrow('differs from its inbox insertion')
const reused = new Session(SessionId('reused-change-message-id'))
appendInjection(reused, message)
reused.append('agent/inbox/spliced', {
target: 'next-step',
start: 1,
inserted: [freezeMessage({
...message,
content: renderGoalChange(edit),
source: { kind: 'goal', goalId: edit.goal.id, revision: 2, round: 0, change: edit },
})],
})
expect(() => foldGoal(reused.events)).toThrow('reuses a message id with different change data')
})
function foldPair(first: GoalSnapshotChangeMeta, second: GoalChangeMeta): ReturnType<typeof foldGoal> {
const session = new Session(SessionId(`validation-pair-${Math.random()}`))
appendChange(session, first)
@@ -831,7 +718,7 @@ describe('goal replay validation', () => {
expect(() => foldGoal(clearedSession.events)).toThrow('fresh active revision-one')
})
it('rejects goal-source context without matching durable metadata', () => {
it('rejects non-positive goal round sources', () => {
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)
@@ -840,7 +727,7 @@ describe('goal replay validation', () => {
content: [{ type: 'text', text: 'missing' }], source,
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
expect(() => foldGoal(session.events)).toThrow('lacks source change data')
expect(() => foldGoal(session.events)).toThrow('goal message source is invalid')
})
it('rejects malformed snapshots, refs, counters, and timestamps', () => {
@@ -876,21 +763,6 @@ describe('goal replay validation', () => {
})).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, {
source: { kind: 'goal', goalId: GoalId('goal-imposter'), revision: 1, round: 0, change },
}))).toThrow('mismatched source attribution')
expect(() => foldGoal(oneChange(change, {
source: { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0, change },
}))).toThrow('mismatched source attribution')
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))

View File

@@ -1,9 +1,8 @@
import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
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'
@@ -26,14 +25,6 @@ const change: GoalSnapshotChangeMeta = {
updatedAt: 1,
}
const changeSource = {
kind: 'goal',
goalId: change.goal.id,
revision: change.goal.revision,
round: 0,
change,
} as const
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -46,17 +37,8 @@ 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'))
const message = createUserMessage({
content: renderGoalChange(change),
source: changeSource,
})
session.append('agent/inbox/spliced', {
target: 'next-step', start: 0, inserted: [message],
})
session.append('goal/change', change)
session.append('turn/start', { turn: 1 })
session.append('user/message', message, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', { turn: 2 })
expect(() => {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'continue' }],
@@ -65,25 +47,18 @@ describe('goal stream invariants', () => {
}).not.toThrow()
})
it('rejects model-visible drift before committing it and keeps the fold reusable', async () => {
it('rejects a malformed goal change before committing it and keeps the fold reusable', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('goal-invariant-invalid'))
const message = createUserMessage({ content: renderGoalChange(change), source: changeSource })
session.append('agent/inbox/spliced', {
target: 'next-step', start: 0, inserted: [message],
})
session.append('turn/start', { turn: 1 })
expect(() => {
session.append('user/message', freezeMessage({ ...message,
content: [{ type: 'text', text: 'counterfeit' }],
}), { surfaceOp: 'append' })
session.append('goal/change', { ...change, extra: true } as never)
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-goal',
}))
expect(session.seq).toBe(2)
expect(session.seq).toBe(0)
expect(() => {
session.append('user/message', message, { surfaceOp: 'append' })
session.append('goal/change', change)
}).not.toThrow()
})
@@ -91,17 +66,11 @@ describe('goal stream invariants', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('goal-invariant-late-load'))
const message = createUserMessage({ content: renderGoalChange(change), source: changeSource })
session.append('agent/inbox/spliced', {
target: 'next-step', start: 0, inserted: [message],
})
session.append('turn/start', { turn: 1 })
session.append('user/message', message, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('goal/change', change)
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(GoalInvariantCompanion)
session.append('turn/start', { turn: 2 })
session.append('turn/start', { turn: 1 })
expect(() => {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'continue after load' }],

View File

@@ -125,41 +125,20 @@ describe('goal projection unit', () => {
}
})
it('does not revive a cleared goal when its create message is reinserted', async () => {
it('does not let inbox changes revive a cleared goal', async () => {
const bench = await harness(true)
const created = bench.ctx.goals.create(bench.agent, { objective: 'stay cleared' })
const createMessage = bench.agent.inbox.nextStep.find(message => message.source.kind === 'goal'
&& message.source.change?.operation === 'create')
if (createMessage === undefined) throw new Error('missing create message')
bench.ctx.goals.clear(bench.agent, created)
bench.agent.inbox.claim('next-step')
bench.agent.inbox.prepend('next-step', createMessage)
bench.agent.inbox.prepend('next-step', createUserMessage({
content: [{ type: 'text', text: 'unrelated pending context' }],
source: { kind: 'plugin', plugin: 'test' },
}))
expect(bench.tailValues().goal).toBeNull()
expect(foldGoal(bench.session.events).goal).toBeUndefined()
})
it('does not regress a goal revision when its create message is reinserted', async () => {
const bench = await harness(true)
const created = bench.ctx.goals.create(bench.agent, { objective: 'first revision' })
const createMessage = bench.agent.inbox.nextStep.find(message => message.source.kind === 'goal'
&& message.source.change?.operation === 'create')
if (createMessage === undefined) throw new Error('missing create message')
const edited = bench.ctx.goals.edit(bench.agent, created, { objective: 'second revision' })
bench.agent.inbox.claim('next-step')
bench.agent.inbox.prepend('next-step', createMessage)
expect(bench.tailValues().goal).toMatchObject({
goal: { revision: edited.revision, objective: 'second revision' },
})
expect(foldGoal(bench.session.events).goal).toMatchObject({
revision: edited.revision,
objective: 'second revision',
})
})
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
@@ -171,28 +150,22 @@ describe('goal projection unit', () => {
})
const user = { type: 'user/message', seq: 0, time: 1, data: plainUser } as never
const state = { goal: { id: 'g1', revision: 1, objective: 'x', phase: 'active', maxGoalRounds: 4 }, roundsStarted: 0, createdAt: 1, updatedAt: 1 } as never
const empty = [null, []] as const
const empty = null
expect(applyGoalProjection(empty, user)).toBe(empty)
const queuedUser = {
type: 'agent/inbox/spliced', seq: 1, time: 2,
data: { target: 'next-step', start: 0, inserted: [plainUser] },
} as never
const current = [state, []] as const
const current = state
expect(applyGoalProjection(current, queuedUser)).toBe(current)
const malformedMessage = createUserMessage({
content: [{ type: 'text', text: 'broken' }],
source: { kind: 'goal', goalId: 'g-broken', revision: 1, round: 0 } as never,
})
const malformed = { type: 'user/message', seq: 1, time: 2, data: malformedMessage } as never
const malformed = {
type: 'goal/change', seq: 1, time: 2,
data: { kind: 'goal/change', version: 1, operation: 'create' },
} as never
// Same-reference return: the registry's Object.is gate sees no change.
expect(applyGoalProjection(current, malformed)).toBe(current)
expect(applyGoalProjection(empty, malformed)).toBe(empty)
const queuedMalformed = {
type: 'agent/inbox/spliced', seq: 2, time: 3,
data: { target: 'next-step', start: 0, inserted: [malformedMessage] },
} as never
expect(applyGoalProjection(current, queuedMalformed)).toBe(current)
const queuedRound = {
type: 'agent/inbox/spliced', seq: 3, time: 4,
@@ -203,29 +176,14 @@ describe('goal projection unit', () => {
} as never
expect(applyGoalProjection(current, queuedRound)).toBe(current)
const validGoalUser = { type: 'user/message', seq: 2, time: 3, data: createUserMessage({
content: [{ type: 'text', text: 'legacy direct change' }],
source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0, change: { kind: 'goal/change' } } as never,
}) } as never
expect(applyGoalProjection(empty, validGoalUser)).toBe(empty)
// A non-message event (the registry drives EVERY committed event through
// apply): early same-reference return.
const turnStart = { type: 'turn/start', seq: 3, time: 4, data: { turn: 1 } } as never
expect(applyGoalProjection(current, turnStart)).toBe(current)
// A round-zero goal source whose change carries a foreign kind: same posture.
const foreignMessage = createUserMessage({
content: [{ type: 'text', text: 'foreign' }],
source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0, change: { kind: 'not-a-goal-change' } } as never,
})
const foreignKind = { type: 'user/message', seq: 2, time: 3, data: foreignMessage } as never
// A goal/change event whose payload carries a foreign kind is ignored.
const foreignKind = { type: 'goal/change', seq: 4, time: 5, data: { kind: 'not-a-goal-change' } } as never
expect(applyGoalProjection(current, foreignKind)).toBe(current)
const queuedForeignKind = {
type: 'agent/inbox/spliced', seq: 4, time: 5,
data: { target: 'next-step', start: 0, inserted: [foreignMessage] },
} as never
expect(applyGoalProjection(current, queuedForeignKind)).toBe(current)
})
it('has no goal key when the goal service is not composed', async () => {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/goal/tool-goal/README.md
README.md: a4742e4117ca89f4395a1c59264f6ea6c3ab8b96
README.zh.md: 48e89332db7dfbe746d1ba4e57077eb787a10a66
README.md: c8c1ab84c237ee34db7abcd63962476693b5e56c
README.zh.md: b90a8af79c35986e1fc86be90370a214ab517185

View File

@@ -61,15 +61,15 @@ Prefix-stable while the plugin scope, configured threshold, and guidance text ar
#### What the model sees
The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. A mutation queues the goal domain's raw `<goal_state>` snapshot after the tool batch; a later pre-step may admit it, while discarding the queued context does not roll back the durable mutation. `activation` in a result is a live observation and never becomes replay authority.
The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. A mutation appends the goal domain's durable `goal/change` event without queuing model context. `activation` in a result is a live observation and never becomes replay authority.
#### Token effect
Fixed schema cost plus one compact result per call. An admitted mutation context retains the domain snapshot until compaction; one discarded before admission adds no model tokens.
Fixed schema cost plus one compact result per call. The durable mutation adds no separate model-visible context.
#### KV Cache effect
Schemas are prefix-stable while their definitions and visibility are unchanged. Calls, results, and admitted goal snapshots append after the reusable request prefix without invalidating earlier entries.
Schemas are prefix-stable while their definitions and visibility are unchanged. Calls and results append after the reusable request prefix without invalidating earlier entries.
## Known Limitations and Deferred Work

View File

@@ -61,15 +61,15 @@ Use goal tools for one long-running completion objective in the current session.
#### 模型看到的内容
生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更会在工具批次结束后将 goal 领域的原始 `<goal_state>` 快照排队;后续 pre-step 可以准入它,而丢弃已排队的上下文不会回滚持久变更。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。
生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更会追加 goal 领域的持久 `goal/change` 事件,而不会把模型上下文排队。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。
#### Token 影响
固定 schema 成本,加上每次调用的一条紧凑结果。获准的变更上下文会保留领域快照直到压缩compaction准入前被丢弃的上下文不增加模型 token
固定 schema 成本,加上每次调用的一条紧凑结果。持久变更不会增加单独的模型可见上下文
#### KV Cache 影响
schema 的定义与可见性不变时,前缀保持稳定。调用结果和已准入的 goal 快照会追加到可复用请求前缀之后,不会使更早条目失效。
schema 的定义与可见性不变时,前缀保持稳定。调用结果会追加到可复用请求前缀之后,不会使更早条目失效。
## 已知限制与暂缓事项

View File

@@ -16,7 +16,7 @@ import { createUserMessage,
type LlmModelReasoningInfo,
createMessage,
} from '@deepseek-ai/dsh-llm'
import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
import { GOAL_CHANGE_VERSION, GoalId, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact'
import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session'
@@ -1239,19 +1239,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
}
const result = await setup({
beforeMount(session) {
const message = createUserMessage({
content: renderGoalChange(change),
source: {
kind: 'goal',
goalId: change.goal.id,
revision: change.goal.revision,
round: 0,
change,
},
})
session.append('agent/inbox/spliced', {
target: 'next-step', start: 0, inserted: [message],
})
session.append('goal/change', change)
},
})
expect(result.terminal.output).toContain('Goal restored (active) with automatic continuation disarmed')
@@ -1380,7 +1368,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
// A non-plugin injected source (goal) has no `plugin` field, so its context
// card label falls back to the source kind.
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'goal context' }], source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0 } as never,
content: [{ type: 'text', text: 'goal context' }], source: { kind: 'goal', goalId: 'g1', revision: 1, round: 1 } as never,
}), { surfaceOp: 'append' })
appendAssistant(result.session, [])
result.session.append('step/end', { turn: 1, step: 1 })