refactor: identify and freeze messages at creation
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, {} from '@deepseek-ai/dsh-agent'
|
||||
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 { Session, SessionId, type UserMessageData } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
|
||||
|
||||
interface Harness {
|
||||
@@ -17,7 +17,7 @@ interface Harness {
|
||||
}
|
||||
|
||||
/** Append one idle injection using the public Agent contract. */
|
||||
function appendInjection(session: Session, input: UserMessageData): void {
|
||||
function appendInjection(session: Session, input: UserMessage): void {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
@@ -32,10 +32,10 @@ function stubAgent(id: string): { agent: Agent; session: Session } {
|
||||
ctx: new Context(),
|
||||
get status() { return status },
|
||||
get acceptsNextStep() { return status === 'running' },
|
||||
send: () => AgentMessageId('stub'),
|
||||
followup: () => AgentMessageId('stub'),
|
||||
steer: () => AgentMessageId('stub'),
|
||||
inject(input) { appendInjection(session, input); return AgentMessageId('stub') },
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input) { appendInjection(session, input) },
|
||||
cancel() { status = 'idle' },
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { FiberState } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { GoalMessageSource, GoalRef, GoalView } from '@deepseek-ai/dsh-goal'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { classifyGoalRound } from './outcome.ts'
|
||||
@@ -226,7 +226,7 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
state.attempt = reservation
|
||||
try {
|
||||
agent.followup({ content: content, source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round } })
|
||||
agent.followup(createUserMessage({ content, source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round } }))
|
||||
} catch (error: unknown) {
|
||||
state.attempt = undefined
|
||||
ctx.logger.warn(`goal-session: could not queue round ${round} for agent "${agent.id}": ${renderThrown(error)}`)
|
||||
@@ -407,7 +407,8 @@ export function apply(ctx: Context): void {
|
||||
&& source.round === goal.roundsStarted + 1
|
||||
}
|
||||
|
||||
ctx.on('agent/prompt-submit', async (agent, content, source, _signal, next): Promise<PromptDecision> => {
|
||||
ctx.on('agent/prompt-submit', async (agent, message, _signal, next): Promise<PromptDecision> => {
|
||||
const { content, source } = message
|
||||
if (!isGoalRoundSource(source)) return next()
|
||||
const state = stateFor(agent)
|
||||
let valid = false
|
||||
|
||||
@@ -6,7 +6,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import GoalService, { foldGoal, GoalId } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalView } from '@deepseek-ai/dsh-goal'
|
||||
import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
@@ -254,7 +254,7 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('maps a downstream prompt veto to blocked without admitting the round', async () => {
|
||||
const test = await harness([])
|
||||
test.ctx.on('agent/prompt-submit', (_agent, _content, source, _signal, next) => source.kind === 'goal'
|
||||
test.ctx.on('agent/prompt-submit', (_agent, message, _signal, next) => message.source.kind === 'goal'
|
||||
? Promise.resolve({ kind: 'block', reason: 'deployment policy' })
|
||||
: next())
|
||||
test.ctx.goals.create(test.agent, { objective: 'respect policy' })
|
||||
@@ -269,11 +269,11 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('does not reserve again when a stopped-goal observer queues ordinary work', async () => {
|
||||
const test = await harness([textResponse('human follow-up')])
|
||||
test.ctx.on('agent/prompt-submit', (_agent, _content, source, _signal, next) => source.kind === 'goal'
|
||||
test.ctx.on('agent/prompt-submit', (_agent, message, _signal, next) => message.source.kind === 'goal'
|
||||
? Promise.resolve({ kind: 'block', reason: 'stop this round' })
|
||||
: next())
|
||||
test.ctx.on('goal/changed', (agent, change) => {
|
||||
if (change.operation === 'block') agent.followup({ content: [{ type: 'text', text: 'inspect the blocker' }], source: { kind: 'user' } })
|
||||
if (change.operation === 'block') agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the blocker' }], source: { kind: 'user' } }))
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'stop and inspect' })
|
||||
|
||||
@@ -324,7 +324,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.followup({ content: [{ type: 'text', text: 'human goes first' }], source: { kind: 'user' } })
|
||||
test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human goes first' }], source: { kind: 'user' } }))
|
||||
|
||||
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
|
||||
|
||||
@@ -365,7 +365,7 @@ describe('same-session goal driving', () => {
|
||||
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return
|
||||
inserted = true
|
||||
agent.followup({ content: [{ type: 'text', text: 'human joined the pending batch' }], source: { kind: 'user' } })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human joined the pending batch' }], source: { kind: 'user' } }))
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'yield to nested human input', maxGoalRounds: 1 })
|
||||
|
||||
@@ -402,8 +402,8 @@ describe('same-session goal driving', () => {
|
||||
it('rechecks revision after downstream prompt hooks before admitting', async () => {
|
||||
const test = await harness([textResponse('new revision')])
|
||||
let edited = false
|
||||
test.ctx.on('agent/prompt-submit', (agent, _content, source, _signal, next) => {
|
||||
if (source.kind === 'goal' && !edited) {
|
||||
test.ctx.on('agent/prompt-submit', (agent, message, _signal, next) => {
|
||||
if (message.source.kind === 'goal' && !edited) {
|
||||
edited = true
|
||||
const current = test.ctx.goals.get(agent)
|
||||
if (current === undefined) throw new Error('missing goal during prompt edit')
|
||||
@@ -509,8 +509,8 @@ describe('same-session goal driving', () => {
|
||||
// attempt through cancel-requested) and THEN throws: the catch finds no
|
||||
// matching reservation and must not reschedule a paused goal.
|
||||
let fired = false
|
||||
test.ctx.on('agent/prompt-submit', async (agent, _content, source, _signal, next) => {
|
||||
if (source.kind === 'goal' && !fired) {
|
||||
test.ctx.on('agent/prompt-submit', async (agent, message, _signal, next) => {
|
||||
if (message.source.kind === 'goal' && !fired) {
|
||||
fired = true
|
||||
agent.cancel({ kind: 'user' })
|
||||
throw new Error('hook cancelled then exploded')
|
||||
@@ -533,8 +533,8 @@ describe('same-session goal driving', () => {
|
||||
// Registered after goal-session's own listener: the throw propagates back
|
||||
// through goal-session's next() await, dropping the whole admission.
|
||||
let threw = false
|
||||
test.ctx.on('agent/prompt-submit', async (_agent, _content, source, _signal, next) => {
|
||||
if (source.kind === 'goal' && !threw) {
|
||||
test.ctx.on('agent/prompt-submit', async (_agent, message, _signal, next) => {
|
||||
if (message.source.kind === 'goal' && !threw) {
|
||||
threw = true
|
||||
throw new Error('downstream admission hook exploded')
|
||||
}
|
||||
@@ -567,7 +567,7 @@ describe('same-session goal driving', () => {
|
||||
// is not yet reserved: the retry trigger must not adopt or clear
|
||||
// anything (the attempt is absent), and the goal proceeds normally.
|
||||
test.ctx.goals.create(test.agent, { objective: 'ignore foreign retries', maxGoalRounds: 1 })
|
||||
test.agent.followup({ content: [{ type: 'text', text: 'human work' }], source: { kind: 'user' } })
|
||||
test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human work' }], source: { kind: 'user' } }))
|
||||
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
|
||||
expect(goal?.blockedReason?.code).toBe('round-limit')
|
||||
@@ -583,7 +583,7 @@ describe('same-session goal driving', () => {
|
||||
if (input.source.kind === 'goal') {
|
||||
throw new Error('queue rejected')
|
||||
}
|
||||
return realFollowup(input)
|
||||
realFollowup(input)
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'handle queue failure' })
|
||||
|
||||
@@ -605,7 +605,7 @@ describe('same-session goal driving', () => {
|
||||
test.ctx.goals.disarm(test.agent)
|
||||
throw new Error('queue rejected after disarm')
|
||||
}
|
||||
return realFollowup(input)
|
||||
realFollowup(input)
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' })
|
||||
|
||||
@@ -680,8 +680,8 @@ describe('same-session goal driving', () => {
|
||||
it('fails a post-hook read closed before the prompt can enter history', async () => {
|
||||
const test = await harness([])
|
||||
let armed = true
|
||||
test.ctx.on('agent/prompt-submit', (_agent, _content, source, _signal, next) => {
|
||||
if (source.kind === 'goal' && armed) {
|
||||
test.ctx.on('agent/prompt-submit', (_agent, message, _signal, next) => {
|
||||
if (message.source.kind === 'goal' && armed) {
|
||||
armed = false
|
||||
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
|
||||
throw new Error('post-hook projection failed')
|
||||
@@ -699,7 +699,7 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('blocks forged goal attribution without touching an absent reservation', async () => {
|
||||
const test = await harness([])
|
||||
test.agent.followup({ content: [{ type: 'text', text: 'forged automatic work' }], source: { kind: 'goal', goalId: GoalId('forged-goal'), revision: 1, round: 1 } })
|
||||
test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'forged automatic work' }], source: { kind: 'goal', goalId: GoalId('forged-goal'), revision: 1, round: 1 } }))
|
||||
await test.agent.whenIdle()
|
||||
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
@@ -708,7 +708,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.followup({ content: [{ type: 'text', text: 'cancel ordinary work' }], source: { kind: 'user' } })
|
||||
test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'cancel ordinary work' }], source: { kind: 'user' } }))
|
||||
test.agent.cancel({ kind: 'user' })
|
||||
await test.agent.whenIdle()
|
||||
|
||||
@@ -718,7 +718,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.followup({ content: [{ type: 'text', text: 'inspect something first' }], source: { kind: 'user' } })
|
||||
test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect something first' }], source: { kind: 'user' } }))
|
||||
await waitForRequests(test.adapter, 1)
|
||||
const created = test.ctx.goals.create(test.agent, { objective: 'continue after inspection' })
|
||||
|
||||
@@ -755,8 +755,8 @@ describe('same-session goal driving', () => {
|
||||
it('blocks admission when downstream cancellation clears the reservation', async () => {
|
||||
const test = await harness([])
|
||||
let cancelled = false
|
||||
test.ctx.on('agent/prompt-submit', (agent, _content, source, _signal, next) => {
|
||||
if (source.kind === 'goal' && !cancelled) {
|
||||
test.ctx.on('agent/prompt-submit', (agent, message, _signal, next) => {
|
||||
if (message.source.kind === 'goal' && !cancelled) {
|
||||
cancelled = true
|
||||
agent.cancel({ kind: 'user' })
|
||||
}
|
||||
@@ -824,8 +824,8 @@ describe('same-session goal driving', () => {
|
||||
it('leaves a queued reservation pending when the driver runs before its turn settles', async () => {
|
||||
const test = await harness([textResponse('settled later')])
|
||||
let woken = false
|
||||
test.ctx.on('agent/prompt-submit', async (_agent, _content, source, _signal, next) => {
|
||||
if (source.kind === 'goal' && !woken) {
|
||||
test.ctx.on('agent/prompt-submit', async (_agent, message, _signal, next) => {
|
||||
if (message.source.kind === 'goal' && !woken) {
|
||||
woken = true
|
||||
// A concurrent driver pass must observe the still-unsettled attempt
|
||||
// and yield rather than double-book or clear the reservation.
|
||||
@@ -890,7 +890,7 @@ describe('same-session goal driving', () => {
|
||||
sessionId: SessionId('goal-session-retired'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
handle.agent.followup({ content: [{ type: 'text', text: 'one ordinary turn' }], source: { kind: 'user' } })
|
||||
handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'one ordinary turn' }], source: { kind: 'user' } }))
|
||||
await handle.agent.whenIdle()
|
||||
const closed = handle.agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
if (closed?.type !== 'turn/end') throw new Error('expected a closed turn')
|
||||
@@ -911,7 +911,7 @@ describe('same-session goal driving', () => {
|
||||
if (event.type === 'turn/start' && event.data.trigger.kind === 'message'
|
||||
&& event.data.trigger.source.kind === 'goal') {
|
||||
queued = true
|
||||
test.agent.followup({ content: [{ type: 'text', text: 'human interleaved' }], source: { kind: 'user' } })
|
||||
test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human interleaved' }], source: { kind: 'user' } }))
|
||||
}
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'survive a stale failure', maxGoalRounds: 1 })
|
||||
@@ -929,7 +929,7 @@ describe('same-session goal driving', () => {
|
||||
const test = await harness(['hang', textResponse('inspection answer')])
|
||||
test.ctx.on('goal/changed', (agent, change) => {
|
||||
if (agent === test.agent && change.operation === 'pause') {
|
||||
agent.followup({ content: [{ type: 'text', text: 'inspect the pause' }], source: { kind: 'user' } })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the pause' }], source: { kind: 'user' } }))
|
||||
}
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'pause then inspect' })
|
||||
@@ -950,8 +950,8 @@ describe('same-session goal driving', () => {
|
||||
it('does not re-block a goal the downstream veto already saw cancelled', async () => {
|
||||
const test = await harness([])
|
||||
let vetoed = false
|
||||
test.ctx.on('agent/prompt-submit', (agent, _content, source, _signal, next) => {
|
||||
if (source.kind === 'goal' && !vetoed) {
|
||||
test.ctx.on('agent/prompt-submit', (agent, message, _signal, next) => {
|
||||
if (message.source.kind === 'goal' && !vetoed) {
|
||||
vetoed = true
|
||||
agent.cancel({ kind: 'user' })
|
||||
return Promise.resolve<PromptDecision>({ kind: 'block', reason: 'cancelled by policy' })
|
||||
@@ -973,8 +973,8 @@ describe('same-session goal driving', () => {
|
||||
it('awaits an unadmitted reservation stuck in admission during teardown without cancelling', async () => {
|
||||
const test = await harness([])
|
||||
let release: (() => void) | undefined
|
||||
test.ctx.on('agent/prompt-submit', async (_agent, _content, source, _signal, next) => {
|
||||
if (source.kind === 'goal' && release === undefined) {
|
||||
test.ctx.on('agent/prompt-submit', async (_agent, message, _signal, next) => {
|
||||
if (message.source.kind === 'goal' && release === undefined) {
|
||||
await new Promise<void>((resolve) => { release = resolve })
|
||||
}
|
||||
return next()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import {
|
||||
@@ -41,17 +42,19 @@ function view(roundsStarted: number): GoalView {
|
||||
|
||||
function appendChange(session: Session): void {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
function appendRound(session: Session, turn: number, content = renderGoalRoundPrompt(view(turn - 2), turn - 1)): void {
|
||||
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: turn - 1 } as const
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source } })
|
||||
session.append('user/message', { content, source }, { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content, source,
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
@@ -80,19 +83,19 @@ describe('goal-session prompt invariants', () => {
|
||||
|
||||
const userSource = { kind: 'user' } as const
|
||||
session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: userSource } })
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'ordinary human message' }],
|
||||
source: userSource,
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 4, reason: { kind: 'completed' } })
|
||||
|
||||
const stateSource = { ...changeSource, round: 0 } as const
|
||||
session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: stateSource } })
|
||||
expect(() => {
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'round zero is not a driver continuation' }],
|
||||
source: stateSource,
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -114,10 +117,10 @@ describe('goal-session prompt invariants', () => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } })
|
||||
|
||||
expect(() => {
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalRoundPrompt(view(0), 1),
|
||||
source,
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
|
||||
packageName: '@deepseek-ai/dsh-goal-session',
|
||||
}))
|
||||
@@ -126,10 +129,10 @@ 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('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'counterfeit goal state' }],
|
||||
source: changeSource,
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
appendRound(session, 2)
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
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 } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
applyGoalChange,
|
||||
@@ -490,10 +491,10 @@ export class GoalService extends Service {
|
||||
const pending: PendingGoalChange = { change, activation, applied: false }
|
||||
cache.pending.push(pending)
|
||||
try {
|
||||
agent.inject({
|
||||
agent.inject(createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change },
|
||||
})
|
||||
}))
|
||||
} catch (error: unknown) {
|
||||
const index = cache.pending.indexOf(pending)
|
||||
/* v8 ignore next -- a committed goal append cannot reject after its contained observers run */
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, type UserMessageData } from '@deepseek-ai/dsh-session'
|
||||
import { createUserMessage, HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import GoalService, {
|
||||
GoalError,
|
||||
GoalId,
|
||||
@@ -13,7 +13,7 @@ import GoalService, {
|
||||
} from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
|
||||
|
||||
type DeferredInjection = UserMessageData
|
||||
type DeferredInjection = UserMessage
|
||||
|
||||
interface StubAgent {
|
||||
agent: Agent
|
||||
@@ -30,7 +30,7 @@ function nextTurn(session: Session): number {
|
||||
}
|
||||
|
||||
/** Mirror the public Agent.inject contract for domain tests. */
|
||||
function appendInjection(session: Session, input: UserMessageData): void {
|
||||
function appendInjection(session: Session, input: UserMessage): void {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
@@ -47,13 +47,12 @@ function stubAgentForSession(session: Session): StubAgent {
|
||||
ctx: new Context(),
|
||||
get status() { return status },
|
||||
get acceptsNextStep() { return status === 'running' },
|
||||
send: () => AgentMessageId('stub'),
|
||||
followup: () => AgentMessageId('stub'),
|
||||
steer: () => AgentMessageId('stub'),
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input) {
|
||||
if (shouldDefer) deferred.push(input)
|
||||
else appendInjection(session, input)
|
||||
return AgentMessageId('stub')
|
||||
},
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
@@ -90,7 +89,9 @@ function appendRound(session: Session, ref: GoalRef, round: number): void {
|
||||
const source = { kind: 'goal', goalId: ref.id, revision: ref.revision, round } as const
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: `round ${round}` }], source }, { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `round ${round}` }], source,
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
@@ -421,7 +422,9 @@ describe('GoalService mutations', () => {
|
||||
expect(deferred).toHaveLength(3)
|
||||
expect(session.events).toHaveLength(0)
|
||||
|
||||
appendInjection(session, { content: [{ type: 'text', text: 'unrelated' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
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)
|
||||
@@ -457,7 +460,7 @@ describe('GoalService mutations', () => {
|
||||
let reject = true
|
||||
stub.agent.inject = (input) => {
|
||||
if (reject) throw new Error('injection rejected')
|
||||
return append(input)
|
||||
append(input)
|
||||
}
|
||||
ctx.agents.register(stub.agent)
|
||||
|
||||
@@ -501,9 +504,9 @@ describe('GoalService mutations', () => {
|
||||
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change } as const
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(change), source,
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
|
||||
expect(ctx.goals.get(agent)).toMatchObject({
|
||||
@@ -531,15 +534,17 @@ describe('GoalService mutations', () => {
|
||||
createdAt: 12,
|
||||
updatedAt: 12,
|
||||
}
|
||||
appendInjection(session, { content: renderGoalChange(change),
|
||||
appendInjection(session, createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change },
|
||||
})
|
||||
appendInjection(session, { content: [{ type: 'text', text: 'corrupt' }],
|
||||
}))
|
||||
appendInjection(session, createUserMessage({
|
||||
content: [{ type: 'text', text: 'corrupt' }],
|
||||
source: {
|
||||
kind: 'goal', goalId: change.goal.id, revision: 2, round: 0,
|
||||
change: { ...change, operation: 'edit', extra: true } as never,
|
||||
},
|
||||
})
|
||||
}))
|
||||
|
||||
expect(() => ctx.goals.get(agent)).toThrow('invalid shape')
|
||||
expect(() => ctx.goals.get(agent)).toThrow('invalid shape')
|
||||
@@ -580,10 +585,10 @@ describe('goal replay validation', () => {
|
||||
}
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: overrides.content ?? renderGoalChange(change),
|
||||
source,
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
@@ -628,14 +633,17 @@ describe('goal replay validation', () => {
|
||||
expect(decodeGoalChange(undefined)).toBeUndefined()
|
||||
expect(decodeGoalChange({ kind: 'other' })).toBeUndefined()
|
||||
const session = new Session(SessionId('unrelated'))
|
||||
appendInjection(session, { content: [{ type: 'text', text: 'other' }],
|
||||
appendInjection(session, createUserMessage({
|
||||
content: [{ type: 'text', text: 'other' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
}))
|
||||
expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 })
|
||||
const source = { kind: 'plugin', plugin: 'ordinary-user-message' } as const
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'ordinary' }], source }, { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'ordinary' }], source,
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 })
|
||||
})
|
||||
@@ -775,9 +783,9 @@ 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('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'missing' }], source,
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
expect(() => foldGoal(session.events)).toThrow('lacks source change data')
|
||||
})
|
||||
@@ -843,9 +851,9 @@ describe('goal replay validation', () => {
|
||||
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, trigger: { kind: 'injection', source } })
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(clear), source,
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
expect(foldGoal(session.events)).toEqual({
|
||||
roundsStarted: 0,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import {
|
||||
@@ -46,10 +47,10 @@ 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('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
@@ -59,10 +60,10 @@ describe('goal stream invariants', () => {
|
||||
},
|
||||
})
|
||||
expect(() => {
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'continue' }],
|
||||
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 },
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -71,20 +72,20 @@ 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('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'counterfeit' }],
|
||||
source: changeSource,
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-goal',
|
||||
}))
|
||||
expect(session.seq).toBe(1)
|
||||
expect(() => {
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -93,10 +94,10 @@ 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('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
@@ -109,10 +110,10 @@ describe('goal stream invariants', () => {
|
||||
},
|
||||
})
|
||||
expect(() => {
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'continue after load' }],
|
||||
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 },
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,8 +70,8 @@ export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolE
|
||||
function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean {
|
||||
if (!ctx.agents.roots().includes(execution.agent)) return false
|
||||
return execution.events.some(event =>
|
||||
(event.type === 'user/message' || event.type === 'steering/message')
|
||||
&& event.data.source.kind === 'user')
|
||||
(event.type === 'user/message' && event.data.source.kind === 'user')
|
||||
|| (event.type === 'steering/message' && event.data.message.source.kind === 'user'))
|
||||
}
|
||||
|
||||
/** Whether this turn is the current goal's exact admitted round. */
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import AgentRegistry, { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus } 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'
|
||||
import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -32,12 +32,11 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
|
||||
get status() { return status },
|
||||
get acceptsNextStep() { return status === 'running' },
|
||||
ctx: new Context(),
|
||||
send: () => AgentMessageId('stub'),
|
||||
followup: () => AgentMessageId('stub'),
|
||||
steer: () => AgentMessageId('stub'),
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
return AgentMessageId('stub')
|
||||
},
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
@@ -51,10 +50,10 @@ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): numb
|
||||
.filter(event => event.type === 'turn/start')
|
||||
.reduce((max, event) => Math.max(max, event.data.turn), 0) + 1
|
||||
stub.session.append('turn/start', { turn, trigger: { kind: 'message', source } })
|
||||
stub.session.append('user/message', {
|
||||
stub.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source,
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
return turn
|
||||
}
|
||||
|
||||
@@ -304,8 +303,10 @@ describe('goal tool execution authority', () => {
|
||||
})
|
||||
root.session.append('steering/message', {
|
||||
turn: round,
|
||||
content: [{ type: 'text', text: 'pause now' }],
|
||||
source: { kind: 'user' },
|
||||
message: createUserMessage({
|
||||
content: [{ type: 'text', text: 'pause now' }],
|
||||
source: { kind: 'user' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
const paused = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'pause',
|
||||
|
||||
Reference in New Issue
Block a user