refactor(agent-loop): simplify message machine
This commit is contained in:
@@ -20,7 +20,7 @@ interface Harness {
|
||||
function appendInjection(session: Session, input: UserMessage): void {
|
||||
const lastStart = session.events.findLast(event => event.type === 'turn/start')
|
||||
const turn = (lastStart?.data.turn ?? 0) + 1
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source: input.source } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
@@ -37,7 +37,6 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session }
|
||||
ctx: new Context(),
|
||||
get status() { return status },
|
||||
get acceptsNextStep() { return status === 'running' },
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input) { appendInjection(session, input) },
|
||||
|
||||
@@ -9,7 +9,7 @@ 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 { createUserMessage, assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { classifyGoalRound } from './outcome.ts'
|
||||
import type { GoalRoundOutcome } from './outcome.ts'
|
||||
@@ -33,6 +33,7 @@ interface RoundIdentity {
|
||||
|
||||
/** One queued or admitted attempt, retained until its physical turn settles. */
|
||||
interface RoundAttempt extends RoundIdentity {
|
||||
readonly messageId: MessageId
|
||||
readonly content: ContentBlock[]
|
||||
phase: 'queued' | 'admitted'
|
||||
turn: number | undefined
|
||||
@@ -214,10 +215,15 @@ export function apply(ctx: Context): void {
|
||||
|
||||
const round = goal.roundsStarted + 1
|
||||
const content = renderGoalRoundPrompt(goal, round)
|
||||
const message = createUserMessage({
|
||||
content,
|
||||
source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round },
|
||||
})
|
||||
const reservation: RoundAttempt = {
|
||||
goalId: goal.id,
|
||||
revision: goal.revision,
|
||||
round,
|
||||
messageId: message.id,
|
||||
content,
|
||||
phase: 'queued',
|
||||
turn: undefined,
|
||||
@@ -226,7 +232,7 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
state.attempt = reservation
|
||||
try {
|
||||
agent.followup(createUserMessage({ content, source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round } }))
|
||||
agent.followup(message)
|
||||
} catch (error: unknown) {
|
||||
state.attempt = undefined
|
||||
ctx.logger.warn(`goal-session: could not queue round ${round} for agent "${agent.id}": ${renderThrown(error)}`)
|
||||
@@ -277,9 +283,8 @@ export function apply(ctx: Context): void {
|
||||
})
|
||||
}
|
||||
|
||||
// One composite effect owns every listener and the quiescent close. Cordis
|
||||
// unloads sibling effects concurrently; nesting makes the close run first
|
||||
// and keeps the admission fence installed until its drain settles.
|
||||
// One composite effect keeps the admission fence installed until this
|
||||
// plugin's own scheduling tasks settle.
|
||||
ctx.effect(function* () {
|
||||
/** Mark a post-turn persistence failure before idle scheduling can run. */
|
||||
ctx.on('agent/error', (agent, turn) => {
|
||||
@@ -304,40 +309,21 @@ export function apply(ctx: Context): void {
|
||||
const state = stateFor(agent)
|
||||
if (status === 'idle') {
|
||||
state.competingQueued = false
|
||||
const attempt = state.attempt
|
||||
const goal = currentGoal(state)
|
||||
if (attempt !== undefined && attempt.turn === undefined && attempt.reason === undefined
|
||||
&& goal?.phase === 'active' && goal.activation === 'armed') {
|
||||
state.attempt = undefined
|
||||
try {
|
||||
applyOutcome(state, goal, { kind: 'pause', reason: 'cancelled' })
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`)
|
||||
disarm(state)
|
||||
}
|
||||
}
|
||||
requestDrive(state)
|
||||
}
|
||||
})
|
||||
ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
const state = stateFor(agent)
|
||||
const attempt = state.attempt
|
||||
if (attempt !== undefined && sameQueued(info.content, info.source, attempt)) return
|
||||
state.competingQueued = true
|
||||
if (attempt?.phase === 'queued') attempt.stale = true
|
||||
})
|
||||
ctx.on('agent/cancel-requested', (agent, cause) => {
|
||||
const state = stateFor(agent)
|
||||
const attempt = state.attempt
|
||||
state.competingQueued = false
|
||||
const goal = currentGoal(state)
|
||||
if (goal?.phase === 'active' && goal.activation === 'armed') {
|
||||
if (attempt === undefined) {
|
||||
disarm(state)
|
||||
return
|
||||
}
|
||||
// An admitted round closes durably as aborted; retain it so the normal
|
||||
// turn outcome path appends pause after cancellation reaches idle.
|
||||
// Pausing here would stage context into the active outbox only for this
|
||||
// same cancel() call to discard it.
|
||||
if (attempt.turn !== undefined || attempt.phase === 'admitted') return
|
||||
state.attempt = undefined
|
||||
try {
|
||||
applyOutcome(state, goal, { kind: 'pause', reason: cause.kind })
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`)
|
||||
disarm(state)
|
||||
}
|
||||
}
|
||||
})
|
||||
ctx.on('goal/changed', (agent) => {
|
||||
const state = stateFor(agent)
|
||||
state.needsCheckpoint = true
|
||||
@@ -349,35 +335,22 @@ export function apply(ctx: Context): void {
|
||||
if (agent === undefined || agent.session !== session) return
|
||||
const state = stateFor(agent)
|
||||
switch (event.type) {
|
||||
case 'turn/start':
|
||||
case 'agent/inbox/added': {
|
||||
const attempt = state.attempt
|
||||
const { content, source } = event.data
|
||||
if (attempt !== undefined && sameQueued(content, source, attempt)) return
|
||||
state.competingQueued = true
|
||||
if (attempt?.phase === 'queued') attempt.stale = true
|
||||
return
|
||||
}
|
||||
case 'turn/start': {
|
||||
state.openTurn = event.data.turn
|
||||
switch (event.data.trigger.kind) {
|
||||
case 'message':
|
||||
if (state.attempt !== undefined && isGoalRoundSource(event.data.trigger.source)
|
||||
&& sameRound(event.data.trigger.source, state.attempt)) {
|
||||
state.attempt.turn = event.data.turn
|
||||
}
|
||||
return
|
||||
case 'retry':
|
||||
// A recovery policy (llm-retry) closed the round's failed turn
|
||||
// and reopened its history: the attempt rides the retry turn,
|
||||
// and the failed turn's provisional reason no longer settles
|
||||
// the round — the retry's own outcome does.
|
||||
if (state.attempt !== undefined && state.attempt.reason !== undefined
|
||||
&& state.attempt.reason.kind === 'error') {
|
||||
state.attempt.turn = event.data.turn
|
||||
state.attempt.reason = undefined
|
||||
}
|
||||
return
|
||||
default:
|
||||
// Injection and merge-extensible plugin triggers cannot admit a queued goal message.
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'user/message':
|
||||
if (state.attempt !== undefined && isGoalRoundSource(event.data.source)
|
||||
&& sameRound(event.data.source, state.attempt)) {
|
||||
if (state.attempt !== undefined && event.data.id === state.attempt.messageId) {
|
||||
state.attempt.phase = 'admitted'
|
||||
/* v8 ignore next -- this driver's admitted message always follows its observed turn/start */
|
||||
/* v8 ignore next -- the loop logs admitted input inside an open turn */
|
||||
if (state.openTurn !== undefined) state.attempt.turn = state.openTurn
|
||||
}
|
||||
return
|
||||
@@ -407,8 +380,10 @@ export function apply(ctx: Context): void {
|
||||
&& source.round === goal.roundsStarted + 1
|
||||
}
|
||||
|
||||
ctx.on('agent/prompt-submit', async (agent, message, _signal, next): Promise<PromptDecision> => {
|
||||
const { content, source } = message
|
||||
ctx.on('agent/prompt-submit', async (agent, messages, _signal, next): Promise<PromptDecision> => {
|
||||
const submitted = messages.find(message => isGoalRoundSource(message.source))
|
||||
if (submitted === undefined) return next()
|
||||
const { content, source } = submitted
|
||||
if (!isGoalRoundSource(source)) return next()
|
||||
const state = stateFor(agent)
|
||||
let valid = false
|
||||
@@ -494,7 +469,6 @@ export function apply(ctx: Context): void {
|
||||
if (attempt.phase === 'admitted' && state.agent.status === 'running') {
|
||||
state.agent.cancel({ kind: 'parent' })
|
||||
}
|
||||
waits.push(state.agent.whenIdle())
|
||||
}
|
||||
if (state.run !== undefined) waits.push(state.run)
|
||||
}
|
||||
|
||||
@@ -28,15 +28,17 @@ export function classifyGoalRound(reason: TurnEndReason, durable: boolean): Goal
|
||||
case 'aborted':
|
||||
return { kind: 'pause', reason: 'cancelled' }
|
||||
case 'error': {
|
||||
const { code, message } = reason.failure ?? reason
|
||||
const error = reason.error
|
||||
const code = typeof error === 'object' && error !== null && 'code' in error
|
||||
? error.code
|
||||
: undefined
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return code === 'RATE_LIMIT' || code === 'QUOTA'
|
||||
? { kind: 'blocked', code: 'usage-limited', message }
|
||||
: { kind: 'blocked', code: 'turn-error', message }
|
||||
}
|
||||
case 'max-tokens':
|
||||
return { kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' }
|
||||
case 'disposed':
|
||||
return { kind: 'disarm', reason: 'disposed' }
|
||||
case 'interrupted':
|
||||
return { kind: 'disarm', reason: 'interrupted' }
|
||||
// TurnEndReason is merge-extensible. An unknown producer cannot opt into
|
||||
|
||||
@@ -12,13 +12,6 @@ import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import * as goalSession from '../src/index.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface TurnTriggerMap {
|
||||
/** Test-only plugin turn with no message source. */
|
||||
'test-metadata': { kind: 'test-metadata' }
|
||||
}
|
||||
}
|
||||
|
||||
type ScriptEntry = StreamChunk[] | Error | 'hang' | ((options: GenerateOptions) => StreamChunk[])
|
||||
|
||||
/** Small request-recording adapter with controllable failure and cancellation. */
|
||||
@@ -334,31 +327,6 @@ describe('same-session goal driving', () => {
|
||||
expect(requestText(test.adapter.requests[1]!)).toContain('<goal_round>')
|
||||
})
|
||||
|
||||
it('ignores plugin-owned turn triggers while a goal round is queued', async () => {
|
||||
const test = await harness([textResponse('goal answer')])
|
||||
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/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')
|
||||
const turn = (lastStart?.data.turn ?? 0) + 1
|
||||
agent.session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'test-metadata' },
|
||||
})
|
||||
agent.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'ignore metadata', maxGoalRounds: 1 })
|
||||
|
||||
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
|
||||
|
||||
expect(inserted).toBe(true)
|
||||
expect(test.adapter.requests).toHaveLength(1)
|
||||
expect(warnings.some(warning => warning.includes('session/event listener threw'))).toBe(false)
|
||||
})
|
||||
|
||||
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
|
||||
@@ -908,8 +876,7 @@ describe('same-session goal driving', () => {
|
||||
let queued = false
|
||||
test.ctx.on('session/event', (session, event) => {
|
||||
if (session !== test.agent.session || queued) return
|
||||
if (event.type === 'turn/start' && event.data.trigger.kind === 'message'
|
||||
&& event.data.trigger.source.kind === 'goal') {
|
||||
if (event.type === 'user/message' && event.data.source.kind === 'goal') {
|
||||
queued = true
|
||||
test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human interleaved' }], source: { kind: 'user' } }))
|
||||
}
|
||||
@@ -997,7 +964,6 @@ describe('same-session goal driving', () => {
|
||||
const orphan = test.ctx.sessions.create(SessionId('goal-session-orphan'))
|
||||
orphan.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } },
|
||||
})
|
||||
orphan.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ function view(roundsStarted: number): GoalView {
|
||||
}
|
||||
|
||||
function appendChange(session: Session): void {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
@@ -51,7 +51,7 @@ function appendChange(session: Session): void {
|
||||
|
||||
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('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content, source,
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -82,7 +82,7 @@ describe('goal-session prompt invariants', () => {
|
||||
ctx.sessions.create(SessionId('goal-session-invariant-dispatch'))
|
||||
|
||||
const userSource = { kind: 'user' } as const
|
||||
session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: userSource } })
|
||||
session.append('turn/start', { turn: 4 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'ordinary human message' }],
|
||||
source: userSource,
|
||||
@@ -90,7 +90,7 @@ describe('goal-session prompt invariants', () => {
|
||||
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 } })
|
||||
session.append('turn/start', { turn: 5 })
|
||||
expect(() => {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'round zero is not a driver continuation' }],
|
||||
@@ -114,7 +114,7 @@ describe('goal-session prompt invariants', () => {
|
||||
it('rejects a goal round without a reconstructable active goal', async () => {
|
||||
const { session } = await mount()
|
||||
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 } as const
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
|
||||
expect(() => {
|
||||
session.append('user/message', createUserMessage({
|
||||
@@ -128,7 +128,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('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'counterfeit goal state' }],
|
||||
source: changeSource,
|
||||
|
||||
@@ -47,7 +47,6 @@ function stubAgentForSession(session: Session): StubAgent {
|
||||
ctx: new Context(),
|
||||
get status() { return status },
|
||||
get acceptsNextStep() { return status === 'running' },
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input) {
|
||||
@@ -88,7 +87,7 @@ async function harness(config: { defaultMaxGoalRounds?: number } = {}) {
|
||||
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('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `round ${round}` }], source,
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -503,7 +502,7 @@ 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('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(change), source,
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -584,7 +583,7 @@ describe('goal replay validation', () => {
|
||||
change,
|
||||
}
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: overrides.content ?? renderGoalChange(change),
|
||||
source,
|
||||
@@ -640,7 +639,7 @@ describe('goal replay validation', () => {
|
||||
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('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'ordinary' }], source,
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -782,7 +781,7 @@ describe('goal replay validation', () => {
|
||||
const session = new Session(SessionId('goal-source-without-meta'))
|
||||
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('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'missing' }], source,
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -850,7 +849,7 @@ 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('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(clear), source,
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('goal stream invariants', () => {
|
||||
it('accepts canonical goal snapshots and sequential admitted rounds', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('goal-invariant-valid'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
@@ -70,7 +70,7 @@ describe('goal stream invariants', () => {
|
||||
it('rejects model-visible drift before committing it and keeps the fold reusable', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('goal-invariant-invalid'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
expect(() => {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'counterfeit' }],
|
||||
@@ -93,7 +93,7 @@ describe('goal stream invariants', () => {
|
||||
const ctx = new Context()
|
||||
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('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
|
||||
@@ -38,7 +38,6 @@ function liveAgent(ctx: Context, session: Session): Agent {
|
||||
ctx,
|
||||
get status() { return status },
|
||||
get acceptsNextStep() { return false },
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input: UserMessage) {
|
||||
@@ -145,7 +144,7 @@ describe('goal projection unit', () => {
|
||||
|
||||
// A non-message event (the registry drives EVERY committed event through
|
||||
// apply): early same-reference return.
|
||||
const turnStart = { type: 'turn/start', seq: 3, time: 4, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never
|
||||
const turnStart = { type: 'turn/start', seq: 3, time: 4, data: { turn: 1 } } as never
|
||||
expect(applyGoalProjection(state, turnStart)).toBe(state)
|
||||
|
||||
// A round-zero goal source whose change carries a foreign kind: same posture.
|
||||
|
||||
@@ -32,7 +32,6 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
|
||||
get status() { return status },
|
||||
get acceptsNextStep() { return status === 'running' },
|
||||
ctx: new Context(),
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input) {
|
||||
@@ -49,7 +48,7 @@ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): numb
|
||||
const turn = stub.session.events
|
||||
.filter(event => event.type === 'turn/start')
|
||||
.reduce((max, event) => Math.max(max, event.data.turn), 0) + 1
|
||||
stub.session.append('turn/start', { turn, trigger: { kind: 'message', source } })
|
||||
stub.session.append('turn/start', { turn })
|
||||
stub.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source,
|
||||
|
||||
Reference in New Issue
Block a user