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
60 changes: 60 additions & 0 deletions common/src/util/__tests__/format-code-search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'bun:test'

import { formatCodeSearchOutput } from '../format-code-search'

describe('formatCodeSearchOutput', () => {
it('adds a match count and line labels', () => {
const output = formatCodeSearchOutput(
[
'src/a.ts:12:const alpha = true',
'src/a.ts:18:return alpha',
'src/b.ts:3:export const beta = false',
].join('\n'),
{ matchCount: 3 },
)

expect(output).toBe(
[
'Found 3 matches',
'src/a.ts:',
' Line 12: const alpha = true',
' Line 18: return alpha',
'',
'src/b.ts:',
' Line 3: export const beta = false',
].join('\n'),
)
})

it('uses the provided match count instead of counting context lines', () => {
const output = formatCodeSearchOutput(
[
'src/a.ts:10:const before = true',
'src/a.ts:11:const match = true',
'src/a.ts:12:const after = true',
].join('\n'),
{ matchCount: 1 },
)

expect(output).toContain('Found 1 matches')
expect(output).toContain(' Line 10: const before = true')
expect(output).toContain(' Line 11: const match = true')
expect(output).toContain(' Line 12: const after = true')
})

it('does not count native ripgrep context lines as matches', () => {
const output = formatCodeSearchOutput(
[
'src/a.ts-10-const before = true',
'src/a.ts:11:const match = true',
'src/a.ts-12-const after = true',
].join('\n'),
)

expect(output).toContain('Found 1 matches')
})

it('reports zero matches for empty output', () => {
expect(formatCodeSearchOutput('')).toBe('Found 0 matches')
})
})
88 changes: 57 additions & 31 deletions common/src/util/format-code-search.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,31 @@
/**
* Formats code search output to group matches by file.
*
* Input format: ./file.ts:line content
* Input format: ./file.ts:line:content
* Output format:
* Found 3 matches
* ./file.ts:
* line content
* another line content
* yet another line content
* Line 1: content
* Line 2: another line content
* Line 3: yet another line content
*
* (double newline between distinct files)
*
* @param stdout The raw stdout from ripgrep
* @param options.matchCount The number of actual matches, excluding context lines
* @returns Formatted output with matches grouped by file
*/
export function formatCodeSearchOutput(stdout: string): string {
export function formatCodeSearchOutput(
stdout: string,
options: { matchCount?: number } = {},
): string {
if (!stdout) {
return 'No results'
return 'Found 0 matches'
}
const lines = stdout.split('\n')
const formatted: string[] = []
const formatted: string[] = [
`Found ${options.matchCount ?? countFormattedMatches(lines)} matches`,
]
let currentFile: string | null = null

for (const line of lines) {
Expand All @@ -38,30 +45,13 @@ export function formatCodeSearchOutput(stdout: string): string {

// Use regex to find the pattern: separator + digits + separator
// This handles filenames with hyphens/colons by matching the line number pattern
let separatorIndex = -1
let filePath = ''
const parsedLine = parseRipgrepLine(line)

// Try match line pattern: filename:digits:content
const matchLinePattern = /(.*?):(\d+):(.*)$/
const matchLineMatch = line.match(matchLinePattern)
if (matchLineMatch) {
filePath = matchLineMatch[1]
separatorIndex = matchLineMatch[1].length
} else {
// Try context line pattern: filename-digits-content
const contextLinePattern = /(.*?)-(\d+)-(.*)$/
const contextLineMatch = line.match(contextLinePattern)
if (contextLineMatch) {
filePath = contextLineMatch[1]
separatorIndex = contextLineMatch[1].length
}
}

if (separatorIndex === -1) {
if (!parsedLine) {
formatted.push(line)
continue
}
const content = line.substring(separatorIndex)
const { filePath, lineNumber, content } = parsedLine

// Check if this is a new file (file paths don't start with whitespace)
if (filePath && !filePath.startsWith(' ') && !filePath.startsWith('\t')) {
Expand All @@ -73,11 +63,9 @@ export function formatCodeSearchOutput(stdout: string): string {
currentFile = filePath
// Show file path with colon on its own line
formatted.push(filePath + ':')
// Show content without leading separator on next line
formatted.push(content.substring(1))
formatted.push(` Line ${lineNumber}: ${content}`)
} else {
// Same file - just show content without leading separator
formatted.push(content.substring(1))
formatted.push(` Line ${lineNumber}: ${content}`)
}
} else {
// Line doesn't match expected format, keep as-is
Expand All @@ -87,3 +75,41 @@ export function formatCodeSearchOutput(stdout: string): string {

return formatted.join('\n')
}

function parseRipgrepLine(line: string): {
filePath: string
lineNumber: string
content: string
isContext: boolean
} | null {
// Try match line pattern: filename:digits:content
const matchLineMatch = line.match(/(.*?):(\d+):(.*)$/)
if (matchLineMatch) {
return {
filePath: matchLineMatch[1],
lineNumber: matchLineMatch[2],
content: matchLineMatch[3],
isContext: false,
}
}

// Try context line pattern: filename-digits-content
const contextLineMatch = line.match(/(.*?)-(\d+)-(.*)$/)
if (contextLineMatch) {
return {
filePath: contextLineMatch[1],
lineNumber: contextLineMatch[2],
content: contextLineMatch[3],
isContext: true,
}
}

return null
}

function countFormattedMatches(lines: string[]): number {
return lines.filter((line) => {
const parsedLine = parseRipgrepLine(line)
return parsedLine && !parsedLine.isContext
}).length
}
22 changes: 22 additions & 0 deletions packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,28 @@ describe('runProgrammaticStep', () => {
})

describe('tool execution', () => {
it('assigns deterministic per-tool ids to handleSteps tool calls', async () => {
const mockGenerator = (function* () {
yield { toolName: 'read_files', input: { paths: ['first.txt'] } }
yield { toolName: 'read_files', input: { paths: ['second.txt'] } }
yield { toolName: 'end_turn', input: {} }
})() as StepGenerator

mockTemplate.handleSteps = () => mockGenerator

await runProgrammaticStep(mockParams)

expect(executeToolCallSpy.mock.calls[0][0].toolCallId).toBe(
'functions.read_files:0',
)
expect(executeToolCallSpy.mock.calls[1][0].toolCallId).toBe(
'functions.read_files:1',
)
expect(executeToolCallSpy.mock.calls[2][0].toolCallId).toBe(
'functions.end_turn:0',
)
})

it('should not add tool call message for add_message tool', async () => {
const mockGenerator = (function* () {
yield {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,13 +401,16 @@ describe('tool validation error handling', () => {
)
expect(toolCallEvents.length).toBe(1)
expect(toolCallEvents[0].toolName).toBe('read_files')
expect(toolCallEvents[0].toolCallId).toBe('functions.read_files:0')

// Verify tool_result event was emitted
const toolResultEvents = responseChunks.filter(
(chunk): chunk is Extract<PrintModeEvent, { type: 'tool_result' }> =>
typeof chunk !== 'string' && chunk.type === 'tool_result',
)
expect(toolResultEvents.length).toBe(1)
expect(toolResultEvents[0].toolName).toBe('read_files')
expect(toolResultEvents[0].toolCallId).toBe('functions.read_files:0')

// Verify NO error events
const errorEvents = responseChunks.filter(
Expand Down
10 changes: 7 additions & 3 deletions packages/agent-runtime/src/run-programmatic-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { cloneDeep } from 'lodash'
import { clearProposedContentForRun } from './tools/handlers/tool/proposed-content-store'
import { executeToolCall } from './tools/tool-executor'
import { parseTextWithToolCalls } from './util/parse-tool-calls-from-text'

import { createToolCallIdGenerator } from './util/tool-call-id'

import type { FileProcessingState } from './tools/handlers/tool/write-file'
import type { ExecuteToolCallParams } from './tools/tool-executor'
Expand Down Expand Up @@ -213,6 +213,7 @@ export async function runProgrammaticStep(
let toolResult: ToolResultOutput[] | undefined = undefined
let endTurn = false
let generateN: number | undefined = undefined
const getToolCallId = createToolCallIdGenerator(agentState.messageHistory)

let startTime = new Date()
let creditsBefore = agentState.directCreditsUsed
Expand Down Expand Up @@ -273,6 +274,7 @@ export async function runProgrammaticStep(
previousToolCallFinished: Promise.resolve(),
toolCalls,
toolResults,
getToolCallId,
onResponseChunk,
})
}
Expand Down Expand Up @@ -301,6 +303,7 @@ export async function runProgrammaticStep(
previousToolCallFinished: Promise.resolve(),
toolCalls,
toolResults,
getToolCallId,
onResponseChunk,
})

Expand Down Expand Up @@ -432,6 +435,7 @@ type ExecuteToolCallsArrayParams = Omit<
| 'toolResultsToAddToMessageHistory'
> & {
agentState: AgentState
getToolCallId: (toolName: string) => string
onResponseChunk: (chunk: string | PrintModeEvent) => void
}

Expand All @@ -445,7 +449,7 @@ async function executeSingleToolCall(
toolCallToExecute: ToolCallToExecute,
params: ExecuteToolCallsArrayParams,
): Promise<ToolResultOutput[] | undefined> {
const { agentState, onResponseChunk, toolResults } = params
const { agentState, getToolCallId, onResponseChunk, toolResults } = params

// Note: We don't check if the tool is available for the agent template anymore.
// You can run any tool from handleSteps now!
Expand All @@ -455,7 +459,7 @@ async function executeSingleToolCall(
// )
// }

const toolCallId = crypto.randomUUID()
const toolCallId = getToolCallId(toolCallToExecute.toolName)
const excludeToolFromMessageHistory =
toolCallToExecute.includeToolCall === false

Expand Down
4 changes: 0 additions & 4 deletions packages/agent-runtime/src/tool-stream-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ export async function* processStreamWithTools(params: {
}
trackEvent: TrackEventFn
executeXmlToolCall: (params: {
toolCallId: string
toolName: string
input: Record<string, unknown>
}) => Promise<void>
Expand Down Expand Up @@ -150,12 +149,9 @@ export async function* processStreamWithTools(params: {

// Then process and yield any XML tool calls found
for (const toolCall of toolCalls) {
const toolCallId = `xml-${crypto.randomUUID().slice(0, 8)}`

// Execute the tool immediately if callback provided, pausing the stream
// The callback handles emitting tool_call and tool_result events
await executeXmlToolCall({
toolCallId,
toolName: toolCall.toolName,
input: toolCall.input,
})
Expand Down
15 changes: 8 additions & 7 deletions packages/agent-runtime/src/tools/stream-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
assistantMessage,
userMessage,
} from '@codebuff/common/util/messages'
import { generateCompactId } from '@codebuff/common/util/string'

import { processStreamWithTools } from '../tool-stream-parser'
import { INCLUDE_REASONING_IN_MESSAGE_HISTORY } from '../constants'
Expand All @@ -14,6 +13,7 @@ import {
executeToolCall,
tryTransformAgentToolCall,
} from './tool-executor'
import { createToolCallIdGenerator } from '../util/tool-call-id'
import { withSystemTags } from '../util/messages'

import type { CustomToolCall, ExecuteToolCallParams } from './tool-executor'
Expand Down Expand Up @@ -91,6 +91,7 @@ export async function processStream(
const toolCalls: (CodebuffToolCall | CustomToolCall)[] = []
const toolCallsToAddToMessageHistory: (CodebuffToolCall | CustomToolCall)[] = []
const assistantMessages: Message[] = []
const getToolCallId = createToolCallIdGenerator(params.messages)
let hadToolCallError = false
const errorMessages: Message[] = []
const { promise: streamDonePromise, resolve: resolveStreamDonePromise } =
Expand Down Expand Up @@ -137,7 +138,6 @@ export async function processStream(
if (signal.aborted) {
return
}
const toolCallId = generateCompactId()
const isNativeTool = toolNames.includes(toolName as ToolName)

// Check if this is an agent tool call that should be transformed to spawn_agents
Expand All @@ -160,19 +160,20 @@ export async function processStream(
// Determine which executor to use and with what parameters
let toolPromise: Promise<void>
if (isNativeTool || transformed) {
const effectiveToolName = transformed
? transformed.toolName
: (toolName as ToolName)
// Use executeToolCall for native tools or transformed agent calls
toolPromise = executeToolCall({
...params,
toolName: transformed
? transformed.toolName
: (toolName as ToolName),
toolName: effectiveToolName,
input: transformed ? transformed.input : input,
fromHandleSteps: false,

fileProcessingState,
fullResponse: fullResponseChunks.join(''),
previousToolCallFinished: previousPromise,
toolCallId,
toolCallId: getToolCallId(effectiveToolName),
toolCalls,
toolCallsToAddToMessageHistory,
toolResults,
Expand All @@ -191,7 +192,7 @@ export async function processStream(
fileProcessingState,
fullResponse: fullResponseChunks.join(''),
previousToolCallFinished: previousPromise,
toolCallId,
toolCallId: getToolCallId(toolName),
toolCalls,
toolCallsToAddToMessageHistory,
toolResults,
Expand Down
Loading
Loading