diff --git a/test/webview/AgentTracer.helpers.test.tsx b/test/webview/AgentTracer.helpers.test.tsx index 951dc695..bb1a1170 100644 --- a/test/webview/AgentTracer.helpers.test.tsx +++ b/test/webview/AgentTracer.helpers.test.tsx @@ -6,6 +6,10 @@ import { applyHistorySelection, buildTimelineItems, getStepData, + matchesTraceFilter, + entryMetadataMatches, + stepMatchesFilter, + translateStepIndexToFiltered, type TraceHistoryEntry } from '../../webview/src/components/AgentTracer/AgentTracer'; @@ -249,7 +253,7 @@ describe('AgentTracer helpers', () => { const items = buildTimelineItems(trace, () => {}); expect(items).toHaveLength(1); - expect(items[0].label).toBe('Output Evaluation'); + expect(items[0].label).toBe('Reasoning'); expect(items[0].description).toBe('SMALL_TALK'); }); @@ -694,4 +698,200 @@ describe('AgentTracer helpers', () => { items[0].onClick?.(); expect(indices).toEqual([0]); }); + + describe('matchesTraceFilter', () => { + const buildEntry = (overrides: Partial = {}): TraceHistoryEntry => ({ + storageKey: 'agent', + agentId: 'agent', + sessionId: 'session', + planId: 'plan', + userMessage: 'Hello world', + trace: { + type: 'PlanSuccessResponse', + planId: 'plan', + sessionId: 'session', + plan: [ + { type: 'UserInputStep', message: 'Check the weather' }, + { type: 'FunctionStep', function: { name: 'lookup_account', input: { accountId: 'A123' } } } + ] + }, + ...overrides + }); + + it('returns true for an empty or whitespace query', () => { + const entry = buildEntry(); + expect(matchesTraceFilter(entry, '')).toBe(true); + expect(matchesTraceFilter(entry, ' ')).toBe(true); + }); + + it('matches against the user message case-insensitively', () => { + expect(matchesTraceFilter(buildEntry(), 'HELLO')).toBe(true); + expect(matchesTraceFilter(buildEntry(), 'world')).toBe(true); + }); + + it('matches against the step type display name', () => { + expect(matchesTraceFilter(buildEntry(), 'User Input')).toBe(true); + expect(matchesTraceFilter(buildEntry(), 'action executed')).toBe(true); + }); + + it('matches against nested JSON content of a step', () => { + expect(matchesTraceFilter(buildEntry(), 'lookup_account')).toBe(true); + expect(matchesTraceFilter(buildEntry(), 'A123')).toBe(true); + }); + + it('returns false when there is no match', () => { + expect(matchesTraceFilter(buildEntry(), 'nonexistent_value')).toBe(false); + }); + + it('returns false when the trace has no plan and message does not match', () => { + const entry = buildEntry({ + userMessage: undefined, + trace: { type: 'PlanSuccessResponse', planId: 'plan', sessionId: 'session', plan: [] } + }); + expect(matchesTraceFilter(entry, 'anything')).toBe(false); + }); + }); + + describe('entryMetadataMatches', () => { + const buildEntry = (overrides: Partial = {}): TraceHistoryEntry => ({ + storageKey: 'agent', + agentId: 'agent', + sessionId: 'abc-session-123', + planId: 'xyz-plan-456', + userMessage: 'Find weather', + trace: { type: 'PlanSuccessResponse', planId: 'xyz-plan-456', sessionId: 'abc-session-123', plan: [] }, + ...overrides + }); + + it('returns true for empty query', () => { + expect(entryMetadataMatches(buildEntry(), '')).toBe(true); + expect(entryMetadataMatches(buildEntry(), ' ')).toBe(true); + }); + + it('matches against userMessage', () => { + expect(entryMetadataMatches(buildEntry(), 'weather')).toBe(true); + }); + + it('matches against sessionId', () => { + expect(entryMetadataMatches(buildEntry(), 'abc-session')).toBe(true); + }); + + it('matches against planId', () => { + expect(entryMetadataMatches(buildEntry(), 'xyz-plan')).toBe(true); + }); + + it('is case-insensitive', () => { + expect(entryMetadataMatches(buildEntry(), 'WEATHER')).toBe(true); + expect(entryMetadataMatches(buildEntry(), 'ABC-SESSION')).toBe(true); + }); + + it('returns false when nothing matches', () => { + expect(entryMetadataMatches(buildEntry(), 'nonexistent')).toBe(false); + }); + + it('returns false when fields are missing and no match', () => { + const entry = buildEntry({ userMessage: undefined, sessionId: '', planId: '' }); + expect(entryMetadataMatches(entry, 'anything')).toBe(false); + }); + }); + + describe('stepMatchesFilter', () => { + it('returns true for empty query', () => { + expect(stepMatchesFilter({ type: 'UserInputStep' }, '')).toBe(true); + }); + + it('matches by step display name', () => { + expect(stepMatchesFilter({ type: 'FunctionStep' }, 'action executed')).toBe(true); + expect(stepMatchesFilter({ type: 'OutputEvaluationStep' }, 'output evaluation')).toBe(true); + }); + + it('matches by raw step type when not in display map', () => { + expect(stepMatchesFilter({ type: 'CustomUnknownStep' }, 'CustomUnknown')).toBe(true); + }); + + it('matches against nested JSON content', () => { + const step = { type: 'FunctionStep', function: { name: 'lookup_account', input: { accountId: 'A123' } } }; + expect(stepMatchesFilter(step, 'lookup_account')).toBe(true); + expect(stepMatchesFilter(step, 'A123')).toBe(true); + }); + + it('returns false when nothing matches', () => { + expect(stepMatchesFilter({ type: 'UserInputStep', message: 'hello' }, 'goodbye')).toBe(false); + }); + }); + + describe('translateStepIndexToFiltered', () => { + const plan = [ + { type: 'UserInputStep', message: 'hello' }, + { type: 'FunctionStep', function: { name: 'lookup_account', input: { accountId: 'A123' } } }, + { type: 'ReasoningStep', reason: 'topic_selection' }, + { type: 'OutputEvaluationStep', data: { score: 0.9 } } + ]; + + it('returns undefined when index is null', () => { + expect(translateStepIndexToFiltered(plan, null, '')).toBeUndefined(); + }); + + it('returns the original index when filter is empty', () => { + expect(translateStepIndexToFiltered(plan, 2, '')).toBe(2); + expect(translateStepIndexToFiltered(plan, 2, ' ')).toBe(2); + }); + + it('returns the position of the original index in the filtered list', () => { + // Filtering for "Reasoning" matches the ReasoningStep at original index 2. + // After filtering only that step survives, so its filtered index is 0. + expect(translateStepIndexToFiltered(plan, 2, 'topic_selection')).toBe(0); + }); + + it('translates correctly when the original index is the second match in the filtered list', () => { + // "step" matches multiple types. The OutputEvaluationStep at original index 3 + // would be the 4th in a typical filter; for a more controlled case use a query that + // matches indices 1 and 3 only. + const result = translateStepIndexToFiltered(plan, 3, 'OutputEvaluationStep'); + expect(result).toBe(0); // only step 3 matches "OutputEvaluationStep" by raw type + }); + + it('returns undefined when the original index does not match the filter', () => { + // Filter for "A123" only matches index 1; index 0 does not survive. + expect(translateStepIndexToFiltered(plan, 0, 'A123')).toBeUndefined(); + }); + + it('returns undefined when the plan is missing', () => { + expect(translateStepIndexToFiltered(undefined, 0, '')).toBeUndefined(); + }); + }); + + describe('buildTimelineItems with filter', () => { + const trace = { + type: 'PlanSuccessResponse', + planId: 'p', + sessionId: 's', + plan: [ + { type: 'UserInputStep', message: 'hello' }, + { type: 'FunctionStep', function: { name: 'lookup_account', input: { accountId: 'A123' } } }, + { type: 'ReasoningStep', reason: 'topic_selector' } + ] + }; + + it('returns all items when filter is empty', () => { + const items = buildTimelineItems(trace, () => {}); + expect(items).toHaveLength(3); + }); + + it('filters timeline items by step content', () => { + const items = buildTimelineItems(trace, () => {}, 'A123'); + expect(items).toHaveLength(1); + expect(items[0].label).toContain('Action Executed'); + }); + + it('filters timeline items by step display name', () => { + const items = buildTimelineItems(trace, () => {}, 'reasoning'); + expect(items).toHaveLength(1); + }); + + it('returns empty array when no steps match', () => { + const items = buildTimelineItems(trace, () => {}, 'no_match_xyz'); + expect(items).toHaveLength(0); + }); + }); }); diff --git a/test/webview/AgentTracer.test.tsx b/test/webview/AgentTracer.test.tsx index f951fc56..886047c1 100644 --- a/test/webview/AgentTracer.test.tsx +++ b/test/webview/AgentTracer.test.tsx @@ -626,4 +626,336 @@ describe('AgentTracer', () => { expect(screen.getByRole('button', { name: /first/i })).toHaveAttribute('aria-expanded', 'false'); expect(screen.getByRole('button', { name: /second/i })).toHaveAttribute('aria-expanded', 'false'); }); + + describe('search filter', () => { + const renderWithEntries = () => { + render(); + const trace1 = { + type: 'PlanSuccessResponse', + planId: 'plan-1', + sessionId: 'session-1', + plan: [{ type: 'FunctionStep', function: { name: 'lookup_account', input: { accountId: 'A123' } } }] + }; + const trace2 = { + type: 'PlanSuccessResponse', + planId: 'plan-2', + sessionId: 'session-2', + plan: [{ type: 'UserInputStep', message: 'check the weather' }] + }; + dispatchMessage('traceHistory', { + entries: [ + { storageKey: 'agent', agentId: 'agent', planId: 'plan-1', sessionId: 'session-1', userMessage: 'Find account info', trace: trace1 }, + { storageKey: 'agent', agentId: 'agent', planId: 'plan-2', sessionId: 'session-2', userMessage: 'What is the weather', trace: trace2 } + ] + }); + }; + + it('renders the filter input when trace history is present', () => { + renderWithEntries(); + expect(screen.getByLabelText(/Filter trace history/i)).toBeInTheDocument(); + }); + + it('filters entries by user message text', () => { + renderWithEntries(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'weather' } }); + + expect(screen.queryByText('Find account info')).not.toBeInTheDocument(); + expect(screen.getByText('What is the weather')).toBeInTheDocument(); + expect(screen.getByText('1 of 2')).toBeInTheDocument(); + }); + + it('filters by JSON content within steps', () => { + renderWithEntries(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'A123' } }); + + expect(screen.getByText('Find account info')).toBeInTheDocument(); + expect(screen.queryByText('What is the weather')).not.toBeInTheDocument(); + }); + + it('shows empty-state message when nothing matches', () => { + renderWithEntries(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'no_match_xyz' } }); + + expect(screen.getByText(/No traces match/i)).toBeInTheDocument(); + expect(screen.getByText(/0 of 2/)).toBeInTheDocument(); + }); + + it('clears the filter when the clear button is clicked', () => { + renderWithEntries(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'weather' } }); + expect(screen.queryByText('Find account info')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText(/Clear filter/i)); + expect(input.value).toBe(''); + expect(screen.getByText('Find account info')).toBeInTheDocument(); + expect(screen.getByText('What is the weather')).toBeInTheDocument(); + }); + + it('filters by sessionId', () => { + renderWithEntries(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'session-1' } }); + + expect(screen.getByText('Find account info')).toBeInTheDocument(); + expect(screen.queryByText('What is the weather')).not.toBeInTheDocument(); + }); + + it('filters by planId', () => { + renderWithEntries(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'plan-2' } }); + + expect(screen.queryByText('Find account info')).not.toBeInTheDocument(); + expect(screen.getByText('What is the weather')).toBeInTheDocument(); + }); + + it('does not show the counter when the filter is empty', () => { + renderWithEntries(); + expect(screen.queryByText(/of \d+/)).not.toBeInTheDocument(); + }); + + it('does not show the clear button when the filter is empty', () => { + renderWithEntries(); + expect(screen.queryByLabelText(/Clear filter/i)).not.toBeInTheDocument(); + }); + + describe('step-level filtering and counting', () => { + const renderMultiStep = () => { + render(); + const trace = { + type: 'PlanSuccessResponse', + planId: 'plan-A', + sessionId: 'session-A', + plan: [ + { type: 'UserInputStep', message: 'hello' }, + { type: 'FunctionStep', function: { name: 'lookup_account', input: { accountId: 'A123' } } }, + { type: 'ReasoningStep', reason: 'topic_selection' }, + { type: 'OutputEvaluationStep', data: { score: 0.9 } } + ] + }; + dispatchMessage('traceHistory', { + entries: [ + { + storageKey: 'agent', + agentId: 'agent', + planId: 'plan-A', + sessionId: 'session-A', + userMessage: 'My question', + trace + } + ] + }); + }; + + it('counts total steps when filter is empty', () => { + renderMultiStep(); + expect(screen.queryByText(/of 4/)).not.toBeInTheDocument(); + }); + + it('counts only matching steps when query matches step content', () => { + renderMultiStep(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'A123' } }); + + expect(screen.getByText('1 of 4')).toBeInTheDocument(); + }); + + it('counts all steps in entry when query matches entry metadata', () => { + renderMultiStep(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'My question' } }); + + expect(screen.getByText('4 of 4')).toBeInTheDocument(); + }); + + it('counts all steps when query matches sessionId', () => { + renderMultiStep(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'session-A' } }); + + expect(screen.getByText('4 of 4')).toBeInTheDocument(); + }); + + it('hides non-matching steps inside the timeline', () => { + renderMultiStep(); + + // before filtering, all 4 step types should appear in the timeline + expect(screen.getAllByText(/User Input/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Action Executed/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Reasoning/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Output Evaluation/i).length).toBeGreaterThan(0); + + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'A123' } }); + + // only the FunctionStep should remain visible + expect(screen.getAllByText(/Action Executed/i).length).toBeGreaterThan(0); + expect(screen.queryByText(/User Input/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Output Evaluation/i)).not.toBeInTheDocument(); + }); + + it('keeps all steps visible when query matches entry metadata', () => { + renderMultiStep(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'My question' } }); + + expect(screen.getAllByText(/User Input/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Action Executed/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Output Evaluation/i).length).toBeGreaterThan(0); + }); + + const findTimelineItem = (label: RegExp): Element => { + const labels = document.querySelectorAll('.vscode-timeline-item__label'); + for (const labelEl of Array.from(labels)) { + if (label.test(labelEl.textContent ?? '')) { + const item = labelEl.closest('.vscode-timeline-item'); + if (item) return item; + } + } + throw new Error(`Timeline item matching ${label} not found`); + }; + + it('highlights the selected step under the filter using the filtered index', () => { + renderMultiStep(); + + // Click the FunctionStep (original index 1) to select it. + const functionStepItem = findTimelineItem(/Action Executed/i); + fireEvent.click(functionStepItem); + + // Confirm it is rendered as selected before any filter is applied. + expect(document.querySelectorAll('.vscode-timeline-item--selected').length).toBe(1); + + // Apply a filter that hides earlier steps so the selected step's filtered + // position differs from its original plan index. + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'A123' } }); + + // The FunctionStep should still be the only selected timeline item, even though + // its filtered position (0) differs from its original plan index (1). + const selectedAfterFilter = document.querySelectorAll('.vscode-timeline-item--selected'); + expect(selectedAfterFilter.length).toBe(1); + expect(selectedAfterFilter[0].textContent).toMatch(/Action Executed/i); + }); + + it('does not highlight a step that is filtered out', () => { + renderMultiStep(); + + // Select the UserInputStep first + const userInputItem = findTimelineItem(/User Input/i); + fireEvent.click(userInputItem); + expect(document.querySelectorAll('.vscode-timeline-item--selected').length).toBe(1); + + // Filter out everything except the FunctionStep + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'A123' } }); + + // No timeline item should be marked selected + expect(document.querySelectorAll('.vscode-timeline-item--selected').length).toBe(0); + }); + }); + + it('clears the filter when a new traceHistory message arrives', () => { + render(); + const trace = { + type: 'PlanSuccessResponse', + planId: 'plan-A', + sessionId: 'session-A', + plan: [{ type: 'UserInputStep', message: 'hello' }] + }; + dispatchMessage('traceHistory', { + entries: [ + { + storageKey: 'agent', + agentId: 'agent', + planId: 'plan-A', + sessionId: 'session-A', + userMessage: 'first', + trace + } + ] + }); + + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'no_match_xyz' } }); + expect(input.value).toBe('no_match_xyz'); + + // New batch arrives + dispatchMessage('traceHistory', { + entries: [ + { + storageKey: 'agent', + agentId: 'agent', + planId: 'plan-B', + sessionId: 'session-B', + userMessage: 'second', + trace: { ...trace, planId: 'plan-B', sessionId: 'session-B' } + } + ] + }); + + const inputAfter = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + expect(inputAfter.value).toBe(''); + expect(screen.getByText('second')).toBeInTheDocument(); + }); + + it('clears the filter when sessionStarted fires', () => { + render(); + const trace = { + type: 'PlanSuccessResponse', + planId: 'plan-A', + sessionId: 'session-A', + plan: [{ type: 'UserInputStep', message: 'hello' }] + }; + dispatchMessage('traceHistory', { + entries: [ + { + storageKey: 'agent', + agentId: 'agent', + planId: 'plan-A', + sessionId: 'session-A', + userMessage: 'first', + trace + } + ] + }); + + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'something' } }); + expect(input.value).toBe('something'); + + dispatchMessage('sessionStarted', {}); + + // After sessionStarted, the placeholder is shown (no entries). Send a new history + // and verify the filter input renders empty. + dispatchMessage('traceHistory', { + entries: [ + { + storageKey: 'agent', + agentId: 'agent', + planId: 'plan-B', + sessionId: 'session-B', + userMessage: 'second', + trace: { ...trace, planId: 'plan-B', sessionId: 'session-B' } + } + ] + }); + + const inputAfter = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + expect(inputAfter.value).toBe(''); + }); + }); }); diff --git a/webview/src/components/AgentTracer/AgentTracer.css b/webview/src/components/AgentTracer/AgentTracer.css index c9644aec..ee9894d8 100644 --- a/webview/src/components/AgentTracer/AgentTracer.css +++ b/webview/src/components/AgentTracer/AgentTracer.css @@ -66,6 +66,82 @@ body.vscode-high-contrast .tracer-loading .loading-spinner { padding-bottom: 16px; } +.trace-filter { + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px 8px; + flex-shrink: 0; +} + +.trace-filter__input-wrapper { + position: relative; + flex: 1; + display: flex; + align-items: center; +} + +.trace-filter__input { + width: 100%; + padding: 6px 28px 6px 8px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 3px; + font-size: 13px; + font-family: var(--vscode-font-family); + outline: none; +} + +.trace-filter__input--with-count { + padding-right: 70px; +} + +.trace-filter__input::placeholder { + color: var(--vscode-input-placeholderForeground); +} + +.trace-filter__input:focus { + border-color: var(--vscode-focusBorder); +} + +.trace-filter__clear { + position: absolute; + right: 4px; + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + background: transparent; + border: none; + border-radius: 2px; + color: var(--vscode-descriptionForeground); + cursor: pointer; +} + +.trace-filter__clear:hover { + background: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.trace-filter__count { + position: absolute; + right: 28px; + font-size: 11px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; + pointer-events: none; +} + +.trace-filter__empty { + padding: 16px 12px; + font-size: 12px; + color: var(--vscode-descriptionForeground); + text-align: center; +} + .trace-history-selector { width: 100%; flex-shrink: 0; diff --git a/webview/src/components/AgentTracer/AgentTracer.tsx b/webview/src/components/AgentTracer/AgentTracer.tsx index f27795d5..c8406ed6 100644 --- a/webview/src/components/AgentTracer/AgentTracer.tsx +++ b/webview/src/components/AgentTracer/AgentTracer.tsx @@ -100,6 +100,89 @@ export const selectHistoryEntry = (entries: TraceHistoryEntry[], index: number): return entries[index].trace ?? null; }; +export const translateStepIndexToFiltered = ( + plan: any[] | undefined, + originalStepIndex: number | null, + filterQuery: string +): number | undefined => { + if (originalStepIndex === null || originalStepIndex === undefined) { + return undefined; + } + if (!Array.isArray(plan)) { + return undefined; + } + const trimmed = (filterQuery ?? '').trim(); + if (!trimmed) { + return originalStepIndex; + } + + let filteredIndex = -1; + for (let i = 0; i < plan.length; i++) { + if (stepMatchesFilter(plan[i], trimmed)) { + filteredIndex++; + if (i === originalStepIndex) { + return filteredIndex; + } + } + } + return undefined; +}; + +export const stepMatchesFilter = (step: any, query: string): boolean => { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) { + return true; + } + const stepType = step?.type || step?.stepType || ''; + const displayName = STEP_DISPLAY_NAMES[stepType] || stepType; + if (displayName && displayName.toLowerCase().includes(trimmed)) { + return true; + } + try { + if (JSON.stringify(step).toLowerCase().includes(trimmed)) { + return true; + } + } catch { + // ignore stringify failures (e.g. circular refs) + } + return false; +}; + +export const entryMetadataMatches = (entry: TraceHistoryEntry, query: string): boolean => { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) { + return true; + } + if (entry.userMessage && entry.userMessage.toLowerCase().includes(trimmed)) { + return true; + } + if (entry.sessionId && entry.sessionId.toLowerCase().includes(trimmed)) { + return true; + } + if (entry.planId && entry.planId.toLowerCase().includes(trimmed)) { + return true; + } + return false; +}; + +export const matchesTraceFilter = (entry: TraceHistoryEntry, query: string): boolean => { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) { + return true; + } + + if (entryMetadataMatches(entry, trimmed)) { + return true; + } + + const plan = entry.trace?.plan; + if (!Array.isArray(plan)) { + return false; + } + + return plan.some(step => stepMatchesFilter(step, trimmed)); +}; + export const applyHistorySelection = ( entries: TraceHistoryEntry[], preferredIndex: number | null @@ -129,7 +212,9 @@ const STEP_DISPLAY_NAMES: Record = { VariableUpdateStep: 'Variable Update', TransitionStep: 'Subagent Transition', BeforeReasoningStep: 'Before Reasoning', - ReasoningStep: 'Output Evaluation', + BeforeReasoningIterationStep: 'Before Reasoning Iteration', + ReasoningStep: 'Reasoning', + OutputEvaluationStep: 'Output Evaluation', PlannerResponseStep: 'Agent Response', UpdateTopicStep: 'Subagent Selected', FunctionStep: 'Action Executed' @@ -145,7 +230,9 @@ const STEP_ICONS: Record = { VariableUpdateStep: 'symbol-namespace', TransitionStep: 'arrow-right', BeforeReasoningStep: 'checklist', - ReasoningStep: 'search', + BeforeReasoningIterationStep: 'checklist', + ReasoningStep: 'lightbulb', + OutputEvaluationStep: 'search', PlannerResponseStep: 'agent', UpdateTopicStep: 'tag', FunctionStep: 'action' @@ -242,13 +329,16 @@ const getStepDescription = (step: any): string | undefined => { export const buildTimelineItems = ( traceData: PlanSuccessResponse | null, - onSelect: (index: number) => void + onSelect: (index: number) => void, + filterQuery?: string ): TimelineItemProps[] => { if (!traceData || !traceData.plan) { return []; } - return traceData.plan.map((step: any, index: number) => { + const trimmedFilter = filterQuery?.trim() ?? ''; + + const items = traceData.plan.map((step: any, index: number) => { const stepType = step.type || step.stepType || ''; const stepName = step.name || step.label || step.description || ''; @@ -283,9 +373,18 @@ export const buildTimelineItems = ( label, description, icon, - onClick: hasData ? () => onSelect(index) : undefined - }; + onClick: hasData ? () => onSelect(index) : undefined, + _step: step + } as TimelineItemProps & { _step: any }; }); + + if (!trimmedFilter) { + return items.map(({ _step, ...item }) => item); + } + + return items + .filter(item => stepMatchesFilter(item._step, trimmedFilter)) + .map(({ _step, ...item }) => item); }; export const getStepData = (traceData: PlanSuccessResponse | null, selectedStepIndex: number | null): string | null => { @@ -350,6 +449,7 @@ const AgentTracer: React.FC = ({ const [isResizing, setIsResizing] = useState(false); const [traceHistory, setTraceHistory] = useState([]); const [expandedPlanIds, setExpandedPlanIds] = useState>(new Set()); + const [filterQuery, setFilterQuery] = useState(''); const handleRowExpandedChange = useCallback((planId: string, expanded: boolean) => { setExpandedPlanIds(prev => { @@ -425,6 +525,7 @@ const AgentTracer: React.FC = ({ setSelectedStepIndex(null); setSelectedHistoryIndex(null); setExpandedPlanIds(new Set()); + setFilterQuery(''); }); // Request trace data when component mounts @@ -440,6 +541,7 @@ const AgentTracer: React.FC = ({ setSelectedStepIndex(null); setSelectedHistoryIndex(null); setExpandedPlanIds(new Set()); + setFilterQuery(''); setLoading(false); vscodeApi.postTestMessage('testTraceHistoryReceived', { entryCount: 0, entries: [] }); return; @@ -453,6 +555,7 @@ const AgentTracer: React.FC = ({ setTraceData(latestEntry.trace); setSelectedStepIndex(null); setSelectedHistoryIndex(null); + setFilterQuery(''); // Expand only the latest entry (collapse others) setExpandedPlanIds(new Set([latestEntry.planId])); @@ -490,6 +593,25 @@ const AgentTracer: React.FC = ({ const shouldShowPlaceholder = !hasTraceHistory; const selectedStepData = getStepData(traceData, selectedStepIndex); + const trimmedFilter = filterQuery.trim(); + const filteredEntries = trimmedFilter + ? traceHistory + .map((entry, index) => ({ entry, index })) + .filter(({ entry }) => matchesTraceFilter(entry, trimmedFilter)) + : traceHistory.map((entry, index) => ({ entry, index })); + const hasFilterMatches = filteredEntries.length > 0; + + const totalStepCount = traceHistory.reduce((sum, entry) => sum + (entry.trace?.plan?.length ?? 0), 0); + const matchedStepCount = trimmedFilter + ? filteredEntries.reduce((sum, { entry }) => { + const plan = entry.trace?.plan ?? []; + if (entryMetadataMatches(entry, trimmedFilter)) { + return sum + plan.length; + } + return sum + plan.filter((step: any) => stepMatchesFilter(step, trimmedFilter)).length; + }, 0) + : totalStepCount; + // Handle panel resize const handleResizeStart = (e: React.MouseEvent) => { e.preventDefault(); @@ -529,28 +651,71 @@ const AgentTracer: React.FC = ({ ) : hasTraceHistory ? (
-
- {traceHistory.map((entry, index) => { - const { message } = formatHistoryParts(entry, index); - const timelineItems = buildTimelineItems(entry.trace, stepIndex => - handleRowStepSelect(index, stepIndex) - ); - - return ( - handleRowExpandedChange(entry.planId, expanded)} - onOpenJson={() => handleRowOpenJson(entry)} - timelineItems={timelineItems} - message={message} - selectedStepIndex={selectedHistoryIndex === index ? selectedStepIndex ?? undefined : undefined} - /> - ); - })} +
+
+ setFilterQuery(e.target.value)} + aria-label="Filter trace history" + /> + {trimmedFilter && ( + + {matchedStepCount} of {totalStepCount} + + )} + {filterQuery && ( + + )} +
+ {hasFilterMatches ? ( +
+ {filteredEntries.map(({ entry, index }) => { + const { message } = formatHistoryParts(entry, index); + const metadataMatches = !!trimmedFilter && entryMetadataMatches(entry, trimmedFilter); + const stepFilter = metadataMatches ? '' : trimmedFilter; + const timelineItems = buildTimelineItems( + entry.trace, + stepIndex => handleRowStepSelect(index, stepIndex), + stepFilter + ); + + const rowSelectedStepIndex = + selectedHistoryIndex === index + ? translateStepIndexToFiltered(entry.trace?.plan, selectedStepIndex, stepFilter) + : undefined; + + return ( + handleRowExpandedChange(entry.planId, expanded)} + onOpenJson={() => handleRowOpenJson(entry)} + timelineItems={timelineItems} + message={message} + selectedStepIndex={rowSelectedStepIndex} + /> + ); + })} +
+ ) : ( +
No traces match "{trimmedFilter}"
+ )}
) : shouldShowPlaceholder ? (