@open-mercato/ai-assistant 0.6.1-develop.3291.1.6fad645fd0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +30 -4
- package/dist/frontend/components/AiChatButton.js +3 -2
- package/dist/frontend/components/AiChatButton.js.map +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js +364 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +7 -7
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js +182 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.js +316 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +8 -7
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/chat/route.js +43 -20
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/settings/route.js +4 -3
- package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/usage/daily/route.js +111 -0
- package/dist/modules/ai_assistant/api/usage/daily/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/usage/sessions/[sessionId]/route.js +108 -0
- package/dist/modules/ai_assistant/api/usage/sessions/[sessionId]/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/usage/sessions/route.js +153 -0
- package/dist/modules/ai_assistant/api/usage/sessions/route.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +335 -38
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +2 -7
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +44 -35
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.js +282 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.js +25 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/cli.js +12 -0
- package/dist/modules/ai_assistant/cli.js.map +2 -2
- package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js.map +1 -1
- package/dist/modules/ai_assistant/data/entities.js +177 -1
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +104 -2
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiTokenUsageRepository.js +168 -0
- package/dist/modules/ai_assistant/data/repositories/AiTokenUsageRepository.js.map +7 -0
- package/dist/modules/ai_assistant/events.js +8 -0
- package/dist/modules/ai_assistant/events.js.map +2 -2
- package/dist/modules/ai_assistant/i18n/de.json +74 -1
- package/dist/modules/ai_assistant/i18n/en.json +74 -1
- package/dist/modules/ai_assistant/i18n/es.json +75 -2
- package/dist/modules/ai_assistant/i18n/pl.json +74 -1
- package/dist/modules/ai_assistant/lib/agent-policy.js.map +2 -2
- package/dist/modules/ai_assistant/lib/agent-runtime.js +588 -23
- package/dist/modules/ai_assistant/lib/agent-runtime.js.map +3 -3
- package/dist/modules/ai_assistant/lib/agent-tools.js +6 -1
- package/dist/modules/ai_assistant/lib/agent-tools.js.map +2 -2
- package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
- package/dist/modules/ai_assistant/lib/model-factory.js +63 -22
- package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
- package/dist/modules/ai_assistant/lib/token-usage-recorder.js +78 -0
- package/dist/modules/ai_assistant/lib/token-usage-recorder.js.map +7 -0
- package/dist/modules/ai_assistant/lib/usage-serialization.js +33 -0
- package/dist/modules/ai_assistant/lib/usage-serialization.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.js +25 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.js +88 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.js.map +7 -0
- package/dist/modules/ai_assistant/setup.js +34 -0
- package/dist/modules/ai_assistant/setup.js.map +2 -2
- package/dist/modules/ai_assistant/workers/ai-token-usage-prune.js +114 -0
- package/dist/modules/ai_assistant/workers/ai-token-usage-prune.js.map +7 -0
- package/generated/entities/ai_agent_runtime_override/index.ts +7 -0
- package/generated/entities/ai_token_usage_daily/index.ts +16 -0
- package/generated/entities/ai_token_usage_event/index.ts +19 -0
- package/generated/entities.ids.generated.ts +2 -0
- package/generated/entity-fields-registry.ts +47 -1
- package/package.json +15 -7
- package/src/frontend/components/AiChatButton.tsx +3 -2
- package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.ts +521 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +8 -8
- package/src/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.ts +231 -0
- package/src/modules/ai_assistant/__tests__/events.test.ts +4 -3
- package/src/modules/ai_assistant/__tests__/settings-page-logic.test.ts +5 -5
- package/src/modules/ai_assistant/__tests__/token-usage-recorder.test.ts +109 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.ts +388 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +5 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +8 -7
- package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +102 -5
- package/src/modules/ai_assistant/api/ai/chat/route.ts +55 -18
- package/src/modules/ai_assistant/api/settings/route.ts +5 -3
- package/src/modules/ai_assistant/api/usage/daily/__tests__/route.test.ts +159 -0
- package/src/modules/ai_assistant/api/usage/daily/route.ts +126 -0
- package/src/modules/ai_assistant/api/usage/sessions/[sessionId]/__tests__/route.test.ts +143 -0
- package/src/modules/ai_assistant/api/usage/sessions/[sessionId]/route.ts +130 -0
- package/src/modules/ai_assistant/api/usage/sessions/__tests__/route.test.ts +123 -0
- package/src/modules/ai_assistant/api/usage/sessions/route.ts +184 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +372 -16
- package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +1 -4
- package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +26 -9
- package/src/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.tsx +469 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.ts +23 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/usage/page.tsx +12 -0
- package/src/modules/ai_assistant/cli.ts +18 -0
- package/src/modules/ai_assistant/components/AiAssistantSettingsPageClient.tsx +1 -1
- package/src/modules/ai_assistant/data/entities.ts +237 -0
- package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +135 -3
- package/src/modules/ai_assistant/data/repositories/AiTokenUsageRepository.ts +213 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentRuntimeOverrideRepository.test.ts +223 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiTokenUsageRepository.test.ts +58 -0
- package/src/modules/ai_assistant/events.ts +8 -0
- package/src/modules/ai_assistant/i18n/de.json +74 -1
- package/src/modules/ai_assistant/i18n/en.json +74 -1
- package/src/modules/ai_assistant/i18n/es.json +75 -2
- package/src/modules/ai_assistant/i18n/pl.json +74 -1
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase0.test.ts +439 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase1.test.ts +243 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase2.test.ts +388 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase3.test.ts +359 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-phase4a.test.ts +2 -2
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +2 -1
- package/src/modules/ai_assistant/lib/__tests__/max-steps-budget.integration.test.ts +12 -13
- package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +77 -14
- package/src/modules/ai_assistant/lib/agent-policy.ts +9 -0
- package/src/modules/ai_assistant/lib/agent-runtime.ts +1148 -43
- package/src/modules/ai_assistant/lib/agent-tools.ts +5 -1
- package/src/modules/ai_assistant/lib/ai-agent-definition.ts +289 -2
- package/src/modules/ai_assistant/lib/model-factory.ts +128 -43
- package/src/modules/ai_assistant/lib/token-usage-recorder.ts +122 -0
- package/src/modules/ai_assistant/lib/usage-serialization.ts +29 -0
- package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +791 -0
- package/src/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.ts +25 -0
- package/src/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.ts +89 -0
- package/src/modules/ai_assistant/setup.ts +49 -0
- package/src/modules/ai_assistant/workers/__tests__/ai-token-usage-prune.test.ts +144 -0
- package/src/modules/ai_assistant/workers/ai-token-usage-prune.ts +188 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 0 unit tests for the agentic loop control surface.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - resolveEffectiveLoopConfig — precedence chain (caller > agent.loop > legacyMaxSteps > wrapper default)
|
|
6
|
+
* - translateStopConditions — mapping of AiAgentLoopStopCondition to SDK helpers + hard stepCountIs fallback
|
|
7
|
+
* - mergeStepOverrides — security-critical tool-allowlist enforcement
|
|
8
|
+
* - assertLoopObjectModeCompatible — object-mode field rejection
|
|
9
|
+
*
|
|
10
|
+
* Phase 0 of spec 2026-04-28-ai-agents-agentic-loop-controls.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const stepCountIsMock = jest.fn((count: number) => ({ __kind: 'stepCount', count }))
|
|
14
|
+
const hasToolCallMock = jest.fn((name: string) => ({ __kind: 'hasToolCall', name }))
|
|
15
|
+
|
|
16
|
+
jest.mock('ai', () => {
|
|
17
|
+
const actual = jest.requireActual('ai')
|
|
18
|
+
return {
|
|
19
|
+
...actual,
|
|
20
|
+
stepCountIs: (count: number) => stepCountIsMock(count),
|
|
21
|
+
hasToolCall: (name: string) => hasToolCallMock(name),
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
import type { AiAgentDefinition } from '../ai-agent-definition'
|
|
26
|
+
import type { PrepareStepResult, ToolSet } from 'ai'
|
|
27
|
+
import {
|
|
28
|
+
resolveEffectiveLoopConfig,
|
|
29
|
+
translateStopConditions,
|
|
30
|
+
mergeStepOverrides,
|
|
31
|
+
buildWrapperPrepareStep,
|
|
32
|
+
assertLoopObjectModeCompatible,
|
|
33
|
+
} from '../agent-runtime'
|
|
34
|
+
import { AgentPolicyError } from '../agent-tools'
|
|
35
|
+
|
|
36
|
+
function makeAgent(
|
|
37
|
+
overrides: Partial<AiAgentDefinition> & Pick<AiAgentDefinition, 'id' | 'moduleId'>,
|
|
38
|
+
): AiAgentDefinition {
|
|
39
|
+
return {
|
|
40
|
+
label: `${overrides.id} label`,
|
|
41
|
+
description: `${overrides.id} description`,
|
|
42
|
+
systemPrompt: 'System prompt.',
|
|
43
|
+
allowedTools: [],
|
|
44
|
+
...overrides,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// resolveEffectiveLoopConfig
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
describe('resolveEffectiveLoopConfig', () => {
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
jest.clearAllMocks()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('returns wrapper default when agent has no loop config and no caller override', () => {
|
|
58
|
+
const agent = makeAgent({ id: 'mod.agent', moduleId: 'mod' })
|
|
59
|
+
const result = resolveEffectiveLoopConfig(agent, undefined, { maxSteps: 10 })
|
|
60
|
+
expect(result.maxSteps).toBe(10)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('uses legacy agent.maxSteps when agent.loop is absent', () => {
|
|
64
|
+
const agent = makeAgent({ id: 'mod.agent', moduleId: 'mod', maxSteps: 5 })
|
|
65
|
+
const result = resolveEffectiveLoopConfig(agent, undefined, { maxSteps: 10 })
|
|
66
|
+
expect(result.maxSteps).toBe(5)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('agent.loop wins over legacy maxSteps when both are present', () => {
|
|
70
|
+
const agent = makeAgent({
|
|
71
|
+
id: 'mod.agent',
|
|
72
|
+
moduleId: 'mod',
|
|
73
|
+
maxSteps: 5,
|
|
74
|
+
loop: { maxSteps: 8 },
|
|
75
|
+
})
|
|
76
|
+
const result = resolveEffectiveLoopConfig(agent, undefined, { maxSteps: 10 })
|
|
77
|
+
expect(result.maxSteps).toBe(8)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('caller loop override wins over agent.loop', () => {
|
|
81
|
+
const agent = makeAgent({
|
|
82
|
+
id: 'mod.agent',
|
|
83
|
+
moduleId: 'mod',
|
|
84
|
+
loop: { maxSteps: 8 },
|
|
85
|
+
})
|
|
86
|
+
const result = resolveEffectiveLoopConfig(agent, { maxSteps: 3 }, { maxSteps: 10 })
|
|
87
|
+
expect(result.maxSteps).toBe(3)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('caller loop preserves agent-level stopWhen when caller does not override it', () => {
|
|
91
|
+
const agentStop = { kind: 'hasToolCall' as const, toolName: 'mod.tool' }
|
|
92
|
+
const agent = makeAgent({
|
|
93
|
+
id: 'mod.agent',
|
|
94
|
+
moduleId: 'mod',
|
|
95
|
+
loop: { stopWhen: agentStop },
|
|
96
|
+
})
|
|
97
|
+
const result = resolveEffectiveLoopConfig(agent, { maxSteps: 3 }, { maxSteps: 10 })
|
|
98
|
+
expect(result.stopWhen).toEqual(agentStop)
|
|
99
|
+
expect(result.maxSteps).toBe(3)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('caller override replaces agent stopWhen when caller sets stopWhen', () => {
|
|
103
|
+
const agentStop = { kind: 'hasToolCall' as const, toolName: 'mod.tool' }
|
|
104
|
+
const callerStop = { kind: 'stepCount' as const, count: 2 }
|
|
105
|
+
const agent = makeAgent({
|
|
106
|
+
id: 'mod.agent',
|
|
107
|
+
moduleId: 'mod',
|
|
108
|
+
loop: { stopWhen: agentStop },
|
|
109
|
+
})
|
|
110
|
+
const result = resolveEffectiveLoopConfig(agent, { stopWhen: callerStop }, { maxSteps: 10 })
|
|
111
|
+
expect(result.stopWhen).toEqual(callerStop)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('legacy maxSteps is NOT applied when agent.loop is present (loop wins)', () => {
|
|
115
|
+
const agent = makeAgent({
|
|
116
|
+
id: 'mod.agent',
|
|
117
|
+
moduleId: 'mod',
|
|
118
|
+
maxSteps: 99,
|
|
119
|
+
loop: { maxSteps: 7 },
|
|
120
|
+
})
|
|
121
|
+
const result = resolveEffectiveLoopConfig(agent)
|
|
122
|
+
expect(result.maxSteps).toBe(7)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('returns wrapper default maxSteps when no source provides maxSteps', () => {
|
|
126
|
+
const agent = makeAgent({ id: 'mod.agent', moduleId: 'mod' })
|
|
127
|
+
const result = resolveEffectiveLoopConfig(agent, undefined, { maxSteps: 10 })
|
|
128
|
+
expect(result.maxSteps).toBe(10)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// translateStopConditions
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
describe('translateStopConditions', () => {
|
|
137
|
+
beforeEach(() => {
|
|
138
|
+
jest.clearAllMocks()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('always includes stepCountIs(maxSteps) as the final element', () => {
|
|
142
|
+
const result = translateStopConditions({ maxSteps: 5 })
|
|
143
|
+
expect(stepCountIsMock).toHaveBeenCalledWith(5)
|
|
144
|
+
expect(result).toHaveLength(1)
|
|
145
|
+
expect(result[0]).toEqual({ __kind: 'stepCount', count: 5 })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('defaults to maxSteps=10 when maxSteps is not set', () => {
|
|
149
|
+
translateStopConditions({})
|
|
150
|
+
expect(stepCountIsMock).toHaveBeenCalledWith(10)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('maps kind:stepCount to stepCountIs', () => {
|
|
154
|
+
const result = translateStopConditions({
|
|
155
|
+
maxSteps: 10,
|
|
156
|
+
stopWhen: { kind: 'stepCount', count: 3 },
|
|
157
|
+
})
|
|
158
|
+
expect(stepCountIsMock).toHaveBeenCalledWith(3)
|
|
159
|
+
expect(result).toHaveLength(2)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('maps kind:hasToolCall to hasToolCall', () => {
|
|
163
|
+
const result = translateStopConditions(
|
|
164
|
+
{
|
|
165
|
+
maxSteps: 10,
|
|
166
|
+
stopWhen: { kind: 'hasToolCall', toolName: 'mod.update' },
|
|
167
|
+
},
|
|
168
|
+
(name) => name.replace(/\./g, '__'),
|
|
169
|
+
)
|
|
170
|
+
expect(hasToolCallMock).toHaveBeenCalledWith('mod__update')
|
|
171
|
+
expect(result).toHaveLength(2)
|
|
172
|
+
expect(result[0]).toEqual({ __kind: 'hasToolCall', name: 'mod__update' })
|
|
173
|
+
expect(result[1]).toEqual({ __kind: 'stepCount', count: 10 })
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('passes kind:custom predicates through as-is', () => {
|
|
177
|
+
const customStop = jest.fn(() => false) as unknown as import('ai').StopCondition<Record<string, unknown>>
|
|
178
|
+
const result = translateStopConditions({
|
|
179
|
+
maxSteps: 10,
|
|
180
|
+
stopWhen: { kind: 'custom', stop: customStop },
|
|
181
|
+
})
|
|
182
|
+
expect(result[0]).toBe(customStop)
|
|
183
|
+
expect(result).toHaveLength(2)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('handles an array of stopWhen conditions', () => {
|
|
187
|
+
const result = translateStopConditions({
|
|
188
|
+
maxSteps: 4,
|
|
189
|
+
stopWhen: [
|
|
190
|
+
{ kind: 'hasToolCall', toolName: 'mod.a' },
|
|
191
|
+
{ kind: 'hasToolCall', toolName: 'mod.b' },
|
|
192
|
+
],
|
|
193
|
+
})
|
|
194
|
+
expect(hasToolCallMock).toHaveBeenCalledWith('mod.a')
|
|
195
|
+
expect(hasToolCallMock).toHaveBeenCalledWith('mod.b')
|
|
196
|
+
expect(result).toHaveLength(3)
|
|
197
|
+
expect(result[2]).toEqual({ __kind: 'stepCount', count: 4 })
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// mergeStepOverrides
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
describe('mergeStepOverrides', () => {
|
|
206
|
+
beforeEach(() => {
|
|
207
|
+
jest.clearAllMocks()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const agent = makeAgent({
|
|
211
|
+
id: 'mod.agent',
|
|
212
|
+
moduleId: 'mod',
|
|
213
|
+
allowedTools: ['mod.read', 'mod.write'],
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
const wrappedRead = { execute: jest.fn(), description: 'read tool' }
|
|
217
|
+
const wrappedWrite = { execute: jest.fn(), description: 'write tool', isMutation: true }
|
|
218
|
+
const wrappedRegistry = {
|
|
219
|
+
mod__read: wrappedRead,
|
|
220
|
+
mod__write: wrappedWrite,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
it('returns wrapperOverride unchanged when userOverride is null', () => {
|
|
224
|
+
const wrapper: PrepareStepResult<ToolSet> = { activeTools: ['mod__read'] }
|
|
225
|
+
expect(mergeStepOverrides(wrapper, null, agent, wrappedRegistry)).toBe(wrapper)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('returns wrapperOverride unchanged when userOverride is undefined', () => {
|
|
229
|
+
const wrapper: PrepareStepResult<ToolSet> = { activeTools: ['mod__read'] }
|
|
230
|
+
expect(mergeStepOverrides(wrapper, undefined, agent, wrappedRegistry)).toBe(wrapper)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('merges model from userOverride', () => {
|
|
234
|
+
const fakeModel = { id: 'gpt-5-mini' } as unknown as import('ai').LanguageModel
|
|
235
|
+
const result = mergeStepOverrides({}, { model: fakeModel }, agent, wrappedRegistry)
|
|
236
|
+
expect(result.model).toBe(fakeModel)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('merges toolChoice from userOverride', () => {
|
|
240
|
+
const result = mergeStepOverrides({}, { toolChoice: 'none' }, agent, wrappedRegistry)
|
|
241
|
+
expect(result.toolChoice).toBe('none')
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('filters user activeTools to only those in agent.allowedTools (dotted names)', () => {
|
|
245
|
+
const result = mergeStepOverrides(
|
|
246
|
+
{},
|
|
247
|
+
{ activeTools: ['mod.read', 'mod.write', 'outside.tool'] },
|
|
248
|
+
agent,
|
|
249
|
+
wrappedRegistry,
|
|
250
|
+
)
|
|
251
|
+
expect(result.activeTools).toEqual(['mod.read', 'mod.write'])
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('accepts already-sanitized activeTools and normalizes them back to dotted contract names', () => {
|
|
255
|
+
const result = mergeStepOverrides(
|
|
256
|
+
{},
|
|
257
|
+
{ activeTools: ['mod__read', 'outside__tool'] },
|
|
258
|
+
agent,
|
|
259
|
+
wrappedRegistry,
|
|
260
|
+
)
|
|
261
|
+
expect(result.activeTools).toEqual(['mod.read'])
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('replaces user tools with wrapped counterparts from wrappedRegistry', () => {
|
|
265
|
+
const rawHandler = { execute: jest.fn() }
|
|
266
|
+
const result = mergeStepOverrides(
|
|
267
|
+
{},
|
|
268
|
+
{ tools: { mod__read: rawHandler } as unknown as PrepareStepResult<ToolSet>['tools'] },
|
|
269
|
+
agent,
|
|
270
|
+
wrappedRegistry,
|
|
271
|
+
)
|
|
272
|
+
expect((result.tools as Record<string, unknown>)['mod__read']).toBe(wrappedRead)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('drops user tools not present in wrappedRegistry with a warning', () => {
|
|
276
|
+
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
277
|
+
const rawHandler = { execute: jest.fn() }
|
|
278
|
+
const result = mergeStepOverrides(
|
|
279
|
+
{},
|
|
280
|
+
{ tools: { unknown__tool: rawHandler } as unknown as PrepareStepResult<ToolSet>['tools'] },
|
|
281
|
+
agent,
|
|
282
|
+
wrappedRegistry,
|
|
283
|
+
)
|
|
284
|
+
expect((result.tools as Record<string, unknown>)['unknown__tool']).toBeUndefined()
|
|
285
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
286
|
+
expect.stringContaining('unknown__tool'),
|
|
287
|
+
)
|
|
288
|
+
consoleSpy.mockRestore()
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('throws loop_violates_mutation_policy when user returns raw mutation handler', () => {
|
|
292
|
+
const { toolRegistry: registry } = jest.requireActual('../tool-registry') as {
|
|
293
|
+
toolRegistry: { getTool: (name: string) => unknown }
|
|
294
|
+
}
|
|
295
|
+
jest.spyOn(registry, 'getTool').mockImplementation((name: string) => {
|
|
296
|
+
if (name === 'mod.write') return { isMutation: true }
|
|
297
|
+
return undefined
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
const rawMutationHandler = { execute: jest.fn() }
|
|
301
|
+
expect(() =>
|
|
302
|
+
mergeStepOverrides(
|
|
303
|
+
{},
|
|
304
|
+
{ tools: { mod__write: rawMutationHandler } as unknown as PrepareStepResult<ToolSet>['tools'] },
|
|
305
|
+
agent,
|
|
306
|
+
wrappedRegistry,
|
|
307
|
+
),
|
|
308
|
+
).toThrow(AgentPolicyError)
|
|
309
|
+
|
|
310
|
+
jest.restoreAllMocks()
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
describe('buildWrapperPrepareStep', () => {
|
|
315
|
+
const agent = makeAgent({
|
|
316
|
+
id: 'mod.agent',
|
|
317
|
+
moduleId: 'mod',
|
|
318
|
+
allowedTools: ['mod.read', 'mod.write'],
|
|
319
|
+
})
|
|
320
|
+
const wrappedRegistry = {
|
|
321
|
+
mod__read: { execute: jest.fn(), description: 'read tool' },
|
|
322
|
+
mod__write: { execute: jest.fn(), description: 'write tool' },
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
it('maps dotted activeTools from user prepareStep to SDK-safe tool keys', async () => {
|
|
326
|
+
const prepareStep = buildWrapperPrepareStep(
|
|
327
|
+
agent,
|
|
328
|
+
{
|
|
329
|
+
prepareStep: async () => ({ activeTools: ['mod.read', 'mod.write'] }),
|
|
330
|
+
},
|
|
331
|
+
wrappedRegistry,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
const result = await prepareStep({
|
|
335
|
+
stepNumber: 0,
|
|
336
|
+
steps: [],
|
|
337
|
+
messages: [],
|
|
338
|
+
model: {} as never,
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
expect(result?.activeTools).toEqual(['mod__read', 'mod__write'])
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// assertLoopObjectModeCompatible
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
describe('assertLoopObjectModeCompatible', () => {
|
|
350
|
+
it('does not throw for object-safe loop fields', () => {
|
|
351
|
+
expect(() =>
|
|
352
|
+
assertLoopObjectModeCompatible({
|
|
353
|
+
maxSteps: 5,
|
|
354
|
+
budget: { maxTokens: 50000 },
|
|
355
|
+
onStepFinish: jest.fn(),
|
|
356
|
+
onStepStart: jest.fn(),
|
|
357
|
+
allowRuntimeOverride: true,
|
|
358
|
+
}),
|
|
359
|
+
).not.toThrow()
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('throws loop_unsupported_in_object_mode for prepareStep', () => {
|
|
363
|
+
expect(() =>
|
|
364
|
+
assertLoopObjectModeCompatible({ prepareStep: jest.fn() }),
|
|
365
|
+
).toThrow(AgentPolicyError)
|
|
366
|
+
try {
|
|
367
|
+
assertLoopObjectModeCompatible({ prepareStep: jest.fn() })
|
|
368
|
+
} catch (error) {
|
|
369
|
+
expect(error).toBeInstanceOf(AgentPolicyError)
|
|
370
|
+
expect((error as AgentPolicyError).code).toBe('loop_unsupported_in_object_mode')
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('throws for repairToolCall', () => {
|
|
375
|
+
expect(() =>
|
|
376
|
+
assertLoopObjectModeCompatible({ repairToolCall: jest.fn() }),
|
|
377
|
+
).toThrow(AgentPolicyError)
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('throws for stopWhen', () => {
|
|
381
|
+
expect(() =>
|
|
382
|
+
assertLoopObjectModeCompatible({ stopWhen: { kind: 'stepCount', count: 3 } }),
|
|
383
|
+
).toThrow(AgentPolicyError)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('throws for activeTools', () => {
|
|
387
|
+
expect(() =>
|
|
388
|
+
assertLoopObjectModeCompatible({ activeTools: ['mod.read'] }),
|
|
389
|
+
).toThrow(AgentPolicyError)
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('throws for toolChoice', () => {
|
|
393
|
+
expect(() =>
|
|
394
|
+
assertLoopObjectModeCompatible({ toolChoice: 'none' }),
|
|
395
|
+
).toThrow(AgentPolicyError)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('mentions all unsupported fields in the error message', () => {
|
|
399
|
+
try {
|
|
400
|
+
assertLoopObjectModeCompatible({ prepareStep: jest.fn(), stopWhen: { kind: 'stepCount', count: 2 } })
|
|
401
|
+
} catch (error) {
|
|
402
|
+
expect((error as Error).message).toContain('prepareStep')
|
|
403
|
+
expect((error as Error).message).toContain('stopWhen')
|
|
404
|
+
}
|
|
405
|
+
})
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// ai-agent-definition legacy maxSteps and loop field acceptance
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
describe('defineAiAgent loop field acceptance (Phase 0 BC)', () => {
|
|
413
|
+
it('legacy maxSteps is still accepted on AiAgentDefinition', () => {
|
|
414
|
+
const agent = makeAgent({ id: 'mod.agent', moduleId: 'mod', maxSteps: 5 })
|
|
415
|
+
expect(agent.maxSteps).toBe(5)
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it('loop field is accepted on AiAgentDefinition', () => {
|
|
419
|
+
const agent = makeAgent({
|
|
420
|
+
id: 'mod.agent',
|
|
421
|
+
moduleId: 'mod',
|
|
422
|
+
loop: { maxSteps: 7, stopWhen: { kind: 'hasToolCall', toolName: 'mod.update' } },
|
|
423
|
+
})
|
|
424
|
+
expect(agent.loop?.maxSteps).toBe(7)
|
|
425
|
+
const stopWhen = agent.loop?.stopWhen
|
|
426
|
+
expect(stopWhen).toEqual({ kind: 'hasToolCall', toolName: 'mod.update' })
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('loop and maxSteps can coexist (loop wins in resolveEffectiveLoopConfig)', () => {
|
|
430
|
+
const agent = makeAgent({
|
|
431
|
+
id: 'mod.agent',
|
|
432
|
+
moduleId: 'mod',
|
|
433
|
+
maxSteps: 99,
|
|
434
|
+
loop: { maxSteps: 4 },
|
|
435
|
+
})
|
|
436
|
+
const result = resolveEffectiveLoopConfig(agent)
|
|
437
|
+
expect(result.maxSteps).toBe(4)
|
|
438
|
+
})
|
|
439
|
+
})
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 1 unit tests for per-call loop overrides on runAiAgentText /
|
|
3
|
+
* runAiAgentObject.
|
|
4
|
+
*
|
|
5
|
+
* Covers:
|
|
6
|
+
* - Caller override is accepted when allowRuntimeOverride is true (default).
|
|
7
|
+
* - AgentPolicyError loop_runtime_override_disabled when agent opts out.
|
|
8
|
+
* - Object-mode loop subset: chat-only fields throw loop_unsupported_in_object_mode.
|
|
9
|
+
* - resolveEffectiveLoopConfig caller precedence with gating.
|
|
10
|
+
*
|
|
11
|
+
* Phase 1 of spec 2026-04-28-ai-agents-agentic-loop-controls.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const stepCountIsMock = jest.fn((count: number) => ({ __kind: 'stepCount', count }))
|
|
15
|
+
|
|
16
|
+
jest.mock('ai', () => {
|
|
17
|
+
const actual = jest.requireActual('ai')
|
|
18
|
+
return {
|
|
19
|
+
...actual,
|
|
20
|
+
stepCountIs: (count: number) => stepCountIsMock(count),
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
import type { AiAgentDefinition, AiAgentLoopConfig } from '../ai-agent-definition'
|
|
25
|
+
import {
|
|
26
|
+
resolveEffectiveLoopConfig,
|
|
27
|
+
assertLoopObjectModeCompatible,
|
|
28
|
+
} from '../agent-runtime'
|
|
29
|
+
import { AgentPolicyError } from '../agent-tools'
|
|
30
|
+
|
|
31
|
+
function makeAgent(
|
|
32
|
+
overrides: Partial<AiAgentDefinition> & Pick<AiAgentDefinition, 'id' | 'moduleId'>,
|
|
33
|
+
): AiAgentDefinition {
|
|
34
|
+
return {
|
|
35
|
+
label: `${overrides.id} label`,
|
|
36
|
+
description: `${overrides.id} description`,
|
|
37
|
+
systemPrompt: 'System prompt.',
|
|
38
|
+
allowedTools: [],
|
|
39
|
+
...overrides,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Per-call loop override gating
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
describe('Phase 1: caller loop override gating (loop_runtime_override_disabled)', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
jest.clearAllMocks()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('accepts per-call loop override when agent has no allowRuntimeOverride set (default true)', () => {
|
|
53
|
+
const agent = makeAgent({ id: 'mod.agent', moduleId: 'mod' })
|
|
54
|
+
expect(() =>
|
|
55
|
+
resolveEffectiveLoopConfig(agent, { maxSteps: 3 }),
|
|
56
|
+
).not.toThrow()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('accepts per-call loop override when agent explicitly sets allowRuntimeOverride: true', () => {
|
|
60
|
+
const agent = makeAgent({
|
|
61
|
+
id: 'mod.agent',
|
|
62
|
+
moduleId: 'mod',
|
|
63
|
+
loop: { allowRuntimeOverride: true, maxSteps: 8 },
|
|
64
|
+
})
|
|
65
|
+
expect(() =>
|
|
66
|
+
resolveEffectiveLoopConfig(agent, { maxSteps: 3 }),
|
|
67
|
+
).not.toThrow()
|
|
68
|
+
const result = resolveEffectiveLoopConfig(agent, { maxSteps: 3 })
|
|
69
|
+
expect(result.maxSteps).toBe(3)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('throws loop_runtime_override_disabled when agent sets allowRuntimeOverride: false', () => {
|
|
73
|
+
const agent = makeAgent({
|
|
74
|
+
id: 'mod.agent',
|
|
75
|
+
moduleId: 'mod',
|
|
76
|
+
loop: { allowRuntimeOverride: false, maxSteps: 8 },
|
|
77
|
+
})
|
|
78
|
+
expect(() =>
|
|
79
|
+
resolveEffectiveLoopConfig(agent, { maxSteps: 3 }),
|
|
80
|
+
).toThrow(AgentPolicyError)
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
resolveEffectiveLoopConfig(agent, { maxSteps: 3 })
|
|
84
|
+
} catch (error) {
|
|
85
|
+
expect(error).toBeInstanceOf(AgentPolicyError)
|
|
86
|
+
expect((error as AgentPolicyError).code).toBe('loop_runtime_override_disabled')
|
|
87
|
+
expect((error as AgentPolicyError).message).toContain('mod.agent')
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('does NOT throw when allowRuntimeOverride: false but no caller loop is supplied', () => {
|
|
92
|
+
const agent = makeAgent({
|
|
93
|
+
id: 'mod.agent',
|
|
94
|
+
moduleId: 'mod',
|
|
95
|
+
loop: { allowRuntimeOverride: false, maxSteps: 8 },
|
|
96
|
+
})
|
|
97
|
+
expect(() =>
|
|
98
|
+
resolveEffectiveLoopConfig(agent, undefined),
|
|
99
|
+
).not.toThrow()
|
|
100
|
+
const result = resolveEffectiveLoopConfig(agent, undefined)
|
|
101
|
+
expect(result.maxSteps).toBe(8)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('caller loop fields override agent loop fields selectively', () => {
|
|
105
|
+
const agentStop = { kind: 'hasToolCall' as const, toolName: 'mod.update' }
|
|
106
|
+
const agent = makeAgent({
|
|
107
|
+
id: 'mod.agent',
|
|
108
|
+
moduleId: 'mod',
|
|
109
|
+
loop: { maxSteps: 8, stopWhen: agentStop },
|
|
110
|
+
})
|
|
111
|
+
const result = resolveEffectiveLoopConfig(agent, { maxSteps: 3 })
|
|
112
|
+
expect(result.maxSteps).toBe(3)
|
|
113
|
+
expect(result.stopWhen).toEqual(agentStop)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Object-mode loop subset
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
describe('Phase 1: object-mode loop subset enforcement', () => {
|
|
122
|
+
it('accepts maxSteps in object mode', () => {
|
|
123
|
+
expect(() =>
|
|
124
|
+
assertLoopObjectModeCompatible({ maxSteps: 3 }),
|
|
125
|
+
).not.toThrow()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('accepts budget in object mode', () => {
|
|
129
|
+
expect(() =>
|
|
130
|
+
assertLoopObjectModeCompatible({ budget: { maxTokens: 50000 } }),
|
|
131
|
+
).not.toThrow()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('accepts onStepFinish in object mode', () => {
|
|
135
|
+
expect(() =>
|
|
136
|
+
assertLoopObjectModeCompatible({ onStepFinish: jest.fn() }),
|
|
137
|
+
).not.toThrow()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('accepts onStepStart in object mode', () => {
|
|
141
|
+
expect(() =>
|
|
142
|
+
assertLoopObjectModeCompatible({ onStepStart: jest.fn() }),
|
|
143
|
+
).not.toThrow()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('accepts allowRuntimeOverride in object mode', () => {
|
|
147
|
+
expect(() =>
|
|
148
|
+
assertLoopObjectModeCompatible({ allowRuntimeOverride: true }),
|
|
149
|
+
).not.toThrow()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('rejects prepareStep with loop_unsupported_in_object_mode', () => {
|
|
153
|
+
try {
|
|
154
|
+
assertLoopObjectModeCompatible({ prepareStep: jest.fn() })
|
|
155
|
+
fail('Expected AgentPolicyError')
|
|
156
|
+
} catch (error) {
|
|
157
|
+
expect(error).toBeInstanceOf(AgentPolicyError)
|
|
158
|
+
expect((error as AgentPolicyError).code).toBe('loop_unsupported_in_object_mode')
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('rejects stopWhen with loop_unsupported_in_object_mode', () => {
|
|
163
|
+
try {
|
|
164
|
+
assertLoopObjectModeCompatible({ stopWhen: { kind: 'stepCount', count: 2 } })
|
|
165
|
+
fail('Expected AgentPolicyError')
|
|
166
|
+
} catch (error) {
|
|
167
|
+
expect(error).toBeInstanceOf(AgentPolicyError)
|
|
168
|
+
expect((error as AgentPolicyError).code).toBe('loop_unsupported_in_object_mode')
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('rejects repairToolCall with loop_unsupported_in_object_mode', () => {
|
|
173
|
+
try {
|
|
174
|
+
assertLoopObjectModeCompatible({ repairToolCall: jest.fn() })
|
|
175
|
+
fail('Expected AgentPolicyError')
|
|
176
|
+
} catch (error) {
|
|
177
|
+
expect(error).toBeInstanceOf(AgentPolicyError)
|
|
178
|
+
expect((error as AgentPolicyError).code).toBe('loop_unsupported_in_object_mode')
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('rejects activeTools with loop_unsupported_in_object_mode', () => {
|
|
183
|
+
try {
|
|
184
|
+
assertLoopObjectModeCompatible({ activeTools: ['mod.read'] })
|
|
185
|
+
fail('Expected AgentPolicyError')
|
|
186
|
+
} catch (error) {
|
|
187
|
+
expect(error).toBeInstanceOf(AgentPolicyError)
|
|
188
|
+
expect((error as AgentPolicyError).code).toBe('loop_unsupported_in_object_mode')
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('rejects toolChoice with loop_unsupported_in_object_mode', () => {
|
|
193
|
+
try {
|
|
194
|
+
assertLoopObjectModeCompatible({ toolChoice: 'auto' })
|
|
195
|
+
fail('Expected AgentPolicyError')
|
|
196
|
+
} catch (error) {
|
|
197
|
+
expect(error).toBeInstanceOf(AgentPolicyError)
|
|
198
|
+
expect((error as AgentPolicyError).code).toBe('loop_unsupported_in_object_mode')
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('lists all unsupported fields in the error message when multiple are set', () => {
|
|
203
|
+
try {
|
|
204
|
+
assertLoopObjectModeCompatible({
|
|
205
|
+
prepareStep: jest.fn(),
|
|
206
|
+
stopWhen: { kind: 'stepCount', count: 1 },
|
|
207
|
+
activeTools: ['mod.read'],
|
|
208
|
+
})
|
|
209
|
+
fail('Expected AgentPolicyError')
|
|
210
|
+
} catch (error) {
|
|
211
|
+
const message = (error as Error).message
|
|
212
|
+
expect(message).toContain('prepareStep')
|
|
213
|
+
expect(message).toContain('stopWhen')
|
|
214
|
+
expect(message).toContain('activeTools')
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// AgentPolicyDenyCode exhaustiveness check
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
describe('Phase 1: AgentPolicyDenyCode has loop override codes', () => {
|
|
224
|
+
it('AgentPolicyError can be constructed with loop_runtime_override_disabled', () => {
|
|
225
|
+
const error = new AgentPolicyError('loop_runtime_override_disabled', 'test')
|
|
226
|
+
expect(error.code).toBe('loop_runtime_override_disabled')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('AgentPolicyError can be constructed with loop_unsupported_in_object_mode', () => {
|
|
230
|
+
const error = new AgentPolicyError('loop_unsupported_in_object_mode', 'test')
|
|
231
|
+
expect(error.code).toBe('loop_unsupported_in_object_mode')
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('AgentPolicyError can be constructed with loop_violates_mutation_policy', () => {
|
|
235
|
+
const error = new AgentPolicyError('loop_violates_mutation_policy', 'test')
|
|
236
|
+
expect(error.code).toBe('loop_violates_mutation_policy')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('AgentPolicyError can be constructed with loop_active_tools_outside_allowlist', () => {
|
|
240
|
+
const error = new AgentPolicyError('loop_active_tools_outside_allowlist', 'test')
|
|
241
|
+
expect(error.code).toBe('loop_active_tools_outside_allowlist')
|
|
242
|
+
})
|
|
243
|
+
})
|