fix inbox lifecycle downstream contracts
This commit is contained in:
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 已经领取完批次,它可能赶不上该请求。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 栈和此命令。ACP(Agent Client
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
斜杠输入与直接状态/错误输出不会进入模型请求。已接受的变更稍后会通过 goal 领域的原始 `<goal_state>` 快照或 clear tombstone 出现;这样既满足模型可见内容必须记录日志的不变量,也无需记录呈现文本。
|
||||
斜杠输入与直接状态/错误输出不会进入模型请求。已接受的变更会将 goal 领域的原始 `<goal_state>` 快照或 clear tombstone 排队;只有后续 pre-step 准入该上下文时,模型才会看到它。如果已排队的消息被丢弃,变更仍然持久;呈现文本绝不会记录到日志中。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
读取状态或收到直接命令错误不会增加模型 token。每项已接受变更都会增加 goal 领域保留的完整快照;已启用的同会话驱动器还可能增加后续 Goal Round 提示词。
|
||||
读取状态或收到直接命令错误不会增加模型 token。获准的变更上下文会增加 goal 领域保留的完整快照,准入前被丢弃的上下文则不会增加;已启用的同会话驱动器还可能增加后续 Goal Round 提示词。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
命令发现与直接输出不会影响缓存。变更会追加到可复用历史前缀之后;后续压缩可能替换派生历史后缀。
|
||||
命令发现与直接输出不会影响缓存。获准的变更上下文会追加到可复用历史前缀之后;后续压缩可能替换派生历史后缀。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' } })
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 id/revision 与 `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 id/revision 与 `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 内仅追加:每项可见变更都位于可复用请求前缀和既有历史之后。压缩可能替换派生历史后缀,并移动可复用边界。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 快照会追加到可复用请求前缀之后,不会使更早条目失效。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
46
packages/llm/llm/tests/adapter-failure.spec.ts
Normal file
46
packages/llm/llm/tests/adapter-failure.spec.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user