fix inbox lifecycle downstream contracts

This commit is contained in:
_Kerman
2026-07-31 22:00:39 +08:00
parent 8e88b17c9f
commit afedf18ccf
219 changed files with 5660 additions and 4556 deletions

View File

@@ -230,7 +230,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
await record.agent.whenIdle()
return { stopReason: inflight.cancelled ? 'cancelled' : 'end_turn' }
} finally {
if (record.inflight === inflight) record.inflight = undefined
record.inflight = undefined
}
},

View File

@@ -164,6 +164,23 @@ describe('ACP prompt lifecycle', () => {
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
})
it('cancels autonomous running work without an in-flight prompt', async () => {
harness = await makeBridgeHarness({ script: ['hang'] })
const sessionId = await newSession(harness)
const agent = harness.ctx.agents.get(SessionId(sessionId))!
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'autonomous work' }],
source: { kind: 'plugin', plugin: 'test' },
}))
await vi.waitFor(() => { expect(agent.status).toBe('running') })
await harness.client.cancel({ sessionId })
await agent.whenIdle()
expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason)
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
})
it('an idle cancel does not affect the following prompt', async () => {
harness = await makeBridgeHarness({ script: [textResponse('answer')] })
const sessionId = await newSession(harness)

View File

@@ -502,13 +502,13 @@ 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: a round-zero goal-sourced user message advances the goal unit.
if (type === 'user/message') {
const source = (event as unknown as { data?: { source?: { kind?: string; round?: number } } }).data?.source
if (source?.kind === 'goal' && source.round === 0) {
// 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 }]
}
return []
}
// Standing-plan fold: writes replace the list; turn/start clears it (null).
if (type === 'todo/write' || type === 'turn/start') {
@@ -603,7 +603,7 @@ interface FxGoalProjection {
updatedAt: number
}
/** One durable goal change riding a round-zero goal-sourced user message. */
/** One durable goal change riding a round-zero goal-sourced inbox insertion. */
type FxGoalChange =
| { kind: 'goal/change'; version: 1; operation: 'clear'; cleared: { id: string; revision: number }; clearedAt: number }
| {
@@ -616,6 +616,14 @@ 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).
@@ -624,16 +632,18 @@ 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?: { source?: { kind?: string; round?: number; change?: FxGoalChange } }
data?: { inserted?: UserMessage[] }
} | undefined
if (event === undefined || event.type !== 'user/message') continue
const source = event.data?.source
if (source?.kind !== 'goal' || source.round !== 0) continue
const change = source.change
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (change === undefined || change.kind !== 'goal/change') 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 !== '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 }
}
}
return null
}
@@ -869,20 +879,35 @@ 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 user message (host GoalService parallel). */
/** Append one goal/change as its round-zero goal-sourced inbox insertion (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: 'user/message', surfaceOp: 'append',
data: 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: '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,
),
],
},
})
return backscanGoal(logOf(id)) as FxGoalProjection
return backscanGoal(log) as FxGoalProjection
}
/** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */

View File

@@ -862,6 +862,32 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
// complete → complete is an invalid transition.
expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false)
expect((await client.goals.clear({ sessionId: id, ref })).result).toEqual({ ok: true, value: { cleared: true } })
const goalHistory = await client.sessions.history({ sessionId: id })
if (!goalHistory.result.ok) throw new Error('goal history failed')
const goalEvents = goalHistory.result.value.events.map(entry => entry.event as unknown as {
type: string
data: {
target?: string
start?: number
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))
.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)
})
it('maps empty, prompt-reject, and workspace-first query scenarios', 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/client/ui-goal/README.md
README.md: 2c109ab1fbe0b566b8749a6af44ec5e0055fe3b2
README.zh.md: b81113c67566fd834b3ddb10931d4ecc630aa2f9
README.md: caeaef4db9b1bd82090f1897c1a470a835294a39
README.zh.md: 60dd661b640d04917ac93c9930e1a4755a5817ff

View File

@@ -8,11 +8,11 @@ The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar
## Model Experience
Indirectly, through the `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation appends a model-visible `goal/change` context message to the session (the same durable event the projection folds), so the model sees the updated goal state on its next turn. The strip itself adds no prompt content.
Indirectly, through the `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation commits in a durable `agent/inbox/spliced` insertion, which the goal projection folds immediately, and queues a `goal/change` context message. The model sees that context only if a later pre-step admits it; discarding the queued message does not roll back the projected state. The strip itself adds no prompt content.
#### KV Cache effect
None beyond the goal mutation's own context event, which appends to the log tail like any other message.
None unless the queued goal context is admitted. An admitted context extends the history tail like any other message; an insertion discarded before admission does not affect the cache.
## Known Limitations and Deferred Work

View File

@@ -8,11 +8,11 @@ Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input
## Model Experience
间接影响:条带动词提交的 `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPC 每次被接受后,会向会话追加一条模型可见的 `goal/change` 上下文消息(与投影折叠的正是同一条持久事件),模型在下一轮即可看到更新后的 goal 状态。条带自身不添加任何提示词内容。
间接影响:条带动词提交的 `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPC 每次被接受后,变更都会在持久 `agent/inbox/spliced` 插入项中提交goal 投影会立即折叠该插入项,同时将一条 `goal/change` 上下文消息排队。只有后续 pre-step 准入该上下文时,模型才会看到它;丢弃已排队的消息不会回滚投影状态。条带自身不添加任何提示词内容。
#### KV Cache effect
除 goal 变更自身的上下文事件(如同任何消息一样追加在日志尾部)外无额外影响
非已排队的 goal 上下文获准,否则没有影响。获准的上下文会像其他消息一样扩展历史尾部;准入前被丢弃的插入项不会影响缓存
## Known Limitations and Deferred Work

View File

@@ -137,6 +137,9 @@ describe('time-context invariants', () => {
const ended = preparing(1, 1)
ended.append('step/end', { turn: 1, step: 1 })
expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/at a prompt boundary/)
const notEntered = new Session(SessionId('time-invariant-turn-only'))
notEntered.append('turn/start', { turn: 1 })
expect(() => { ctx.emit('session/event', notEntered, event(reading())) }).toThrow(/at a prompt boundary/)
expect(() => {
ctx.emit('session/event', new Session(SessionId('time-invariant-empty')), event(reading()))
}).toThrow(/at a prompt boundary/)

View File

@@ -2,35 +2,28 @@
* Workspace instruction loader for AGENTS.md-compatible files.
*
* Baseline instructions enter durable context before the first request; successful fs
* tool touches reconcile nested, changed, and removed instructions through
* `tools/post-execute` for the next model request. Plugin lifecycle reads use
* tool touches mark nested, changed, and removed instructions for reconciliation
* at the next pre-step. Plugin lifecycle reads use
* the optional `ctx.fs` provider, so providerless products mount it as a no-op.
*
* @module @deepseek-ai/dsh-workspace-context
*/
import type { Context } from 'cordis'
import { isDeepStrictEqual } from 'node:util'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { createUserMessage, type MessageId } from '@deepseek-ai/dsh-llm'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import { Config, resolveConfig, type ResolvedConfig } from './config.ts'
import { loadBaselineInstructionSet } from './files.ts'
import {
applyInstructionVersionUpdates,
baselineInstructionState,
commitPendingInstructionContexts,
dynamicInstructionContext,
name,
observeInstructionSessionEvent,
reconcileInstructionContext,
retainedInstructionVersionUpdates,
rollbackPendingInstructionChanges,
workspaceContextMessage,
type InstructionVersionCache,
type InstructionVersionState,
type InstructionVersionUpdate,
type PendingInstructionChange,
} from './state.ts'
import type { WorkspaceInstructionChange } from './render.ts'
@@ -55,200 +48,198 @@ function hasVisibleBaseline(agent: Agent): boolean {
})
}
function isWorkspaceContext(message: UserMessage): boolean {
return message.source.kind === 'workspace-instructions'
}
function sameContextPayload(left: UserMessage, right: UserMessage): boolean {
return isDeepStrictEqual(left.content, right.content)
&& isDeepStrictEqual(left.source, right.source)
}
const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit'])
function filePathFromExecution(exec: ToolExecution): string | undefined {
if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return undefined
if (typeof exec.arguments !== 'object' || exec.arguments === null) return undefined
if (!('file_path' in exec.arguments) || typeof exec.arguments.file_path !== 'string') return undefined
const filePath = exec.arguments.file_path.trim()
return filePath.length > 0 ? filePath : undefined
}
export function apply(ctx: Context, config: Config): void {
const resolved: ResolvedConfig = resolveConfig(config)
const pendingNestedChanges = new WeakMap<object, Map<string, PendingInstructionChange>>()
const baselineSessions = new WeakSet<object>()
const instructionVersions: InstructionVersionCache = new WeakMap()
const pendingVersionUpdates = new Map<ToolExecutionToken, InstructionVersionUpdate[]>()
const baselineLoaded = new WeakSet<object>()
const pendingBaselineCommits = new WeakMap<object, {
messageIds: Set<MessageId>
versions: Map<string, InstructionVersionState>
}>()
// Sessions whose lifecycle start this mount witnessed. A startup or resume
// emits agent/session-start before the first step; a hot remount attaches to
// an already-live session and never sees it. Resumes always re-compose the
// baseline from current files. Hot remounts retain a baseline only while its
// typed event remains model-visible.
const lifecycleWitnessed = new WeakSet<object>()
const pendingByParent = new Map<ToolExecutionToken, {
agent: Agent
changes: WorkspaceInstructionChange[]
versionUpdates: InstructionVersionUpdate[]
}>()
const pendingTouches = new Map<ToolExecutionToken, { agent: Agent; paths: Set<string> }>()
const touchedPaths = new WeakMap<Agent, Set<string>>()
ctx.on('agent/session-start', (agent: Agent) => {
lifecycleWitnessed.add(agent.session)
})
const compose = async (
agent: Agent,
signal: AbortSignal,
claimed: readonly UserMessage[],
pending: readonly UserMessage[],
touchedPaths: readonly string[] = [],
): Promise<{
desired?: UserMessage
versions: Map<string, import('./state.ts').InstructionVersionState>
}> => {
signal.throwIfAborted()
const candidateVersions: InstructionVersionCache = new WeakMap()
const candidateVersionStates = new Map(instructionVersions.get(agent.session) ?? [])
candidateVersions.set(agent.session, candidateVersionStates)
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) {
return { versions: new Map() }
}
const fileSystem = ctx.get('fs')
if (fileSystem === undefined) return { versions: new Map() }
const content: UserMessage['content'][number][] = []
const changes: WorkspaceInstructionChange[] = []
let desiredBaseline = false
const authorityMessages = [...claimed]
const baselinePresent = hasVisibleBaseline(agent) || claimed.some(message =>
message.source.kind === 'workspace-instructions' && message.source.baseline === true)
if (!baselinePresent) {
/* v8 ignore next -- normal agents carry an absolute session cwd. */
const cwd = agent.session.header.cwd ?? process.cwd()
const instructions = await loadBaselineInstructionSet({
cwd,
dshHome: resolved.dshHome,
projectRootMarkers: resolved.projectRootMarkers,
maxBytes: resolved.maxBytes,
maxSourceBytes: resolved.maxSourceBytes,
instructionFileCandidates: resolved.instructionFileCandidates,
localInstructionFileCandidates: resolved.localInstructionFileCandidates,
signal,
}, fileSystem)
const baseline = baselineInstructionState(instructions?.included ?? [])
for (const [scope, state] of baseline.versions) candidateVersionStates.set(scope, state)
if (instructions !== undefined && instructions.rendered.text.length > 0) {
content.push(...workspaceContextMessage(instructions.rendered.text).content)
changes.push(...baseline.changes.values())
desiredBaseline = true
}
}
const update = await reconcileInstructionContext(
agent,
resolved,
candidateVersions,
fileSystem,
{ authorityMessages, scopeMessages: pending, includeBaselineScopes: baselinePresent, touchedPaths, signal },
)
if (update !== undefined) {
content.push(...update.context.content)
/* v8 ignore next -- reconciliation constructs only workspace-instructions contexts. */
if (update.context.source.kind === 'workspace-instructions') {
changes.push(...update.context.source.changes)
}
applyInstructionVersionUpdates(agent.session, update.versionUpdates, candidateVersions)
}
const versions = new Map(candidateVersions.get(agent.session) ?? [])
return content.length === 0
? { versions }
: {
desired: createUserMessage({
content,
source: {
kind: 'workspace-instructions',
...desiredBaseline ? { baseline: true } : {},
changes,
},
}),
versions,
}
}
ctx.on('session/event', (session, event) => {
observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions)
const pending = pendingBaselineCommits.get(session)
if (pending === undefined || event.type !== 'user/message'
|| !pending.messageIds.delete(event.data.id) || pending.messageIds.size > 0) return
baselineSessions.add(session)
if (pending.versions.size === 0) instructionVersions.delete(session)
else instructionVersions.set(session, pending.versions)
baselineLoaded.add(session)
pendingBaselineCommits.delete(session)
})
const syncInbox = (agent: Agent, claimed: readonly UserMessage[], desired: UserMessage | undefined): void => {
const pending = agent.inbox.nextStep.filter(isWorkspaceContext)
const alreadySupplied = desired !== undefined && (
claimed.some(message => sameContextPayload(message, desired))
|| agent.session.surface.nodes.some((seq) => {
const event = agent.session.events[seq]
return event?.type === 'user/message' && sameContextPayload(event.data, desired)
})
)
if (desired === undefined || alreadySupplied) {
for (const message of pending) agent.inbox.remove('next-step', message.id)
return
}
const reusable = pending.find(message => sameContextPayload(message, desired))
if (reusable !== undefined) {
for (const message of pending) {
if (message !== reusable) agent.inbox.remove('next-step', message.id)
}
return
}
const replaced = pending[0]
if (replaced === undefined) agent.inbox.prepend('next-step', desired)
else agent.inbox.update('next-step', replaced.id, desired)
for (const message of pending.slice(1)) agent.inbox.remove('next-step', message.id)
}
const commitSync = (
agent: Agent,
claimed: readonly UserMessage[],
desired: UserMessage | undefined,
versions: Map<string, import('./state.ts').InstructionVersionState>,
): void => {
syncInbox(agent, claimed, desired)
if (versions.size === 0) instructionVersions.delete(agent.session)
else instructionVersions.set(agent.session, versions)
}
const restoreTouchedPaths = (agent: Agent, paths: Set<string> | undefined): void => {
if (paths === undefined || paths.size === 0) return
const current = touchedPaths.get(agent)
if (current === undefined) touchedPaths.set(agent, paths)
else for (const path of paths) current.add(path)
}
ctx.on('agent/pre-step', async (
agent: Agent,
_messages,
messages,
{ signal },
next,
): Promise<PreStepDecision> => {
const decision = await next()
if (signal.aborted || baselineLoaded.has(agent.session)) return decision
const previous = pendingBaselineCommits.get(agent.session)
if (decision.kind === 'enter' && previous !== undefined
&& [...previous.messageIds].every(id => decision.messages.some(message => message.id === id))) {
const pending = agent.inbox.nextStep.filter(isWorkspaceContext)
const paths = touchedPaths.get(agent)
touchedPaths.delete(agent)
try {
const composed = await compose(agent, signal, messages, pending, [...paths ?? []])
/* v8 ignore next 4 -- every awaited filesystem operation checks this signal before settling. */
if (signal.aborted) {
restoreTouchedPaths(agent, paths)
return decision
}
commitSync(agent, messages, composed.desired, composed.versions)
return decision
}
if (previous !== undefined) {
for (const id of previous.messageIds) agent.inbox.remove('next-step', id)
pendingBaselineCommits.delete(agent.session)
}
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) {
baselineLoaded.add(agent.session)
pendingBaselineCommits.delete(agent.session)
return decision
}
const fileSystem = ctx.get('fs')
if (fileSystem === undefined) {
baselineLoaded.add(agent.session)
pendingBaselineCommits.delete(agent.session)
return decision
}
/* v8 ignore next -- normal agents carry an absolute session cwd. */
const cwd = agent.session.header.cwd ?? process.cwd()
const instructions = await loadBaselineInstructionSet({
cwd,
dshHome: resolved.dshHome,
projectRootMarkers: resolved.projectRootMarkers,
maxBytes: resolved.maxBytes,
maxSourceBytes: resolved.maxSourceBytes,
instructionFileCandidates: resolved.instructionFileCandidates,
localInstructionFileCandidates: resolved.localInstructionFileCandidates,
signal,
}, fileSystem)
const baseline = baselineInstructionState(instructions?.included ?? [])
const candidateVersions: InstructionVersionCache = new WeakMap()
candidateVersions.set(agent.session, new Map(baseline.versions))
const contexts: UserMessage[] = []
const update = await reconcileInstructionContext(
agent,
resolved,
pendingNestedChanges,
candidateVersions,
fileSystem,
{ includeBaselineScopes: false, signal },
)
if (update !== undefined) {
contexts.push(update.context)
applyInstructionVersionUpdates(agent.session, update.versionUpdates, candidateVersions)
}
const keepVisibleBaseline = !lifecycleWitnessed.has(agent.session) && hasVisibleBaseline(agent)
if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) {
const baselineMessage = workspaceContextMessage(instructions.rendered.text)
contexts.push(createUserMessage({
content: baselineMessage.content,
source: {
kind: 'workspace-instructions',
baseline: true,
changes: [...baseline.changes.values()],
},
}))
}
const versions = candidateVersions.get(agent.session)
?? new Map<string, InstructionVersionState>()
if (contexts.length === 0) {
baselineSessions.add(agent.session)
if (versions.size === 0) instructionVersions.delete(agent.session)
else instructionVersions.set(agent.session, versions)
baselineLoaded.add(agent.session)
pendingBaselineCommits.delete(agent.session)
return decision
}
pendingBaselineCommits.set(agent.session, {
messageIds: new Set(contexts.map(context => context.id)),
versions,
})
for (const context of contexts.toReversed()) {
agent.inbox.prepend('next-step', context)
}
return decision
})
ctx.on('tools/post-execute', async (
exec: ToolExecution,
result: ToolExecutionResult,
next,
): Promise<PostToolDecision> => {
const downstream = await next()
// A downstream listener/policy blocked this call: the registry turns it
// into a final `isError` result, so treat it like a failed fs touch and
// load nothing. Reconciling here would surface workspace instructions from
// a call the pipeline rejected, violating the "successful fs tool touches"
// contract, and would advance the nested/baseline tracking state off a
// touch that never really happened.
if (downstream.kind === 'block') return downstream
const fileSystem = ctx.get('fs')
if (fileSystem === undefined) return downstream
const update = await dynamicInstructionContext(
exec.agent,
exec,
result,
resolved,
pendingNestedChanges,
baselineSessions,
instructionVersions,
fileSystem,
)
if (update === undefined) return downstream
pendingVersionUpdates.set(exec.token, update.versionUpdates)
return {
...downstream,
additionalContexts: [update.context, ...downstream.additionalContexts ?? []],
} catch (error: unknown) {
restoreTouchedPaths(agent, paths)
throw error
}
})
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
const ownVersionUpdates = pendingVersionUpdates.get(exec.token) ?? []
pendingVersionUpdates.delete(exec.token)
const staged = pendingTouches.get(exec.token)
pendingTouches.delete(exec.token)
if (exec.parent !== undefined) {
if (exec.agent === undefined) return
// Child contexts participate in duplicate suppression within one composite
// run, but remain provisional until the parent reaches its final policy.
const changes = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
if (changes.length === 0) return
const versionUpdates = retainedInstructionVersionUpdates(ownVersionUpdates, changes)
const staged = pendingByParent.get(exec.parent)
if (staged === undefined) pendingByParent.set(exec.parent, { agent: exec.agent, changes, versionUpdates })
else {
staged.changes.push(...changes)
staged.versionUpdates.push(...versionUpdates)
const paths = new Set(staged?.paths ?? [])
const ownPath = result.isError ? undefined : filePathFromExecution(exec)
if (ownPath !== undefined) paths.add(ownPath)
if (!result.isError && exec.agent !== undefined && paths.size > 0) {
const parent = pendingTouches.get(exec.parent)
if (parent === undefined) pendingTouches.set(exec.parent, { agent: exec.agent, paths })
else for (const path of paths) parent.paths.add(path)
}
return
}
// The parent result is authoritative: remove every provisional child change,
// then commit only contexts that survived outer post-execute policy.
const staged = pendingByParent.get(exec.token)
if (staged !== undefined) {
pendingByParent.delete(exec.token)
rollbackPendingInstructionChanges(staged.agent, staged.changes, pendingNestedChanges)
}
if (exec.agent === undefined) return
const committed = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
const stagedVersionUpdates = staged?.versionUpdates ?? []
const versionUpdates = retainedInstructionVersionUpdates(
[...stagedVersionUpdates, ...ownVersionUpdates],
committed,
)
applyInstructionVersionUpdates(exec.agent.session, versionUpdates, instructionVersions)
if (result.isError || exec.agent === undefined) return
const paths = new Set(staged?.paths ?? [])
const ownPath = filePathFromExecution(exec)
if (ownPath !== undefined) paths.add(ownPath)
if (paths.size === 0) return
const pending = touchedPaths.get(exec.agent)
if (pending === undefined) touchedPaths.set(exec.agent, paths)
else for (const path of paths) pending.add(path)
})
}

View File

@@ -7,9 +7,8 @@
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Message } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session'
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ResolvedConfig } from './config.ts'
import { instructionContentSha1, trimmedInstructionDigest } from './digest.ts'
import {
@@ -34,8 +33,6 @@ import {
export const name = 'workspace-context'
const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit'])
/** Durable provenance and reconciliation facts for one workspace context. */
export interface WorkspaceInstructionSource {
kind: 'workspace-instructions'
@@ -50,13 +47,6 @@ declare module '@deepseek-ai/dsh-llm' {
}
}
/** Dynamic state waiting for the loop to append its returned context event. */
export interface PendingInstructionChange {
change: WorkspaceInstructionChange
afterSeq: number
step?: { turn: number; step: number }
}
/** Per-scope metadata cache; instruction prose is deliberately not retained. */
export interface InstructionVersionState {
path: string
@@ -103,14 +93,6 @@ export function workspaceContextMessage(text: string): Message {
})
}
function filePathFromExecution(exec: ToolExecution): string | undefined {
if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return undefined
if (typeof exec.arguments !== 'object' || exec.arguments === null) return undefined
if (!('file_path' in exec.arguments) || typeof exec.arguments.file_path !== 'string') return undefined
const filePath = exec.arguments.file_path.trim()
return filePath.length > 0 ? filePath : undefined
}
function isWorkspaceContextSource(
source: unknown,
): source is { kind: 'workspace-instructions'; changes: unknown[] } {
@@ -149,7 +131,7 @@ function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstru
function visibleInstructionChanges(
agent: Agent,
pending: Map<string, PendingInstructionChange>,
authorityMessages: readonly UserMessage[],
): Map<string, WorkspaceInstructionChange> {
const visibleSeqs = new Set(agent.session.surface.nodes)
const visible = new Map<string, WorkspaceInstructionChange>()
@@ -157,14 +139,15 @@ function visibleInstructionChanges(
if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue
const changes = workspaceInstructionChanges(event.data.source)
for (const change of changes) {
const waiting = pending.get(change.scope)
if (waiting !== undefined && seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
pending.delete(change.scope)
}
if (visibleSeqs.has(seq)) visible.set(change.scope, change)
}
}
for (const { change } of pending.values()) visible.set(change.scope, change)
for (const message of authorityMessages) {
if (!isWorkspaceContextSource(message.source)) continue
for (const change of workspaceInstructionChanges(message.source)) {
visible.set(change.scope, change)
}
}
return visible
}
@@ -242,164 +225,35 @@ export function applyInstructionVersionUpdates(
if (states.size === 0) cache.delete(session)
}
function pendingChangesFor(
session: object,
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
): Map<string, PendingInstructionChange> {
let pending = pendingBySession.get(session)
if (pending === undefined) {
pending = new Map()
pendingBySession.set(session, pending)
}
return pending
}
function openStep(session: Session): { turn: number; step: number } | undefined {
const boundary = session.events.findLast(event => event.type === 'step/start' || event.type === 'step/end')
return boundary?.type === 'step/start' ? boundary.data : undefined
}
function invalidateInstructionVersions(
session: Session,
scopes: readonly string[],
cache: InstructionVersionCache,
): void {
const states = cache.get(session)
if (states === undefined) return
for (const scope of scopes) states.delete(scope)
if (states.size === 0) cache.delete(session)
}
/**
* Settle provisional tool-result state against durable session events.
* A matching context event confirms the transition. If its owning step closes
* first, both duplicate suppression and the metadata fast path are re-armed for
* the next successful touch.
* @param session - session whose append-only log emitted `event`.
* @param event - newly committed session event.
* @param pendingBySession - provisional transitions awaiting log confirmation.
* @param versionCache - metadata fast path coupled to those transitions.
*/
export function observeInstructionSessionEvent(
session: Session,
event: SessionEvent,
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
versionCache: InstructionVersionCache,
): void {
const pending = pendingBySession.get(session)
if (pending === undefined) return
switch (event.type) {
case 'user/message': {
if (!isWorkspaceContextSource(event.data.source)) return
for (const change of workspaceInstructionChanges(event.data.source)) {
const waiting = pending.get(change.scope)
if (waiting !== undefined && event.seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
pending.delete(change.scope)
}
}
if (pending.size === 0) pendingBySession.delete(session)
return
}
case 'step/end': {
const discardedScopes: string[] = []
for (const [scope, waiting] of pending) {
const step = waiting.step
if (step === undefined || step.turn !== event.data.turn || step.step !== event.data.step) continue
pending.delete(scope)
discardedScopes.push(scope)
}
if (pending.size === 0) pendingBySession.delete(session)
invalidateInstructionVersions(session, discardedScopes, versionCache)
return
}
default:
// SessionEventMap is merge-extensible; unrelated events do not settle workspace state.
return
}
}
/**
* Commit only workspace contexts that survived the complete tool pipeline.
* The observe-only `tools/result` notification calls this before the loop can
* append the returned contexts, closing that short pending window without
* trusting an intermediate post-execute decision.
* @param agent - session that will receive the final result contexts.
* @param contexts - immutable contexts on the authoritative top-level result.
* @param pendingBySession - per-session pending transition maps.
* @returns transitions committed into the short pending window.
*/
export function commitPendingInstructionContexts(
agent: Agent,
contexts: readonly UserMessage[] | undefined,
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
): WorkspaceInstructionChange[] {
const committed: WorkspaceInstructionChange[] = []
const step = openStep(agent.session)
for (const context of contexts ?? []) {
if (!isWorkspaceContextSource(context.source)) continue
const changes = workspaceInstructionChanges(context.source)
if (changes.length === 0) continue
const pending = pendingChangesFor(agent.session, pendingBySession)
for (const change of changes) {
pending.set(change.scope, {
change,
afterSeq: agent.session.seq,
...step === undefined ? {} : { step },
})
committed.push(change)
}
}
return committed
}
/**
* Roll back parent-token state when an enclosing tool result discards deferred
* contexts. A newer transition for the same scope is left intact.
* @param agent - session whose pending state was staged.
* @param changes - exact staged transitions to remove when still current.
* @param pendingBySession - per-session pending transition maps.
*/
export function rollbackPendingInstructionChanges(
agent: Agent,
changes: readonly WorkspaceInstructionChange[],
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
): void {
const pending = pendingBySession.get(agent.session)
if (pending === undefined) return
for (const change of changes) {
const current = pending.get(change.scope)
if (current !== undefined && sameInstructionChange(current.change, change)) pending.delete(change.scope)
}
if (pending.size === 0) pendingBySession.delete(agent.session)
}
function relativeScope(projectRoot: string, dir: string): string {
const scope = relativeDisplay(projectRoot, dir)
return scope.length === 0 ? '.' : scope
}
/**
* Compare visible/pending state with provider-visible files and render transitions.
* Compare visible state with provider-visible files and render transitions.
* @param agent - session owner whose visible surface supplies durable state.
* @param resolved - normalized plugin configuration.
* @param pendingBySession - short pending window before returned context is logged.
* @param versionCache - per-session scope metadata used to skip unchanged reads.
* @param fileSystem - provider used for current file probes.
* @param options - touched path and whether baseline scopes should participate.
* @param options - authoritative claimed context, pending scope hints, touched paths, and baseline participation.
* @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable.
*/
export async function reconcileInstructionContext(
agent: Agent,
resolved: ResolvedConfig,
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
versionCache: InstructionVersionCache,
fileSystem: FileSystem,
options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal },
options: {
authorityMessages: readonly UserMessage[]
scopeMessages: readonly UserMessage[]
touchedPaths: readonly string[]
includeBaselineScopes: boolean
signal?: AbortSignal
},
): Promise<ReconciledInstructionContext | undefined> {
const session = agent.session
const pending = pendingChangesFor(session, pendingBySession)
const effective = visibleInstructionChanges(agent, pending)
const effective = visibleInstructionChanges(agent, options.authorityMessages)
/* v8 ignore next -- normal agents carry an absolute session cwd. */
const cwd = session.header.cwd ?? process.cwd()
// TODO(frozen-project-root): retain the baseline root for the loop instance;
@@ -419,14 +273,22 @@ export async function reconcileInstructionContext(
if (options.includeBaselineScopes) {
for (const scope of baselineScopes) scopes.add(scope)
}
for (const message of options.scopeMessages) {
/* v8 ignore next -- the plugin passes its workspace-only pending projection. */
if (!isWorkspaceContextSource(message.source)) continue
for (const change of workspaceInstructionChanges(message.source)) {
if (!options.includeBaselineScopes && baselineScopes.has(change.scope)) continue
scopes.add(change.scope)
}
}
for (const scope of effective.keys()) {
if (!options.includeBaselineScopes && baselineScopes.has(scope)) continue
const { directory } = decodeScopeKey(scope)
if (directory === USER_GLOBAL_DIRECTORY) scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
else addDirScopes(scopes, directory)
}
if (options.touchedPath !== undefined) {
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(scopes, dir)
for (const touchedPath of options.touchedPaths) {
for (const dir of descendantDirsBetween(cwd, touchedPath)) addProjectScopes(scopes, dir)
}
const versions = versionStatesFor(session, versionCache)
@@ -452,116 +314,97 @@ export async function reconcileInstructionContext(
items.push({ change, file: { absolutePath: `removed:${scope}`, displayPath: path, content: '' } })
versionUpdates.push({ change })
}
const scopesByDirectory = new Map<string, string[]>()
for (const scope of scopes) {
const { directory } = decodeScopeKey(scope)
const previous = effective.get(scope)
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
if (probe.kind === 'unavailable') {
// Last-good-state: the candidate stays effective, so its cached trimmed
// digest must keep occupying the directory's dedup slot — otherwise an
// identical later sibling would be emitted as a duplicate `set` until the
// next successful reconciliation removed it again.
const cached = versions.get(scope)
if (cached !== undefined && previous !== undefined && previous.action !== 'remove') {
registerKeptTrimmed(directory, cached.trimmedDigest)
const directoryScopes = scopesByDirectory.get(directory)
if (directoryScopes === undefined) scopesByDirectory.set(directory, [scope])
else directoryScopes.push(scope)
}
for (const [directory, directoryScopes] of scopesByDirectory) {
const itemStart = items.length
const versionUpdateStart = versionUpdates.length
const addedAbsolutePaths: string[] = []
const priorVersions = new Map(directoryScopes.map(scope => [scope, versions.get(scope)]))
for (const scope of directoryScopes) {
const previous = effective.get(scope)
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
if (probe.kind === 'unavailable') {
if (previous === undefined || previous.action === 'remove') continue
// Same-directory candidates form one deduplicated authority group. If an
// active member cannot be observed, preserve the entire last-good group;
// cache warmth must never decide whether a sibling transition is emitted.
items.splice(itemStart)
versionUpdates.splice(versionUpdateStart)
for (const [candidateScope, prior] of priorVersions) {
if (prior === undefined) versions.delete(candidateScope)
else versions.set(candidateScope, prior)
}
for (const absolutePath of addedAbsolutePaths) seenAbsolutePaths.delete(absolutePath)
keptTrimmedByDir.delete(directory)
break
}
if (probe.kind === 'absent') {
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
else pushRemoval(scope, previous.path)
continue
}
const { file: probedFile } = probe
if (seenAbsolutePaths.has(probedFile.absolutePath)) continue
seenAbsolutePaths.add(probedFile.absolutePath)
addedAbsolutePaths.push(probedFile.absolutePath)
const cached = versions.get(scope)
if (
cached !== undefined
&& cached.path === probedFile.displayPath
&& cached.version === probedFile.version
&& previous !== undefined
&& previous.action !== 'remove'
&& previous.path === cached.path
&& previous.digest === cached.digest
) {
// Unchanged and previously rendered: keep it, but an earlier sibling that
// now matches its trimmed content makes this the duplicate to remove.
if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path)
continue
}
continue
}
if (probe.kind === 'absent') {
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
else pushRemoval(scope, previous.path)
continue
}
const { file: probedFile } = probe
if (seenAbsolutePaths.has(probedFile.absolutePath)) continue
seenAbsolutePaths.add(probedFile.absolutePath)
const cached = versions.get(scope)
if (
cached !== undefined
&& cached.path === probedFile.displayPath
&& cached.version === probedFile.version
&& previous !== undefined
&& previous.action !== 'remove'
&& previous.path === cached.path
&& previous.digest === cached.digest
) {
// Unchanged and previously rendered: keep it, but an earlier sibling that
// now matches its trimmed content makes this the duplicate to remove.
if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path)
continue
}
const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
if (file === undefined) continue
const currentDigest = instructionContentSha1(file.content)
const trimmedDigest = trimmedInstructionDigest(file.content)
if (registerKeptTrimmed(directory, trimmedDigest)) {
// A distinct file whose trimmed content already appeared earlier in this
// directory: drop it, removing any copy that was previously rendered.
if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path)
else versions.delete(scope)
continue
const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
if (file === undefined) continue
const currentDigest = instructionContentSha1(file.content)
const trimmedDigest = trimmedInstructionDigest(file.content)
if (registerKeptTrimmed(directory, trimmedDigest)) {
// A distinct file whose trimmed content already appeared earlier in this
// directory: drop it, removing any copy that was previously rendered.
if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path)
else versions.delete(scope)
continue
}
const nextVersion: InstructionVersionState = {
path: file.displayPath,
version: probedFile.version,
digest: currentDigest,
trimmedDigest,
}
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
versions.set(scope, nextVersion)
continue
}
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
const change: WorkspaceInstructionChange = {
action,
scope,
path: file.displayPath,
digest: currentDigest,
}
items.push({ change, file })
versionUpdates.push({ change, state: nextVersion })
}
const nextVersion: InstructionVersionState = {
path: file.displayPath,
version: probedFile.version,
digest: currentDigest,
trimmedDigest,
}
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
versions.set(scope, nextVersion)
continue
}
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
const change: WorkspaceInstructionChange = {
action,
scope,
path: file.displayPath,
digest: currentDigest,
}
items.push({ change, file })
versionUpdates.push({ change, state: nextVersion })
}
if (items.length === 0) return undefined
const rendered = renderInstructionChanges(items, resolved.maxBytes)
if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined
return {
context: workspaceContextHook(rendered.text, rendered.changes),
versionUpdates: retainedInstructionVersionUpdates(versionUpdates, rendered.changes),
}
}
/**
* Validate a successful structured file touch and reconcile its applicable scopes.
* @param agent - optional agent attached to the tool execution.
* @param exec - completed tool execution descriptor.
* @param result - original tool result before post-execute decisions.
* @param resolved - normalized plugin configuration.
* @param pendingNestedChanges - per-session pending transition maps.
* @param baselineSessions - sessions whose configured baseline scopes should be probed.
* @param versionCache - per-session scope metadata used to skip unchanged reads.
* @param fileSystem - provider used for current file probes.
* @returns rendered context plus deferred cache updates, or undefined for irrelevant/failed/unchanged calls.
*/
export async function dynamicInstructionContext(
agent: Agent | undefined,
exec: ToolExecution,
result: ToolExecutionResult,
resolved: ResolvedConfig,
pendingNestedChanges: WeakMap<object, Map<string, PendingInstructionChange>>,
baselineSessions: WeakSet<object>,
versionCache: InstructionVersionCache,
fileSystem: FileSystem,
): Promise<ReconciledInstructionContext | undefined> {
if (agent === undefined || result.isError) return undefined
const touchedPath = filePathFromExecution(exec)
if (touchedPath === undefined) return undefined
return reconcileInstructionContext(
agent, resolved, pendingNestedChanges, versionCache, fileSystem,
{
touchedPath,
includeBaselineScopes: baselineSessions.has(agent.session),
signal: exec.signal,
},
)
}

View File

@@ -1316,7 +1316,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 context event is\n * already appended or queued in that agent\'s active tool-batch FIFO.\n * Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - agent whose session owns the goal.\n * @param change - fresh current projection or clear tombstone.\n * @mode emit\n */',
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 */',
summary: 'Goal mutation accepted by one live agent.',
},
{
@@ -1933,7 +1933,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'Inbox',
declaration: 'export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n claim(target: InboxTarget): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n update(target: InboxTarget, messageId: MessageId, newMessage: UserMessage): boolean;\n remove(target: InboxTarget, messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n}',
declaration: 'export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n update(target: InboxTarget, messageId: MessageId, newMessage: UserMessage): boolean;\n remove(target: InboxTarget, messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n}',
},
{
name: 'InboxNotifications',

View File

@@ -112,10 +112,7 @@ export class ReactLoopAgent implements Agent {
}
cancel(cause: AgentCancelCause, options: CancelOptions = {}): void {
if (!options.keepInbox) {
this.inbox.splice('next-step', 0, this.inbox.nextStep.length, [])
this.inbox.splice('next-turn', 0, this.inbox.nextTurn.length, [])
}
if (!options.keepInbox) this.inbox.clear()
if (this.phase.kind !== 'idle') this.phase.abort.abort(cause)
}

View File

@@ -470,6 +470,40 @@ describe('unrenderable failure settlement', () => {
})
describe('driver bookkeeping edges', () => {
it('rejects a direct turn invocation without a driver reservation', async () => {
const ctx = await harness(new MockAdapter([]))
const agent = ctx.agentLoop.create(SessionId('turn-without-reservation'), { provider: 'mock', model: 'mock' })
await expect((agent as unknown as { turn(): Promise<boolean> }).turn())
.rejects.toThrow('turn without driver reservation')
expect(agent.status).toBe('idle')
})
it('closes an entered turn as blocked when its next step is rejected', async () => {
const adapter = new MockAdapter([textResponse('first step')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('reject-next-step'), { provider: 'mock', model: 'mock' })
let proposals = 0
ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => {
proposals += 1
return proposals === 2 ? { kind: 'reject' } : next()
})
ctx.on('agent/turn-stopping', (subject) => {
subject.inject(createUserMessage({
content: [{ type: 'text', text: 'do not enter the next step' }],
source: { kind: 'plugin', plugin: 'test' },
}))
})
send(agent, 'go')
await agent.whenIdle()
expect(proposals).toBe(2)
expect(adapter.requests).toHaveLength(1)
const end = agent.session.events.findLast(event => event.type === 'turn/end')
expect(end?.type === 'turn/end' && end.data.reason).toEqual({ kind: 'blocked' })
})
it('a request failure that concludes recovery after step/end closed keeps the boundary balanced', async () => {
const { LlmError } = await import('@deepseek-ai/dsh-llm')
// The failure finish-chunk path returns request-failed AFTER step() has

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/core/agent/README.md
README.md: 8942dcd976f9c7c5a8109ad3979fefe2a913cba5
README.zh.md: 274a37556a9c2af140c57e5346da8abf7bc49bf3
README.md: e1f1b121787645930fa9c41b3d0d5ee9880ef4ad
README.zh.md: becc0ae299269e9982d6a63d7a2df966b999cfa9

View File

@@ -62,7 +62,7 @@ Turn and step boundaries and the model token stream are durable `session/event`
The handle every plugin programs against:
- `agent.inbox` — the agent-owned projection of durable `agent/inbox/spliced` events. `nextTurn` and `nextStep` expose pending `UserMessage` values. `append`, `prepend`, `update`, `remove`, and `splice` mutate them; ordinary removals are durable cancellations and emit `agent/inbox/discarded`. `claim(target)` atomically removes the next proposed batch with pure deletion splices; the loop then emits `agent/inbox/claimed`. `MessageId` is the only occurrence identity and must remain unique while pending.
- `agent.inbox` — the agent-owned projection of durable `agent/inbox/spliced` events. `nextTurn` and `nextStep` expose pending `UserMessage` values. `append`, `prepend`, `update`, `remove`, `clear`, and `splice` mutate them; ordinary removals and `clear()` are durable cancellations and emit `agent/inbox/discarded`. `claim(target)` atomically removes the next proposed batch with pure deletion splices; the loop then emits `agent/inbox/claimed`. `MessageId` is the only occurrence identity and must remain unique while pending.
- `agent.followup(message)` — queue an ordinary `next-turn` message and wake the driver. It returns no completion handle; the message id identifies inbox insertion, claim, and discard facts, not a later output or `turn/end`.
- `agent.steer(message)` — queue waking `next-step` input. An idle driver schedules a turn; collecting and running drivers consume it at their next step boundary.
- `agent.inject(message)` — queue non-waking `next-step` context. A collecting or running driver claims it at the nearest later pre-step boundary; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. It may miss a request whose pre-step already claimed its batch.

View File

@@ -62,7 +62,7 @@ inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserte
每个插件面向的 handle
- `agent.inbox`agent 所拥有的持久 `agent/inbox/spliced` 事件投影。`nextTurn``nextStep` 暴露待处理的 `UserMessage` 值。`append``prepend``update``remove``splice` 用于变更队列;普通删除是持久取消,并发出 `agent/inbox/discarded``claim(target)` 通过纯删除 splice 原子移除下一个候选批次,随后由循环发出 `agent/inbox/claimed``MessageId` 是唯一的入队项标识,在消息待处理期间必须保持唯一。
- `agent.inbox`agent 所拥有的持久 `agent/inbox/spliced` 事件投影。`nextTurn``nextStep` 暴露待处理的 `UserMessage` 值。`append``prepend``update``remove``clear``splice` 用于变更队列;普通删除`clear()`是持久取消,并发出 `agent/inbox/discarded``claim(target)` 通过纯删除 splice 原子移除下一个候选批次,随后由循环发出 `agent/inbox/claimed``MessageId` 是唯一的入队项标识,在消息待处理期间必须保持唯一。
- `agent.followup(message)`:将一条普通 `next-turn` 消息排队并唤醒驱动器。它不返回完成 handle消息 id 标识 inbox 的插入、领取与丢弃事实,而不标识之后的输出或 `turn/end`
- `agent.steer(message)`:将会唤醒的 `next-step` 输入排队。空闲驱动器会调度一个轮次collecting 和 running 驱动器会在各自的下一步骤边界消费该输入。
- `agent.inject(message)`:将不会唤醒的 `next-step` 上下文排队。collecting 或 running 驱动器会在最近的后续 pre-step 边界领取它idle 驱动器则会让它保持待处理,直至 `followup()``steer()` 唤醒驱动器。若某次请求的 pre-step 已经领取完批次,它可能赶不上该请求。

View File

@@ -54,6 +54,12 @@ export class Inbox {
return this.nextTurn.length > 0 || this.nextStep.length > 0
}
/** Durably cancel all pending input, clearing next-step before next-turn. */
clear(): void {
this.splice('next-step', 0, this.nextStep.length, [])
this.splice('next-turn', 0, this.nextTurn.length, [])
}
/**
* Remove and return the complete batch proposed for one step. The durable
* splices are pure deletions; the caller publishes claimed notifications.

View File

@@ -1,6 +1,7 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context, Service, symbols } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
import AgentRegistry, {
agentEvents,
Inbox,
@@ -34,6 +35,69 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
return Object.assign(agent, overrides)
}
describe('Inbox', () => {
it('rejects an invalid durable splice during reconstruction', () => {
const session = new Session(SessionId('invalid-inbox-replay'))
session.append('agent/inbox/spliced', {
target: 'next-turn',
start: 1,
inserted: [],
})
expect(() => new Inbox(session, { inserted: () => {}, discarded: () => {} }))
.toThrow('invalid persisted inbox splice at session seq 0')
})
it('updates a pending message by identity and reports a missing identity', () => {
const session = new Session(SessionId('update-inbox'))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
const original = createUserMessage({
content: [{ type: 'text', text: 'original' }],
source: { kind: 'user' },
})
const replacement = freezeMessage({
...original,
content: [{ type: 'text', text: 'replacement' }],
})
inbox.append('next-turn', original)
expect(inbox.update('next-turn', createUserMessage({
content: [{ type: 'text', text: 'missing' }],
source: { kind: 'user' },
}).id, replacement)).toBe(false)
expect(inbox.update('next-turn', original.id, replacement)).toBe(true)
expect(inbox.nextTurn).toEqual([replacement])
})
it('clears both pending lists as durable cancellations', () => {
const session = new Session(SessionId('clear-inbox'))
const discarded: UserMessage[] = []
const inbox = new Inbox(session, {
inserted: () => {},
discarded: message => void discarded.push(message),
})
const nextTurn = createUserMessage({ content: [{ type: 'text', text: 'turn' }], source: { kind: 'user' } })
const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } })
inbox.append('next-turn', nextTurn)
inbox.append('next-step', nextStep)
const beforeClear = session.events.length
inbox.clear()
expect(inbox.hasPending).toBe(false)
expect(discarded).toEqual([nextStep, nextTurn])
expect(session.events.slice(beforeClear).map(event => event.type === 'agent/inbox/spliced'
? event.data
: event.type)).toEqual([
{ target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
{ target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
])
inbox.clear()
expect(session.events).toHaveLength(beforeClear + 2)
})
})
describe('AgentRegistry', () => {
it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
const ctx = new Context()
@@ -184,6 +248,21 @@ describe('agentEvents()', () => {
'agent event "agent/status" listener rejected: Error: async listener',
])
})
it('dispatches serial listeners with the fused agent subject', async () => {
const ctx = new Context()
const agent = stubAgent('serial-event')
const signal = new AbortController().signal
const heard: Array<{ agent: Agent; turn: number; signal: AbortSignal }> = []
ctx.on('agent/turn-stopping', async (subject, turn, receivedSignal) => {
await Promise.resolve()
heard.push({ agent: subject, turn, signal: receivedSignal })
})
await agentEvents(ctx, agent).serial('agent/turn-stopping', 3, signal)
expect(heard).toEqual([{ agent, turn: 3, signal }])
})
})
describe('explicit cancellation contract', () => {

View File

@@ -337,6 +337,16 @@ describe('runOneShot and executeCli', () => {
expect(files.some(file => file.endsWith('.jsonl.zstd'))).toBe(true)
})
it('writes correlated session events in stream-json mode', async () => {
const { ctx } = await harness([textResponse('streamed answer')])
const output = await invoke(ctx, ['--output-format', 'stream-json', 'task'])
const records = output.stdout.trim().split('\n').map(line => JSON.parse(line) as { type: string })
expect(output.code).toBe(0)
expect(records.some(record => record.type === 'session_event')).toBe(true)
expect(records.at(-1)).toMatchObject({ type: 'result', output: 'streamed answer' })
})
it('sums usage across tool steps and selects the last text-bearing assistant message', async () => {
const first = { inputTokens: 10, outputTokens: 3, cacheReadTokens: 2, cacheWriteTokens: 1 }
const second = { inputTokens: 7, outputTokens: 5, cacheReadTokens: 4, reasoningTokens: 6 }

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: 8e1a5b417467c8701ea935e25acfece11c5a70d4
README.zh.md: 66f237ac7ea8ef5d158917998b0dfd49189289f1
README.md: 47f81a5ae303d3587c0af1a26407f0f1f0ba0d88
README.zh.md: f5d22fa4889da8b7a1e2ac73e78ebe76714b8d42

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; every accepted mutation is persisted and made model-visible by `dsh-goal` rather than by this plugin.
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.
## 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 later appears through the goal domain's raw `<goal_state>` snapshot or clear tombstone; this preserves the model-visible-is-logged invariant without logging presentation text.
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.
#### Token effect
Reading status or receiving a direct command error adds no model tokens. Each accepted mutation adds the goal domain's retained full snapshot, and an enabled same-session driver may add later goal-round prompts.
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.
#### KV Cache effect
Command discovery and direct output do not affect the cache. A mutation appends after the reusable history prefix; later compaction may replace the derived-history suffix.
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.
## 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` 持久化并提供给模型,而不是由此插件完成
可预期的领域拒绝会变成稳定的直接命令错误,不公开带品牌类型的 id 或 revision。意外实现失败仍会 reject 分发,使适配器能将其报告为命令失败。通用命令文本和输出仍属于实时 UI 状态;`dsh-goal` 通过持久 inbox 插入项持久化每项已接受变更,并单独将其面向模型的上下文排队
## 组合
@@ -40,15 +40,15 @@ TUI 应用默认启用完整的持久 goal 栈和此命令。ACPAgent Client
#### 模型看到的内容
斜杠输入与直接状态/错误输出不会进入模型请求。已接受的变更稍后会通过 goal 领域的原始 `<goal_state>` 快照或 clear tombstone 出现;这样既满足模型可见内容必须记录日志的不变量,也无需记录呈现文本
斜杠输入与直接状态/错误输出不会进入模型请求。已接受的变更会将 goal 领域的原始 `<goal_state>` 快照或 clear tombstone 排队;只有后续 pre-step 准入该上下文时,模型才会看到它。如果已排队的消息被丢弃,变更仍然持久;呈现文本绝不会记录到日志中
#### Token 影响
读取状态或收到直接命令错误不会增加模型 token。每项已接受变更都会增加 goal 领域保留的完整快照;已启用的同会话驱动器还可能增加后续 Goal Round 提示词。
读取状态或收到直接命令错误不会增加模型 token。获准的变更上下文会增加 goal 领域保留的完整快照,准入前被丢弃的上下文则不会增加;已启用的同会话驱动器还可能增加后续 Goal Round 提示词。
#### KV Cache 影响
命令发现与直接输出不会影响缓存。变更会追加到可复用历史前缀之后;后续压缩可能替换派生历史后缀。
命令发现与直接输出不会影响缓存。获准的变更上下文会追加到可复用历史前缀之后;后续压缩可能替换派生历史后缀。
## 已知限制与暂缓事项

View File

@@ -6,7 +6,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import GoalService from '@deepseek-ai/dsh-goal'
import type { GoalRef } from '@deepseek-ai/dsh-goal'
import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
interface Harness {
@@ -16,31 +16,23 @@ interface Harness {
readonly plugin: Awaited<ReturnType<Context['plugin']>>
}
/** Commit one injected message as an already admitted turn for the Agent test double. */
function appendInjection(session: Session, input: UserMessage): void {
const lastStart = session.events.findLast(event => event.type === 'turn/start')
const turn = (lastStart?.data.turn ?? 0) + 1
session.append('turn/start', { turn })
session.append('user/message', input, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
/** Build a live idle agent accepted by the exact-identity goal service. */
function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
// Store-created: the command executor durably logs lifecycle events on it.
const session = ctx.sessions.create(SessionId(id))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
let status: AgentStatus = 'idle'
const agent: Agent = {
id: session.id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
inbox,
ctx: new Context(),
get status() { return status },
send: () => {},
followup: () => {},
steer: () => {},
inject(input) { appendInjection(session, input) },
inject(input) { inbox.append('next-step', input) },
cancel() { status = 'idle' },
whenIdle() { return Promise.resolve() },
}
@@ -131,7 +123,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(['turn/start', 'user/message', 'turn/end'])
expect(domainEvents(test.session).map(event => event.type)).toEqual(['agent/inbox/spliced'])
const count = domainEvents(test.session).length
await expect(run(test, ' replacement')).resolves.toEqual({

View File

@@ -125,7 +125,8 @@ export function apply(ctx: Context): void {
/** Preserve claimed step context when this driver drops only its own round. */
function restoreOtherClaimed(agent: Agent, messages: UserMessage[], messageId: MessageId): void {
const retained = messages.filter(message => message.id !== messageId)
const retained = messages.filter(message => message.id !== messageId
&& !(message.source.kind === 'goal' && message.source.round === 0))
for (const message of retained.toReversed()) {
if (agent.inbox.nextStep.some(candidate => candidate.id === message.id)
|| agent.inbox.nextTurn.some(candidate => candidate.id === message.id)) continue

View File

@@ -101,15 +101,14 @@ async function harness(script: ScriptEntry[]): Promise<Harness> {
return { ctx, adapter, agent, driver }
}
/** Observe inserted inbox messages after the session append boundary closes. */
/** Observe inserted inbox messages after the insertion call completes. */
function onInboxMessage(
ctx: Context,
agent: Agent,
listener: (message: UserMessage) => void,
): () => void {
return ctx.on('session/event', (session, event) => {
if (session !== agent.session || event.type !== 'agent/inbox/spliced') return
for (const message of event.data.inserted) queueMicrotask(() => { listener(message) })
return ctx.on('agent/inbox/inserted', (subject, { message }) => {
if (subject === agent) queueMicrotask(() => { listener(message) })
})
}
@@ -388,6 +387,56 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(1)
})
it('restores non-goal step context when a claimed reservation becomes stale', async () => {
const test = await harness([textResponse('side contexts'), textResponse('revised goal')])
const claimedContext = createUserMessage({
content: [{ type: 'text', text: 'claimed context to restore' }],
source: { kind: 'plugin', plugin: 'test' },
})
const queuedStepContext = createUserMessage({
content: [{ type: 'text', text: 'context already queued for the next step' }],
source: { kind: 'plugin', plugin: 'test' },
})
const queuedTurnContext = createUserMessage({
content: [{ type: 'text', text: 'context already queued for the next turn' }],
source: { kind: 'plugin', plugin: 'test' },
})
let staged = false
const stopInserted = onInboxMessage(test.ctx, test.agent, (message) => {
if (message.source.kind !== 'goal' || message.source.round <= 0 || staged) return
staged = true
test.agent.inbox.prepend('next-step', claimedContext)
})
let edited = false
test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => {
const decision = await next()
if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0) || edited) return decision
edited = true
agent.inbox.prepend('next-step', queuedStepContext)
agent.inbox.append('next-turn', queuedTurnContext)
const goal = test.ctx.goals.get(agent)
if (goal === undefined) throw new Error('missing claimed goal')
test.ctx.goals.edit(agent, goal, { objective: 'revised after claim' })
return decision.kind === 'reject' ? decision : {
kind: 'enter' as const,
messages: [...decision.messages, queuedStepContext, queuedTurnContext],
}
})
test.ctx.goals.create(test.agent, { objective: 'stale before admission', maxGoalRounds: 1 })
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
stopInserted()
expect(goal).toMatchObject({ objective: 'revised after claim', roundsStarted: 1 })
expect(test.adapter.requests).toHaveLength(2)
expect(requestText(test.adapter.requests[0]!)).toContain('claimed context to restore')
expect(requestText(test.adapter.requests[0]!)).toContain('context already queued for the next step')
expect(requestText(test.adapter.requests[0]!)).toContain('context already queued for the next turn')
expect(requestText(test.adapter.requests[0]!)).not.toContain('<goal_round>')
expect(requestText(test.adapter.requests[1]!)).toContain('revised after claim')
expect(requestText(test.adapter.requests[1]!)).not.toContain('stale before admission')
})
it('disarms without dispatch when a durability checkpoint fails', async () => {
const test = await harness([])
test.ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable')))
@@ -704,7 +753,7 @@ describe('same-session goal driving', () => {
it('falls back to disarming when a cancelled reservation cannot be paused', async () => {
const test = await harness([])
const cancel = onInboxMessage(test.ctx, test.agent, (message) => {
if (message.source.kind !== 'goal') return
if (message.source.kind !== 'goal' || message.source.round <= 0) return
cancel()
vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => {
throw new Error('pause failed')

View File

@@ -41,11 +41,15 @@ function view(roundsStarted: number): GoalView {
}
function appendChange(session: Session): void {
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
const message = createUserMessage({
content: renderGoalChange(change),
source: changeSource,
}), { surfaceOp: 'append' })
})
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' } })
}

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: cba28b2de6cb59932f25ad57a32bc25e0d5e287a
README.zh.md: aebb3a1e773038888a109dc040d3389988231953
README.md: f72efe2306f11dfa5f30ac927bb1b900f691a7be
README.zh.md: 4ca86a6c1aea228e069fbf25b22ec248297fae03

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 queues a complete versioned snapshot through `agent.inject()`; clear uses a revisioned tombstone. A later entering pre-step records it as a model-visible `user/message`, whose content and typed `{ kind: 'goal', change }` source must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward.
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.
Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. Reentrant append observers see each accepted mutation exactly once, and incremental replay retains its cursor at the first corrupt event. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained.
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.
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, 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 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.
## 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 is one raw user-role context block. A snapshot is rendered as `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. The descriptive XML delimiter follows this repository's existing `<workspace_context>` convention and [Anthropic's published XML-tag prompting guidance](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags); it is public model-experience prior art, not a claim about any provider's proprietary training corpus.
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.
#### Token effect
Every retained mutation adds one full snapshot to derived history until compaction shadows it. Full snapshots make each record independently inspectable but repeat the objective and lifecycle fields.
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.
#### KV Cache effect
Append-only within an epoch: each mutation follows the reusable request prefix and preceding history. Compaction may replace the derived-history suffix and move the reusable boundary.
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.
## Known Limitations and Deferred Work

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。后续返回 enter 的 pre-step 会把它记录为模型可见的 `user/message`内容带类型的 `{ kind: 'goal', change }` 来源必须完全一致。回放会拒绝形状错误、来源/内容漂移、不连续 revision、非法生命周期转换、每目标时间戳非单调以及不连续的 Goal Round。挂钟时间倒退时变更时间戳会限制在不早于上一次目标更新的值
每次变更都会通过 `agent.inject()` 传递完整的版本化快照clear 使用带 revision 的 tombstone。注入将消息记录到持久 `agent/inbox/spliced` 插入项时,变更即已提交,即使该上下文仍在队列中且从未抵达模型也是如此。移除或丢弃已排队的消息不会回滚变更。如果同一消息随后获准成为模型可见的 `user/message`回放会验证其 id、内容带类型的 `{ kind: 'goal', change }` 来源与插入项一致,而不会再次应用变更
注入可以立即追加,也可能在活跃工具批次 FIFO 中等待。服务会在内存中叠加已接受的待处理变更,并在每个完全一致的载荷进入日志时逐一完成对账,因此连续的模型工具变更可以看到自身最新 revision而不会把尚未记录的缓存当作持久状态。可重入追加观察者会且只会看到每项已接受变更一次;增量回放会把游标保留在第一个损坏事件处。追加或入队成功后才触发 `goal/changed`监听器失败会被隔离处理。
严格回放只从 inbox 插入项派生变更,并拒绝形状错误、以不同变更复用消息 id、准入时的来源内容漂移、不连续 revision、非法生命周期转换、每目标时间戳非单调以及不连续的已准入 Goal Round。只有获准的 `user/message` 事件会推进正数 Round。挂钟时间倒退时变更时间戳会限制在不早于上一次目标更新的值。可重入插入观察者会且只会看到每项已接受变更一次;增量回放会把游标保留在第一个损坏事件处`goal/changed` 会在注入成功后触发,监听器失败会被隔离处理。
续行启用状态绝不持久化。新缓存与每次触发 `agent/session-start` 时都会停用续行,即使回放找到了持久 phase 为 active 的目标。续行驱动器在卸载前或持久性不确定后也会调用 `disarm()`。因此会话恢复、fork 与驱动器替换会保留目标、phase、revision 和已准入 Round 数量,却不会启动工作;之后必须通过显式 resume 变更重新启用续行。
单独发布的 `./invariant` 配套模块会为每个已挂接会话维护独立折叠。它会在候选事件进入持久日志前拒绝格式错误的 goal 来源变更、模型可见内容漂移、不连续 revision、非法生命周期转换、时间戳回退以及不连续的已准入 round。
单独发布的 `./invariant` 配套模块会为每个已挂接会话维护独立折叠。它会在候选事件进入持久日志前拒绝格式错误的 goal 来源变更、相同 id 在插入与准入之间的变更漂移、模型可见内容漂移、不连续 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_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);它是公开的模型体验先例,并非关于任何提供方专有训练语料的声明。
#### Token 影响
每项保留的变更会向派生历史增加一份完整快照直到压缩compaction将其遮蔽。完整快照让每条记录都能独立检查但会重复目标和生命周期字段。
获准的变更会向派生历史增加一份完整快照直到压缩compaction将其遮蔽;准入前被丢弃的插入项不消耗模型 token。完整快照让每条获准记录都能独立检查,但会重复目标和生命周期字段。
#### KV Cache 影响
在一个 epoch 内仅追加:每项变更都位于可复用请求前缀和既有历史之后。压缩可能替换派生历史后缀,并移动可复用边界。
准入后在一个 epoch 内仅追加:每项可见变更都位于可复用请求前缀和既有历史之后。压缩可能替换派生历史后缀,并移动可复用边界。
## 已知限制与暂缓事项

View File

@@ -35,7 +35,7 @@ export type GoalOperation =
| 'block'
| 'clear'
/** Full-snapshot goal mutation retained in a model-visible context event. */
/** Full-snapshot goal mutation committed by an injected inbox message. */
export interface GoalSnapshotChangeMeta {
readonly kind: 'goal/change'
readonly version: 1
@@ -101,7 +101,7 @@ export interface EditGoalRequest {
readonly maxGoalRounds?: number
}
/** Live notification after one goal mutation has been accepted for logging. */
/** Live notification after one goal mutation commits through inbox insertion. */
export interface GoalChanged {
readonly operation: GoalOperation
readonly ref: GoalRef
@@ -124,9 +124,9 @@ export type GoalErrorCode =
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.
* 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.
* 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,6 +2,7 @@
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'
@@ -14,8 +15,6 @@ import type {
GoalSnapshotChangeMeta,
} from './domain.ts'
type UserMessageEvent = Extract<SessionEvent, { type: 'user/message' }>
const SNAPSHOT_OPERATIONS: ReadonlySet<Exclude<GoalOperation, 'clear'>> = new Set([
'create',
'edit',
@@ -34,6 +33,7 @@ export interface GoalFoldState {
updatedAt: number | undefined
lastRef: GoalRef | undefined
seenGoalIds: Set<GoalSnapshot['id']>
insertedChangeMessages: Map<UserMessage['id'], GoalChangeMeta>
}
/**
@@ -48,6 +48,7 @@ export function emptyGoalFoldState(): GoalFoldState {
updatedAt: undefined,
lastRef: undefined,
seenGoalIds: new Set(),
insertedChangeMessages: new Map(),
}
}
@@ -307,55 +308,75 @@ export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): v
}
/**
* Decode and verify one model-visible goal state change without folding it. A
* goal state change is a round-zero goal-sourced `user/message` carrying the
* complete change in its source; any other user message returns `undefined`.
* A mismatched attribution, source change, or rendered body
* fails replay loudly.
* @param event - user message whose source and rendered content must agree.
* 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.
*/
export function decodeGoalEvent(event: UserMessageEvent): GoalChangeMeta | undefined {
const source = goalSource(event.data.source)
function decodeGoalMessage(message: UserMessage, location: string): GoalChangeMeta | undefined {
const source = goalSource(message.source)
if (source === undefined) {
const [block] = event.data.content
const [block] = message.content
if (block?.type === 'text' && block.text.startsWith('<goal_state>')) {
throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`)
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 session event ${event.seq} lacks source change data`)
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 session event ${event.seq} has mismatched source attribution`)
throw new Error(`goal change at ${location} has mismatched source attribution`)
}
if (JSON.stringify(event.data.content) !== JSON.stringify(renderGoalChange(change))) {
throw new Error(`goal change at session event ${event.seq} has mismatched model-visible content`)
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 and return its goal change, when present.
* Apply one session event to the strict durable goal fold.
* @param state - mutable fold accumulator.
* @param event - next event in sequence order.
* @returns decoded change for pending-overlay reconciliation.
*/
export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalChangeMeta | undefined {
if (event.type === 'user/message') {
// A goal state change carries a complete source change (round zero).
const change = decodeGoalEvent(event)
if (change !== undefined) {
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)
return change
state.insertedChangeMessages.set(message.id, 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 undefined
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 -- decodeGoalEvent returns the change or fails loud for every
/* 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) {
@@ -369,7 +390,6 @@ export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalC
}
state.roundsStarted = source.round
}
return undefined
}
/**

View File

@@ -12,13 +12,11 @@ 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 } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, UserMessage } 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,
decodeGoalEvent,
emptyGoalFoldState,
goalChangeRef,
} from './fold.ts'
@@ -82,6 +80,12 @@ 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
@@ -95,23 +99,31 @@ const goalProjectionSchema: ZodType<GoalProjection | null> = zod.union([
* @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
// Session-log data is a durable boundary: the static type promises the kind,
// but a foreign or corrupted change record must degrade to same-reference,
// never feed the zod parse in the registry drive.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- durable-boundary guard
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,
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,
}
}
return insertedChangeMessageIds === undefined
? state
: [projection, insertedChangeMessageIds]
}
/** Deployment defaults for goal creation. */
@@ -126,19 +138,12 @@ export interface ResolvedConfig {
defaultMaxGoalRounds: number
}
/** One accepted mutation waiting to enter or be observed in the session log. */
interface PendingGoalChange {
readonly change: GoalChangeMeta
readonly activation: GoalActivation
applied: boolean
}
/** Process-local cache plus mutations waiting in the active tool-batch FIFO. */
/** Process-local cache plus activation intent crossing the synchronous injection boundary. */
interface GoalCache {
readonly state: GoalFoldState
activation: GoalActivation
observedSeq: number
readonly pending: PendingGoalChange[]
readonly pendingActivations: Map<UserMessage['id'], GoalActivation>
}
/** Validated create input with every deployment default materialized. */
@@ -188,11 +193,6 @@ function resolveBlockReason(reason: unknown): GoalBlockReason {
return { code, message: message.trim() }
}
/** Compare the complete canonical payloads used for deferred reconciliation. */
function sameChange(left: GoalChangeMeta, right: GoalChangeMeta): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
/** Goal service (`ctx.goals`) backed exclusively by the owning session log. */
export class GoalService extends Service {
static inject = ['agents']
@@ -216,13 +216,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', GoalProjection | null>({
projectionCtx.sessionProjections.register<'goal', GoalProjectionState>({
key: 'goal',
schema: goalProjectionSchema,
init: () => null,
init: () => [null, []],
apply: applyGoalProjection,
view: state => state,
stateVersion: 1,
view: state => state[0],
stateVersion: 3,
})
})
}
@@ -436,34 +436,24 @@ export class GoalService extends Service {
state,
activation: 'disarmed',
observedSeq: session.seq,
pending: [],
pendingActivations: new Map(),
}
this.caches.set(session, cache)
return cache
}
/** Incrementally observe durable events without losing deferred mutations. */
/** 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)) {
// A goal state change is a round-zero goal-sourced user message; a
// positive round is a continuation prompt handled by applyGoalEvent.
if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round === 0) {
const change = decodeGoalEvent(event)
if (change !== undefined) {
const pending = cache.pending[0]
if (pending !== undefined && sameChange(pending.change, change)) {
if (!pending.applied) {
applyGoalChange(cache.state, change)
cache.activation = pending.activation
pending.applied = true
}
cache.pending.shift()
cache.observedSeq += 1
continue
}
}
}
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)
}
cache.observedSeq += 1
}
}
@@ -555,7 +545,7 @@ export class GoalService extends Service {
}
this.commit(agent, cache, change, activation)
const view = this.view(cache)
/* v8 ignore next -- applyGoalChange installs the snapshot immediately before this read */
/* v8 ignore next -- the durable inbox insertion installs the snapshot before this read */
if (view === undefined) throw new Error('snapshot commit cleared the goal unexpectedly')
return view
}
@@ -563,26 +553,21 @@ export class GoalService extends Service {
/** Accept one mutation into the agent injection queue, cache, and live event stream. */
private commit(agent: Agent, cache: GoalCache, change: GoalChangeMeta, activation: GoalActivation): void {
const ref = goalChangeRef(change)
const pending: PendingGoalChange = { change, activation, applied: false }
cache.pending.push(pending)
const message = createUserMessage({
content: renderGoalChange(change),
source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change },
})
cache.pendingActivations.set(message.id, activation)
try {
agent.inject(createUserMessage({
content: renderGoalChange(change),
source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change },
}))
agent.inject(message)
} catch (error: unknown) {
const index = cache.pending.indexOf(pending)
/* v8 ignore next -- a committed goal append cannot reject after its contained observers run */
if (index < 0) throw new Error('goal injection failed after its pending mutation was reconciled', { cause: error })
cache.pending.splice(index, 1)
cache.pendingActivations.delete(message.id)
throw error
}
if (!pending.applied) {
applyGoalChange(cache.state, change)
cache.activation = activation
pending.applied = true
}
this.sync(agent.session, cache)
if (cache.pendingActivations.delete(message.id)) {
throw new Error('goal injection returned without a durable inbox insertion')
}
const goal = this.view(cache)
const notification: GoalChanged = {
operation: change.operation,

View File

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

View File

@@ -1,8 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import { createUserMessage, HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage, freezeMessage, HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
import GoalService, {
GoalError,
@@ -13,15 +13,9 @@ import GoalService, {
} from '@deepseek-ai/dsh-goal'
import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
type DeferredInjection = UserMessage
interface StubAgent {
agent: Agent
session: Session
deferred: DeferredInjection[]
setDeferred(value: boolean): void
setStatus(value: AgentStatus): void
drain(): void
}
/** Number the next balanced test-fixture turn. */
@@ -31,46 +25,34 @@ function nextTurn(session: Session): number {
/** Mirror the public Agent.inject contract for domain tests. */
function appendInjection(session: Session, input: UserMessage): void {
session.append('user/message', input, { surfaceOp: 'append' })
new Inbox(session, { inserted: () => {}, discarded: () => {} }).append('next-step', input)
}
/** Build a registry-compatible agent around one concrete session. */
function stubAgentForSession(session: Session): StubAgent {
const id = session.id
const deferred: DeferredInjection[] = []
let shouldDefer = false
let status: AgentStatus = 'idle'
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
const agent: Agent = {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
inbox,
ctx: new Context(),
get status() { return status },
status: 'idle',
send: () => {},
followup: () => {},
steer: () => {},
inject(input) {
if (shouldDefer) deferred.push(input)
else appendInjection(session, input)
},
inject(input) { inbox.append('next-step', input) },
cancel() {},
whenIdle() { return Promise.resolve() },
}
return {
agent,
session,
deferred,
setDeferred(value) { shouldDefer = value },
setStatus(value) { status = value },
drain() {
shouldDefer = false
for (const injection of deferred.splice(0)) appendInjection(session, injection)
},
}
}
/** Build a registry-compatible agent with controllable context deferral. */
/** Build a registry-compatible agent around a fresh session. */
function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[]): StubAgent {
return stubAgentForSession(new Session(SessionId(rawId), seed))
}
@@ -117,16 +99,18 @@ describe('GoalService creation and replay', () => {
})
expect(goal.id).toMatch(/^goal-/)
expect(seen).toEqual(['create'])
expect(session.events.map(event => event.type)).toEqual(['user/message'])
expect(session.events.map(event => event.type)).toEqual(['agent/inbox/spliced'])
const context = session.events[0]
expect(context?.type).toBe('user/message')
if (context?.type !== 'user/message') throw new Error('expected goal context')
expect(context.data.source).toMatchObject({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 })
const change = context.data.source.kind === 'goal' ? decodeGoalChange(context.data.source.change) : undefined
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
if (change === undefined) throw new Error('expected decoded goal change')
expect(change).toMatchObject({ operation: 'create', goal: { id: goal.id } })
expect(context.data.content).toEqual(renderGoalChange(change))
expect(session.deriveMessages()).toEqual([context.data])
expect(message.content).toEqual(renderGoalChange(change))
expect(session.deriveMessages()).toEqual([])
expect(foldGoal(session.events)).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 })
vi.useRealTimers()
})
@@ -390,9 +374,11 @@ describe('GoalService mutations', () => {
vi.setSystemTime(80)
ctx.goals.clear(agent, goal)
const clear = session.events
.filter(event => event.type === 'user/message' && event.data.source.kind === 'goal')
.map(event => event.type === 'user/message' && event.data.source.kind === 'goal'
? decodeGoalChange(event.data.source.change)
.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)
.at(-1)
expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 })
@@ -411,23 +397,15 @@ describe('GoalService mutations', () => {
expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer'))
})
it('preserves multiple pending revisions until deferred injections enter the log', async () => {
const test = await harness()
const { ctx, agent, session, deferred } = test
test.setDeferred(true)
it('commits consecutive revisions through synchronous inbox insertions', 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(deferred).toHaveLength(3)
expect(session.events).toHaveLength(0)
appendInjection(session, createUserMessage({
content: [{ type: 'text', text: 'unrelated' }], source: { kind: 'plugin', plugin: 'test' },
}))
expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' })
test.drain()
expect(deferred).toHaveLength(0)
expect(session.events.map(event => event.type)).toEqual([
'agent/inbox/spliced', 'agent/inbox/spliced', 'agent/inbox/spliced',
])
expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' })
expect(foldGoal(session.events)).toMatchObject({ goal: { revision: 3, phase: 'paused' } })
})
@@ -441,7 +419,8 @@ 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 === 'user/message' && event.data.source.kind === 'goal') observed = ctx.goals.get(stub.agent)
if (session === stub.session && event.type === 'agent/inbox/spliced'
&& event.data.inserted.some(message => message.source.kind === 'goal')) observed = ctx.goals.get(stub.agent)
})
const created = ctx.goals.create(stub.agent, { objective: 'publish once' })
@@ -472,15 +451,19 @@ describe('GoalService mutations', () => {
})
})
it('rejects deferred goal mutations that enter the log out of FIFO order', async () => {
const test = await harness()
test.setDeferred(true)
const created = test.ctx.goals.create(test.agent, { objective: 'ordered' })
test.ctx.goals.edit(test.agent, created, { objective: 'ordered edit' })
const second = test.deferred[1]
if (second === undefined) throw new Error('expected a second deferred goal mutation')
appendInjection(test.session, second)
expect(() => test.ctx.goals.get(test.agent)).toThrow('advance the current goal')
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 })
})
it('observes a valid goal snapshot appended after an empty cache was established', async () => {
@@ -502,12 +485,9 @@ describe('GoalService mutations', () => {
updatedAt: 12,
}
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change } as const
const turn = nextTurn(session)
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
appendInjection(session, createUserMessage({
content: renderGoalChange(change), source,
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}))
expect(ctx.goals.get(agent)).toMatchObject({
id: change.goal.id,
@@ -583,12 +563,14 @@ describe('goal replay validation', () => {
round: 0,
change,
}
const turn = nextTurn(session)
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
const message = createUserMessage({
content: overrides.content ?? renderGoalChange(change),
source,
}), { surfaceOp: 'append' })
})
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' } })
}
@@ -622,6 +604,74 @@ describe('goal replay validation', () => {
}
}
it('commits queued changes before admission and verifies the admitted copy without applying it twice', () => {
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)
expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } })
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(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)
@@ -848,13 +898,7 @@ describe('goal replay validation', () => {
cleared: { id: change.goal.id, revision: 2 },
clearedAt: 20,
}
const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0, change: clear } as const
const turn = nextTurn(session)
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
content: renderGoalChange(clear), source,
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
appendChange(session, clear)
expect(foldGoal(session.events)).toEqual({
roundsStarted: 0,
lastRef: { id: change.goal.id, revision: 2 },

View File

@@ -1,4 +1,4 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import {
@@ -46,11 +46,15 @@ describe('goal stream invariants', () => {
it('accepts canonical goal snapshots and sequential admitted rounds', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('goal-invariant-valid'))
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
const message = createUserMessage({
content: renderGoalChange(change),
source: changeSource,
}), { surfaceOp: 'append' })
})
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('turn/start', { turn: 2 })
expect(() => {
@@ -64,22 +68,22 @@ describe('goal stream invariants', () => {
it('rejects model-visible drift before committing it and keeps the fold reusable', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('goal-invariant-invalid'))
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', createUserMessage({
session.append('user/message', freezeMessage({ ...message,
content: [{ type: 'text', text: 'counterfeit' }],
source: changeSource,
}), { surfaceOp: 'append' })
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-goal',
}))
expect(session.seq).toBe(1)
expect(session.seq).toBe(2)
expect(() => {
session.append('user/message', createUserMessage({
content: renderGoalChange(change),
source: changeSource,
}), { surfaceOp: 'append' })
session.append('user/message', message, { surfaceOp: 'append' })
}).not.toThrow()
})
@@ -87,11 +91,12 @@ 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', createUserMessage({
content: renderGoalChange(change),
source: changeSource,
}), { surfaceOp: 'append' })
session.append('user/message', message, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.plugin(InvariantService, { enabled: true })

View File

@@ -17,7 +17,7 @@ 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 GoalService, { applyGoalProjection, foldGoal } from '@deepseek-ai/dsh-goal'
import type { GoalRef } from '@deepseek-ai/dsh-goal'
interface Bench {
@@ -31,18 +31,19 @@ interface Bench {
/** Register a minimal registry-compatible live agent over a store session. */
function liveAgent(ctx: Context, session: Session): Agent {
const status: AgentStatus = 'idle'
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
const agent: Agent = {
id: session.id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
inbox,
ctx,
get status() { return status },
send: () => {},
followup: () => {},
steer: () => {},
inject(input: UserMessage) {
session.append('user/message', input, { surfaceOp: 'append' })
inbox.append('next-step', input)
},
cancel() {},
whenIdle() { return Promise.resolve() },
@@ -123,37 +124,107 @@ describe('goal projection unit', () => {
}
})
it('does not revive a cleared goal when its create message is reinserted', 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)
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
// (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({
const plainUser = createUserMessage({
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'user' },
}) } as never
expect(applyGoalProjection(null, user)).toBeNull()
})
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
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
expect(applyGoalProjection(current, queuedUser)).toBe(current)
const malformed = { type: 'user/message', seq: 1, time: 2, data: createUserMessage({
const malformedMessage = 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
})
const malformed = { type: 'user/message', seq: 1, time: 2, data: malformedMessage } 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()
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,
data: { target: 'next-step', start: 0, inserted: [createUserMessage({
content: [{ type: 'text', text: 'later round' }],
source: { kind: 'goal', goalId: 'g1', revision: 1, round: 1 } as never,
})] },
} 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(state, turnStart)).toBe(state)
expect(applyGoalProjection(current, turnStart)).toBe(current)
// A round-zero goal source whose change carries a foreign kind: same posture.
const foreignKind = { type: 'user/message', seq: 2, time: 3, data: createUserMessage({
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,
}) } as never
expect(applyGoalProjection(state, foreignKind)).toBe(state)
})
const foreignKind = { type: 'user/message', seq: 2, time: 3, data: foreignMessage } 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: aaed61dd517aeb2f94efa22c34c64d1068155d46
README.zh.md: 5365b64ef65fb3d3f00e19357327479ebd8285a8
README.md: a4742e4117ca89f4395a1c59264f6ea6c3ab8b96
README.zh.md: 48e89332db7dfbe746d1ba4e57077eb787a10a66

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. Mutation results are followed by the goal domain's raw `<goal_state>` snapshot after the tool batch. `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 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.
#### Token effect
Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction.
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.
#### KV Cache effect
Schemas are prefix-stable while their definitions and visibility are unchanged. Calls, results, and resulting goal snapshots append after the reusable request prefix without invalidating earlier entries.
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.
## 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>` 快照。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。
生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更会在工具批次结束后 goal 领域的原始 `<goal_state>` 快照排队;后续 pre-step 可以准入它,而丢弃已排队的上下文不会回滚持久变更。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。
#### Token 影响
固定 schema 成本,加上每次调用的一条紧凑结果。变更还会保留领域快照直到压缩compaction
固定 schema 成本,加上每次调用的一条紧凑结果。获准的变更上下文会保留领域快照直到压缩compaction;准入前被丢弃的上下文不增加模型 token
#### KV Cache 影响
schema 的定义与可见性不变时,前缀保持稳定。调用、结果和生成的 goal 快照会追加到可复用请求前缀之后,不会使更早条目失效。
schema 的定义与可见性不变时,前缀保持稳定。调用、结果和已准入的 goal 快照会追加到可复用请求前缀之后,不会使更早条目失效。
## 已知限制与暂缓事项

View File

@@ -21,7 +21,7 @@ interface StubAgent {
setStatus(status: AgentStatus): void
}
/** Build one registry-compatible live agent whose injections append in place. */
/** Build one registry-compatible live agent whose injections enter the durable inbox. */
function stubAgent(rawId: string, supplied?: Session): StubAgent {
const session = supplied ?? new Session(SessionId(rawId))
let status: AgentStatus = 'running'
@@ -36,7 +36,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
followup: () => {},
steer: () => {},
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
this.inbox.append('next-step', input)
},
cancel() {},
whenIdle() { return Promise.resolve() },
@@ -49,11 +49,17 @@ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): numb
const turn = stub.session.events
.filter(event => event.type === 'turn/start')
.reduce((max, event) => Math.max(max, event.data.turn), 0) + 1
stub.session.append('turn/start', { turn })
stub.session.append('user/message', createUserMessage({
const message = createUserMessage({
content: [{ type: 'text', text }],
source,
}), { surfaceOp: 'append' })
})
stub.agent.inbox.append('next-turn', message)
const claimed = stub.agent.inbox.claim('next-turn')
if (claimed.length === 0) throw new Error('expected queued turn input')
stub.session.append('turn/start', { turn })
for (const admitted of claimed) {
stub.session.append('user/message', admitted, { surfaceOp: 'append' })
}
return turn
}

View File

@@ -110,7 +110,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(existsSync(marker)).toBe(true) // substituted command ran
})
}, 15_000) // Real agent and hook subprocess startup can exceed Vitest's default under coverage concurrency.
it('warns and honors updatedInput as a no-op (input rewrite deferred)', async () => {
const d = dir()

View File

@@ -103,7 +103,7 @@ describe('hooks-codex bridge', () => {
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going: address the goal')
})
}, 15_000) // Two real hook subprocesses and agent steps need startup and teardown headroom under load.
it('turn cancellation aborts and reaps a running UserPromptSubmit hook before idle', async () => {
const dir = configDir()

View File

@@ -199,7 +199,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
})
}, 10_000) // The real hook subprocess needs startup and teardown headroom under full-suite contention.
it('SessionStart additionalContext is injected for the first request', async () => {
const d = dir()

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
@@ -193,10 +193,11 @@ describe('directory-picker-browse client half', () => {
/>,
)
// The dialog opened at home; its confirm (browser.open) adopts the listed level.
const openButton = await screen.findByRole('button', { name: 'browser.open' })
openButton.click()
const openButton = screen.getByRole<HTMLButtonElement>('button', { name: 'browser.open' })
await waitFor(() => { expect(openButton.disabled).toBe(false) })
fireEvent.click(openButton)
expect(props.onPicked).toHaveBeenCalledWith(HOME)
screen.getByRole('button', { name: 'browser.cancel' }).click()
fireEvent.click(screen.getByRole('button', { name: 'browser.cancel' }))
expect(props.onCancel).toHaveBeenCalled()
expect(props.onError).not.toHaveBeenCalled()
})

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { normalizeLlmFailure } from '../src/adapter-failure.ts'
describe('adapter failure normalization', () => {
it('contains hostile non-Error coercion', () => {
const thrown = { [Symbol.toPrimitive]: () => { throw new Error('coercion failed') } }
expect(normalizeLlmFailure(thrown)).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' })
})
it('contains hostile Error property reflection', () => {
const withFailure = new Error('provider failed') as Error & { failure: unknown; code: string }
withFailure.failure = { message: 'provider failed', code: 'FOREIGN' }
withFailure.code = 'FOREIGN'
const hostileCode = new Proxy(withFailure, {
getOwnPropertyDescriptor(target, property) {
if (property === 'code') throw new Error('code descriptor failed')
return Reflect.getOwnPropertyDescriptor(target, property)
},
})
expect(normalizeLlmFailure(hostileCode)).toEqual({ message: 'provider failed', code: 'UNKNOWN' })
const hostileFailure = new Proxy(new Error('provider failed'), {
getOwnPropertyDescriptor() { throw new Error('failure descriptor failed') },
})
expect(normalizeLlmFailure(hostileFailure)).toEqual({ message: 'provider failed', code: 'UNKNOWN' })
})
it('rejects malformed or accessor-backed failure snapshots', () => {
const malformed = new Error('provider failed') as Error & { failure: unknown; code: string }
malformed.failure = { message: 'provider failed', code: 'FOREIGN', requestId: '' }
malformed.code = 'FOREIGN'
expect(normalizeLlmFailure(malformed)).toEqual({ message: 'provider failed', code: 'UNKNOWN' })
const accessorBacked = new Error('provider failed') as Error & { failure: unknown }
accessorBacked.failure = Object.defineProperty({}, 'message', {
get() { throw new Error('failure getter failed') },
})
expect(normalizeLlmFailure(accessorBacked)).toEqual({ message: 'provider failed', code: 'UNKNOWN' })
})
it('falls back when an Error message accessor throws', () => {
const error = new Error('provider failed')
Object.defineProperty(error, 'message', { get() { throw new Error('message getter failed') } })
expect(normalizeLlmFailure(error)).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' })
})
})

View File

@@ -489,6 +489,23 @@ describe('LlmService', () => {
expect(cleanupCalls).toBe(1)
})
it('allows downstream close when an adapter iterator has no return method', async () => {
const adapter = new class extends LlmAdapter {
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
return {
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
return { next: () => Promise.resolve({ done: false, value: SCRIPT[0]! }) }
},
}
}
}()
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test'], adapter)
for await (const _chunk of ctx.llm.stream({ provider: 'test', model: 'test', messages: [] })) break
})
it('unregisters adapters when the owning fiber is disposed (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)

View File

@@ -249,6 +249,15 @@ describe('ctx.planMode: get/set', () => {
})
describe('the boundary flush', () => {
it('is inert when no selection is pending', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
const service = ctx.planMode as unknown as { onBoundary(session: Session): void }
expect(() => { service.onBoundary(agent.session) }).not.toThrow()
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
it('flushes from pre-step before the following step/start', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)

View File

@@ -13,6 +13,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import {
DeepSeekHarness,
HarnessClient,
HarnessSession,
JsonRpcResponseError,
RequestTimeoutError,
SdkProtocolError,
@@ -53,6 +54,53 @@ async function tempDir(prefix: string): Promise<string> {
}
describe('DeepSeekHarness', () => {
it('ignores notifications that precede the submitted message receipt', async () => {
const notifications = [
{ method: 'session.status', params: { sessionId: 'owned', status: 'running' } },
{
method: 'session.event',
params: {
sessionId: 'owned',
event: {
type: 'agent/inbox/spliced',
seq: 0,
time: 0,
data: {
target: 'next-turn',
start: 0,
inserted: [{ id: 'accepted-message', role: 'user', content: [], source: { kind: 'user' } }],
},
},
},
},
{ method: 'session.status', params: { sessionId: 'owned', status: 'idle' } },
] as HarnessNotification[]
let closed = false
const harness = {
start: () => Promise.resolve(),
client: {
prompt: () => Promise.resolve('accepted-message'),
subscribeSessionTree: () => ({
next: async () => {
const notification = notifications.shift()
if (notification === undefined) throw new Error('scripted notification queue exhausted')
return notification
},
tryNext: () => notifications.shift(),
close: () => { closed = true },
async * [Symbol.asyncIterator]() {},
}),
},
} as unknown as DeepSeekHarness
const result = await new HarnessSession(harness, 'owned').run('go')
expect(result.notifications.map(notification => notification.method))
.toEqual(['session.event', 'session.status'])
expect(result.events.map(event => event.type)).toEqual(['agent/inbox/spliced'])
expect(closed).toBe(true)
})
it('runs a turn end to end and reuses the runtime across sessions', async () => {
const harness = harnessWith({ FAKE_TEXT: 'turn answer' })
const first = await harness.run('say hi')
@@ -293,9 +341,10 @@ describe('HarnessClient', () => {
const all = client.subscribe()
const idleOnly = client.subscribe(n => n.method === 'session.status' && n.params.status === 'idle')
const firstPending = all.next()
await client.prompt('sub-test', normalizeInput('go'))
const first = await all.next()
const first = await firstPending
expect(first.method).toBe('session.event')
const idle = await idleOnly.next()
expect(idle.method).toBe('session.status')

View File

@@ -4,6 +4,7 @@ import z from 'schemastery'
import { chmod, lstat, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { SettingsLocal, resolveSpec } from '../src/index.ts'
@@ -119,7 +120,7 @@ describe('boot and reads', () => {
it('fails loud at boot on unparsable yaml', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme: [unclosed\n')
await writeFileAtomic(path, 'ui-theme: [unclosed\n', { mode: 0o600 })
await expect(boot({ path, watch: false })).rejects.toThrow()
})
@@ -366,7 +367,7 @@ describe('watch', () => {
await new Promise(resolve => setTimeout(resolve, 300))
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
await writeFile(path, 'ui-theme:\n theme: dark\n')
await writeFileAtomic(path, 'ui-theme:\n theme: dark\n', { mode: 0o600 })
await vi.waitFor(() => {
expect(scope.get().theme).toBe('dark')
}, { timeout: 5000 })

View File

@@ -5,10 +5,10 @@ import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import { createUserMessage, CallId, type Message } from '@deepseek-ai/dsh-llm'
import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { Session, SessionId, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { agentEvents, Inbox, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent'
import SkillService from '@deepseek-ai/dsh-skill'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
@@ -95,6 +95,20 @@ async function fireStep(ctx: Context, agent: Agent, turn: number, step: number):
}
}
async function proposeStep(
ctx: Context,
agent: Agent,
messages: UserMessage[],
): Promise<PreStepDecision> {
const signal = new AbortController().signal
return await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
messages,
{ turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages }),
)
}
function catalogMessages(session: Session): Extract<SessionEvent, { type: 'user/message' }>[] {
return session.events.filter((event): event is Extract<SessionEvent, { type: 'user/message' }> => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
@@ -334,6 +348,60 @@ describe('dsh-tool-skill', () => {
expect(catalogMessages(session)).toEqual([])
})
it('deduplicates or replaces a catalog already proposed for the same step', async () => {
const home = await tempDir('tool-proposed-catalog')
const ctx = await setup(home)
const disposeFirst = ctx.skills.register({
name: 'first-skill',
description: 'First skill',
source: 'runtime',
content: 'First body.',
})
const session = new Session(SessionId('proposed-catalog'))
const agent = sessionAgent(session)
openMessageTurn(session)
await fireStep(ctx, agent, 1, 1)
const initial = catalogMessages(session)[0]?.data
if (initial === undefined) throw new Error('expected initial catalog')
const duplicate = await proposeStep(ctx, agent, [initial])
expect(duplicate).toEqual({ kind: 'enter', messages: [] })
ctx.skills.register({
name: 'second-skill',
description: 'Second skill',
source: 'runtime',
content: 'Second body.',
})
const companion = createUserMessage({
content: [{ type: 'text', text: 'keep this message' }],
source: { kind: 'user' },
})
const replaced = await proposeStep(ctx, agent, [companion, initial])
expect(replaced.kind).toBe('enter')
if (replaced.kind === 'reject') throw new Error('expected catalog replacement')
expect(replaced.messages).toHaveLength(2)
expect(replaced.messages[0]).toBe(companion)
expect(replaced.messages[1]?.id).not.toBe(initial.id)
expect(JSON.stringify(replaced.messages[1]?.content)).toContain('second-skill')
disposeFirst()
})
it('removes a stale proposed catalog before the first empty baseline', async () => {
const home = await tempDir('tool-proposed-empty-catalog')
const ctx = await setup(home)
const session = new Session(SessionId('proposed-empty-catalog'))
const stale = createUserMessage({
content: catalogContent(['- `stale-skill`: Stale skill']),
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
})
const decision = await proposeStep(ctx, sessionAgent(session), [stale])
expect(decision).toEqual({ kind: 'enter', messages: [] })
})
it('injects complete replacement catalogs for additions and an empty tombstone for removals', async () => {
const home = await tempDir('tool-dynamic-catalog')
const ctx = await setup(home)

View File

@@ -165,6 +165,17 @@ describe('dsh-subagent-dsh-sdk provider', () => {
await ctx.fiber.dispose()
})
it('keeps streamed text when a malformed final message prevents completion', async () => {
const ctx = await setup({ FAKE_MALFORMED_MESSAGE: '1', FAKE_TEXT: 'stream-only answer' })
const run = await ctx.subagents.start('dsh-sdk', request())
const result = await run.result
expect(result.stopReason).toBe('error')
expect(text(result.output)).toBe('stream-only answer')
await run.dispose()
await ctx.fiber.dispose()
})
it('reports a settled-without-turn child as an error', async () => {
const ctx = await setup({ FAKE_REASON_KIND: 'none', FAKE_STATUS: 'error' })
const run = await ctx.subagents.start('dsh-sdk', request())

View File

@@ -50,6 +50,7 @@ const WAIT_POLL_INTERVAL_MS = 10
* `waitForTurnStart` waits for an open durable turn, optionally at or beyond a
* specified turn number. `waitForTurnEnd` holds the subprocess open until the
* selected session's latest complete raw-JSONL turn boundary is `turn/end`.
* `waitForInboxMessage` waits for inserted inbox text containing a scenario marker.
* `waitForTitleAfterTurnEnd` additionally waits for a later durable title.
* A standalone `cancel` may also wait for a cwd-relative readiness marker.
* All wait timeouts default to 10s.
@@ -68,6 +69,7 @@ export type InputStep =
}
| { op: 'waitForTurnStart'; minimumTurn?: number; timeoutMs?: number }
| { op: 'waitForTurnEnd'; timeoutMs?: number }
| { op: 'waitForInboxMessage'; text: string; timeoutMs?: number }
| { op: 'waitForTitleAfterTurnEnd'; timeoutMs?: number }
| { op: 'cancel'; waitForFile?: { path: string; timeoutMs?: number } }
@@ -290,6 +292,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
(id) => { sessionId = id },
(id, timeoutMs, minimumTurn) => waitForPersistedTurnStart(sessionsRoot, id, timeoutMs, minimumTurn),
(id, timeoutMs) => waitForPersistedTurnEnd(sessionsRoot, id, timeoutMs),
(id, text, timeoutMs) => waitForPersistedInboxMessage(sessionsRoot, id, text, timeoutMs),
(id, timeoutMs) => waitForPersistedTitleAfterTurnEnd(sessionsRoot, id, timeoutMs),
)
// A permission exchange happens while a step's request is in flight, so
@@ -364,6 +367,7 @@ async function runStep(
setSessionId: (id: string) => void,
waitForTurnStart: (sessionId: string, timeoutMs?: number, minimumTurn?: number) => Promise<void>,
waitForTurnEnd: (sessionId: string, timeoutMs?: number) => Promise<void>,
waitForInboxMessage: (sessionId: string, text: string, timeoutMs?: number) => Promise<void>,
waitForTitleAfterTurnEnd: (sessionId: string, timeoutMs?: number) => Promise<void>,
): Promise<void> {
switch (step.op) {
@@ -442,6 +446,12 @@ async function runStep(
await waitForTurnEnd(sessionId, step.timeoutMs)
return
}
case 'waitForInboxMessage': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: waitForInboxMessage before newSession')
await waitForInboxMessage(sessionId, step.text, step.timeoutMs)
return
}
case 'waitForTitleAfterTurnEnd': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: waitForTitleAfterTurnEnd before newSession')
@@ -515,6 +525,29 @@ async function waitForPersistedTurnEnd(
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
}
/** Wait until an inserted inbox message contains scenario-owned text. */
async function waitForPersistedInboxMessage(
root: string,
sessionId: string,
text: string,
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
): Promise<void> {
await vi.waitFor(async () => {
const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId)
const matched = log?.content.split('\n').some((line) => {
if (line.length === 0) return false
const record = JSON.parse(line) as {
type?: unknown
data?: { inserted?: Array<{ content?: Array<{ type?: unknown; text?: unknown }> }> }
}
return record.type === 'agent/inbox/spliced' && record.data?.inserted?.some(message =>
message.content?.some(block => block.type === 'text'
&& typeof block.text === 'string' && block.text.includes(text))) === true
}) ?? false
if (!matched) throw new Error(`snapshot-harness: session "${sessionId}" did not persist expected inbox message within ${timeoutMs}ms`)
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
}
/** Wait until a complete provider or fallback title record follows the latest closed turn. */
async function waitForPersistedTitleAfterTurnEnd(
root: string,

View File

@@ -584,6 +584,55 @@ describe('runScenario', () => {
expect(result.sessionLogs[0]?.content).toContain('"type":"turn/end"')
})
it('waitForInboxMessage holds the app through a matching durable insertion', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({
prompt: 'hang-until-cancel',
persistLogsOnCancel: true,
logs: [{
file: 'project/main/session.jsonl',
lines: [
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
{
type: 'agent/inbox/spliced',
seq: 0,
time: 2,
data: {
target: 'next-turn',
start: 0,
inserted: [{ role: 'user', content: [{ type: 'text', text: 'durable marker' }] }],
},
},
],
}],
})
const result = await runScenario(
{ steps: [...boot, { op: 'promptAndCancel', text: 'hang' }, { op: 'waitForInboxMessage', text: 'marker' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)
expect(result.sessionLogs[0]?.content).toContain('durable marker')
})
it('waitForInboxMessage times out when the session log or matching insertion is absent', { timeout: 20_000 }, async () => {
const absent = await scenario({ prompt: 'hang-until-cancel', persistLogsOnCancel: true })
await expect(runScenario(
{ steps: [...boot, { op: 'promptAndCancel', text: 'hang' }, { op: 'waitForInboxMessage', text: 'missing', timeoutMs: 20 }] },
{ agent: AGENT, mode: 'replay', fixtureFile: absent.fixtureFile },
)).rejects.toThrow(/did not persist expected inbox message within 20ms/)
const unmatched = await scenario({
prompt: 'hang-until-cancel',
persistLogsOnCancel: true,
logs: [{
file: 'project/main/session.jsonl',
lines: [{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 }],
}],
})
await expect(runScenario(
{ steps: [...boot, { op: 'promptAndCancel', text: 'hang' }, { op: 'waitForInboxMessage', text: 'missing', timeoutMs: 20 }] },
{ agent: AGENT, mode: 'replay', fixtureFile: unmatched.fixtureFile },
)).rejects.toThrow(/did not persist expected inbox message within 20ms/)
})
it('waitForTitleAfterTurnEnd holds the app through a standalone durable title', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({
prompt: 'hang-until-cancel',
@@ -872,6 +921,7 @@ describe('runScenario', () => {
[{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/],
[{ op: 'waitForTurnStart' }, /waitForTurnStart before newSession/],
[{ op: 'waitForTurnEnd' }, /waitForTurnEnd before newSession/],
[{ op: 'waitForInboxMessage', text: 'marker' }, /waitForInboxMessage before newSession/],
[{ op: 'waitForTitleAfterTurnEnd' }, /waitForTitleAfterTurnEnd before newSession/],
[{ op: 'cancel' }, /cancel before newSession/],
] as [InputStep, RegExp][])('rejects %j before newSession', { timeout: 20_000 }, async (step, message) => {

View File

@@ -173,9 +173,10 @@ export function parseSessionHeader(text: string): { id: string; createdAt: numbe
/**
* Reconstruct the per-`stream()` replay script from a recorded session log.
*
* Groups `assistant/chunk` events by turn and step. Every group must end in a
* `finish`; a missing terminator means the live stream threw, so derivation
* rejects and the scenario must provide an explicit override.
* Splits `assistant/chunk` events at every `finish`, using turn and step changes
* to detect an unterminated prior call. A missing terminator means the live
* stream threw, so derivation rejects and the scenario must provide an explicit
* override. Multiple calls may share one turn and step when the loop retries.
* @param events - the recorded session's events; only `assistant/chunk` is consulted.
* @returns one `chunks` entry per recorded model call, in call order.
*/
@@ -197,14 +198,16 @@ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] {
if (event.type !== 'assistant/chunk') continue
const { turn, step, chunk } = event.data
const key = `${turn}/${step}`
if (key !== currentKey) {
// A new (turn, step) — i.e. a new stream() call. Close the previous one
// (skip the initial empty buffer before any chunk has been seen).
if (current.length > 0 && key !== currentKey) {
close(currentKey, current)
currentKey = key
}
if (current.length === 0) currentKey = key
current.push(chunk)
if (chunk.type === 'finish') {
close(currentKey, current)
currentKey = undefined
current = []
}
current.push(chunk)
}
close(currentKey, current)
return script

View File

@@ -106,11 +106,27 @@ describe('parseSessionLog', () => {
})
describe('deriveReplayScript', () => {
it('groups assistant/chunk by (turn, step) into one entry per stream() call', () => {
it('groups one finished assistant/chunk stream into one replay entry', () => {
const events: SessionEvent[] = TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))
expect(deriveReplayScript(events)).toEqual([{ kind: 'chunks', chunks: TEXT_CHUNKS }])
})
it('separates retry calls that share one turn and step at their finish chunks', () => {
const failed: StreamChunk[] = [
{ type: 'usage', usage: { inputTokens: 0, outputTokens: 0 } },
{ type: 'finish', reason: { kind: 'error', failure: { message: 'empty', code: 'EMPTY_RESPONSE' } } },
]
let seq = 1
const events: SessionEvent[] = [
...failed.map(chunk => chunkEvent(seq++, 1, 1, chunk)),
...TEXT_CHUNKS.map(chunk => chunkEvent(seq++, 1, 1, chunk)),
]
expect(deriveReplayScript(events)).toEqual([
{ kind: 'chunks', chunks: failed },
{ kind: 'chunks', chunks: TEXT_CHUNKS },
])
})
it('produces one entry per distinct (turn, step), in log order', () => {
const callA = TEXT_CHUNKS
const callB: StreamChunk[] = [
@@ -177,6 +193,14 @@ describe('deriveReplayScript', () => {
]
expect(() => deriveReplayScript(events)).toThrow(/2\/3/)
})
it('rejects an unfinished call before consuming chunks from a new step', () => {
const events: SessionEvent[] = [
chunkEvent(1, 1, 1, { type: 'block-start', index: 0, blockType: 'text' }),
chunkEvent(2, 1, 2, { type: 'finish', reason: { kind: 'stop' } }),
]
expect(() => deriveReplayScript(events)).toThrow(/model call 1\/1 ended without a finish chunk/)
})
})
describe('loadReplayScript', () => {

View File

@@ -459,7 +459,7 @@ function resumeTurnLabel(snapshot: SessionLogSnapshot): string {
const reason = event.data.reason
switch (reason.kind) {
case 'completed': return `turn ${event.data.turn}: completed`
case 'aborted': return `turn ${event.data.turn}: cancelled`
case 'aborted': return `turn ${event.data.turn}: ${reason.reason.kind === 'disposed' ? 'disposed' : 'cancelled'}`
case 'error': return `turn ${event.data.turn}: error`
case 'max-tokens': return `turn ${event.data.turn}: max tokens`
case 'interrupted': return `turn ${event.data.turn}: interrupted`

View File

@@ -664,6 +664,16 @@ export function createTuiChat(
chat.addChild(streaming.timing)
}
const trailAssistantStep = (): void => {
if (streaming === undefined) return
for (const child of [streaming, streaming.timing]) {
const index = chat.children.indexOf(child)
/* v8 ignore next -- an open step keeps both assistant children attached until it settles or retracts. */
if (index >= 0) chat.children.splice(index, 1)
chat.addChild(child)
}
}
const renderEvent = (
event: SessionEvent,
options: {
@@ -682,6 +692,7 @@ export function createTuiChat(
if (references !== undefined) {
chat.addChild(new Spacer(1))
chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 0, 0))
trailAssistantStep()
break
}
const text = contentText(event.data.content).trim()
@@ -701,6 +712,7 @@ export function createTuiChat(
chat.addChild(new Spacer(1))
chat.addChild(card)
}
trailAssistantStep()
break
}
const text = displayText(contentText(event.data.content).trim())
@@ -709,6 +721,7 @@ export function createTuiChat(
chat.addChild(new UserMessageComponent(text, palette, mdTheme))
if (options.addHistory) editor.addToHistory(text)
}
trailAssistantStep()
break
}
case 'steering/message': {
@@ -799,7 +812,9 @@ export function createTuiChat(
break
}
case 'aborted':
appendNotice('Turn cancelled.', 'warning')
appendNotice(reason.reason.kind === 'disposed'
? 'Turn stopped: the agent was disposed.'
: 'Turn cancelled.', 'warning')
break
case 'max-tokens':
appendNotice('The model reached its output-token limit.', 'warning')

View File

@@ -11,26 +11,26 @@ viewport
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Reasoning "
style 0-8 dim italic
6| "Inspecting width and styles. "
style 0-27 dim italic
7| "Streaming visible state… "
style 10-22 bold
8| " "
9| "ts "
style 0-1 dim
10| " const visible = true "
style 2-21 fg=cyan
11| " "
12| "Model wait 1.0s · Thinking 2.0s "
style 0-30 dim
13| <blank>
14| "You "
4| "You "
style 0-2 fg=bright-magenta bold underline
15| "Show the live update. "
5| "Show the live update. "
6| <blank>
7| "Assistant "
style 0-8 fg=bright-magenta bold underline
8| "Reasoning "
style 0-8 dim italic
9| "Inspecting width and styles. "
style 0-27 dim italic
10| "Streaming visible state… "
style 10-22 bold
11| " "
12| "ts "
style 0-1 dim
13| " const visible = true "
style 2-21 fg=cyan
14| " "
15| "Model wait 1.0s · Thinking 2.0s "
style 0-30 dim
16| <blank>
17| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-magenta bold

View File

@@ -11,15 +11,15 @@ buffer
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Session inspected. "
6| "Model wait 0.0s "
style 0-14 dim
7| <blank>
8| "You "
4| "You "
style 0-2 fg=bright-magenta bold underline
9| "inspect this session "
5| "inspect this session "
6| <blank>
7| "Assistant "
style 0-8 fg=bright-magenta bold underline
8| "Session inspected. "
9| "Model wait 0.0s "
style 0-14 dim
10| <blank>
11| "╭─ Session status ─────────────────────────────────────╮"
style 0-2 dim

View File

@@ -11,15 +11,15 @@ buffer
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Session inspected. "
6| "Model wait 0.0s "
style 0-14 dim
7| <blank>
8| "You "
4| "You "
style 0-2 fg=bright-magenta bold underline
9| "inspect this session "
5| "inspect this session "
6| <blank>
7| "Assistant "
style 0-8 fg=bright-magenta bold underline
8| "Session inspected. "
9| "Model wait 0.0s "
style 0-14 dim
10| <blank>
11| "╭─ Session status ────────────────────────────────────────────────────────────────────────╮"
style 0-2 dim

View File

@@ -11,13 +11,13 @@ buffer
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "You "
4| "You "
style 0-2 fg=bright-magenta bold underline
7| "Old prompt with a long line that exercises "
8| "wrapping and stays visible after compaction."
5| "Old prompt with a long line that exercises "
6| "wrapping and stays visible after compaction."
7| <blank>
8| "Assistant "
style 0-8 fg=bright-magenta bold underline
9| <blank>
10| "● Tool / bash / Run the coverage gate"
style 0-36 fg=green

View File

@@ -11,12 +11,12 @@ buffer
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "You "
4| "You "
style 0-2 fg=bright-magenta bold underline
7| "Old prompt with a long line that exercises wrapping and stays visible after compaction. "
5| "Old prompt with a long line that exercises wrapping and stays visible after compaction. "
6| <blank>
7| "Assistant "
style 0-8 fg=bright-magenta bold underline
8| <blank>
9| "● Tool / bash / Run the coverage gate"
style 0-36 fg=green

View File

@@ -11,13 +11,13 @@ buffer
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "You "
4| "You "
style 0-2 fg=bright-magenta bold underline
7| "Old prompt with a long line that exercises wrapping and stays visible after "
8| "compaction. "
5| "Old prompt with a long line that exercises wrapping and stays visible after "
6| "compaction. "
7| <blank>
8| "Assistant "
style 0-8 fg=bright-magenta bold underline
9| <blank>
10| "● Tool / bash / Run the coverage gate"
style 0-36 fg=green

View File

@@ -11,12 +11,12 @@ buffer
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "You "
4| "You "
style 0-2 fg=bright-magenta bold underline
7| "Old prompt with a long line that exercises wrapping and stays visible after compaction. "
5| "Old prompt with a long line that exercises wrapping and stays visible after compaction. "
6| <blank>
7| "Assistant "
style 0-8 fg=bright-magenta bold underline
8| <blank>
9| "● Tool / bash / Run the coverage gate"
style 0-36 fg=green

View File

@@ -11,15 +11,15 @@ viewport
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Tracking the steps. "
6| "Model wait 0.0s · Completed 2026-07-21 14:45:00 "
style 0-46 dim
7| <blank>
8| "You "
4| "You "
style 0-2 fg=bright-magenta bold underline
9| "Plan the work. "
5| "Plan the work. "
6| <blank>
7| "Assistant "
style 0-8 fg=bright-magenta bold underline
8| "Tracking the steps. "
9| "Model wait 0.0s · Completed 2026-07-21 14:45:00 "
style 0-46 dim
10| <blank>
11| "You "
style 0-2 fg=bright-magenta bold underline

View File

@@ -11,30 +11,30 @@ buffer
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "You "
4| "You "
style 0-2 fg=bright-magenta bold underline
7| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
8| <blank>
9| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
style 0-81 fg=green
10| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-59 dim
11| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-52 dim
12| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-58 dim
13| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] "
style 0-56 fg=red
14| "Model wait 0.0s · Completed 2026-07-21 15:00:00 "
style 0-46 dim
15| <blank>
16| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
5| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
6| <blank>
7| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
style 0-61 dim
17| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
8| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-59 dim
9| <blank>
10| "Assistant "
style 0-8 fg=bright-magenta bold underline
11| <blank>
12| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
style 0-81 fg=green
13| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-59 dim
14| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-52 dim
15| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-58 dim
16| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] "
style 0-56 fg=red
17| "Model wait 0.0s · Completed 2026-07-21 15:00:00 "
style 0-46 dim
18| <blank>
19| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-62 fg=red

View File

@@ -633,6 +633,10 @@ describe('TUI terminal-state snapshots', () => {
},
beforeMount(session) {
appendUser(session, `Unsafe user ${CONTROL_PROBE}`)
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `Unsafe context ${CONTROL_PROBE}` }],
source: { kind: 'plugin', plugin: `unsafe-${CONTROL_PROBE}` },
}), { surfaceOp: 'append' })
appendAssistant(session, [
{ type: 'reasoning', text: `Unsafe reasoning ${CONTROL_PROBE}` },
{ type: 'text', text: `Unsafe assistant ${CONTROL_PROBE}` },
@@ -642,10 +646,6 @@ describe('TUI terminal-state snapshots', () => {
session.append('todo/write', {
todos: [{ content: `Unsafe todo ${CONTROL_PROBE}`, status: 'in_progress' }],
})
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `Unsafe context ${CONTROL_PROBE}` }],
source: { kind: 'plugin', plugin: `unsafe-${CONTROL_PROBE}` },
}), { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', {
turn: 1,

View File

@@ -460,7 +460,7 @@ describe('goodbye message and /resume', () => {
it.each([
[{ kind: 'aborted', reason: { kind: 'user' } }, 'cancelled'],
[{ kind: 'error', error: 'failed' }, 'error'],
[{ kind: 'aborted', reason: { kind: 'disposed' } }, 'cancelled'],
[{ kind: 'aborted', reason: { kind: 'disposed' } }, 'disposed'],
[{ kind: 'max-tokens' }, 'max tokens'],
[{ kind: 'interrupted' }, 'interrupted'],
[{ kind: 'future-result' } as unknown as TurnEndReason, 'unknown result'],
@@ -1232,7 +1232,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
}
const result = await setup({
beforeMount(session) {
session.append('user/message', createUserMessage({
const message = createUserMessage({
content: renderGoalChange(change),
source: {
kind: 'goal',
@@ -1241,7 +1241,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
round: 0,
change,
},
}), { surfaceOp: 'append' })
})
session.append('agent/inbox/spliced', {
target: 'next-step', start: 0, inserted: [message],
})
},
})
expect(result.terminal.output).toContain('Goal restored (active) with automatic continuation disarmed')
@@ -3772,6 +3775,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(events.terminal.output).toContain('live failure')
expect(events.terminal.output).toContain('durable failure')
expect(events.terminal.output).toContain('Turn cancelled')
expect(events.terminal.output).toContain('Turn stopped: the agent was disposed')
expect(events.terminal.output).toContain('structured provider failure')
expect(events.terminal.output).not.toContain('[object Object]')
expect(events.terminal.output).toContain('output-token limit')

View File

@@ -520,6 +520,25 @@ describe('approval policy (the approval/policy fold)', () => {
expect(narrations(session)).toHaveLength(1)
})
it('preserves a rejected pre-step without adding policy narration', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session } = sessionAgent('sess-narr-rejected')
appendHeader(session, ASK_MARKER)
setApprovalPolicy(session, 'never')
const signal = new AbortController().signal
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
[],
{ turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'reject' as const }),
)
expect(decision).toEqual({ kind: 'reject' })
expect(narrations(session)).toEqual([])
})
it('reads what the model was told back from the folded header text after a restart', async () => {
// A session whose last request carried the never sentence resumes under
// an ask default: the narrator attributes the change to the operator.