Merge commit 'refs/codex-unblock/20260724/pr591-base-current' into worktree/unblock-pr-591-ci-20260724
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import GoalService from '@deepseek-ai/dsh-goal'
|
||||
@@ -27,10 +27,10 @@ function nextTurn(session: Session): number {
|
||||
|
||||
/** Append one idle injection using the public Agent contract's balanced shape. */
|
||||
function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void {
|
||||
const source: MessageSource = options?.source ?? { kind: 'user' }
|
||||
const source: MessageSource = options?.source ?? { kind: 'plugin', plugin: '' }
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content,
|
||||
source,
|
||||
...options?.meta === undefined ? {} : { meta: options.meta },
|
||||
@@ -48,9 +48,11 @@ function stubAgent(id: string): { agent: Agent; session: Session } {
|
||||
session,
|
||||
ctx: new Context(),
|
||||
get status() { return status },
|
||||
send() {},
|
||||
steer() {},
|
||||
inject(content, options) { appendInjection(session, content, options) },
|
||||
followup: () => AgentMessageId('stub'),
|
||||
queue: () => AgentMessageId('stub'),
|
||||
steer: () => AgentMessageId('stub'),
|
||||
inject(content, options) { appendInjection(session, content, options); return AgentMessageId('stub') },
|
||||
send: () => AgentMessageId('stub'),
|
||||
cancel() { status = 'idle' },
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
@@ -125,7 +127,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(test.session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end'])
|
||||
expect(test.session.events.map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end'])
|
||||
|
||||
const count = test.session.events.length
|
||||
await expect(run(test, ' replacement')).resolves.toEqual({
|
||||
|
||||
@@ -219,7 +219,7 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
state.attempt = reservation
|
||||
try {
|
||||
agent.send(content, {
|
||||
agent.followup(content, {
|
||||
source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round },
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
@@ -306,10 +306,10 @@ export function apply(ctx: Context): void {
|
||||
requestDrive(state)
|
||||
}
|
||||
})
|
||||
ctx.on('agent/queued', (agent, content, info) => {
|
||||
ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
const state = stateFor(agent)
|
||||
const attempt = state.attempt
|
||||
if (attempt !== undefined && sameQueued(content, info.source, attempt)) return
|
||||
if (attempt !== undefined && sameQueued(info.content, info.source, attempt)) return
|
||||
state.competingQueued = true
|
||||
if (attempt?.phase === 'queued') attempt.stale = true
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { GoalView } from '@deepseek-ai/dsh-goal'
|
||||
* Render the complete goal-round instruction retained in session history.
|
||||
* @param goal - exact active goal revision being admitted.
|
||||
* @param round - next positive round number.
|
||||
* @returns a fresh one-block prompt for `Agent.send()`.
|
||||
* @returns a fresh one-block prompt for `Agent.followup()`.
|
||||
*/
|
||||
export function renderGoalRoundPrompt(goal: GoalView, round: number): ContentBlock[] {
|
||||
return [{
|
||||
|
||||
@@ -207,7 +207,9 @@ describe('same-session goal driving', () => {
|
||||
expect(test.adapter.requests).toHaveLength(2)
|
||||
const rounds: number[] = []
|
||||
for (const event of test.agent.session.events) {
|
||||
if (event.type === 'user/message' && event.data.source.kind === 'goal') {
|
||||
// Round zero is a durable goal state change; positive rounds are the
|
||||
// admitted continuation prompts this test counts.
|
||||
if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0) {
|
||||
rounds.push(event.data.source.round)
|
||||
}
|
||||
}
|
||||
@@ -274,7 +276,7 @@ describe('same-session goal driving', () => {
|
||||
? Promise.resolve({ kind: 'block', reason: 'stop this round' })
|
||||
: next())
|
||||
test.ctx.on('goal/changed', (agent, change) => {
|
||||
if (change.operation === 'block') agent.send([{ type: 'text', text: 'inspect the blocker' }])
|
||||
if (change.operation === 'block') agent.followup([{ type: 'text', text: 'inspect the blocker' }])
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'stop and inspect' })
|
||||
|
||||
@@ -287,7 +289,7 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('pauses and drops a reserved round when cancellation lands before admission', async () => {
|
||||
const test = await harness([])
|
||||
const cancel = test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent === test.agent && info.source.kind === 'goal') {
|
||||
cancel()
|
||||
agent.cancel({ kind: 'user' })
|
||||
@@ -299,8 +301,10 @@ describe('same-session goal driving', () => {
|
||||
|
||||
expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' })
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
// No admitted continuation round (positive round); goal state changes
|
||||
// (round zero) are expected in the log.
|
||||
expect(test.agent.session.events.some(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'goal')).toBe(false)
|
||||
&& event.data.source.kind === 'goal' && event.data.source.round > 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('pauses an admitted round when cancellation aborts an active step', async () => {
|
||||
@@ -319,7 +323,7 @@ describe('same-session goal driving', () => {
|
||||
it('lets already-queued human work finish before reserving the next round', async () => {
|
||||
const test = await harness([textResponse('human answer'), textResponse('goal answer')])
|
||||
test.ctx.goals.create(test.agent, { objective: 'continue after the human', maxGoalRounds: 1 })
|
||||
test.agent.send([{ type: 'text', text: 'human goes first' }])
|
||||
test.agent.followup([{ type: 'text', text: 'human goes first' }])
|
||||
|
||||
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
|
||||
|
||||
@@ -334,7 +338,7 @@ describe('same-session goal driving', () => {
|
||||
const warnings: string[] = []
|
||||
test.ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof test.ctx.logger.warn
|
||||
let inserted = false
|
||||
test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return
|
||||
inserted = true
|
||||
const lastStart = agent.session.events.findLast(event => event.type === 'turn/start')
|
||||
@@ -357,10 +361,10 @@ describe('same-session goal driving', () => {
|
||||
it('makes a reserved round stale when a listener queues human work behind it', async () => {
|
||||
const test = await harness([textResponse('human batch'), textResponse('later goal')])
|
||||
let inserted = false
|
||||
test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return
|
||||
inserted = true
|
||||
agent.send([{ type: 'text', text: 'human joined the pending batch' }])
|
||||
agent.followup([{ type: 'text', text: 'human joined the pending batch' }])
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'yield to nested human input', maxGoalRounds: 1 })
|
||||
|
||||
@@ -375,7 +379,7 @@ describe('same-session goal driving', () => {
|
||||
it('blocks a queued reservation made stale by a goal edit and continues the new revision', async () => {
|
||||
const test = await harness([textResponse('new revision')])
|
||||
let edited = false
|
||||
test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal' || edited) return
|
||||
edited = true
|
||||
const current = test.ctx.goals.get(agent)
|
||||
@@ -391,7 +395,7 @@ describe('same-session goal driving', () => {
|
||||
expect(blocked?.type === 'prompt/blocked' ? blocked.data.reason : undefined)
|
||||
.toBe('stale goal-round reservation')
|
||||
const admitted = test.agent.session.events.find(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'goal')
|
||||
&& event.data.source.kind === 'goal' && event.data.source.round > 0)
|
||||
expect(admitted?.type === 'user/message' && admitted.data.source.kind === 'goal'
|
||||
? admitted.data.source.revision
|
||||
: undefined).toBe(2)
|
||||
@@ -475,10 +479,16 @@ describe('same-session goal driving', () => {
|
||||
expect(injectedTurn).toBeGreaterThan(goalTurn)
|
||||
})
|
||||
|
||||
it('blocks the goal when a custom agent rejects the otherwise valid send', async () => {
|
||||
it('blocks the goal when a custom agent rejects the otherwise valid follow-up', async () => {
|
||||
const test = await harness([])
|
||||
vi.spyOn(test.agent, 'send').mockImplementationOnce(() => {
|
||||
throw new Error('queue rejected')
|
||||
// Reject only the goal-sourced round follow-up, not the state-change injection
|
||||
// that precedes it.
|
||||
const realFollowup = test.agent.followup.bind(test.agent)
|
||||
vi.spyOn(test.agent, 'followup').mockImplementation((content, options) => {
|
||||
if (options?.source?.kind === 'goal') {
|
||||
throw new Error('queue rejected')
|
||||
}
|
||||
return realFollowup(content, options)
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'handle queue failure' })
|
||||
|
||||
@@ -492,11 +502,15 @@ describe('same-session goal driving', () => {
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('preserves a custom agent side effect when send disarms before throwing', async () => {
|
||||
it('preserves a custom agent side effect when followup disarms before throwing', async () => {
|
||||
const test = await harness([])
|
||||
vi.spyOn(test.agent, 'send').mockImplementationOnce(() => {
|
||||
test.ctx.goals.disarm(test.agent)
|
||||
throw new Error('queue rejected after disarm')
|
||||
const realFollowup = test.agent.followup.bind(test.agent)
|
||||
vi.spyOn(test.agent, 'followup').mockImplementation((content, options) => {
|
||||
if (options?.source?.kind === 'goal') {
|
||||
test.ctx.goals.disarm(test.agent)
|
||||
throw new Error('queue rejected after disarm')
|
||||
}
|
||||
return realFollowup(content, options)
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' })
|
||||
|
||||
@@ -554,7 +568,7 @@ describe('same-session goal driving', () => {
|
||||
it('fails a pre-admission read closed even when the first disarm attempt throws', async () => {
|
||||
const test = await harness([textResponse('retry after containment')])
|
||||
let armed = true
|
||||
test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal' || !armed) return
|
||||
armed = false
|
||||
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
|
||||
@@ -595,7 +609,7 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('blocks forged goal attribution without touching an absent reservation', async () => {
|
||||
const test = await harness([])
|
||||
test.agent.send([{ type: 'text', text: 'forged automatic work' }], {
|
||||
test.agent.followup([{ type: 'text', text: 'forged automatic work' }], {
|
||||
source: { kind: 'goal', goalId: GoalId('forged-goal'), revision: 1, round: 1 },
|
||||
})
|
||||
await test.agent.whenIdle()
|
||||
@@ -607,7 +621,7 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('does not invent goal state when ordinary queued work is cancelled', async () => {
|
||||
const test = await harness([])
|
||||
test.agent.send([{ type: 'text', text: 'cancel ordinary work' }])
|
||||
test.agent.followup([{ type: 'text', text: 'cancel ordinary work' }])
|
||||
test.agent.cancel({ kind: 'user' })
|
||||
await test.agent.whenIdle()
|
||||
|
||||
@@ -617,7 +631,7 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('disarms without durably pausing when cancellation belongs to unrelated human work', async () => {
|
||||
const test = await harness(['hang'])
|
||||
test.agent.send([{ type: 'text', text: 'inspect something first' }])
|
||||
test.agent.followup([{ type: 'text', text: 'inspect something first' }])
|
||||
await waitForRequests(test.adapter, 1)
|
||||
const created = test.ctx.goals.create(test.agent, { objective: 'continue after inspection' })
|
||||
|
||||
@@ -635,7 +649,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 = test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal') return
|
||||
cancel()
|
||||
vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => {
|
||||
@@ -689,7 +703,7 @@ describe('same-session goal driving', () => {
|
||||
it('cancels an accepted queued round and awaits its driver task during teardown', async () => {
|
||||
const test = await harness([])
|
||||
let unloading: Promise<void> | undefined
|
||||
test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent === test.agent && info.source.kind === 'goal' && unloading === undefined) {
|
||||
unloading = Promise.resolve(test.driver.dispose())
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ function view(roundsStarted: number): GoalView {
|
||||
|
||||
function appendChange(session: Session): void {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
meta: change as never,
|
||||
@@ -126,7 +126,7 @@ describe('goal-session prompt invariants', () => {
|
||||
it('attributes an invalid durable prefix during late loading', async () => {
|
||||
const { ctx, session } = await mount(true)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'counterfeit goal state' }],
|
||||
source: changeSource,
|
||||
meta: change as never,
|
||||
|
||||
@@ -19,7 +19,7 @@ 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 non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The `context/message` content projected verbatim to the model, its `{ kind: 'goal' }` source, and its metadata 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 non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The round-zero `user/message` content projected verbatim to the model, its `{ kind: 'goal' }` source, and its metadata 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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
GoalSnapshotChangeMeta,
|
||||
} from './types.ts'
|
||||
|
||||
type ContextMessageEvent = Extract<SessionEvent, { type: 'context/message' }>
|
||||
type UserMessageEvent = Extract<SessionEvent, { type: 'user/message' }>
|
||||
|
||||
const SNAPSHOT_OPERATIONS: ReadonlySet<Exclude<GoalOperation, 'clear'>> = new Set([
|
||||
'create',
|
||||
@@ -310,17 +310,18 @@ export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): v
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode and verify one model-visible goal context event without folding it.
|
||||
* @param event - context event whose metadata and rendered content must agree.
|
||||
* @returns validated change or `undefined` for an unrelated context event.
|
||||
* 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
|
||||
* `goal/change` metadata; any other user message returns `undefined`. Goal
|
||||
* metadata on a non-goal source, or a mismatched attribution or rendered body,
|
||||
* fails replay loudly.
|
||||
* @param event - user message whose metadata and rendered content must agree.
|
||||
* @returns validated change, or `undefined` when the message is not a goal state change.
|
||||
*/
|
||||
export function decodeGoalEvent(event: ContextMessageEvent): GoalChangeMeta | undefined {
|
||||
export function decodeGoalEvent(event: UserMessageEvent): GoalChangeMeta | undefined {
|
||||
const change = decodeGoalChange(event.data.meta)
|
||||
if (change === undefined) return undefined
|
||||
const source = goalSource(event.data.source)
|
||||
if (change === undefined) {
|
||||
if (source !== undefined) throw new Error(`goal source at session event ${event.seq} lacks goal change metadata`)
|
||||
return undefined
|
||||
}
|
||||
const ref = goalChangeRef(change)
|
||||
if (source === undefined || source.goalId !== ref.id || source.revision !== ref.revision || source.round !== 0) {
|
||||
throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`)
|
||||
@@ -338,23 +339,27 @@ export function decodeGoalEvent(event: ContextMessageEvent): GoalChangeMeta | un
|
||||
* @returns decoded change for pending-overlay reconciliation.
|
||||
*/
|
||||
export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalChangeMeta | undefined {
|
||||
if (event.type === 'context/message') {
|
||||
const change = decodeGoalEvent(event)
|
||||
if (change === undefined) return undefined
|
||||
applyGoalChange(state, change)
|
||||
return change
|
||||
}
|
||||
if (event.type === 'user/message') {
|
||||
const source = goalSource(event.data.source)
|
||||
if (source !== undefined) {
|
||||
const current = state.goal
|
||||
if (current === undefined || current.phase !== 'active' || source.goalId !== current.id
|
||||
|| source.revision !== current.revision || source.round !== state.roundsStarted + 1
|
||||
|| source.round > current.maxGoalRounds) {
|
||||
throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`)
|
||||
}
|
||||
state.roundsStarted = source.round
|
||||
// A goal state change carries `goal/change` metadata (round zero).
|
||||
const change = decodeGoalEvent(event)
|
||||
if (change !== undefined) {
|
||||
applyGoalChange(state, change)
|
||||
return change
|
||||
}
|
||||
const source = goalSource(event.data.source)
|
||||
if (source === undefined) return undefined
|
||||
// A goal-sourced message without change metadata must be a positive-round
|
||||
// admitted continuation prompt; round zero owes durable change metadata.
|
||||
if (source.round === 0) {
|
||||
throw new Error(`goal source at session event ${event.seq} lacks goal change metadata`)
|
||||
}
|
||||
const current = state.goal
|
||||
if (current === undefined || current.phase !== 'active' || source.goalId !== current.id
|
||||
|| source.revision !== current.revision || source.round !== state.roundsStarted + 1
|
||||
|| source.round > current.maxGoalRounds) {
|
||||
throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`)
|
||||
}
|
||||
state.roundsStarted = source.round
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -370,7 +370,9 @@ export class GoalService extends Service {
|
||||
/** Incrementally observe durable events without losing deferred mutations. */
|
||||
private sync(session: Session, cache: GoalCache): void {
|
||||
for (const event of session.events.slice(cache.observedSeq)) {
|
||||
if (event.type === 'context/message') {
|
||||
// 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]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GoalErrorCode, GoalId as GoalIdType } from './types.ts'
|
||||
|
||||
/** Version of the goal change metadata embedded in `context/message`. */
|
||||
/** Version of the goal change metadata embedded in a round-zero `user/message`. */
|
||||
export const GOAL_CHANGE_VERSION = 1
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,7 +89,7 @@ export interface GoalClearChangeMeta {
|
||||
readonly clearedAt: number
|
||||
}
|
||||
|
||||
/** Durable metadata union carried by a goal-owned `context/message`. */
|
||||
/** Durable metadata union carried by a goal-owned round-zero `user/message`. */
|
||||
export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
|
||||
|
||||
/** Message attribution for durable goal state and continuation rounds. */
|
||||
|
||||
@@ -50,11 +50,11 @@ describe('goal domain through a real cordis.yml and headless process', () => {
|
||||
expect(result['result']).toContain('CLI tool round trip complete')
|
||||
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(1)
|
||||
|
||||
const contexts = events.filter(event => event.type === 'context/message'
|
||||
const contexts = events.filter(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'goal')
|
||||
expect(contexts).toHaveLength(1)
|
||||
const context = contexts[0]
|
||||
if (context?.type !== 'context/message') throw new Error('expected goal context event')
|
||||
if (context?.type !== 'user/message') throw new Error('expected goal context event')
|
||||
const change = decodeGoalChange(context.data.meta)
|
||||
if (change === undefined) throw new Error('expected durable goal change')
|
||||
expect(change).toMatchObject({
|
||||
@@ -69,7 +69,9 @@ describe('goal domain through a real cordis.yml and headless process', () => {
|
||||
})
|
||||
expect(context.data.content).toEqual(renderGoalChange(change))
|
||||
expect(JSON.stringify(context)).not.toContain('activation')
|
||||
// No admitted continuation round ran (the snapshot mounts without starting
|
||||
// a round); the round-zero state change from create is expected above.
|
||||
expect(events.filter(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'goal')).toHaveLength(0)
|
||||
&& event.data.source.kind === 'goal' && event.data.source.round > 0)).toHaveLength(0)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -34,7 +34,7 @@ function nextTurn(session: Session): number {
|
||||
|
||||
/** Mirror the public Agent.inject idle/open-turn contract for domain tests. */
|
||||
function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void {
|
||||
const source: MessageSource = options?.source ?? { kind: 'user' }
|
||||
const source: MessageSource = options?.source ?? { kind: 'plugin', plugin: '' }
|
||||
const context = {
|
||||
content,
|
||||
source,
|
||||
@@ -43,12 +43,12 @@ function appendInjection(session: Session, content: ContentBlock[], options?: In
|
||||
const last = session.events.at(-1)
|
||||
const open = last !== undefined && last.type !== 'turn/end'
|
||||
if (open) {
|
||||
session.append('context/message', context, { surfaceOp: 'append' })
|
||||
session.append('user/message', context, { surfaceOp: 'append' })
|
||||
return
|
||||
}
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('context/message', context, { surfaceOp: 'append' })
|
||||
session.append('user/message', context, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
@@ -64,12 +64,15 @@ function stubAgentForSession(session: Session): StubAgent {
|
||||
session,
|
||||
ctx: new Context(),
|
||||
get status() { return status },
|
||||
send() {},
|
||||
steer() {},
|
||||
followup: () => AgentMessageId('stub'),
|
||||
queue: () => AgentMessageId('stub'),
|
||||
steer: () => AgentMessageId('stub'),
|
||||
inject(content, options) {
|
||||
if (shouldDefer) deferred.push({ content, options })
|
||||
else appendInjection(session, content, options)
|
||||
return AgentMessageId('stub')
|
||||
},
|
||||
send: () => AgentMessageId('stub'),
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
@@ -131,10 +134,10 @@ describe('GoalService creation and replay', () => {
|
||||
})
|
||||
expect(goal.id).toMatch(/^goal-/)
|
||||
expect(seen).toEqual(['create'])
|
||||
expect(session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end'])
|
||||
expect(session.events.map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end'])
|
||||
const context = session.events[1]
|
||||
expect(context?.type).toBe('context/message')
|
||||
if (context?.type !== 'context/message') throw new Error('expected goal context')
|
||||
expect(context?.type).toBe('user/message')
|
||||
if (context?.type !== 'user/message') throw new Error('expected goal context')
|
||||
expect(context.data.source).toEqual({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 })
|
||||
const change = decodeGoalChange(context.data.meta)
|
||||
if (change === undefined) throw new Error('expected decoded goal change')
|
||||
@@ -266,7 +269,9 @@ describe('GoalService creation and replay', () => {
|
||||
|
||||
it('requires the exact live registry instance for reads and mutations', async () => {
|
||||
const { ctx, agent } = await harness()
|
||||
const impostor = { ...agent, session: new Session(agent.id) }
|
||||
// A same-id agent backed by a different session object — the live-instance
|
||||
// check must reject it even though the ids match.
|
||||
const impostor = stubAgentForSession(new Session(agent.id)).agent
|
||||
expect(() => ctx.goals.get(impostor)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' }))
|
||||
expect(() => ctx.goals.create(impostor, { objective: 'no' })).toThrow(expect.objectContaining({
|
||||
code: 'GOAL_AGENT_NOT_LIVE',
|
||||
@@ -407,8 +412,8 @@ describe('GoalService mutations', () => {
|
||||
vi.setSystemTime(80)
|
||||
ctx.goals.clear(agent, goal)
|
||||
const clear = session.events
|
||||
.filter(event => event.type === 'context/message')
|
||||
.map(event => decodeGoalChange(event.data.meta))
|
||||
.filter(event => event.type === 'user/message' && event.data.source.kind === 'goal')
|
||||
.map(event => event.type === 'user/message' ? decodeGoalChange(event.data.meta) : undefined)
|
||||
.at(-1)
|
||||
expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 })
|
||||
expect(() => foldGoal(session.events)).not.toThrow()
|
||||
@@ -454,7 +459,7 @@ describe('GoalService mutations', () => {
|
||||
ctx.agents.register(stub.agent)
|
||||
let observed: ReturnType<GoalService['get']>
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === stub.session && event.type === 'context/message') observed = ctx.goals.get(stub.agent)
|
||||
if (session === stub.session && event.type === 'user/message' && event.data.source.kind === 'goal') observed = ctx.goals.get(stub.agent)
|
||||
})
|
||||
|
||||
const created = ctx.goals.create(stub.agent, { objective: 'publish once' })
|
||||
@@ -473,7 +478,7 @@ describe('GoalService mutations', () => {
|
||||
let reject = true
|
||||
stub.agent.inject = (content, options) => {
|
||||
if (reject) throw new Error('injection rejected')
|
||||
append(content, options)
|
||||
return append(content, options)
|
||||
}
|
||||
ctx.agents.register(stub.agent)
|
||||
|
||||
@@ -517,7 +522,7 @@ describe('GoalService mutations', () => {
|
||||
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 } as const
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: renderGoalChange(change), source, meta: change as never,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
@@ -594,7 +599,7 @@ describe('goal replay validation', () => {
|
||||
}
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: overrides.content ?? renderGoalChange(change),
|
||||
source,
|
||||
meta: change as never,
|
||||
@@ -791,7 +796,7 @@ describe('goal replay validation', () => {
|
||||
const source = { kind: 'goal', goalId: GoalId('goal-missing-meta'), revision: 1, round: 0 } as const
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'missing' }], source,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
@@ -853,7 +858,7 @@ describe('goal replay validation', () => {
|
||||
const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 } as const
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: renderGoalChange(clear), source, meta: clear as never,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('goal stream invariants', () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('goal-invariant-valid'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
meta: change as never,
|
||||
@@ -71,7 +71,7 @@ describe('goal stream invariants', () => {
|
||||
const session = ctx.sessions.create(SessionId('goal-invariant-invalid'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
expect(() => {
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'counterfeit' }],
|
||||
source: changeSource,
|
||||
meta: change as never,
|
||||
@@ -82,7 +82,7 @@ describe('goal stream invariants', () => {
|
||||
}))
|
||||
expect(session.seq).toBe(1)
|
||||
expect(() => {
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
meta: change as never,
|
||||
@@ -95,7 +95,7 @@ describe('goal stream invariants', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('goal-invariant-late-load'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
meta: change as never,
|
||||
|
||||
@@ -18,7 +18,7 @@ An autonomous goal round that successfully reports `complete` or `blocked` contr
|
||||
|
||||
Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` initiator, running status, and an open turn. Create, edit, pause, and resume additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. Durable fork lineage does not demote a resumed root; live subagent ownership does.
|
||||
|
||||
`{ kind: 'user' }` is a host attestation. `Agent.send()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority.
|
||||
`{ kind: 'user' }` is a host attestation. `Agent.followup()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority.
|
||||
|
||||
Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted and must describe it in `blocked_reason`. Direct human authority may stop a goal immediately.
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolE
|
||||
|
||||
/**
|
||||
* Whether host-attested human input appears in the current root-agent turn.
|
||||
* An omitted `Agent.send()` / `steer()` source resolves to `user`, so non-human
|
||||
* An omitted `Agent.followup()` / `steer()` source resolves to `user`, so non-human
|
||||
* producers must supply their own source rather than inheriting this authority.
|
||||
*/
|
||||
function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
|
||||
import GoalService, { GoalId } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef } from '@deepseek-ai/dsh-goal'
|
||||
@@ -31,16 +31,19 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
|
||||
session,
|
||||
get status() { return status },
|
||||
ctx: new Context(),
|
||||
send() {},
|
||||
steer() {},
|
||||
followup: () => AgentMessageId('stub'),
|
||||
queue: () => AgentMessageId('stub'),
|
||||
steer: () => AgentMessageId('stub'),
|
||||
inject(content: ContentBlock[], options?: InjectOptions) {
|
||||
const source = options?.source ?? { kind: 'user' }
|
||||
session.append('context/message', {
|
||||
const source = options?.source ?? { kind: 'plugin', plugin: '' }
|
||||
session.append('user/message', {
|
||||
content,
|
||||
source,
|
||||
...options?.meta === undefined ? {} : { meta: options.meta },
|
||||
}, { surfaceOp: 'append' })
|
||||
return AgentMessageId('stub')
|
||||
},
|
||||
send: () => AgentMessageId('stub'),
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
@@ -228,7 +231,9 @@ describe('goal tool execution authority', () => {
|
||||
it('rejects stale agent objects and agents outside running status through the executor', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
openTurn(root, { kind: 'user' })
|
||||
const stale = { ...root.agent }
|
||||
// A distinct agent object over root's exact session: same id, not the live
|
||||
// registered instance, so the executor must reject it.
|
||||
const stale = stubAgent('goal-tool-stale', root.agent.session).agent
|
||||
const staleResult = await execute(ctx, 'get_goal', {}, stale, stale)
|
||||
expect(staleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user