feat(agent): unify send(target × wakeup), coalesce context/message into user/message
Replace send/steer/inject with one Agent.send primitive over the (target × wakeup) matrix; followup/steer/inject become fixed-preset alias methods on the now-abstract Agent class. Coalesce context/message into user/message (injected context is a non-user source). Replace agent/queued with agent/inbox/enqueue/dequeue/discard, add cancel keepInbox, and add a FIFO-conservation invariant.
This commit is contained in:
@@ -2,7 +2,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 type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus, AliasSendOptions } 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'
|
||||
@@ -26,11 +26,11 @@ 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' }
|
||||
function appendInjection(session: Session, content: ContentBlock[], options?: AliasSendOptions): void {
|
||||
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 },
|
||||
@@ -49,6 +49,7 @@ function stubAgent(id: string): { agent: Agent; session: Session } {
|
||||
ctx: new Context(),
|
||||
get status() { return status },
|
||||
send() {},
|
||||
followup() {},
|
||||
steer() {},
|
||||
inject(content, options) { appendInjection(session, content, options) },
|
||||
cancel() { status = 'idle' },
|
||||
@@ -125,7 +126,7 @@ describe('/goal human command', () => {
|
||||
expect(created.text).toContain('Rounds: 0/256')
|
||||
expect(created.text).toContain('Activation: armed')
|
||||
expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release')
|
||||
expect(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({
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
@@ -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,7 +361,7 @@ 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' }])
|
||||
@@ -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)
|
||||
@@ -477,8 +481,14 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('blocks the goal when a custom agent rejects the otherwise valid send', async () => {
|
||||
const test = await harness([])
|
||||
vi.spyOn(test.agent, 'send').mockImplementationOnce(() => {
|
||||
throw new Error('queue rejected')
|
||||
// inject shares send, so reject only the round send (a goal-sourced
|
||||
// next-turn item), not the goal state-change injection that precedes it.
|
||||
const realSend = test.agent.send.bind(test.agent)
|
||||
vi.spyOn(test.agent, 'send').mockImplementation((content, options) => {
|
||||
if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') {
|
||||
throw new Error('queue rejected')
|
||||
}
|
||||
realSend(content, options)
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'handle queue failure' })
|
||||
|
||||
@@ -494,9 +504,13 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('preserves a custom agent side effect when send 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 realSend = test.agent.send.bind(test.agent)
|
||||
vi.spyOn(test.agent, 'send').mockImplementation((content, options) => {
|
||||
if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') {
|
||||
test.ctx.goals.disarm(test.agent)
|
||||
throw new Error('queue rejected after disarm')
|
||||
}
|
||||
realSend(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(() => {
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus, AliasSendOptions } 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'
|
||||
import GoalService, {
|
||||
@@ -15,7 +15,7 @@ import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-
|
||||
|
||||
interface DeferredInjection {
|
||||
content: ContentBlock[]
|
||||
options: InjectOptions | undefined
|
||||
options: AliasSendOptions | undefined
|
||||
}
|
||||
|
||||
interface StubAgent {
|
||||
@@ -33,8 +33,8 @@ 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' }
|
||||
function appendInjection(session: Session, content: ContentBlock[], options?: AliasSendOptions): void {
|
||||
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' } })
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ function stubAgentForSession(session: Session): StubAgent {
|
||||
ctx: new Context(),
|
||||
get status() { return status },
|
||||
send() {},
|
||||
followup() {},
|
||||
steer() {},
|
||||
inject(content, options) {
|
||||
if (shouldDefer) deferred.push({ content, options })
|
||||
@@ -131,10 +132,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 +267,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 +410,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 +457,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' })
|
||||
@@ -517,7 +520,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 +597,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 +794,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 +856,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,
|
||||
|
||||
@@ -2,7 +2,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 type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent'
|
||||
import GoalService, { GoalId } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef } from '@deepseek-ai/dsh-goal'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
@@ -32,10 +32,11 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
|
||||
get status() { return status },
|
||||
ctx: new Context(),
|
||||
send() {},
|
||||
followup() {},
|
||||
steer() {},
|
||||
inject(content: ContentBlock[], options?: InjectOptions) {
|
||||
const source = options?.source ?? { kind: 'user' }
|
||||
session.append('context/message', {
|
||||
inject(content: ContentBlock[], options?: AliasSendOptions) {
|
||||
const source = options?.source ?? { kind: 'plugin', plugin: '' }
|
||||
session.append('user/message', {
|
||||
content,
|
||||
source,
|
||||
...options?.meta === undefined ? {} : { meta: options.meta },
|
||||
@@ -225,7 +226,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?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user