feat(goal): add model-facing goal tools
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { decodeGoalChange } from '@deepseek-ai/dsh-goal'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url))
|
||||
const configPath = fileURLToPath(new URL(
|
||||
'../../../../examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml',
|
||||
import.meta.url,
|
||||
))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
const PROCESS_TIMEOUT_MS = 30_000
|
||||
const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
|
||||
|
||||
let child: ChildProcessWithoutNullStreams | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
|
||||
child = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
async function jsonlFiles(dir: string): Promise<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
const paths = await Promise.all(entries.map(async (entry) => {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) return jsonlFiles(path)
|
||||
return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
|
||||
}))
|
||||
return paths.flat()
|
||||
}
|
||||
|
||||
async function runComposition(): Promise<{ stdout: string; stderr: string }> {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'goal-tools-e2e-'))
|
||||
const cwd = workdir
|
||||
return new Promise((resolve, reject) => {
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: binScript,
|
||||
configArgs: [configPath],
|
||||
tsconfigPath: repoTsconfig,
|
||||
exposeInternals: true,
|
||||
env: {
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
})
|
||||
const proc = spawn(launch.command, launch.args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...launch.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
child = proc
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let pauseSent = false
|
||||
let inputClosed = false
|
||||
proc.stdout.setEncoding('utf8')
|
||||
proc.stdout.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
if (!pauseSent && stdout.includes('GOAL CREATED') && stdout.includes('\n> ')) {
|
||||
pauseSent = true
|
||||
proc.stdin.write('pause\n')
|
||||
}
|
||||
if (!inputClosed && stdout.includes('GOAL PAUSED')) {
|
||||
inputClosed = true
|
||||
proc.stdin.end()
|
||||
}
|
||||
})
|
||||
proc.stderr.setEncoding('utf8')
|
||||
proc.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill('SIGKILL')
|
||||
reject(new Error(
|
||||
`goal-tools e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`,
|
||||
))
|
||||
}, PROCESS_TIMEOUT_MS)
|
||||
proc.on('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (code === 0) resolve({ stdout, stderr })
|
||||
else reject(new Error(`goal-tools e2e exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
})
|
||||
proc.on('error', (error) => { clearTimeout(timer); reject(error) })
|
||||
proc.stdin.write('start\n')
|
||||
})
|
||||
}
|
||||
|
||||
describe('goal tools through a real Loader, app, and stdio process', () => {
|
||||
it('creates, reads, and pauses one root goal with durable tool and state records', async () => {
|
||||
const { stdout, stderr } = await runComposition()
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
expect(stdout).toContain('goal-tools e2e ready.')
|
||||
expect(stdout).toContain('GOAL CREATED')
|
||||
expect(stdout).toContain('GOAL PAUSED')
|
||||
|
||||
const logs = await jsonlFiles(join(workdir as string, '.sessions'))
|
||||
expect(logs).toHaveLength(1)
|
||||
const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
|
||||
const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
|
||||
const calls = events.filter(event => event.type === 'tool/call')
|
||||
expect(calls.map(event => event.data.name)).toEqual(['create_goal', 'get_goal', 'update_goal'])
|
||||
const results = events.filter(event => event.type === 'tool/result')
|
||||
expect(results).toHaveLength(3)
|
||||
expect(results.every(event => !event.data.isError)).toBe(true)
|
||||
|
||||
const changes = events
|
||||
.filter(event => event.type === 'context/message' && event.data.source.kind === 'goal')
|
||||
.map(event => event.type === 'context/message' ? decodeGoalChange(event.data.meta) : undefined)
|
||||
expect(changes.map(change => change?.operation)).toEqual(['create', 'pause'])
|
||||
expect(changes[1]).toMatchObject({ goal: { phase: 'paused', revision: 2, maxGoalRounds: 7 } })
|
||||
expect(JSON.stringify(changes)).not.toContain('activation')
|
||||
|
||||
const headers = events.filter(event => event.type === 'request/header')
|
||||
expect(JSON.stringify(headers)).toContain('infer goal intent')
|
||||
expect(JSON.stringify(headers)).toContain('at least 3 consecutive goal rounds')
|
||||
}, TEST_TIMEOUT_MS)
|
||||
})
|
||||
@@ -0,0 +1,374 @@
|
||||
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 GoalService, { GoalId } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef } from '@deepseek-ai/dsh-goal'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import * as toolGoal from '@deepseek-ai/dsh-tool-goal'
|
||||
|
||||
interface StubAgent {
|
||||
readonly agent: Agent
|
||||
readonly session: Session
|
||||
setStatus(status: AgentStatus): void
|
||||
}
|
||||
|
||||
/** Build one registry-compatible live agent whose injections append in place. */
|
||||
function stubAgent(rawId: string): StubAgent {
|
||||
const session = new Session(SessionId(rawId))
|
||||
let status: AgentStatus = 'running'
|
||||
const agent: Agent = {
|
||||
id: session.id,
|
||||
options: {},
|
||||
session,
|
||||
get status() { return status },
|
||||
ctx: new Context(),
|
||||
send() {},
|
||||
steer() {},
|
||||
inject(content: ContentBlock[], options?: InjectOptions) {
|
||||
const source = options?.source ?? { kind: 'user' }
|
||||
session.append('context/message', {
|
||||
content,
|
||||
source,
|
||||
...options?.envelope === undefined ? {} : { envelope: options.envelope },
|
||||
...options?.meta === undefined ? {} : { meta: options.meta },
|
||||
}, { surfaceOp: 'append' })
|
||||
},
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
return { agent, session, setStatus(value) { status = value } }
|
||||
}
|
||||
|
||||
/** Open one message-triggered turn with its accepted model-visible input. */
|
||||
function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): number {
|
||||
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('user/message', {
|
||||
content: [{ type: 'text', text }],
|
||||
source,
|
||||
}, { surfaceOp: 'append' })
|
||||
return turn
|
||||
}
|
||||
|
||||
/** Close the currently open test turn. */
|
||||
function closeTurn(stub: StubAgent, turn: number): void {
|
||||
stub.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
async function harness(config: toolGoal.Config = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(GoalService)
|
||||
const fiber = await ctx.plugin(toolGoal, config)
|
||||
const root = stubAgent(`goal-tool-root-${Math.random()}`)
|
||||
ctx.agents.register(root.agent)
|
||||
return { ctx, fiber, root }
|
||||
}
|
||||
|
||||
/** Execute one registered tool under an optional driver initiator. */
|
||||
async function execute(
|
||||
ctx: Context,
|
||||
name: string,
|
||||
args: unknown,
|
||||
agent?: Agent,
|
||||
initiator: Agent | undefined = agent,
|
||||
): Promise<ToolExecutionResult> {
|
||||
const run = () => ctx.tools.execute({
|
||||
callId: CallId(`call-${Math.random()}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...agent === undefined ? {} : { agent },
|
||||
})
|
||||
return initiator === undefined ? run() : ctx.agents.withInitiator(initiator, run)
|
||||
}
|
||||
|
||||
/** Parse the compact JSON returned by a successful goal tool. */
|
||||
function resultJson(result: ToolExecutionResult): Record<string, unknown> {
|
||||
expect(result.isError).toBe(false)
|
||||
const block = result.content[0]
|
||||
if (block?.type !== 'text') throw new Error('expected text tool result')
|
||||
return JSON.parse(block.text) as Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Read the returned goal sub-object. */
|
||||
function resultGoal(result: ToolExecutionResult): Record<string, unknown> {
|
||||
const goal = resultJson(result)['goal']
|
||||
if (typeof goal !== 'object' || goal === null) throw new Error('expected returned goal')
|
||||
return goal as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('goal tool registration and presentation', () => {
|
||||
it('registers three exclusive tools plus configured guidance and disposes all contributions', async () => {
|
||||
const { ctx, fiber } = await harness({ blockedAfterConsecutiveRounds: 5 })
|
||||
expect(['create_goal', 'get_goal', 'update_goal'].map(name => ctx.tools.get(name)?.name))
|
||||
.toEqual(['create_goal', 'get_goal', 'update_goal'])
|
||||
for (const name of ['create_goal', 'get_goal', 'update_goal']) {
|
||||
expect(ctx.tools.executionMode({ callId: CallId(name), name, arguments: {} }))
|
||||
.toEqual({ kind: 'exclusive' })
|
||||
}
|
||||
const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal')
|
||||
expect(section?.text).toContain('infer goal intent')
|
||||
expect(section?.text).toContain('at least 5 consecutive goal rounds')
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.get('get_goal')).toBeUndefined()
|
||||
expect((await ctx.systemPrompt.assemble()).sections.some(item => item.name === 'tool:goal')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses args-only generic render intent and soft-fails malformed replay args', async () => {
|
||||
const { ctx } = await harness()
|
||||
expect(ctx.tools.get('get_goal')?.presentCall?.({})).toEqual({
|
||||
card: 'generic', title: 'Read current goal', kind: 'read',
|
||||
})
|
||||
expect(ctx.tools.get('create_goal')?.presentCall?.({ objective: 'ship' })).toEqual({
|
||||
card: 'generic', title: 'Create goal', kind: 'other', rawInput: 'ship',
|
||||
})
|
||||
expect(ctx.tools.get('update_goal')?.presentCall?.({
|
||||
goal_id: 'goal-1', revision: 2, action: 'blocked',
|
||||
})).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'goal-1' })
|
||||
expect(ctx.tools.get('update_goal')?.presentCall?.({
|
||||
goal_id: 'goal-1', revision: 2, action: 'resume',
|
||||
})).toEqual({ card: 'generic', title: 'Resume goal', kind: 'other', rawInput: 'goal-1' })
|
||||
expect(ctx.tools.get('update_goal')?.presentCall?.({ wrong: true })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('has the Loader-safe namespace export shape', () => {
|
||||
expect('default' in toolGoal).toBe(false)
|
||||
expect(toolGoal.name).toBe('tool-goal')
|
||||
expect(toolGoal.inject).toEqual(['agents', 'goals', 'tools', 'systemPrompt'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
expect(loader.unwrapExports(toolGoal)).toBe(toolGoal)
|
||||
})
|
||||
|
||||
it('fails invalid direct config before registering anything', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(GoalService)
|
||||
expect(() => {
|
||||
toolGoal.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 })
|
||||
}).toThrow(
|
||||
'blockedAfterConsecutiveRounds must be a positive safe integer',
|
||||
)
|
||||
expect(ctx.tools.get('get_goal')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves the direct-apply default before registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(GoalService)
|
||||
toolGoal.apply(ctx, {})
|
||||
const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal')
|
||||
expect(section?.text).toContain('at least 3 consecutive goal rounds')
|
||||
})
|
||||
})
|
||||
|
||||
describe('goal tool execution authority', () => {
|
||||
it('lets a root model infer create intent from its accepted human turn', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
openTurn(root, { kind: 'user' }, '请持续工作直到这个功能完成')
|
||||
const result = await execute(ctx, 'create_goal', {
|
||||
objective: 'Finish the feature', max_goal_rounds: 9,
|
||||
}, root.agent)
|
||||
expect(resultGoal(result)).toMatchObject({
|
||||
objective: 'Finish the feature', revision: 1, phase: 'active', maxGoalRounds: 9,
|
||||
})
|
||||
expect(resultJson(result)['activation']).toBe('armed')
|
||||
expect(ctx.goals.get(root.agent)?.objective).toBe('Finish the feature')
|
||||
})
|
||||
|
||||
it('rejects agentless, driverless, non-human, and live-child creation', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const agentless = await execute(ctx, 'get_goal', {})
|
||||
expect(agentless.error?.code).toBe('GOAL_TOOL_AGENT_REQUIRED')
|
||||
|
||||
openTurn(root, { kind: 'user' })
|
||||
const driverless = await ctx.tools.execute({
|
||||
callId: CallId('call-driverless'),
|
||||
name: 'get_goal',
|
||||
arguments: {},
|
||||
agent: root.agent,
|
||||
})
|
||||
expect(driverless.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
closeTurn(root, 1)
|
||||
|
||||
openTurn(root, { kind: 'plugin', plugin: 'test' })
|
||||
const nonHuman = await execute(ctx, 'create_goal', { objective: 'forged' }, root.agent)
|
||||
expect(nonHuman.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
|
||||
closeTurn(root, 2)
|
||||
|
||||
const child = stubAgent('goal-tool-child')
|
||||
ctx.agents.enter(child.agent, root.agent)
|
||||
ctx.agents.announce(child.agent)
|
||||
openTurn(child, { kind: 'user' })
|
||||
const childResult = await execute(ctx, 'create_goal', { objective: 'child goal' }, child.agent)
|
||||
expect(childResult.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
|
||||
})
|
||||
|
||||
it('rejects calls before a turn and after its end boundary', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const before = await execute(ctx, 'get_goal', {}, root.agent)
|
||||
expect(before.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
|
||||
const turn = openTurn(root, { kind: 'user' })
|
||||
closeTurn(root, turn)
|
||||
const after = await execute(ctx, 'get_goal', {}, root.agent)
|
||||
expect(after.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
})
|
||||
|
||||
it('rejects terminal reporting without human input or a current goal round', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
openTurn(root, { kind: 'plugin', plugin: 'test' })
|
||||
const result = await execute(ctx, 'update_goal', {
|
||||
goal_id: 'goal-missing', revision: 1, action: 'complete',
|
||||
}, root.agent)
|
||||
expect(result.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
|
||||
})
|
||||
|
||||
it('accepts direct human steering in a goal-sourced root turn', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const humanTurn = openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'steer me' })
|
||||
closeTurn(root, humanTurn)
|
||||
const round = openTurn(root, {
|
||||
kind: 'goal', goalId: created.id, revision: created.revision, round: 1,
|
||||
})
|
||||
root.session.append('steering/message', {
|
||||
turn: round,
|
||||
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',
|
||||
}, root.agent)
|
||||
expect(resultGoal(paused)).toMatchObject({ phase: 'paused', revision: 2 })
|
||||
})
|
||||
|
||||
it('rejects an initiator different from exec.agent', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const other = stubAgent('goal-tool-other')
|
||||
ctx.agents.register(other.agent)
|
||||
openTurn(other, { kind: 'user' })
|
||||
const result = await execute(ctx, 'get_goal', {}, other.agent, root.agent)
|
||||
expect(result.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
})
|
||||
})
|
||||
|
||||
describe('goal tool state transitions', () => {
|
||||
it('reads null, then edits, pauses, and resumes by exact revision in one human turn', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
openTurn(root, { kind: 'user' })
|
||||
expect(resultJson(await execute(ctx, 'get_goal', {}, root.agent))).toEqual({ goal: null })
|
||||
let goal = resultGoal(await execute(ctx, 'create_goal', { objective: 'old' }, root.agent))
|
||||
goal = resultGoal(await execute(ctx, 'update_goal', {
|
||||
goal_id: goal['id'], revision: goal['revision'], action: 'edit',
|
||||
objective: 'new', max_goal_rounds: 8,
|
||||
}, root.agent))
|
||||
expect(goal).toMatchObject({ objective: 'new', revision: 2, maxGoalRounds: 8 })
|
||||
goal = resultGoal(await execute(ctx, 'update_goal', {
|
||||
goal_id: goal['id'], revision: goal['revision'], action: 'pause',
|
||||
}, root.agent))
|
||||
expect(goal).toMatchObject({ phase: 'paused', revision: 3 })
|
||||
goal = resultGoal(await execute(ctx, 'update_goal', {
|
||||
goal_id: goal['id'], revision: goal['revision'], action: 'resume',
|
||||
}, root.agent))
|
||||
expect(goal).toMatchObject({ phase: 'active', revision: 4 })
|
||||
})
|
||||
|
||||
it('rearms a restored active goal only after a new direct human prompt', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
let turn = openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'continue later' })
|
||||
closeTurn(root, turn)
|
||||
agentEvents(ctx, root.agent).emit('agent/session-start', 'resume')
|
||||
expect(ctx.goals.get(root.agent)?.activation).toBe('disarmed')
|
||||
turn = openTurn(root, { kind: 'user' }, '继续')
|
||||
const resumed = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'resume',
|
||||
}, root.agent)
|
||||
expect(resultGoal(resumed)).toMatchObject({ phase: 'active', revision: 2 })
|
||||
expect(resultJson(resumed)['activation']).toBe('armed')
|
||||
closeTurn(root, turn)
|
||||
})
|
||||
|
||||
it('returns structured domain and conditional-argument failures', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
openTurn(root, { kind: 'user' })
|
||||
const invalidCreate = await execute(ctx, 'create_goal', { objective: ' ' }, root.agent)
|
||||
expect(invalidCreate.error?.code).toBe('GOAL_INVALID_OBJECTIVE')
|
||||
const created = ctx.goals.create(root.agent, { objective: 'valid' })
|
||||
const replacement = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id,
|
||||
revision: created.revision,
|
||||
action: 'pause',
|
||||
objective: 'not valid for pause',
|
||||
}, root.agent)
|
||||
expect(replacement.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const malformedRef = await execute(ctx, 'update_goal', {
|
||||
goal_id: '', revision: 0, action: 'edit', objective: 'x',
|
||||
}, root.agent)
|
||||
expect(malformedRef.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
})
|
||||
|
||||
it('allows exact goal rounds to complete but not edit or pause', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const humanTurn = openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'round-owned' })
|
||||
closeTurn(root, humanTurn)
|
||||
openTurn(root, { kind: 'goal', goalId: created.id, revision: created.revision, round: 1 })
|
||||
const edit = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'edit', objective: 'forbidden',
|
||||
}, root.agent)
|
||||
expect(edit.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
|
||||
const complete = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'complete',
|
||||
}, root.agent)
|
||||
expect(resultGoal(complete)).toMatchObject({ phase: 'complete', revision: 2, roundsStarted: 1 })
|
||||
})
|
||||
|
||||
it('enforces the configured model self-block lower bound across admitted rounds', async () => {
|
||||
const { ctx, root } = await harness({ blockedAfterConsecutiveRounds: 3 })
|
||||
let turn = openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'blocked eventually' })
|
||||
closeTurn(root, turn)
|
||||
const ref: GoalRef = { id: GoalId(created.id), revision: created.revision }
|
||||
|
||||
for (let round = 1; round <= 2; round += 1) {
|
||||
turn = openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round })
|
||||
const result = await execute(ctx, 'update_goal', {
|
||||
goal_id: ref.id, revision: ref.revision, action: 'blocked',
|
||||
}, root.agent)
|
||||
expect(result.error?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD')
|
||||
closeTurn(root, turn)
|
||||
}
|
||||
openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 3 })
|
||||
const blocked = await execute(ctx, 'update_goal', {
|
||||
goal_id: ref.id, revision: ref.revision, action: 'blocked',
|
||||
}, root.agent)
|
||||
expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked', roundsStarted: 3 })
|
||||
})
|
||||
|
||||
it('lets direct human authority block before the model threshold', async () => {
|
||||
const { ctx, root } = await harness({ blockedAfterConsecutiveRounds: 9 })
|
||||
openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'human stop' })
|
||||
const blocked = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'blocked',
|
||||
}, root.agent)
|
||||
expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked', roundsStarted: 0 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user