skimpyclaw 0.3.14 → 0.4.0
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/README.md +47 -37
- package/dist/__tests__/adapter-types.test.d.ts +4 -0
- package/dist/__tests__/adapter-types.test.js +63 -0
- package/dist/__tests__/anthropic-adapter.test.d.ts +4 -0
- package/dist/__tests__/anthropic-adapter.test.js +264 -0
- package/dist/__tests__/api.test.js +0 -1
- package/dist/__tests__/cli.integration.test.js +2 -4
- package/dist/__tests__/cli.test.js +0 -1
- package/dist/__tests__/code-agents-notifications.test.js +137 -0
- package/dist/__tests__/code-agents-parser.test.js +19 -1
- package/dist/__tests__/code-agents-preflight.test.js +3 -28
- package/dist/__tests__/code-agents-utils.test.js +34 -9
- package/dist/__tests__/code-agents-worktrees.test.js +116 -0
- package/dist/__tests__/codex-adapter.test.js +184 -0
- package/dist/__tests__/codex-auth.test.js +66 -0
- package/dist/__tests__/codex-provider-gating.test.js +35 -0
- package/dist/__tests__/codex-unified-loop.test.js +111 -0
- package/dist/__tests__/config-security.test.js +127 -0
- package/dist/__tests__/config.test.js +23 -0
- package/dist/__tests__/context-manager.test.js +243 -164
- package/dist/__tests__/cron-run.test.js +250 -0
- package/dist/__tests__/cron.test.js +12 -38
- package/dist/__tests__/digests.test.js +67 -0
- package/dist/__tests__/discord-attachments.test.js +211 -0
- package/dist/__tests__/discord-docs.test.d.ts +1 -0
- package/dist/__tests__/discord-docs.test.js +27 -0
- package/dist/__tests__/discord-thread-agents.test.d.ts +1 -0
- package/dist/__tests__/discord-thread-agents.test.js +115 -0
- package/dist/__tests__/discord-thread-context.test.d.ts +1 -0
- package/dist/__tests__/discord-thread-context.test.js +42 -0
- package/dist/__tests__/doctor.formatters.test.js +4 -4
- package/dist/__tests__/doctor.index.test.js +1 -1
- package/dist/__tests__/doctor.runner.test.js +3 -15
- package/dist/__tests__/env-sanitizer.test.d.ts +1 -0
- package/dist/__tests__/env-sanitizer.test.js +45 -0
- package/dist/__tests__/exec-approval.test.js +61 -0
- package/dist/__tests__/fetch-tool.test.d.ts +1 -0
- package/dist/__tests__/fetch-tool.test.js +85 -0
- package/dist/__tests__/gateway-status-auth.test.d.ts +1 -0
- package/dist/__tests__/gateway-status-auth.test.js +72 -0
- package/dist/__tests__/heartbeat.test.js +3 -3
- package/dist/__tests__/interactive-sessions.test.d.ts +1 -0
- package/dist/__tests__/interactive-sessions.test.js +96 -0
- package/dist/__tests__/langfuse.test.js +6 -18
- package/dist/__tests__/model-selection.test.js +3 -4
- package/dist/__tests__/providers-init.test.js +2 -8
- package/dist/__tests__/providers-routing.test.js +1 -1
- package/dist/__tests__/providers-utils.test.js +13 -3
- package/dist/__tests__/sessions.test.js +14 -10
- package/dist/__tests__/setup.test.js +12 -29
- package/dist/__tests__/skills.test.js +10 -7
- package/dist/__tests__/stream-formatter.test.d.ts +1 -0
- package/dist/__tests__/stream-formatter.test.js +114 -0
- package/dist/__tests__/token-efficiency.test.js +131 -15
- package/dist/__tests__/tool-loop.test.d.ts +4 -0
- package/dist/__tests__/tool-loop.test.js +505 -0
- package/dist/__tests__/tools.test.js +101 -276
- package/dist/__tests__/utils.test.d.ts +1 -0
- package/dist/__tests__/utils.test.js +14 -0
- package/dist/__tests__/voice.test.js +21 -0
- package/dist/agent.js +35 -4
- package/dist/api.js +113 -37
- package/dist/channels/discord/attachments.d.ts +50 -0
- package/dist/channels/discord/attachments.js +137 -0
- package/dist/channels/discord/delegation.d.ts +5 -0
- package/dist/channels/discord/delegation.js +136 -0
- package/dist/channels/discord/handlers.js +694 -7
- package/dist/channels/discord/index.d.ts +16 -1
- package/dist/channels/discord/index.js +64 -1
- package/dist/channels/discord/thread-agents.d.ts +54 -0
- package/dist/channels/discord/thread-agents.js +323 -0
- package/dist/channels/discord/threads.d.ts +58 -0
- package/dist/channels/discord/threads.js +192 -0
- package/dist/channels/discord/types.js +4 -2
- package/dist/channels/discord/utils.d.ts +16 -0
- package/dist/channels/discord/utils.js +86 -6
- package/dist/channels/telegram/index.d.ts +1 -1
- package/dist/channels/telegram/types.js +1 -1
- package/dist/channels/telegram/utils.js +9 -3
- package/dist/channels.d.ts +1 -1
- package/dist/cli.js +20 -400
- package/dist/code-agents/executor.d.ts +1 -1
- package/dist/code-agents/executor.js +101 -45
- package/dist/code-agents/index.d.ts +2 -7
- package/dist/code-agents/index.js +111 -80
- package/dist/code-agents/interactive-resume.d.ts +6 -0
- package/dist/code-agents/interactive-resume.js +98 -0
- package/dist/code-agents/interactive-sessions.d.ts +20 -0
- package/dist/code-agents/interactive-sessions.js +132 -0
- package/dist/code-agents/parser.js +5 -1
- package/dist/code-agents/registry.d.ts +7 -1
- package/dist/code-agents/registry.js +11 -23
- package/dist/code-agents/stream-formatter.d.ts +8 -0
- package/dist/code-agents/stream-formatter.js +92 -0
- package/dist/code-agents/types.d.ts +16 -24
- package/dist/code-agents/utils.d.ts +35 -11
- package/dist/code-agents/utils.js +349 -95
- package/dist/code-agents/worktrees.d.ts +37 -0
- package/dist/code-agents/worktrees.js +116 -0
- package/dist/config.d.ts +2 -4
- package/dist/config.js +123 -23
- package/dist/cron.d.ts +1 -6
- package/dist/cron.js +175 -82
- package/dist/dashboard/assets/index-B345aOO-.js +65 -0
- package/dist/dashboard/assets/index-ZWK4dalJ.css +1 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/digests.d.ts +1 -0
- package/dist/digests.js +132 -42
- package/dist/doctor/checks.d.ts +0 -3
- package/dist/doctor/checks.js +1 -108
- package/dist/doctor/runner.js +1 -4
- package/dist/env-sanitizer.d.ts +2 -0
- package/dist/env-sanitizer.js +61 -0
- package/dist/exec-approval.d.ts +11 -1
- package/dist/exec-approval.js +17 -4
- package/dist/gateway.d.ts +3 -1
- package/dist/gateway.js +17 -7
- package/dist/heartbeat.js +1 -6
- package/dist/langfuse.js +3 -29
- package/dist/model-selection.js +3 -1
- package/dist/providers/adapter.d.ts +118 -0
- package/dist/providers/adapter.js +6 -0
- package/dist/providers/adapters/anthropic-adapter.d.ts +22 -0
- package/dist/providers/adapters/anthropic-adapter.js +204 -0
- package/dist/providers/adapters/codex-adapter.d.ts +26 -0
- package/dist/providers/adapters/codex-adapter.js +203 -0
- package/dist/providers/anthropic.d.ts +1 -0
- package/dist/providers/anthropic.js +10 -272
- package/dist/providers/codex.d.ts +21 -0
- package/dist/providers/codex.js +149 -330
- package/dist/providers/content.d.ts +1 -1
- package/dist/providers/content.js +2 -2
- package/dist/providers/context-manager.d.ts +18 -6
- package/dist/providers/context-manager.js +199 -223
- package/dist/providers/index.d.ts +9 -1
- package/dist/providers/index.js +73 -64
- package/dist/providers/loop-utils.d.ts +20 -0
- package/dist/providers/loop-utils.js +30 -0
- package/dist/providers/tool-loop.d.ts +12 -0
- package/dist/providers/tool-loop.js +251 -0
- package/dist/providers/utils.d.ts +19 -3
- package/dist/providers/utils.js +100 -29
- package/dist/secure-store.d.ts +8 -0
- package/dist/secure-store.js +80 -0
- package/dist/service.js +3 -28
- package/dist/sessions.d.ts +3 -0
- package/dist/sessions.js +147 -18
- package/dist/setup-templates.js +13 -25
- package/dist/setup.d.ts +10 -6
- package/dist/setup.js +84 -292
- package/dist/skills.js +3 -11
- package/dist/tools/agent-delegation.d.ts +19 -0
- package/dist/tools/agent-delegation.js +49 -0
- package/dist/tools/bash-tool.js +89 -34
- package/dist/tools/definitions.d.ts +199 -302
- package/dist/tools/definitions.js +70 -123
- package/dist/tools/execute-context.d.ts +13 -4
- package/dist/tools/fetch-tool.js +109 -13
- package/dist/tools/file-tools.js +7 -1
- package/dist/tools.d.ts +7 -7
- package/dist/tools.js +133 -151
- package/dist/types.d.ts +37 -30
- package/dist/utils.js +4 -6
- package/dist/voice.d.ts +1 -1
- package/dist/voice.js +17 -4
- package/package.json +33 -23
- package/templates/TOOLS.md +0 -27
- package/dist/__tests__/audit.test.js +0 -122
- package/dist/__tests__/code-agents-orchestrator.test.js +0 -216
- package/dist/__tests__/code-agents-sandbox.test.js +0 -163
- package/dist/__tests__/orchestrator.test.js +0 -425
- package/dist/__tests__/sandbox-bridge.test.js +0 -116
- package/dist/__tests__/sandbox-manager.test.js +0 -144
- package/dist/__tests__/sandbox-mount-security.test.js +0 -139
- package/dist/__tests__/sandbox-runtime.test.js +0 -176
- package/dist/__tests__/subagent.test.js +0 -240
- package/dist/__tests__/telegram.test.js +0 -42
- package/dist/code-agents/orchestrator.d.ts +0 -29
- package/dist/code-agents/orchestrator.js +0 -694
- package/dist/code-agents/worktree.d.ts +0 -40
- package/dist/code-agents/worktree.js +0 -215
- package/dist/dashboard/assets/index-BoTHPby4.js +0 -65
- package/dist/dashboard/assets/index-D4mufvBg.css +0 -1
- package/dist/dashboard.d.ts +0 -8
- package/dist/dashboard.js +0 -4071
- package/dist/discord.d.ts +0 -8
- package/dist/discord.js +0 -792
- package/dist/mcp-context-a8c.d.ts +0 -13
- package/dist/mcp-context-a8c.js +0 -34
- package/dist/orchestrator.d.ts +0 -15
- package/dist/orchestrator.js +0 -676
- package/dist/providers/openai.d.ts +0 -10
- package/dist/providers/openai.js +0 -355
- package/dist/sandbox/bridge.d.ts +0 -5
- package/dist/sandbox/bridge.js +0 -63
- package/dist/sandbox/index.d.ts +0 -5
- package/dist/sandbox/index.js +0 -4
- package/dist/sandbox/manager.d.ts +0 -7
- package/dist/sandbox/manager.js +0 -100
- package/dist/sandbox/mount-security.d.ts +0 -12
- package/dist/sandbox/mount-security.js +0 -122
- package/dist/sandbox/runtime.d.ts +0 -39
- package/dist/sandbox/runtime.js +0 -192
- package/dist/sandbox-utils.d.ts +0 -6
- package/dist/sandbox-utils.js +0 -36
- package/dist/subagent.d.ts +0 -19
- package/dist/subagent.js +0 -407
- package/dist/telegram.d.ts +0 -2
- package/dist/telegram.js +0 -11
- package/dist/tools/browser-tool.d.ts +0 -3
- package/dist/tools/browser-tool.js +0 -266
- package/sandbox/Dockerfile +0 -40
- /package/dist/__tests__/{audit.test.d.ts → code-agents-notifications.test.d.ts} +0 -0
- /package/dist/__tests__/{code-agents-orchestrator.test.d.ts → code-agents-worktrees.test.d.ts} +0 -0
- /package/dist/__tests__/{code-agents-sandbox.test.d.ts → codex-adapter.test.d.ts} +0 -0
- /package/dist/__tests__/{orchestrator.test.d.ts → codex-auth.test.d.ts} +0 -0
- /package/dist/__tests__/{sandbox-bridge.test.d.ts → codex-provider-gating.test.d.ts} +0 -0
- /package/dist/__tests__/{sandbox-manager.test.d.ts → codex-unified-loop.test.d.ts} +0 -0
- /package/dist/__tests__/{sandbox-mount-security.test.d.ts → config-security.test.d.ts} +0 -0
- /package/dist/__tests__/{sandbox-runtime.test.d.ts → cron-run.test.d.ts} +0 -0
- /package/dist/__tests__/{subagent.test.d.ts → digests.test.d.ts} +0 -0
- /package/dist/__tests__/{telegram.test.d.ts → discord-attachments.test.d.ts} +0 -0
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { describe, it, expect, afterAll } from 'vitest';
|
|
2
|
-
import { truncateToolResult } from '../providers/utils.js';
|
|
1
|
+
import { describe, it, expect, afterAll, vi } from 'vitest';
|
|
3
2
|
import { existsSync, readdirSync, unlinkSync } from 'fs';
|
|
4
3
|
import { join } from 'path';
|
|
5
4
|
import { homedir } from 'os';
|
|
6
|
-
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
const testHome = fileURLToPath(new URL('../..', import.meta.url));
|
|
7
|
+
vi.mock('os', async () => {
|
|
8
|
+
const actual = await vi.importActual('os');
|
|
9
|
+
return { ...actual, homedir: () => testHome };
|
|
10
|
+
});
|
|
11
|
+
const { truncateToolResult, splitToolResult } = await import('../providers/utils.js');
|
|
12
|
+
const scratchDir = join(testHome, '.skimpyclaw', 's');
|
|
7
13
|
// Clean up scratch files created during tests
|
|
8
14
|
afterAll(() => {
|
|
9
15
|
try {
|
|
@@ -25,25 +31,135 @@ describe('token efficiency', () => {
|
|
|
25
31
|
expect(truncateToolResult(result)).toBe(result);
|
|
26
32
|
});
|
|
27
33
|
it('returns results under mask threshold unchanged', () => {
|
|
28
|
-
const result = 'x'.repeat(
|
|
34
|
+
const result = 'x'.repeat(799);
|
|
29
35
|
expect(truncateToolResult(result)).toBe(result);
|
|
30
36
|
});
|
|
31
|
-
it('masks large results to scratch file with
|
|
37
|
+
it('masks large results to scratch file with path only', () => {
|
|
32
38
|
const result = 'START' + 'x'.repeat(10_000) + 'END';
|
|
33
39
|
const masked = truncateToolResult(result);
|
|
34
40
|
expect(masked.length).toBeLessThan(result.length);
|
|
35
|
-
expect(masked).toContain('
|
|
36
|
-
expect(masked).toContain('
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
// Summary includes head and tail
|
|
40
|
-
expect(masked).toContain('START');
|
|
41
|
-
expect(masked).toContain('END');
|
|
42
|
-
});
|
|
43
|
-
it('includes char count in masked output', () => {
|
|
41
|
+
expect(masked).toContain('→');
|
|
42
|
+
expect(masked).toContain('.skimpyclaw/s/');
|
|
43
|
+
});
|
|
44
|
+
it('produces minimal output for masked results', () => {
|
|
44
45
|
const result = 'y'.repeat(20_000);
|
|
45
46
|
const masked = truncateToolResult(result);
|
|
46
|
-
expect(masked
|
|
47
|
+
expect(masked.startsWith('→')).toBe(true);
|
|
48
|
+
expect(masked).toContain('.skimpyclaw/s/');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('splitToolResult', () => {
|
|
52
|
+
it('returns small results unchanged', () => {
|
|
53
|
+
const result = 'short output';
|
|
54
|
+
expect(splitToolResult('Read', { file_path: '/foo.ts' }, result)).toBe(result);
|
|
55
|
+
});
|
|
56
|
+
it('returns results at mask threshold unchanged', () => {
|
|
57
|
+
const result = 'x'.repeat(800);
|
|
58
|
+
expect(splitToolResult('Bash', { command: 'echo hi' }, result)).toBe(result);
|
|
59
|
+
});
|
|
60
|
+
describe('scratch file reads', () => {
|
|
61
|
+
it('does not split reads from scratch directory', () => {
|
|
62
|
+
const largeResult = 'x'.repeat(20_000);
|
|
63
|
+
const scratchPath = join(testHome, '.skimpyclaw', 'scratch', '12345-abc.txt');
|
|
64
|
+
const split = splitToolResult('Read', { file_path: scratchPath }, largeResult);
|
|
65
|
+
expect(split).toBe(largeResult);
|
|
66
|
+
});
|
|
67
|
+
it('does not split reads from tilde scratch path', () => {
|
|
68
|
+
const largeResult = 'y'.repeat(20_000);
|
|
69
|
+
const split = splitToolResult('Read', { file_path: '~/.skimpyclaw/s/test.txt' }, largeResult);
|
|
70
|
+
expect(split).toBe(largeResult);
|
|
71
|
+
});
|
|
72
|
+
it('does not split read_file from scratch directory', () => {
|
|
73
|
+
const largeResult = 'z'.repeat(20_000);
|
|
74
|
+
const scratchPath = join(testHome, '.skimpyclaw', 'scratch', 'foo.txt');
|
|
75
|
+
const split = splitToolResult('read_file', { path: scratchPath }, largeResult);
|
|
76
|
+
expect(split).toBe(largeResult);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe('Read tool', () => {
|
|
80
|
+
it('produces preview + scratch path for large read results', () => {
|
|
81
|
+
const lines = Array.from({ length: 500 }, (_, i) => `line ${i + 1}: content here`);
|
|
82
|
+
const result = lines.join('\n');
|
|
83
|
+
const split = splitToolResult('Read', { file_path: '/src/app.ts' }, result);
|
|
84
|
+
expect(split).toContain('Full output saved to');
|
|
85
|
+
expect(split).toContain('.skimpyclaw/s/');
|
|
86
|
+
expect(split).toContain('line 1: content here');
|
|
87
|
+
// Much shorter than original
|
|
88
|
+
expect(split.length).toBeLessThan(result.length);
|
|
89
|
+
});
|
|
90
|
+
it('handles read_file tool name', () => {
|
|
91
|
+
const result = 'x\n'.repeat(5000);
|
|
92
|
+
const split = splitToolResult('read_file', { path: '/foo.txt' }, result);
|
|
93
|
+
expect(split).toContain('Full output saved to');
|
|
94
|
+
expect(split).toContain('.skimpyclaw/s/');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe('Bash tool', () => {
|
|
98
|
+
it('produces preview + scratch path for large bash output', () => {
|
|
99
|
+
const output = Array.from({ length: 400 }, (_, i) => `output line ${i}: ${'x'.repeat(20)}`).join('\n');
|
|
100
|
+
const split = splitToolResult('Bash', { command: 'find . -name "*.ts"' }, output);
|
|
101
|
+
expect(split).toContain('Full output saved to');
|
|
102
|
+
expect(split).toContain('.skimpyclaw/s/');
|
|
103
|
+
expect(split).toContain('output line 0');
|
|
104
|
+
});
|
|
105
|
+
it('extracts exit code when present', () => {
|
|
106
|
+
const output = 'x\n'.repeat(5000) + 'exit code: 1';
|
|
107
|
+
const split = splitToolResult('bash', { command: 'make build' }, output);
|
|
108
|
+
expect(split).toContain('exit=1');
|
|
109
|
+
});
|
|
110
|
+
it('includes error lines when present', () => {
|
|
111
|
+
const lines = ['output1', 'error: something failed', 'output2'];
|
|
112
|
+
const result = lines.join('\n') + '\n' + 'x'.repeat(9000);
|
|
113
|
+
const split = splitToolResult('Bash', { command: 'npm install' }, result);
|
|
114
|
+
expect(split).toContain('error: something failed');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
describe('non-Bash tools', () => {
|
|
118
|
+
it('produces preview + scratch path for Glob', () => {
|
|
119
|
+
const entries = Array.from({ length: 300 }, (_, i) => `src/components/deeply/nested/module${i}/file${i}.ts`).join('\n');
|
|
120
|
+
const split = splitToolResult('Glob', { pattern: '**/*.ts' }, entries);
|
|
121
|
+
expect(split).toContain('Full output saved to');
|
|
122
|
+
expect(split).toContain('.skimpyclaw/s/');
|
|
123
|
+
expect(split).toContain('src/components');
|
|
124
|
+
});
|
|
125
|
+
it('produces preview + scratch path for Fetch', () => {
|
|
126
|
+
const result = 'HTTP/1.1 200 OK\n' + 'x'.repeat(10_000);
|
|
127
|
+
const split = splitToolResult('Fetch', { url: 'https://example.com/api' }, result);
|
|
128
|
+
expect(split).toContain('Full output saved to');
|
|
129
|
+
expect(split).toContain('.skimpyclaw/s/');
|
|
130
|
+
expect(split).toContain('HTTP/1.1 200 OK');
|
|
131
|
+
});
|
|
132
|
+
it('produces preview + scratch path for generic tool output', () => {
|
|
133
|
+
const result = 'x\n'.repeat(5000);
|
|
134
|
+
const split = splitToolResult('custom_tool', { action: 'snapshot' }, result);
|
|
135
|
+
expect(split).toContain('Full output saved to');
|
|
136
|
+
expect(split).toContain('.skimpyclaw/s/');
|
|
137
|
+
});
|
|
138
|
+
it('produces preview + scratch path for MCP', () => {
|
|
139
|
+
const result = 'x\n'.repeat(5000);
|
|
140
|
+
const split = splitToolResult('mcp__context_a8c__search', {}, result);
|
|
141
|
+
expect(split).toContain('Full output saved to');
|
|
142
|
+
expect(split).toContain('.skimpyclaw/s/');
|
|
143
|
+
expect(split).toContain('x\n');
|
|
144
|
+
});
|
|
145
|
+
it('produces preview + scratch path for unknown tools', () => {
|
|
146
|
+
const result = 'z'.repeat(10_000);
|
|
147
|
+
const split = splitToolResult('custom_tool', {}, result);
|
|
148
|
+
expect(split).toContain('Full output saved to');
|
|
149
|
+
expect(split).toContain('.skimpyclaw/s/');
|
|
150
|
+
expect(split).toContain('zzz');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
it('creates scratch file for large results', () => {
|
|
154
|
+
const result = 'data'.repeat(3000);
|
|
155
|
+
const split = splitToolResult('Read', { file_path: '/big.txt' }, result);
|
|
156
|
+
// Extract scratch path from the result
|
|
157
|
+
const pathMatch = split.match(/saved to (\S*\.skimpyclaw\/s\/\S+)/);
|
|
158
|
+
expect(pathMatch).not.toBeNull();
|
|
159
|
+
if (pathMatch) {
|
|
160
|
+
const p = pathMatch[1].startsWith('~/') ? pathMatch[1].replace('~', homedir()) : pathMatch[1];
|
|
161
|
+
expect(existsSync(p)).toBe(true);
|
|
162
|
+
}
|
|
47
163
|
});
|
|
48
164
|
});
|
|
49
165
|
describe('retry prompt compression', () => {
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the unified tool loop orchestrator.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
5
|
+
import { runToolLoop } from '../providers/tool-loop.js';
|
|
6
|
+
// Mock adapter for testing
|
|
7
|
+
class MockAdapter {
|
|
8
|
+
name = 'mock';
|
|
9
|
+
isAvailable() {
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
async chat(_messages, _options, _config) {
|
|
13
|
+
return 'mock response';
|
|
14
|
+
}
|
|
15
|
+
// Track calls for assertions
|
|
16
|
+
callCount = 0;
|
|
17
|
+
buildMessagesCallCount = 0;
|
|
18
|
+
buildToolDefsCallCount = 0;
|
|
19
|
+
appendAssistantCallCount = 0;
|
|
20
|
+
appendToolResultCallCount = 0;
|
|
21
|
+
compactMessagesCallCount = 0;
|
|
22
|
+
recordUsageCallCount = 0;
|
|
23
|
+
// Mock responses to return
|
|
24
|
+
responses = [];
|
|
25
|
+
currentResponseIndex = 0;
|
|
26
|
+
buildMessages(messages, options, config) {
|
|
27
|
+
this.buildMessagesCallCount++;
|
|
28
|
+
return {
|
|
29
|
+
messages: messages.filter(m => m.role !== 'system'),
|
|
30
|
+
systemParam: messages.find(m => m.role === 'system')?.content,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
buildToolDefs(toolDefs, _config) {
|
|
34
|
+
this.buildToolDefsCallCount++;
|
|
35
|
+
return toolDefs;
|
|
36
|
+
}
|
|
37
|
+
async call(messages, toolDefs, options, config) {
|
|
38
|
+
this.callCount++;
|
|
39
|
+
const response = this.responses[this.currentResponseIndex] || {
|
|
40
|
+
hasToolCalls: false,
|
|
41
|
+
toolCalls: [],
|
|
42
|
+
textContent: 'default response',
|
|
43
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
44
|
+
rawResponse: {},
|
|
45
|
+
};
|
|
46
|
+
this.currentResponseIndex++;
|
|
47
|
+
return response;
|
|
48
|
+
}
|
|
49
|
+
appendAssistantResponse(messages, rawResponse) {
|
|
50
|
+
this.appendAssistantCallCount++;
|
|
51
|
+
messages.messages.push({ role: 'assistant', raw: rawResponse });
|
|
52
|
+
}
|
|
53
|
+
appendToolResult(messages, toolCallId, result, isError) {
|
|
54
|
+
this.appendToolResultCallCount++;
|
|
55
|
+
messages.messages.push({ role: 'tool', toolCallId, result, isError });
|
|
56
|
+
}
|
|
57
|
+
async compactMessages(messages, config, iteration, fullConfig) {
|
|
58
|
+
this.compactMessagesCallCount++;
|
|
59
|
+
return {
|
|
60
|
+
messages: messages.messages,
|
|
61
|
+
compacted: false,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
recordUsage(model, usage, trigger, agentId) {
|
|
65
|
+
this.recordUsageCallCount++;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Mock tool execution
|
|
69
|
+
const { mockExecuteTool } = vi.hoisted(() => ({
|
|
70
|
+
mockExecuteTool: vi.fn().mockResolvedValue('tool result'),
|
|
71
|
+
}));
|
|
72
|
+
vi.mock('../tools.js', () => ({
|
|
73
|
+
getToolDefinitions: vi.fn().mockResolvedValue([
|
|
74
|
+
{ name: 'testTool', description: 'A test tool' },
|
|
75
|
+
]),
|
|
76
|
+
executeTool: mockExecuteTool,
|
|
77
|
+
}));
|
|
78
|
+
// Mock audit functions
|
|
79
|
+
vi.mock('../audit.js', () => ({
|
|
80
|
+
startTrace: vi.fn().mockReturnValue('trace-123'),
|
|
81
|
+
addEvent: vi.fn(),
|
|
82
|
+
endTrace: vi.fn().mockResolvedValue(undefined),
|
|
83
|
+
}));
|
|
84
|
+
describe('runToolLoop', () => {
|
|
85
|
+
let adapter;
|
|
86
|
+
let messages;
|
|
87
|
+
let options;
|
|
88
|
+
let config;
|
|
89
|
+
let toolConfig;
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
mockExecuteTool.mockReset();
|
|
92
|
+
mockExecuteTool.mockResolvedValue('tool result');
|
|
93
|
+
adapter = new MockAdapter();
|
|
94
|
+
messages = [
|
|
95
|
+
{ role: 'system', content: 'You are a helpful assistant' },
|
|
96
|
+
{ role: 'user', content: 'Hello' },
|
|
97
|
+
];
|
|
98
|
+
options = {
|
|
99
|
+
model: 'test-model',
|
|
100
|
+
maxTokens: 1000,
|
|
101
|
+
};
|
|
102
|
+
config = {
|
|
103
|
+
gateway: { port: 18790, mode: 'local' },
|
|
104
|
+
agents: { default: 'main', list: {} },
|
|
105
|
+
models: { providers: {}, aliases: {} },
|
|
106
|
+
channels: {
|
|
107
|
+
telegram: {
|
|
108
|
+
enabled: false,
|
|
109
|
+
token: 'test-token',
|
|
110
|
+
allowFrom: [],
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
cron: { jobs: [] },
|
|
114
|
+
heartbeat: { intervalMs: 300000, prompt: 'HEARTBEAT' },
|
|
115
|
+
};
|
|
116
|
+
toolConfig = {
|
|
117
|
+
enabled: true,
|
|
118
|
+
allowedPaths: ['/tmp'],
|
|
119
|
+
maxIterations: 20,
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
it('should complete in one iteration when model returns text without tool calls', async () => {
|
|
123
|
+
adapter.responses = [
|
|
124
|
+
{
|
|
125
|
+
hasToolCalls: false,
|
|
126
|
+
toolCalls: [],
|
|
127
|
+
textContent: 'Hello! How can I help you?',
|
|
128
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
129
|
+
rawResponse: {},
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
const result = await runToolLoop(adapter, messages, options, config, toolConfig);
|
|
133
|
+
expect(result.response).toBe('Hello! How can I help you?');
|
|
134
|
+
expect(result.toolCalls).toEqual([]);
|
|
135
|
+
expect(adapter.callCount).toBe(1);
|
|
136
|
+
expect(adapter.buildMessagesCallCount).toBe(1);
|
|
137
|
+
expect(adapter.recordUsageCallCount).toBe(1);
|
|
138
|
+
});
|
|
139
|
+
it('should handle tool calls and continue iteration', async () => {
|
|
140
|
+
adapter.responses = [
|
|
141
|
+
{
|
|
142
|
+
hasToolCalls: true,
|
|
143
|
+
toolCalls: [
|
|
144
|
+
{
|
|
145
|
+
id: 'call-1',
|
|
146
|
+
name: 'testTool',
|
|
147
|
+
args: { param: 'value' },
|
|
148
|
+
rawArgs: '{"param":"value"}',
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
textContent: '',
|
|
152
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
153
|
+
rawResponse: { content: 'response1' },
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
hasToolCalls: false,
|
|
157
|
+
toolCalls: [],
|
|
158
|
+
textContent: 'Done!',
|
|
159
|
+
usage: { inputTokens: 150, outputTokens: 30 },
|
|
160
|
+
rawResponse: {},
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
const result = await runToolLoop(adapter, messages, options, config, toolConfig);
|
|
164
|
+
expect(result.response).toBe('Done!');
|
|
165
|
+
expect(result.toolCalls).toHaveLength(1);
|
|
166
|
+
expect(adapter.callCount).toBe(2);
|
|
167
|
+
expect(adapter.appendAssistantCallCount).toBe(1);
|
|
168
|
+
expect(adapter.appendToolResultCallCount).toBe(1);
|
|
169
|
+
});
|
|
170
|
+
it('should stop at max iterations', async () => {
|
|
171
|
+
// Return tool calls every time
|
|
172
|
+
adapter.responses = Array(25).fill({
|
|
173
|
+
hasToolCalls: true,
|
|
174
|
+
toolCalls: [
|
|
175
|
+
{
|
|
176
|
+
id: 'call-loop',
|
|
177
|
+
name: 'testTool',
|
|
178
|
+
args: { param: 'value' },
|
|
179
|
+
rawArgs: '{"param":"value"}',
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
textContent: '',
|
|
183
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
184
|
+
rawResponse: {},
|
|
185
|
+
});
|
|
186
|
+
const result = await runToolLoop(adapter, messages, options, config, toolConfig);
|
|
187
|
+
expect(result.response).toContain('maximum iterations');
|
|
188
|
+
expect(adapter.callCount).toBe(20); // maxIterations default
|
|
189
|
+
});
|
|
190
|
+
it('should handle abort signal', async () => {
|
|
191
|
+
const abortController = new AbortController();
|
|
192
|
+
const toolContext = {
|
|
193
|
+
abortSignal: abortController.signal,
|
|
194
|
+
};
|
|
195
|
+
// Set up multiple responses to give abort time to trigger
|
|
196
|
+
adapter.responses = [
|
|
197
|
+
{
|
|
198
|
+
hasToolCalls: true,
|
|
199
|
+
toolCalls: [{ id: 'call-1', name: 'testTool', args: {}, rawArgs: '{}' }],
|
|
200
|
+
textContent: '',
|
|
201
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
202
|
+
rawResponse: {},
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
hasToolCalls: true,
|
|
206
|
+
toolCalls: [{ id: 'call-2', name: 'testTool', args: {}, rawArgs: '{}' }],
|
|
207
|
+
textContent: '',
|
|
208
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
209
|
+
rawResponse: {},
|
|
210
|
+
},
|
|
211
|
+
];
|
|
212
|
+
// Abort immediately
|
|
213
|
+
abortController.abort();
|
|
214
|
+
const result = await runToolLoop(adapter, messages, options, config, toolConfig, toolContext);
|
|
215
|
+
expect(result.response).toContain('Cancelled');
|
|
216
|
+
});
|
|
217
|
+
it('should call compactMessages on each iteration', async () => {
|
|
218
|
+
adapter.responses = [
|
|
219
|
+
{
|
|
220
|
+
hasToolCalls: true,
|
|
221
|
+
toolCalls: [{ id: 'call-1', name: 'testTool', args: {}, rawArgs: '{}' }],
|
|
222
|
+
textContent: '',
|
|
223
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
224
|
+
rawResponse: {},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
hasToolCalls: false,
|
|
228
|
+
toolCalls: [],
|
|
229
|
+
textContent: 'Done',
|
|
230
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
231
|
+
rawResponse: {},
|
|
232
|
+
},
|
|
233
|
+
];
|
|
234
|
+
await runToolLoop(adapter, messages, options, config, toolConfig);
|
|
235
|
+
expect(adapter.compactMessagesCallCount).toBe(2);
|
|
236
|
+
});
|
|
237
|
+
it('should recover from tool execution errors and feed error back to model', async () => {
|
|
238
|
+
mockExecuteTool.mockRejectedValueOnce(new Error('file not found'));
|
|
239
|
+
adapter.responses = [
|
|
240
|
+
{
|
|
241
|
+
hasToolCalls: true,
|
|
242
|
+
toolCalls: [{ id: 'call-err', name: 'testTool', args: { path: '/missing' }, rawArgs: '{"path":"/missing"}' }],
|
|
243
|
+
textContent: '',
|
|
244
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
245
|
+
rawResponse: {},
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
hasToolCalls: false,
|
|
249
|
+
toolCalls: [],
|
|
250
|
+
textContent: 'Recovered from error',
|
|
251
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
252
|
+
rawResponse: {},
|
|
253
|
+
},
|
|
254
|
+
];
|
|
255
|
+
const result = await runToolLoop(adapter, messages, options, config, toolConfig);
|
|
256
|
+
expect(result.response).toBe('Recovered from error');
|
|
257
|
+
expect(adapter.appendToolResultCallCount).toBe(1);
|
|
258
|
+
expect(adapter.callCount).toBe(2);
|
|
259
|
+
// Tool log should contain the error
|
|
260
|
+
expect(result.toolCalls.some(t => t.includes('ERROR'))).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
it('should call endTrace when the loop completes and it created the trace', async () => {
|
|
263
|
+
const { endTrace } = await import('../audit.js');
|
|
264
|
+
vi.mocked(endTrace).mockClear();
|
|
265
|
+
adapter.responses = [
|
|
266
|
+
{
|
|
267
|
+
hasToolCalls: false,
|
|
268
|
+
toolCalls: [],
|
|
269
|
+
textContent: 'Done',
|
|
270
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
271
|
+
rawResponse: {},
|
|
272
|
+
},
|
|
273
|
+
];
|
|
274
|
+
await runToolLoop(adapter, messages, options, config, toolConfig);
|
|
275
|
+
expect(endTrace).toHaveBeenCalledWith('trace-123', 'ok');
|
|
276
|
+
});
|
|
277
|
+
it('should batch multiple tool results via appendToolResults when adapter supports it', async () => {
|
|
278
|
+
// Add batch support to the mock adapter
|
|
279
|
+
const batchedResults = [];
|
|
280
|
+
adapter.appendToolResults = (msgs, results) => {
|
|
281
|
+
batchedResults.push(...results);
|
|
282
|
+
msgs.messages.push({ role: 'user', content: results });
|
|
283
|
+
};
|
|
284
|
+
adapter.responses = [
|
|
285
|
+
{
|
|
286
|
+
hasToolCalls: true,
|
|
287
|
+
toolCalls: [
|
|
288
|
+
{ id: 'call-1', name: 'testTool', args: { a: 1 }, rawArgs: '{"a":1}' },
|
|
289
|
+
{ id: 'call-2', name: 'testTool', args: { b: 2 }, rawArgs: '{"b":2}' },
|
|
290
|
+
],
|
|
291
|
+
textContent: '',
|
|
292
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
293
|
+
rawResponse: {},
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
hasToolCalls: false,
|
|
297
|
+
toolCalls: [],
|
|
298
|
+
textContent: 'Done with both',
|
|
299
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
300
|
+
rawResponse: {},
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
const result = await runToolLoop(adapter, messages, options, config, toolConfig);
|
|
304
|
+
expect(result.response).toBe('Done with both');
|
|
305
|
+
expect(batchedResults).toHaveLength(2);
|
|
306
|
+
expect(batchedResults[0].toolCallId).toBe('call-1');
|
|
307
|
+
expect(batchedResults[1].toolCallId).toBe('call-2');
|
|
308
|
+
// appendToolResult should NOT have been called (batching was used instead)
|
|
309
|
+
expect(adapter.appendToolResultCallCount).toBe(0);
|
|
310
|
+
});
|
|
311
|
+
it('should provide fallback response when model returns empty text after tool use', async () => {
|
|
312
|
+
adapter.responses = [
|
|
313
|
+
{
|
|
314
|
+
hasToolCalls: true,
|
|
315
|
+
toolCalls: [{ id: 'call-1', name: 'testTool', args: {}, rawArgs: '{}' }],
|
|
316
|
+
textContent: '',
|
|
317
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
318
|
+
rawResponse: {},
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
hasToolCalls: false,
|
|
322
|
+
toolCalls: [],
|
|
323
|
+
textContent: '', // Empty response
|
|
324
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
325
|
+
rawResponse: {},
|
|
326
|
+
},
|
|
327
|
+
];
|
|
328
|
+
const result = await runToolLoop(adapter, messages, options, config, toolConfig);
|
|
329
|
+
expect(result.response).toContain('Completed with 1 tool calls');
|
|
330
|
+
});
|
|
331
|
+
describe('onEmptyFinalResponse hook', () => {
|
|
332
|
+
it('should call onEmptyFinalResponse when final text is empty after tool use', async () => {
|
|
333
|
+
adapter.onEmptyFinalResponse = vi.fn().mockResolvedValue('Finalized answer');
|
|
334
|
+
adapter.responses = [
|
|
335
|
+
{
|
|
336
|
+
hasToolCalls: true,
|
|
337
|
+
toolCalls: [{ id: 'call-1', name: 'testTool', args: {}, rawArgs: '{}' }],
|
|
338
|
+
textContent: '',
|
|
339
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
340
|
+
rawResponse: {},
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
hasToolCalls: false,
|
|
344
|
+
toolCalls: [],
|
|
345
|
+
textContent: '', // Empty — triggers finalization hook
|
|
346
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
347
|
+
rawResponse: {},
|
|
348
|
+
},
|
|
349
|
+
];
|
|
350
|
+
const result = await runToolLoop(adapter, messages, options, config, toolConfig);
|
|
351
|
+
expect(result.response).toBe('Finalized answer');
|
|
352
|
+
expect(adapter.onEmptyFinalResponse).toHaveBeenCalledTimes(1);
|
|
353
|
+
});
|
|
354
|
+
it('should NOT call onEmptyFinalResponse when final text is non-empty', async () => {
|
|
355
|
+
adapter.onEmptyFinalResponse = vi.fn().mockResolvedValue('Should not appear');
|
|
356
|
+
adapter.responses = [
|
|
357
|
+
{
|
|
358
|
+
hasToolCalls: true,
|
|
359
|
+
toolCalls: [{ id: 'call-1', name: 'testTool', args: {}, rawArgs: '{}' }],
|
|
360
|
+
textContent: '',
|
|
361
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
362
|
+
rawResponse: {},
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
hasToolCalls: false,
|
|
366
|
+
toolCalls: [],
|
|
367
|
+
textContent: 'Got a real answer',
|
|
368
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
369
|
+
rawResponse: {},
|
|
370
|
+
},
|
|
371
|
+
];
|
|
372
|
+
const result = await runToolLoop(adapter, messages, options, config, toolConfig);
|
|
373
|
+
expect(result.response).toBe('Got a real answer');
|
|
374
|
+
expect(adapter.onEmptyFinalResponse).not.toHaveBeenCalled();
|
|
375
|
+
});
|
|
376
|
+
it('should NOT call onEmptyFinalResponse when no tool calls were made', async () => {
|
|
377
|
+
adapter.onEmptyFinalResponse = vi.fn().mockResolvedValue('Should not appear');
|
|
378
|
+
adapter.responses = [
|
|
379
|
+
{
|
|
380
|
+
hasToolCalls: false,
|
|
381
|
+
toolCalls: [],
|
|
382
|
+
textContent: '', // Empty but no tool calls — no finalization
|
|
383
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
384
|
+
rawResponse: {},
|
|
385
|
+
},
|
|
386
|
+
];
|
|
387
|
+
const result = await runToolLoop(adapter, messages, options, config, toolConfig);
|
|
388
|
+
expect(result.response).toBe('[Model returned empty response — please try again]');
|
|
389
|
+
expect(adapter.onEmptyFinalResponse).not.toHaveBeenCalled();
|
|
390
|
+
});
|
|
391
|
+
it('should fall back to default message when onEmptyFinalResponse returns undefined', async () => {
|
|
392
|
+
adapter.onEmptyFinalResponse = vi.fn().mockResolvedValue(undefined);
|
|
393
|
+
adapter.responses = [
|
|
394
|
+
{
|
|
395
|
+
hasToolCalls: true,
|
|
396
|
+
toolCalls: [{ id: 'call-1', name: 'testTool', args: {}, rawArgs: '{}' }],
|
|
397
|
+
textContent: '',
|
|
398
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
399
|
+
rawResponse: {},
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
hasToolCalls: false,
|
|
403
|
+
toolCalls: [],
|
|
404
|
+
textContent: '',
|
|
405
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
406
|
+
rawResponse: {},
|
|
407
|
+
},
|
|
408
|
+
];
|
|
409
|
+
const result = await runToolLoop(adapter, messages, options, config, toolConfig);
|
|
410
|
+
expect(result.response).toContain('Completed with 1 tool calls');
|
|
411
|
+
expect(adapter.onEmptyFinalResponse).toHaveBeenCalledTimes(1);
|
|
412
|
+
});
|
|
413
|
+
it('should fall back to default message when onEmptyFinalResponse throws', async () => {
|
|
414
|
+
adapter.onEmptyFinalResponse = vi.fn().mockRejectedValue(new Error('finalize failed'));
|
|
415
|
+
adapter.responses = [
|
|
416
|
+
{
|
|
417
|
+
hasToolCalls: true,
|
|
418
|
+
toolCalls: [{ id: 'call-1', name: 'testTool', args: {}, rawArgs: '{}' }],
|
|
419
|
+
textContent: '',
|
|
420
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
421
|
+
rawResponse: {},
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
hasToolCalls: false,
|
|
425
|
+
toolCalls: [],
|
|
426
|
+
textContent: '',
|
|
427
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
428
|
+
rawResponse: {},
|
|
429
|
+
},
|
|
430
|
+
];
|
|
431
|
+
const result = await runToolLoop(adapter, messages, options, config, toolConfig);
|
|
432
|
+
expect(result.response).toContain('Completed with 1 tool calls');
|
|
433
|
+
expect(adapter.onEmptyFinalResponse).toHaveBeenCalledTimes(1);
|
|
434
|
+
});
|
|
435
|
+
it('should call onEmptyFinalResponse when max iterations are reached after tool use', async () => {
|
|
436
|
+
const customToolConfig = { ...toolConfig, maxIterations: 2 };
|
|
437
|
+
adapter.onEmptyFinalResponse = vi.fn().mockResolvedValue('Best effort final answer');
|
|
438
|
+
adapter.responses = Array(2).fill({
|
|
439
|
+
hasToolCalls: true,
|
|
440
|
+
toolCalls: [{ id: 'call-loop', name: 'testTool', args: { x: 1 }, rawArgs: '{"x":1}' }],
|
|
441
|
+
textContent: '',
|
|
442
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
443
|
+
rawResponse: {},
|
|
444
|
+
});
|
|
445
|
+
const result = await runToolLoop(adapter, messages, options, config, customToolConfig);
|
|
446
|
+
expect(result.response).toBe('Best effort final answer');
|
|
447
|
+
expect(adapter.callCount).toBe(2);
|
|
448
|
+
expect(adapter.onEmptyFinalResponse).toHaveBeenCalledTimes(1);
|
|
449
|
+
});
|
|
450
|
+
it('should keep a clear max-iteration fallback when finalization returns empty', async () => {
|
|
451
|
+
const customToolConfig = { ...toolConfig, maxIterations: 2 };
|
|
452
|
+
adapter.onEmptyFinalResponse = vi.fn().mockResolvedValue(undefined);
|
|
453
|
+
adapter.responses = Array(2).fill({
|
|
454
|
+
hasToolCalls: true,
|
|
455
|
+
toolCalls: [{ id: 'call-loop', name: 'testTool', args: { x: 1 }, rawArgs: '{"x":1}' }],
|
|
456
|
+
textContent: '',
|
|
457
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
458
|
+
rawResponse: {},
|
|
459
|
+
});
|
|
460
|
+
const result = await runToolLoop(adapter, messages, options, config, customToolConfig);
|
|
461
|
+
expect(result.response).toContain('maximum iterations');
|
|
462
|
+
expect(adapter.onEmptyFinalResponse).toHaveBeenCalledTimes(1);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
describe('Codex-specific behavior through unified loop', () => {
|
|
466
|
+
it('should accumulate usage across iterations', async () => {
|
|
467
|
+
adapter.responses = [
|
|
468
|
+
{
|
|
469
|
+
hasToolCalls: true,
|
|
470
|
+
toolCalls: [{ id: 'call-1', name: 'testTool', args: {}, rawArgs: '{}' }],
|
|
471
|
+
textContent: '',
|
|
472
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
473
|
+
cost: { input: 0.001, output: 0.002, total: 0.003 },
|
|
474
|
+
rawResponse: {},
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
hasToolCalls: false,
|
|
478
|
+
toolCalls: [],
|
|
479
|
+
textContent: 'Done',
|
|
480
|
+
usage: { inputTokens: 200, outputTokens: 80 },
|
|
481
|
+
cost: { input: 0.002, output: 0.004, total: 0.006 },
|
|
482
|
+
rawResponse: {},
|
|
483
|
+
},
|
|
484
|
+
];
|
|
485
|
+
const result = await runToolLoop(adapter, messages, options, config, toolConfig);
|
|
486
|
+
expect(result.usage?.prompt_tokens).toBe(300);
|
|
487
|
+
expect(result.usage?.completion_tokens).toBe(130);
|
|
488
|
+
expect(result.usage?.total_tokens).toBe(430);
|
|
489
|
+
expect(result.cost?.total).toBeCloseTo(0.009);
|
|
490
|
+
});
|
|
491
|
+
it('should respect custom maxIterations from toolConfig', async () => {
|
|
492
|
+
const customToolConfig = { ...toolConfig, maxIterations: 3 };
|
|
493
|
+
adapter.responses = Array(5).fill({
|
|
494
|
+
hasToolCalls: true,
|
|
495
|
+
toolCalls: [{ id: 'call-x', name: 'testTool', args: { x: 1 }, rawArgs: '{"x":1}' }],
|
|
496
|
+
textContent: '',
|
|
497
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
498
|
+
rawResponse: {},
|
|
499
|
+
});
|
|
500
|
+
const result = await runToolLoop(adapter, messages, options, config, customToolConfig);
|
|
501
|
+
expect(result.response).toContain('maximum iterations');
|
|
502
|
+
expect(adapter.callCount).toBe(3);
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
});
|