Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion common/src/tools/params/__tests__/coerce-to-array.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'bun:test'
import z from 'zod/v4'

import { coerceToArray, normalizeReplacementAliases } from '../utils'
import {
coerceToArray,
coerceToObject,
normalizeReplacementAliases,
} from '../utils'

describe('coerceToArray', () => {
it('passes through arrays unchanged', () => {
Expand Down Expand Up @@ -50,6 +54,25 @@ describe('coerceToArray', () => {
})
})

describe('coerceToObject', () => {
it('passes through objects unchanged', () => {
expect(coerceToObject({ key: 'value' })).toEqual({ key: 'value' })
})

it('parses a stringified JSON object', () => {
expect(coerceToObject('{"key": "value"}')).toEqual({ key: 'value' })
})

it('leaves non-JSON strings untouched', () => {
expect(coerceToObject('not-json')).toBe('not-json')
})

it('passes through arrays and primitives so validation can reject them', () => {
expect(coerceToObject(['a'])).toEqual(['a'])
expect(coerceToObject(1)).toBe(1)
})
})

describe('coerceToArray with Zod schemas', () => {
it('coerces a single string into an array for z.array(z.string())', () => {
const schema = z.object({
Expand Down Expand Up @@ -124,6 +147,23 @@ describe('coerceToArray with Zod schemas', () => {
})
})

describe('coerceToObject with Zod schemas', () => {
it('produces identical JSON schema with or without preprocess', () => {
const plain = z.object({
params: z.record(z.string(), z.any()).optional(),
})
const coerced = z.object({
params: z
.preprocess(coerceToObject, z.record(z.string(), z.any()))
.optional(),
})

const plainSchema = z.toJSONSchema(plain, { io: 'input' })
const coercedSchema = z.toJSONSchema(coerced, { io: 'input' })
expect(coercedSchema).toEqual(plainSchema)
})
})

describe('normalizeReplacementAliases', () => {
it('maps old_str and new_str onto the documented replacement keys', () => {
expect(
Expand Down
3 changes: 2 additions & 1 deletion common/src/tools/params/tool/spawn-agent-inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import z from 'zod/v4'

import {
$getNativeToolCallExampleString,
coerceToObject,
textToolResultSchema,
} from '../utils'

Expand All @@ -14,7 +15,7 @@ const inputSchema = z
agent_type: z.string().describe('Agent to spawn'),
prompt: z.string().optional().describe('Prompt to send to the agent'),
params: z
.record(z.string(), z.any())
.preprocess(coerceToObject, z.record(z.string(), z.any()))
.optional()
.describe('Parameters object for the agent (if any)'),
})
Expand Down
135 changes: 71 additions & 64 deletions common/src/tools/params/tool/spawn-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { jsonObjectSchema } from '../../../types/json'
import {
$getNativeToolCallExampleString,
coerceToArray,
coerceToObject,
jsonToolResultSchema,
} from '../utils'

Expand All @@ -27,70 +28,76 @@ const inputSchema = z
agent_type: z.string().describe('Agent to spawn'),
prompt: z.string().optional().describe('Prompt to send to the agent'),
params: z
.object({
// Common agent fields (all optional hints — each agent validates its own required fields)
command: z
.string()
.optional()
.describe('Terminal command to run (basher, tmux-cli)'),
what_to_summarize: z
.string()
.optional()
.describe(
'What information from the command output is desired (basher)',
),
timeout_seconds: z
.number()
.optional()
.describe(
'Timeout for command. Set to -1 for no timeout. Default 30 (basher)',
),
searchQueries: z
.array(
z.object({
pattern: z.string().describe('The pattern to search for'),
flags: z
.string()
.optional()
.describe(
'Optional ripgrep flags (e.g., "-i", "-g *.ts")',
),
cwd: z
.string()
.optional()
.describe(
'Optional working directory relative to project root',
),
maxResults: z
.number()
.optional()
.describe('Max results per file. Default 15'),
}),
)
.optional()
.describe('Array of code search queries (code-searcher)'),
filePaths: z
.array(z.string())
.optional()
.describe(
'Relevant file paths to read (opus-agent, gpt-5-agent)',
),
directories: z
.array(z.string())
.optional()
.describe('Directories to search within (file-picker)'),
url: z
.string()
.optional()
.describe('Starting URL to navigate to (browser-use)'),
prompts: z
.array(z.string())
.optional()
.describe(
'Array of strategy prompts (editor-multi-prompt, code-reviewer-multi-prompt)',
),
})
.catchall(z.any())
.preprocess(
coerceToObject,
z
.object({
// Common agent fields (all optional hints — each agent validates its own required fields)
command: z
.string()
.optional()
.describe('Terminal command to run (basher, tmux-cli)'),
what_to_summarize: z
.string()
.optional()
.describe(
'What information from the command output is desired (basher)',
),
timeout_seconds: z
.number()
.optional()
.describe(
'Timeout for command. Set to -1 for no timeout. Default 30 (basher)',
),
searchQueries: z
.array(
z.object({
pattern: z
.string()
.describe('The pattern to search for'),
flags: z
.string()
.optional()
.describe(
'Optional ripgrep flags (e.g., "-i", "-g *.ts")',
),
cwd: z
.string()
.optional()
.describe(
'Optional working directory relative to project root',
),
maxResults: z
.number()
.optional()
.describe('Max results per file. Default 15'),
}),
)
.optional()
.describe('Array of code search queries (code-searcher)'),
filePaths: z
.array(z.string())
.optional()
.describe(
'Relevant file paths to read (opus-agent, gpt-5-agent)',
),
directories: z
.array(z.string())
.optional()
.describe('Directories to search within (file-picker)'),
url: z
.string()
.optional()
.describe('Starting URL to navigate to (browser-use)'),
prompts: z
.array(z.string())
.optional()
.describe(
'Array of strategy prompts (editor-multi-prompt, code-reviewer-multi-prompt)',
),
})
.catchall(z.any()),
)
.optional()
.describe('Parameters object for the agent'),
})
Expand Down
25 changes: 25 additions & 0 deletions common/src/tools/params/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,31 @@ export function coerceToArray(val: unknown): unknown {
return val
}

/**
* Coerces a stringified JSON object into an object.
* This is intentionally narrow so malformed values still fail validation.
*/
export function coerceToObject(val: unknown): unknown {
if (typeof val !== 'string') {
return val
}

try {
const parsed = JSON.parse(val)
if (
parsed != null &&
typeof parsed === 'object' &&
!Array.isArray(parsed)
) {
return parsed
}
} catch {
// Leave the original value untouched so schema validation can reject it.
}

return val
}

/**
* Handles common replacement-key aliases emitted by some models while keeping
* the documented schema stable.
Expand Down
6 changes: 3 additions & 3 deletions freebuff/e2e/tests/help-command.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execSync } from 'child_process'
import { execFileSync } from 'node:child_process'

import { afterEach, describe, expect, test } from 'bun:test'

Expand All @@ -9,7 +9,7 @@ const TEST_TIMEOUT = 60_000
describe('Freebuff: --help flag', () => {
test('shows CLI usage information', () => {
const binary = requireFreebuffBinary()
const output = execSync(`'${binary}' --help`, {
const output = execFileSync(binary, ['--help'], {
encoding: 'utf-8',
timeout: 10_000,
})
Expand All @@ -23,7 +23,7 @@ describe('Freebuff: --help flag', () => {

test('does not reference Codebuff', () => {
const binary = requireFreebuffBinary()
const output = execSync(`'${binary}' --help`, {
const output = execFileSync(binary, ['--help'], {
encoding: 'utf-8',
timeout: 10_000,
})
Expand Down
8 changes: 4 additions & 4 deletions freebuff/e2e/tests/version.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execSync } from 'child_process'
import { execFileSync } from 'node:child_process'

import { describe, expect, test } from 'bun:test'

Expand All @@ -7,7 +7,7 @@ import { requireFreebuffBinary } from '../utils'
describe('Freebuff: --version', () => {
test('outputs a version string', () => {
const binary = requireFreebuffBinary()
const output = execSync(`'${binary}' --version`, {
const output = execFileSync(binary, ['--version'], {
encoding: 'utf-8',
timeout: 10_000,
}).trim()
Expand All @@ -18,7 +18,7 @@ describe('Freebuff: --version', () => {

test('exits with code 0', () => {
const binary = requireFreebuffBinary()
// execSync throws on non-zero exit codes, so if this doesn't throw, it exited 0
execSync(`'${binary}' --version`, { encoding: 'utf-8', timeout: 10_000 })
// execFileSync throws on non-zero exit codes, so if this doesn't throw, it exited 0
execFileSync(binary, ['--version'], { encoding: 'utf-8', timeout: 10_000 })
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,48 @@ describe('tool validation error handling', () => {
expect('error' in result).toBe(true)
})

it('should parse stringified params for spawn_agents entries', () => {
const result = parseRawToolCall({
rawToolCall: {
toolName: 'spawn_agents',
toolCallId: 'spawn-agents-stringified-params-tool-call-id',
input: {
agents: [
{
agent_type: 'basher',
prompt: 'Run tests',
params: '{"command":"bun test"}',
},
],
},
},
})

expect('error' in result).toBe(false)
if (!('error' in result)) {
expect(result.input.agents[0].params).toEqual({ command: 'bun test' })
}
})

it('should parse stringified params for spawn_agent_inline', () => {
const result = parseRawToolCall({
rawToolCall: {
toolName: 'spawn_agent_inline',
toolCallId: 'spawn-agent-inline-stringified-params-tool-call-id',
input: {
agent_type: 'basher',
prompt: 'Run tests',
params: '{"command":"bun test"}',
},
},
})

expect('error' in result).toBe(false)
if (!('error' in result)) {
expect(result.input.params).toEqual({ command: 'bun test' })
}
})

it('should accept old_str/new_str aliases for str_replace replacements', () => {
const result = parseRawToolCall({
rawToolCall: {
Expand Down
Loading