Skip to content

Commit 504d553

Browse files
Implement review suggestion
Signed-off-by: Leslie Lazzarino <leslie.lazzarino@gmail.com>
1 parent 01ed2a3 commit 504d553

5 files changed

Lines changed: 120 additions & 24 deletions

File tree

‎src/__tests__/reportTools.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,46 @@ describe("reportTools", () => {
558558
});
559559

560560
describe("aggregate_messages", () => {
561+
test("falls back to the same header matches as search_messages, incl. single-term matches", async () => {
562+
vi.useFakeTimers();
563+
try {
564+
const query = vi.fn(async (info: Record<string, unknown>) => {
565+
if (info.fullText) return new Promise(() => {});
566+
return {
567+
messages: [
568+
{ id: 1, subject: "Lunch", author: "a@x.com", recipients: [], date: new Date("2026-01-01") },
569+
{ id: 2, subject: "Würth Zugang", author: "b@x.com", recipients: [], date: new Date("2026-01-02") },
570+
{ id: 3, subject: "Passwort", author: "c@x.com", recipients: [], date: new Date("2026-01-03") },
571+
],
572+
};
573+
});
574+
setBrowser({ query });
575+
const handlers = createReportToolHandlers({ ...BASE_SCOPE });
576+
577+
// Three terms, but no header matches two of them: single-term matches are counted, as search does.
578+
const pending = handlers.aggregate_messages({
579+
groupBy: "author",
580+
query: "wuerth passwort service",
581+
}) as Promise<{
582+
totalMatched: number;
583+
groups: Array<{ key: string; count: number }>;
584+
note?: string;
585+
}>;
586+
await vi.advanceTimersByTimeAsync(10_000);
587+
const result = await pending;
588+
expect(result.totalMatched).toBe(2);
589+
expect(result.groups.map((g) => g.key).sort()).toEqual(["b@x.com", "c@x.com"]);
590+
expect(result.note).toMatch(/bodies were NOT searched/i);
591+
592+
const search = (await handlers.search_messages({ query: "wuerth passwort service" })) as {
593+
hits: Array<{ id: number }>;
594+
};
595+
expect(search.hits.map((h) => h.id).sort()).toEqual([2, 3]);
596+
} finally {
597+
vi.useRealTimers();
598+
}
599+
});
600+
561601
test("counts messages grouped by author", async () => {
562602
const query = vi.fn().mockResolvedValue({
563603
id: undefined,

‎src/__tests__/reportTrace.test.ts‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from "vitest";
1+
import { describe, expect, it, vi } from "vitest";
22
import type { LlmApiRequestBody, LlmTextCompletionResponse } from "../llmConnection";
33
import { LlmRoles } from "../llmConnection";
44
import { DEFAULT_OPTIONS, type Options } from "../optionsParams";
@@ -66,8 +66,8 @@ describe("ReportTrace", () => {
6666
const trace = newTrace();
6767
trace.llmRequest("https://llm.example", requestBody(["ask"]));
6868
trace.llmResponse(completion(null), 120);
69-
trace.toolCall("search_messages", { query: "foo" });
70-
trace.toolResult("search_messages", '{"hits":3}');
69+
const startedAt = trace.toolCall("search_messages", { query: "foo" });
70+
trace.toolResult("search_messages", '{"hits":3}', startedAt);
7171
trace.llmRequest("https://llm.example", requestBody(["ask", "tool result"]));
7272
trace.llmResponse(completion("the report"), 200);
7373
trace.finish("success", { report: "the report" });
@@ -88,6 +88,37 @@ describe("ReportTrace", () => {
8888
);
8989
});
9090

91+
it("times concurrent tool calls individually and counts their overlap once in toolMs", () => {
92+
vi.useFakeTimers();
93+
try {
94+
const trace = newTrace();
95+
const a = trace.toolCall("search_messages", {}); // t=0
96+
vi.advanceTimersByTime(100);
97+
const b = trace.toolCall("get_thread", {}); // t=100
98+
vi.advanceTimersByTime(50);
99+
trace.toolResult("get_thread", "{}", b); // t=150
100+
vi.advanceTimersByTime(250);
101+
trace.toolResult("search_messages", "{}", a); // t=400
102+
vi.advanceTimersByTime(100);
103+
const c = trace.toolCall("get_messages", {}); // t=500, after an idle gap
104+
vi.advanceTimersByTime(30);
105+
trace.toolResult("get_messages", "{}", c); // t=530
106+
107+
const json = trace.toJson();
108+
const durations = (json.events as Array<Record<string, unknown>>)
109+
.filter((e) => e.type === "tool-result")
110+
.map((e) => [e.name, e.durationMs]);
111+
expect(durations).toEqual([
112+
["get_thread", 50],
113+
["search_messages", 400],
114+
["get_messages", 30],
115+
]);
116+
expect((json.totals as Record<string, unknown>).toolMs).toBe(430);
117+
} finally {
118+
vi.useRealTimers();
119+
}
120+
});
121+
91122
it("marks an aborted run as cancelled and keeps the error message", () => {
92123
const trace = newTrace();
93124
trace.finish("cancelled", { error: new DOMException("Report generation cancelled", "AbortError") });

‎src/llmConnection.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ export async function runAgenticLlm(
320320
async function runToolCall(toolCall: LlmToolCall): Promise<string> {
321321
const handler = toolHandlers[toolCall.function.name];
322322
let resultContent: string;
323+
let startedAt: number | undefined;
323324
if (!handler) {
324325
console.warn(`REPORT: tool '${toolCall.function.name}' is not registered`);
325326
resultContent = JSON.stringify({ error: `Unknown tool: ${toolCall.function.name}` });
@@ -330,16 +331,16 @@ export async function runAgenticLlm(
330331
`REPORT: running tool '${toolCall.function.name}' with arg keys: ${Object.keys(parsedArgs).join(",") || "(none)"}`,
331332
);
332333
reportProgress(describeToolPhase(toolCall.function.name));
333-
currentTrace()?.toolCall(toolCall.function.name, parsedArgs);
334+
startedAt = currentTrace()?.toolCall(toolCall.function.name, parsedArgs);
334335
const result = await handler(parsedArgs);
335336
resultContent = JSON.stringify(result ?? null);
336-
currentTrace()?.toolResult(toolCall.function.name, resultContent);
337+
currentTrace()?.toolResult(toolCall.function.name, resultContent, startedAt);
337338
console.log(`REPORT: tool '${toolCall.function.name}' completed (resultChars=${resultContent.length})`);
338339
} catch (e) {
339340
if ((e as Error).name === "AbortError") throw e;
340341
console.warn(`REPORT: tool '${toolCall.function.name}' failed:`, e);
341342
resultContent = JSON.stringify({ error: (e as Error).message });
342-
currentTrace()?.toolResult(toolCall.function.name, resultContent);
343+
currentTrace()?.toolResult(toolCall.function.name, resultContent, startedAt);
343344
}
344345
}
345346
toolCallCount++;

‎src/reportTools.ts‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -717,8 +717,7 @@ async function collectHeaderMatches(
717717
if (score > 0) matches.push({ score, hit: toHit(msg) });
718718
return ++scanned < MAX_AGGREGATE_SCAN;
719719
});
720-
const strong = matches.filter((m) => m.score >= minTermMatches(terms));
721-
const kept = strong.length > 0 ? strong : matches;
720+
const kept = keepBestMatches(matches, terms);
722721
kept.sort((a, b) => b.score - a.score || b.hit.date.localeCompare(a.hit.date));
723722
return {
724723
hits: kept.slice(0, cap).map((m) => m.hit),
@@ -727,6 +726,15 @@ async function collectHeaderMatches(
727726
};
728727
}
729728

729+
/**
730+
* Keep the matches scoring at least {@link minTermMatches}; if none does, fall back to every match with
731+
* at least one term, so a header fallback never comes back empty while some header mentions a term.
732+
*/
733+
function keepBestMatches<T extends { score: number }>(matches: T[], terms: string[]): T[] {
734+
const strong = matches.filter((m) => m.score >= minTermMatches(terms));
735+
return strong.length > 0 ? strong : matches;
736+
}
737+
730738
/** ISO timestamp at minute precision ("2026-09-11T09:36Z"); seconds cost tokens but add no meaning. */
731739
function compactDate(date: Date | string | number | undefined): string {
732740
return date ? `${new Date(date).toISOString().slice(0, 16)}Z` : "";
@@ -982,24 +990,30 @@ async function handleAggregateMessages(
982990
if (folderRef?.folderId) queryInfo.folderId = folderRef.folderId;
983991

984992
const countMatches = async (info: QueryInfo, terms: string[] | null, timeoutMs: number) => {
985-
const counts = new Map<string, number>();
986-
let scanned = 0;
993+
// With terms, headers are scored like search_messages' fallback and only the kept ones are counted.
994+
const matches: Array<{ score: number; msg: browser.messages.MessageHeader }> = [];
987995
let reachedCap = false;
988996
const { complete, timedOut } = await scanHeaders(info, abortSignal, timeoutMs, (msg) => {
989997
if (filters.subjectFilter && !(msg.subject ?? "").toLowerCase().includes(filters.subjectFilter)) return true;
998+
let score = 1;
990999
if (terms) {
9911000
const text = headerText(msg);
992-
if (terms.filter((t) => text.includes(t)).length < minTermMatches(terms)) return true;
1001+
score = terms.filter((t) => text.includes(t)).length;
1002+
if (score === 0) return true;
9931003
}
994-
scanned++;
1004+
matches.push({ score, msg });
1005+
reachedCap = matches.length >= MAX_AGGREGATE_SCAN;
1006+
return !reachedCap;
1007+
});
1008+
const counted = terms ? keepBestMatches(matches, terms) : matches;
1009+
const counts = new Map<string, number>();
1010+
for (const { msg } of counted) {
9951011
for (const key of groupKeys(msg, groupBy)) {
9961012
counts.set(key, (counts.get(key) ?? 0) + 1);
9971013
}
998-
reachedCap = scanned >= MAX_AGGREGATE_SCAN;
999-
return !reachedCap;
1000-
});
1014+
}
10011015
// A failed or stalled page flags the scan as capped so a partial aggregate is not reported as complete.
1002-
return { counts, scanned, capped: reachedCap || !complete, timedOut };
1016+
return { counts, scanned: counted.length, capped: reachedCap || !complete, timedOut };
10031017
};
10041018

10051019
// Like search_messages, a stalled full-text aggregate falls back to matching terms against headers.

‎src/reportTrace.ts‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ export class ReportTrace {
100100
private responseChars = 0;
101101
private toolResultChars = 0;
102102
private lastLlmRequestAt = 0;
103-
private lastToolCallAt = 0;
103+
/** Tool calls of one step run concurrently; toolMs counts the time at least one was running. */
104+
private activeTools = 0;
105+
private toolsActiveSince = 0;
104106
private outcome: TraceOutcome = "error";
105107
private errorMessage?: string;
106108
private report?: string;
@@ -195,16 +197,24 @@ export class ReportTrace {
195197
});
196198
}
197199

198-
/** Record a tool invocation requested by the model. */
199-
toolCall(name: string, args: Record<string, unknown>): void {
200-
this.lastToolCallAt = Date.now();
200+
/** Record a tool invocation requested by the model; returns its start time for {@link toolResult}. */
201+
toolCall(name: string, args: Record<string, unknown>): number {
202+
const startedAt = Date.now();
203+
if (this.activeTools++ === 0) this.toolsActiveSince = startedAt;
201204
this.push("tool-call", { name, args });
205+
return startedAt;
202206
}
203207

204-
/** Record the result a tool handler returned; `result` is the JSON string handed back to the model. */
205-
toolResult(name: string, result: string): void {
206-
const durationMs = this.lastToolCallAt ? Date.now() - this.lastToolCallAt : 0;
207-
this.toolMs += durationMs;
208+
/**
209+
* Record the result a tool handler returned; `result` is the JSON string handed back to the model and
210+
* `startedAt` the value {@link toolCall} returned (absent when the call failed before it started).
211+
*/
212+
toolResult(name: string, result: string, startedAt?: number): void {
213+
const now = Date.now();
214+
const durationMs = startedAt !== undefined ? now - startedAt : 0;
215+
if (startedAt !== undefined && this.activeTools > 0 && --this.activeTools === 0) {
216+
this.toolMs += now - this.toolsActiveSince;
217+
}
208218
this.toolCalls++;
209219
this.toolResultChars += result.length;
210220
this.push("tool-result", { name, durationMs, resultChars: result.length, result: clamp(result) });

0 commit comments

Comments
 (0)