feat(activities): rank work package activities by relevance

Adds optional query/matchMode to listWorkPackageActivities, ranking the
already-fetched activity list client-side via rankRecords (no extra API
calls). Exposes the same fields on the
openproject_list_work_package_activities tool. When no query is supplied,
every activity is returned exactly as before, and onlyComments continues
to filter independently.

Also fixes a defect in the brief's exact-mode test fixture: the specified
typo query "certificat" is a literal prefix substring of "certificate", so
byte-for-byte exact containment matching (scoreExact in src/search/rank.ts)
finds it regardless of typo tolerance, making the test unable to pass
against a correct implementation. Swapped in "certificatee" (not a
substring of "certificate") to preserve the test's intent without touching
the assertion. Updated one pre-existing test-tools.test.ts call site to
supply matchMode, now required by the extended args type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-18 11:42:39 +02:00
1 parent a28067f61e
commit 8f29eb651d
4 files changed
+105 -2

No files matched your search

+21 -2
View File
@@ -19,7 +19,7 @@ import type {
} from "../client/types.ts";
import { resolveClient, resolveProjectId } from "./helper.ts";
import { isFatalSearchError, searchPipeline } from "../search/pipeline.ts";
import type { FieldSpec, MatchMode } from "../search/rank.ts";
import { rankRecords, type FieldSpec, type MatchMode } from "../search/rank.ts";
export interface ListWorkPackagesParams extends WorkPackageFilterParams {
pageSize?: number;
@@ -249,6 +249,9 @@ export interface WorkPackageActivity {
export interface ListWorkPackageActivitiesOptions {
workPackageId: number;
onlyComments?: boolean;
/** Ranks the fetched activities client-side. No extra API calls. */
query?: string;
matchMode?: MatchMode;
}
/**
@@ -307,6 +310,22 @@ export async function listWorkPackageActivities(
});
}
return activities;
const query = options.query?.trim() ?? "";
if (query.length === 0) {
return activities;
}
const fields: FieldSpec<WorkPackageActivity>[] = [
{ name: "comment", weight: 3, extract: (activity) => activity.comment },
{
name: "details",
weight: 1,
extract: (activity) => activity.details.map((detail) => detail.raw),
},
];
return rankRecords(activities, query, fields, {
matchMode: options.matchMode ?? "fuzzy",
}).map((entry) => entry.record);
}
+17
View File
@@ -181,6 +181,21 @@ export const listWorkPackageActivitiesShape = {
.describe(
"When true, filters out property change audits and returns only comments"
),
query: z
.string()
.optional()
.describe(
"Optional search text to rank activities by relevance. Matched against " +
"comment text and change details; tolerates typos."
),
matchMode: z
.enum(["fuzzy", "exact"])
.optional()
.default("fuzzy")
.describe(
"Matching strategy. 'fuzzy' (default) tolerates typos, word reordering, and " +
"partial words. Use 'exact' only for literal strings you know verbatim."
),
};
export type ListWorkPackageActivitiesArgs = z.infer<
@@ -197,6 +212,8 @@ export async function handleListWorkPackageActivities(
const result = await listWorkPackageActivities({
workPackageId: args.workPackageId,
onlyComments: args.onlyComments,
query: args.query,
matchMode: args.matchMode,
});
return formatToolSuccess(result);
} catch (error) {