@studio-foundation/runner 0.3.0-beta.1 → 0.3.0-beta.6

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.
Files changed (78) hide show
  1. package/package.json +6 -3
  2. package/ARCHITECTURE.md +0 -53
  3. package/configs/agents/analyst.agent.yaml +0 -31
  4. package/configs/agents/code-generator.agent.yaml +0 -31
  5. package/configs/agents/generic.agent.yaml +0 -23
  6. package/src/__tests__/script-executor.test.ts +0 -180
  7. package/src/index.ts +0 -68
  8. package/src/integrations/integration-loader.test.ts +0 -88
  9. package/src/integrations/integration-loader.ts +0 -68
  10. package/src/middleware/anonymization.ts +0 -38
  11. package/src/plugins/index.ts +0 -4
  12. package/src/plugins/mcp-client.test.ts +0 -148
  13. package/src/plugins/mcp-client.ts +0 -128
  14. package/src/plugins/oauth-provider.test.ts +0 -167
  15. package/src/plugins/oauth-provider.ts +0 -175
  16. package/src/plugins/plugin-loader.test.ts +0 -114
  17. package/src/plugins/plugin-loader.ts +0 -90
  18. package/src/prompt-builder.test.ts +0 -167
  19. package/src/prompt-builder.ts +0 -332
  20. package/src/providers/anthropic.test.ts +0 -101
  21. package/src/providers/anthropic.ts +0 -135
  22. package/src/providers/mock.ts +0 -57
  23. package/src/providers/ollama.test.ts +0 -166
  24. package/src/providers/ollama.ts +0 -152
  25. package/src/providers/openai-responses.ts +0 -212
  26. package/src/providers/openai.test.ts +0 -67
  27. package/src/providers/openai.ts +0 -139
  28. package/src/providers/provider.ts +0 -54
  29. package/src/providers/registry.ts +0 -77
  30. package/src/runner.test.ts +0 -343
  31. package/src/runner.ts +0 -396
  32. package/src/script-executor.ts +0 -107
  33. package/src/tools/builtin/git.ts +0 -311
  34. package/src/tools/builtin/patch.ts +0 -257
  35. package/src/tools/builtin/repo-manager.ts +0 -142
  36. package/src/tools/builtin/search.ts +0 -108
  37. package/src/tools/builtin/shell.ts +0 -82
  38. package/src/tools/builtin/studio-run.ts +0 -73
  39. package/src/tools/builtin/web-search.test.ts +0 -122
  40. package/src/tools/builtin/web-search.ts +0 -101
  41. package/src/tools/errors.test.ts +0 -12
  42. package/src/tools/errors.ts +0 -6
  43. package/src/tools/plugin-loader.test.ts +0 -130
  44. package/src/tools/plugin-loader.ts +0 -203
  45. package/src/tools/skills/README.md +0 -49
  46. package/src/tools/skills/skill-loader.test.ts +0 -106
  47. package/src/tools/skills/skill-loader.ts +0 -62
  48. package/src/tools/tool-executor.test.ts +0 -88
  49. package/src/tools/tool-executor.ts +0 -84
  50. package/src/tools/tool-registry.ts +0 -130
  51. package/src/tools/yaml-executor.ts +0 -120
  52. package/src/utils/race-signal.test.ts +0 -50
  53. package/src/utils/race-signal.ts +0 -17
  54. package/templates/integrations/linear.integration.yaml +0 -35
  55. package/templates/integrations/slack.integration.yaml +0 -22
  56. package/templates/integrations/webhook.integration.yaml +0 -17
  57. package/templates/tools/git.tool.yaml +0 -80
  58. package/templates/tools/repo-manager.tool.yaml +0 -64
  59. package/templates/tools/search.tool.yaml +0 -22
  60. package/templates/tools/shell.tool.yaml +0 -19
  61. package/templates/tools/web-search.tool.yaml +0 -24
  62. package/tests/anonymization-middleware.test.ts +0 -61
  63. package/tests/anthropic.test.ts +0 -87
  64. package/tests/apply-patch.test.ts +0 -355
  65. package/tests/fixtures/tools/test-builtin.tool.yaml +0 -14
  66. package/tests/fixtures/tools/test-shell.tool.yaml +0 -19
  67. package/tests/mock-provider.test.ts +0 -104
  68. package/tests/openai.test.ts +0 -72
  69. package/tests/plugin-loader.test.ts +0 -54
  70. package/tests/prompt-builder.test.ts +0 -468
  71. package/tests/runner-anonymization.test.ts +0 -89
  72. package/tests/runner.test.ts +0 -885
  73. package/tests/studio-run.test.ts +0 -94
  74. package/tests/tool-executor.test.ts +0 -115
  75. package/tests/tool-registry.test.ts +0 -84
  76. package/tests/yaml-executor.test.ts +0 -76
  77. package/tsconfig.json +0 -20
  78. package/vitest.config.ts +0 -7
@@ -1,139 +0,0 @@
1
- /**
2
- * OpenAI provider implementation with full tool calling support
3
- */
4
-
5
- import type { LLMRequest, LLMResponse } from '@studio-foundation/contracts';
6
- import type { Provider } from './provider.js';
7
- import OpenAI from 'openai';
8
- import type { ChatCompletionMessageParam, ChatCompletionTool, ChatCompletionChunk } from 'openai/resources/chat/completions';
9
-
10
- export class OpenAIProvider implements Provider {
11
- readonly name = 'openai';
12
- private client: OpenAI;
13
-
14
- constructor(apiKey?: string, private baseUrl?: string) {
15
- this.client = new OpenAI({
16
- apiKey: apiKey || process.env.OPENAI_API_KEY,
17
- baseURL: baseUrl
18
- });
19
- }
20
-
21
- async call(request: LLMRequest, onToken?: (token: string) => void, signal?: AbortSignal): Promise<LLMResponse> {
22
- const openaiMessages = this.buildMessages(request);
23
- const tools = this.buildTools(request);
24
-
25
- if (onToken) {
26
- return this.callStreaming(request, openaiMessages, tools, onToken, signal);
27
- }
28
-
29
- // Non-streaming path (existing behavior)
30
- const completion = await this.client.chat.completions.create({
31
- model: request.model,
32
- messages: openaiMessages,
33
- tools: tools && tools.length > 0 ? tools : undefined,
34
- temperature: request.temperature,
35
- max_completion_tokens: request.max_tokens
36
- }, { signal });
37
-
38
- const choice = completion.choices[0];
39
-
40
- // Extract tool calls if any
41
- const tool_calls = choice.message.tool_calls?.map(tc => ({
42
- id: tc.id,
43
- name: tc.function.name,
44
- arguments: JSON.parse(tc.function.arguments)
45
- })) || [];
46
-
47
- return {
48
- content: choice.message.content || '',
49
- tool_calls,
50
- finish_reason: choice.finish_reason,
51
- usage: {
52
- prompt_tokens: completion.usage?.prompt_tokens || 0,
53
- completion_tokens: completion.usage?.completion_tokens || 0,
54
- total_tokens: completion.usage?.total_tokens || 0
55
- }
56
- };
57
- }
58
-
59
- private async callStreaming(
60
- request: LLMRequest,
61
- openaiMessages: ChatCompletionMessageParam[],
62
- tools: ChatCompletionTool[] | undefined,
63
- onToken: (token: string) => void,
64
- signal?: AbortSignal
65
- ): Promise<LLMResponse> {
66
- const stream = this.client.chat.completions.create({
67
- model: request.model,
68
- messages: openaiMessages,
69
- tools: tools && tools.length > 0 ? tools : undefined,
70
- temperature: request.temperature,
71
- max_completion_tokens: request.max_tokens,
72
- stream: true as const,
73
- stream_options: { include_usage: true },
74
- }, { signal }) as unknown as AsyncIterable<ChatCompletionChunk>;
75
-
76
- let textContent = '';
77
- const toolCallMap = new Map<number, { id: string; name: string; args: string }>();
78
- let finishReason = 'stop';
79
- let usage: LLMResponse['usage'];
80
-
81
- for await (const chunk of stream) {
82
- // KEY FIX: check signal at the start of each chunk so we don't keep
83
- // consuming buffered data after Ctrl-C.
84
- if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
85
- const delta = chunk.choices[0]?.delta;
86
- if (delta?.content) {
87
- textContent += delta.content;
88
- onToken(delta.content);
89
- }
90
- if (delta?.tool_calls) {
91
- for (const tc of delta.tool_calls) {
92
- if (!toolCallMap.has(tc.index)) {
93
- toolCallMap.set(tc.index, { id: '', name: '', args: '' });
94
- }
95
- const acc = toolCallMap.get(tc.index)!;
96
- if (tc.id) acc.id = tc.id;
97
- if (tc.function?.name) acc.name += tc.function.name;
98
- if (tc.function?.arguments) acc.args += tc.function.arguments;
99
- }
100
- }
101
- if (chunk.choices[0]?.finish_reason) finishReason = chunk.choices[0].finish_reason;
102
- if (chunk.usage) {
103
- usage = {
104
- prompt_tokens: chunk.usage.prompt_tokens,
105
- completion_tokens: chunk.usage.completion_tokens,
106
- total_tokens: chunk.usage.total_tokens,
107
- };
108
- }
109
- }
110
-
111
- const tool_calls = Array.from(toolCallMap.values()).map(tc => ({
112
- id: tc.id,
113
- name: tc.name,
114
- arguments: JSON.parse(tc.args || '{}'),
115
- }));
116
-
117
- return { content: textContent, tool_calls, finish_reason: finishReason, usage };
118
- }
119
-
120
- private buildMessages(request: LLMRequest): ChatCompletionMessageParam[] {
121
- return request.messages.map(msg => {
122
- if (msg.role === 'system') return { role: 'system', content: msg.content };
123
- if (msg.role === 'user') return { role: 'user', content: msg.content };
124
- if (msg.role === 'assistant') return { role: 'assistant', content: msg.content };
125
- throw new Error(`Unsupported message role: ${msg.role}`);
126
- });
127
- }
128
-
129
- private buildTools(request: LLMRequest): ChatCompletionTool[] | undefined {
130
- return request.tools?.map(tool => ({
131
- type: 'function' as const,
132
- function: {
133
- name: tool.name,
134
- description: tool.description,
135
- parameters: tool.parameters
136
- }
137
- }));
138
- }
139
- }
@@ -1,54 +0,0 @@
1
- /**
2
- * Provider interface - aligned with @studio/contracts
3
- */
4
-
5
- import type { LLMRequest, LLMResponse } from '@studio-foundation/contracts';
6
-
7
- export interface Provider {
8
- readonly name: string;
9
- call(request: LLMRequest, onToken?: (token: string) => void, signal?: AbortSignal): Promise<LLMResponse>;
10
- }
11
-
12
- export interface ToolCallOutcome {
13
- result?: unknown;
14
- error?: string;
15
- }
16
-
17
- /**
18
- * Extended interface for providers that own the full agent loop.
19
- * Used by providers (e.g. OpenAI Responses API) where multi-turn
20
- * tool calling cannot be expressed as plain text messages.
21
- */
22
- export interface AgentLoopProvider extends Provider {
23
- runAgentLoop(
24
- request: LLMRequest,
25
- executeTool: (name: string, args: Record<string, unknown>, callId: string) => Promise<ToolCallOutcome>,
26
- onToken?: (token: string) => void,
27
- signal?: AbortSignal
28
- ): Promise<AgentLoopResult>;
29
- }
30
-
31
- /**
32
- * Full result of an agent loop execution.
33
- * Distinct from LLMResponse (in @studio/contracts) because it includes
34
- * per-tool-call execution outcomes (result/error) that are only known
35
- * after the runner has actually invoked each tool.
36
- */
37
- export interface AgentLoopResult {
38
- content: string;
39
- tool_calls: Array<{
40
- id: string;
41
- name: string;
42
- arguments: Record<string, unknown>;
43
- } & ToolCallOutcome>;
44
- finish_reason: string;
45
- usage?: {
46
- prompt_tokens: number;
47
- completion_tokens: number;
48
- total_tokens: number;
49
- };
50
- }
51
-
52
- export function isAgentLoopProvider(p: Provider): p is AgentLoopProvider {
53
- return typeof (p as AgentLoopProvider).runAgentLoop === 'function';
54
- }
@@ -1,77 +0,0 @@
1
- /**
2
- * Provider registry - factory for LLM providers
3
- */
4
-
5
- import type { Provider } from './provider.js';
6
- import { OpenAIProvider } from './openai.js';
7
- import { AnthropicProvider } from './anthropic.js';
8
- import { OpenAIResponsesProvider } from './openai-responses.js';
9
- import { OllamaProvider } from './ollama.js';
10
-
11
- export class ProviderRegistry {
12
- private providers: Map<string, Provider> = new Map();
13
-
14
- /**
15
- * Register a provider instance
16
- */
17
- register(provider: Provider): void {
18
- this.providers.set(provider.name, provider);
19
- }
20
-
21
- /**
22
- * Get provider by name
23
- */
24
- get(name: string): Provider {
25
- const provider = this.providers.get(name);
26
- if (!provider) {
27
- const available = this.list();
28
- const detail = available.length > 0 ? available.join(', ') : '(none registered)';
29
- throw new Error(`Provider not found: ${name}. Available providers: ${detail}`);
30
- }
31
- return provider;
32
- }
33
-
34
- /**
35
- * Check if provider exists
36
- */
37
- has(name: string): boolean {
38
- return this.providers.has(name);
39
- }
40
-
41
- /**
42
- * List available provider names
43
- */
44
- list(): string[] {
45
- return Array.from(this.providers.keys());
46
- }
47
- }
48
-
49
- /**
50
- * Factory function to create a registry with default providers
51
- */
52
- export function createDefaultRegistry(config: {
53
- openai?: { apiKey: string; baseUrl?: string };
54
- anthropic?: { apiKey: string };
55
- openaiResponses?: { apiKey: string };
56
- ollama?: { baseUrl?: string };
57
- }): ProviderRegistry {
58
- const registry = new ProviderRegistry();
59
-
60
- if (config.openai) {
61
- registry.register(new OpenAIProvider(config.openai.apiKey, config.openai.baseUrl));
62
- }
63
-
64
- if (config.anthropic) {
65
- registry.register(new AnthropicProvider(config.anthropic.apiKey));
66
- }
67
-
68
- if (config.openaiResponses) {
69
- registry.register(new OpenAIResponsesProvider(config.openaiResponses.apiKey));
70
- }
71
-
72
- if (config.ollama) {
73
- registry.register(new OllamaProvider(config.ollama.baseUrl));
74
- }
75
-
76
- return registry;
77
- }
@@ -1,343 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { runAgent } from './runner.js';
3
- import { ToolRegistry } from './tools/tool-registry.js';
4
- import { ProviderRegistry } from './providers/registry.js';
5
- import { MockProvider } from './providers/mock.js';
6
- import type { ResolvedAgentConfig, LLMRequest, LLMResponse } from '@studio-foundation/contracts';
7
- import type { Provider } from './providers/provider.js';
8
-
9
- /**
10
- * A minimal Chat Completions-style provider (NOT AgentLoopProvider).
11
- * First call returns one tool call; second call returns the final content.
12
- * Tracks the messages received on each call so tests can assert on them.
13
- */
14
- class StandardProvider implements Provider {
15
- readonly name = 'standard-mock';
16
- private callCount = 0;
17
- public receivedMessages: unknown[] = [];
18
-
19
- async call(request: LLMRequest): Promise<LLMResponse> {
20
- this.receivedMessages = request.messages as unknown[];
21
- this.callCount++;
22
- if (this.callCount === 1) {
23
- return {
24
- content: '',
25
- tool_calls: [{ id: 'call-1', name: 'repo_manager-write_file', arguments: { path: '/tmp/foo.ts', content: 'hello' } }],
26
- finish_reason: 'tool_calls',
27
- };
28
- }
29
- // Second call: final response
30
- return {
31
- content: JSON.stringify({ summary: 'done' }),
32
- tool_calls: [],
33
- finish_reason: 'stop',
34
- };
35
- }
36
- }
37
-
38
- function makeConfig(toolCallName: string, toolCallArgs: Record<string, unknown>) {
39
- const toolRegistry = new ToolRegistry();
40
- const mockExecute = vi.fn().mockResolvedValue({ success: true, output: 'wrote file' });
41
- toolRegistry.register({
42
- name: toolCallName,
43
- description: 'A test tool',
44
- parameters: {
45
- type: 'object',
46
- properties: Object.fromEntries(
47
- Object.keys(toolCallArgs).map(k => [k, { type: 'string' }])
48
- ),
49
- },
50
- execute: mockExecute,
51
- });
52
-
53
- const mockProvider = new MockProvider(
54
- new Map([
55
- ['test-stage', {
56
- output: { summary: 'done' },
57
- tool_calls: [{ name: toolCallName, arguments: toolCallArgs }],
58
- }],
59
- ])
60
- );
61
-
62
- const providerRegistry = new ProviderRegistry();
63
- providerRegistry.register(mockProvider);
64
-
65
- const agent: ResolvedAgentConfig = {
66
- name: 'test-agent',
67
- provider: 'mock',
68
- model: 'mock',
69
- };
70
-
71
- return { agent, toolRegistry, providerRegistry, mockExecute };
72
- }
73
-
74
- /**
75
- * A Chat Completions-style provider that always returns a tool call — simulates an infinite loop.
76
- */
77
- class LoopingProvider implements Provider {
78
- readonly name = 'looping-mock';
79
- public callCount = 0;
80
-
81
- async call(_request: LLMRequest): Promise<LLMResponse> {
82
- this.callCount++;
83
- return {
84
- content: '',
85
- tool_calls: [{ id: `call-${this.callCount}`, name: 'repo_manager-write_file', arguments: { path: '/tmp/foo.ts', content: 'hello' } }],
86
- finish_reason: 'tool_calls',
87
- };
88
- }
89
- }
90
-
91
- describe('runner — max tool iterations', () => {
92
- it('returns an error result instead of throwing when max iterations is reached', async () => {
93
- const toolRegistry = new ToolRegistry();
94
- toolRegistry.register({
95
- name: 'repo_manager-write_file',
96
- description: 'Write a file',
97
- parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } } },
98
- execute: vi.fn().mockResolvedValue({ success: true }),
99
- });
100
-
101
- const loopingProvider = new LoopingProvider();
102
- const providerRegistry = new ProviderRegistry();
103
- providerRegistry.register(loopingProvider);
104
-
105
- const agent: ResolvedAgentConfig = { name: 'test-agent', provider: 'looping-mock', model: 'mock' };
106
-
107
- const result = await runAgent({
108
- agent,
109
- task: { description: 'write a file' },
110
- context: {},
111
- toolRegistry,
112
- providerRegistry,
113
- maxToolCalls: 3,
114
- });
115
-
116
- expect(result.error).toBeDefined();
117
- expect(result.error).toContain('Maximum tool calling iterations');
118
- expect(result.error).toContain('3');
119
- expect(loopingProvider.callCount).toBe(3);
120
- });
121
-
122
- it('includes tool calls made before hitting the limit in the error result', async () => {
123
- const toolRegistry = new ToolRegistry();
124
- const mockExecute = vi.fn().mockResolvedValue({ success: true });
125
- toolRegistry.register({
126
- name: 'repo_manager-write_file',
127
- description: 'Write a file',
128
- parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } } },
129
- execute: mockExecute,
130
- });
131
-
132
- const providerRegistry = new ProviderRegistry();
133
- providerRegistry.register(new LoopingProvider());
134
-
135
- const agent: ResolvedAgentConfig = { name: 'test-agent', provider: 'looping-mock', model: 'mock' };
136
-
137
- const result = await runAgent({
138
- agent,
139
- task: { description: 'write a file' },
140
- context: {},
141
- toolRegistry,
142
- providerRegistry,
143
- maxToolCalls: 2,
144
- });
145
-
146
- expect(result.error).toBeDefined();
147
- expect(result.tool_calls).toHaveLength(2);
148
- expect(mockExecute).toHaveBeenCalledTimes(2);
149
- });
150
- });
151
-
152
- describe('runner — onPreToolUse callback', () => {
153
- it('blocks tool execution when callback returns blocked: true', async () => {
154
- const { agent, toolRegistry, providerRegistry, mockExecute } = makeConfig(
155
- 'repo_manager-write_file',
156
- { path: '/tmp/foo.ts', content: 'hello' }
157
- );
158
-
159
- const onPreToolUse = vi.fn().mockResolvedValue({ blocked: true, error: 'pre-hook blocked' });
160
-
161
- const result = await runAgent({
162
- agent,
163
- task: { description: 'write a file', contract_name: 'test-stage' },
164
- context: {},
165
- toolRegistry,
166
- providerRegistry,
167
- callbacks: { onPreToolUse },
168
- });
169
-
170
- // Tool should appear in tool_calls with error (not actually executed)
171
- expect(result.tool_calls).toHaveLength(1);
172
- expect(result.tool_calls[0].error).toContain('pre-hook blocked');
173
- expect(result.tool_calls[0].result).toBeUndefined();
174
- expect(mockExecute).not.toHaveBeenCalled();
175
- });
176
-
177
- it('allows tool execution when callback returns blocked: false', async () => {
178
- const { agent, toolRegistry, providerRegistry, mockExecute } = makeConfig(
179
- 'repo_manager-write_file',
180
- { path: '/tmp/foo.ts', content: 'hello' }
181
- );
182
-
183
- const onPreToolUse = vi.fn().mockResolvedValue({ blocked: false });
184
-
185
- const result = await runAgent({
186
- agent,
187
- task: { description: 'write a file', contract_name: 'test-stage' },
188
- context: {},
189
- toolRegistry,
190
- providerRegistry,
191
- callbacks: { onPreToolUse },
192
- });
193
-
194
- expect(result.tool_calls).toHaveLength(1);
195
- expect(result.tool_calls[0].result).toBe('wrote file');
196
- expect(mockExecute).toHaveBeenCalledOnce();
197
- });
198
-
199
- it('blocks tool execution in standard (Chat Completions) path', async () => {
200
- const toolRegistry = new ToolRegistry();
201
- const mockExecute = vi.fn().mockResolvedValue({ success: true, output: 'wrote file' });
202
- toolRegistry.register({
203
- name: 'repo_manager-write_file',
204
- description: 'A test tool',
205
- parameters: {
206
- type: 'object',
207
- properties: {
208
- path: { type: 'string' },
209
- content: { type: 'string' },
210
- },
211
- },
212
- execute: mockExecute,
213
- });
214
-
215
- const standardProvider = new StandardProvider();
216
- const providerRegistry = new ProviderRegistry();
217
- providerRegistry.register(standardProvider);
218
-
219
- const agent: ResolvedAgentConfig = {
220
- name: 'test-agent',
221
- provider: 'standard-mock',
222
- model: 'mock',
223
- };
224
-
225
- const onPreToolUse = vi.fn().mockResolvedValue({ blocked: true, error: 'standard-path blocked' });
226
-
227
- const result = await runAgent({
228
- agent,
229
- task: { description: 'write a file', contract_name: 'test-stage' },
230
- context: {},
231
- toolRegistry,
232
- providerRegistry,
233
- callbacks: { onPreToolUse },
234
- });
235
-
236
- expect(result.tool_calls).toHaveLength(1);
237
- expect(result.tool_calls[0].error).toContain('standard-path blocked');
238
- expect(result.tool_calls[0].result).toBeUndefined();
239
- expect(mockExecute).not.toHaveBeenCalled();
240
- });
241
- });
242
-
243
- describe('runner — onPostToolUse callback', () => {
244
- it('is called after successful tool execution', async () => {
245
- const { agent, toolRegistry, providerRegistry } = makeConfig(
246
- 'repo_manager-write_file',
247
- { path: '/tmp/foo.ts', content: 'hello' }
248
- );
249
-
250
- const onPostToolUse = vi.fn().mockResolvedValue({});
251
-
252
- await runAgent({
253
- agent,
254
- task: { description: 'write a file', contract_name: 'test-stage' },
255
- context: {},
256
- toolRegistry,
257
- providerRegistry,
258
- callbacks: { onPostToolUse },
259
- });
260
-
261
- expect(onPostToolUse).toHaveBeenCalledOnce();
262
- expect(onPostToolUse.mock.calls[0][0]).toMatchObject({
263
- tool: 'repo_manager-write_file',
264
- params: { path: '/tmp/foo.ts', content: 'hello' },
265
- result: 'wrote file',
266
- });
267
- });
268
-
269
- it('appends hook message to conversation when returned (standard path — no-op in agent loop)', async () => {
270
- // The MockProvider uses the agent loop path, so append_message is a no-op here.
271
- // This test verifies onPostToolUse IS called and no error is thrown.
272
- const { agent, toolRegistry, providerRegistry } = makeConfig(
273
- 'repo_manager-write_file',
274
- { path: '/tmp/foo.ts', content: 'hello' }
275
- );
276
-
277
- const onPostToolUse = vi.fn().mockResolvedValue({
278
- append_message: 'prettier ran successfully',
279
- });
280
-
281
- const result = await runAgent({
282
- agent,
283
- task: { description: 'write a file', contract_name: 'test-stage' },
284
- context: {},
285
- toolRegistry,
286
- providerRegistry,
287
- callbacks: { onPostToolUse },
288
- });
289
-
290
- expect(onPostToolUse).toHaveBeenCalled();
291
- // Result still succeeds
292
- expect(result.tool_calls_count).toBe(1);
293
- });
294
-
295
- it('injects append_message into conversation in standard (Chat Completions) path', async () => {
296
- const toolRegistry = new ToolRegistry();
297
- const mockExecute = vi.fn().mockResolvedValue({ success: true, output: 'wrote file' });
298
- toolRegistry.register({
299
- name: 'repo_manager-write_file',
300
- description: 'A test tool',
301
- parameters: {
302
- type: 'object',
303
- properties: {
304
- path: { type: 'string' },
305
- content: { type: 'string' },
306
- },
307
- },
308
- execute: mockExecute,
309
- });
310
-
311
- const standardProvider = new StandardProvider();
312
- const providerRegistry = new ProviderRegistry();
313
- providerRegistry.register(standardProvider);
314
-
315
- const agent: ResolvedAgentConfig = {
316
- name: 'test-agent',
317
- provider: 'standard-mock',
318
- model: 'mock',
319
- };
320
-
321
- const onPostToolUse = vi.fn().mockResolvedValue({
322
- append_message: 'prettier ran and formatted the file',
323
- });
324
-
325
- const result = await runAgent({
326
- agent,
327
- task: { description: 'write a file', contract_name: 'test-stage' },
328
- context: {},
329
- toolRegistry,
330
- providerRegistry,
331
- callbacks: { onPostToolUse },
332
- });
333
-
334
- expect(onPostToolUse).toHaveBeenCalledOnce();
335
- expect(result.tool_calls_count).toBe(1);
336
-
337
- // Verify the post-hook message was injected into the conversation.
338
- // The second call to provider.call() should include the tool result message with the append.
339
- const secondCallMessages = standardProvider.receivedMessages as Array<{ role: string; content: string }>;
340
- const toolResultMessage = secondCallMessages.find(m => m.role === 'user' && m.content.includes('Tool execution results'));
341
- expect(toolResultMessage?.content).toContain('Post-hook note: prettier ran and formatted the file');
342
- });
343
- });