feat(app): search history by workspace, agent, and branch (#2995)
History was a date-grouped list you scrolled. Finding a session from last week meant eyeballing every row. This adds a search field to the existing filter rail, next to the host picker. Search runs on the daemon, not the client. The daemon already holds every persisted agent in memory, so it ranks the whole history and the client never has to warn that it only looked at the pages it had loaded. A searched response is one ranked page with no cursor. The global top K is always contained in the union of each host's own top K, so merging per-host pages and slicing back to the limit is the exact global answer — no k-way merge, and no offset into a ranking that is recomputed per request, which would duplicate and skip rows as history changes underneath it. Past result 200 of a relevance order the answer is a narrower query, so the footer says that instead of offering a "Load more" that cannot deliver. Matching is the app's existing picker matcher, moved to the protocol package so app and daemon share one implementation instead of drifting into two "why didn't this show up?" rule sets. Typo tolerance is opt-in, so the comboboxes keep exact narrowing; the budget is per token, and four-character tokens get transpositions only, because at that length a free substitution turns "main" into "mail", "maid" and "rain". Ranked rows carry the spans they matched on, so results can mark them. The subsequence and typo tiers match characters the eye would never find unaided, and an unexplained row reads as a bug. The ranker computes the spans anyway; sending them keeps the client from deriving a second opinion that could disagree with the ranking it is explaining. Hosts that fail or are offline are now named above the list. Without that, "No sessions match" is a claim the app has no basis for. Gated on server_info.features.agentHistorySearch, checked once against the connected hosts. Reachability is reported by the banner instead, so one offline host in the registry no longer hides the field.
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type AgentHistorySearchCandidate,
|
||||
describeAgentHistoryMatches,
|
||||
rankAgentHistoryCandidates,
|
||||
scoreAgentHistoryCandidate,
|
||||
} from "./agent-history-search.js";
|
||||
|
||||
function candidate(input: {
|
||||
title?: string | null;
|
||||
workspaceName?: string | null;
|
||||
branch?: string | null;
|
||||
projectName?: string;
|
||||
updatedAt?: string;
|
||||
}): AgentHistorySearchCandidate & { agent: { id: string; updatedAt: string } } {
|
||||
const branch = input.branch ?? null;
|
||||
return {
|
||||
agent: {
|
||||
id: input.title ?? "agent",
|
||||
title: input.title ?? null,
|
||||
updatedAt: input.updatedAt ?? "2026-08-07T00:00:00.000Z",
|
||||
},
|
||||
project: {
|
||||
projectKey: "key",
|
||||
projectName: input.projectName ?? "getpaseo/paseo",
|
||||
workspaceName: input.workspaceName ?? null,
|
||||
checkout: {
|
||||
cwd: "/tmp/repo",
|
||||
isGit: branch !== null,
|
||||
currentBranch: branch,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: "/tmp/repo",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
// The module only reads the four names; the rest of the payload is the
|
||||
// session's business.
|
||||
} as unknown as AgentHistorySearchCandidate & { agent: { id: string; updatedAt: string } };
|
||||
}
|
||||
|
||||
const byUpdatedAtDesc = (
|
||||
left: { agent: { updatedAt: string } },
|
||||
right: { agent: { updatedAt: string } },
|
||||
) => right.agent.updatedAt.localeCompare(left.agent.updatedAt);
|
||||
|
||||
describe("scoreAgentHistoryCandidate", () => {
|
||||
it("matches the workspace name", () => {
|
||||
expect(
|
||||
scoreAgentHistoryCandidate("stripe", candidate({ workspaceName: "Add Stripe billing" })),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("matches the agent title", () => {
|
||||
expect(
|
||||
scoreAgentHistoryCandidate("entitlements", candidate({ title: "Reshape entitlements" })),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("matches the branch name", () => {
|
||||
expect(
|
||||
scoreAgentHistoryCandidate("billing", candidate({ branch: "add-stripe-billing" })),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("matches the project name", () => {
|
||||
expect(
|
||||
scoreAgentHistoryCandidate("paseo", candidate({ projectName: "getpaseo/paseo" })),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("requires every token to match somewhere", () => {
|
||||
const entry = candidate({ workspaceName: "Add Stripe billing", branch: "main" });
|
||||
expect(scoreAgentHistoryCandidate("stripe main", entry)).not.toBeNull();
|
||||
expect(scoreAgentHistoryCandidate("stripe rosetta", entry)).toBeNull();
|
||||
});
|
||||
|
||||
it("tolerates a typo", () => {
|
||||
expect(
|
||||
scoreAgentHistoryCandidate("bulling", candidate({ workspaceName: "Add Stripe billing" })),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("tolerates a transposed branch name", () => {
|
||||
expect(scoreAgentHistoryCandidate("mian", candidate({ branch: "main" }))).not.toBeNull();
|
||||
expect(scoreAgentHistoryCandidate("rain", candidate({ branch: "main" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a blank query so an empty box never filters", () => {
|
||||
expect(scoreAgentHistoryCandidate(" ", candidate({ title: "anything" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rankAgentHistoryCandidates", () => {
|
||||
it("drops candidates that do not match", () => {
|
||||
const ranked = rankAgentHistoryCandidates(
|
||||
"stripe",
|
||||
[candidate({ title: "Add Stripe billing" }), candidate({ title: "Fix terminal resize" })],
|
||||
byUpdatedAtDesc,
|
||||
);
|
||||
expect(ranked.map((result) => result.candidate.agent.title)).toEqual(["Add Stripe billing"]);
|
||||
});
|
||||
|
||||
it("ranks a whole-word hit above a hit buried inside a word", () => {
|
||||
const ranked = rankAgentHistoryCandidates(
|
||||
"bill",
|
||||
[candidate({ title: "unbilled usage report" }), candidate({ title: "bill the customer" })],
|
||||
byUpdatedAtDesc,
|
||||
);
|
||||
expect(ranked.map((result) => result.candidate.agent.title)).toEqual([
|
||||
"bill the customer",
|
||||
"unbilled usage report",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ranks the workspace name above the project name that every session shares", () => {
|
||||
const ranked = rankAgentHistoryCandidates(
|
||||
"paseo",
|
||||
[
|
||||
candidate({ title: "unrelated work", projectName: "getpaseo/paseo" }),
|
||||
candidate({ workspaceName: "paseo", projectName: "getpaseo/paseo" }),
|
||||
],
|
||||
byUpdatedAtDesc,
|
||||
);
|
||||
expect(ranked[0].candidate.project.workspaceName).toBe("paseo");
|
||||
});
|
||||
|
||||
it("ranks every exact hit above a typo hit", () => {
|
||||
const ranked = rankAgentHistoryCandidates(
|
||||
"billing",
|
||||
[
|
||||
candidate({ title: "bulling the customer" }),
|
||||
candidate({ title: "prorated billing edge cases" }),
|
||||
],
|
||||
byUpdatedAtDesc,
|
||||
);
|
||||
expect(ranked.map((result) => result.candidate.agent.title)).toEqual([
|
||||
"prorated billing edge cases",
|
||||
"bulling the customer",
|
||||
]);
|
||||
});
|
||||
|
||||
it("breaks ties with the caller's ordering", () => {
|
||||
const ranked = rankAgentHistoryCandidates(
|
||||
"stripe",
|
||||
[
|
||||
candidate({ title: "stripe one", updatedAt: "2026-08-01T00:00:00.000Z" }),
|
||||
candidate({ title: "stripe two", updatedAt: "2026-08-06T00:00:00.000Z" }),
|
||||
],
|
||||
byUpdatedAtDesc,
|
||||
);
|
||||
expect(ranked.map((result) => result.candidate.agent.title)).toEqual([
|
||||
"stripe two",
|
||||
"stripe one",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeAgentHistoryMatches", () => {
|
||||
it("says which field matched and where", () => {
|
||||
expect(
|
||||
describeAgentHistoryMatches("stripe", candidate({ workspaceName: "Add Stripe billing" })),
|
||||
).toEqual([{ field: "workspace", ranges: [{ start: 4, length: 6 }] }]);
|
||||
});
|
||||
|
||||
it("describes each field a multi-token query landed on", () => {
|
||||
expect(
|
||||
describeAgentHistoryMatches(
|
||||
"stripe main",
|
||||
candidate({ workspaceName: "Add Stripe billing", branch: "main" }),
|
||||
),
|
||||
).toEqual([
|
||||
{ field: "workspace", ranges: [{ start: 4, length: 6 }] },
|
||||
{ field: "branch", ranges: [{ start: 0, length: 4 }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges overlapping spans two tokens found in one field", () => {
|
||||
expect(
|
||||
describeAgentHistoryMatches("bill billing", candidate({ title: "prorated billing" })),
|
||||
).toEqual([{ field: "title", ranges: [{ start: 9, length: 7 }] }]);
|
||||
});
|
||||
|
||||
it("marks the whole word behind a typo", () => {
|
||||
expect(
|
||||
describeAgentHistoryMatches("bulling", candidate({ title: "customer billing" })),
|
||||
).toEqual([{ field: "title", ranges: [{ start: 9, length: 7 }] }]);
|
||||
});
|
||||
|
||||
it("returns nothing when the candidate does not match", () => {
|
||||
expect(
|
||||
describeAgentHistoryMatches("rosetta", candidate({ title: "customer billing" })),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import type {
|
||||
AgentSearchMatch,
|
||||
AgentSnapshotPayload,
|
||||
ProjectPlacementPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
compareMatchScores,
|
||||
fuzzyPolicyForToken,
|
||||
type MatchRange,
|
||||
type MatchScore,
|
||||
matchRanges,
|
||||
scoreMatch,
|
||||
tokenizeQuery,
|
||||
} from "@getpaseo/protocol/search/text-match";
|
||||
|
||||
/**
|
||||
* History search ranks what the daemon already knows about a session: the four
|
||||
* names attached to it. Transcripts are deliberately out — they are unbounded,
|
||||
* and a partial-transcript index would answer "not found" for sessions that do
|
||||
* contain the phrase, which is worse than never claiming to search them.
|
||||
*
|
||||
* Field order is the tie-break order. Workspace and agent titles are what
|
||||
* people actually recall; a project name matches every session in the repo, so
|
||||
* it ranks last and only decides ties.
|
||||
*/
|
||||
|
||||
export interface AgentHistorySearchCandidate {
|
||||
agent: AgentSnapshotPayload;
|
||||
project: ProjectPlacementPayload;
|
||||
}
|
||||
|
||||
/** In tie-break order; the index into this list is a field's rank. */
|
||||
const SEARCH_FIELDS = ["workspace", "title", "branch", "project"] as const;
|
||||
|
||||
type SearchField = (typeof SEARCH_FIELDS)[number];
|
||||
|
||||
function searchableFields(candidate: AgentHistorySearchCandidate): string[] {
|
||||
const { agent, project } = candidate;
|
||||
return [
|
||||
project.workspaceName ?? "",
|
||||
agent.title ?? "",
|
||||
project.checkout.currentBranch ?? "",
|
||||
project.projectName,
|
||||
];
|
||||
}
|
||||
|
||||
interface FieldMatch {
|
||||
score: MatchScore;
|
||||
fieldRank: number;
|
||||
token: string;
|
||||
}
|
||||
|
||||
function bestFieldMatch(token: string, fields: string[]): FieldMatch | null {
|
||||
const fuzzy = fuzzyPolicyForToken(token);
|
||||
let best: FieldMatch | null = null;
|
||||
for (let fieldRank = 0; fieldRank < fields.length; fieldRank += 1) {
|
||||
const field = fields[fieldRank];
|
||||
if (!field) continue;
|
||||
const score = scoreMatch(token, field, { fuzzy });
|
||||
if (!score) continue;
|
||||
if (!best || compareMatchScores(score, best.score) < 0) {
|
||||
best = { score, fieldRank, token };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Overlapping spans from different tokens would double-mark the same glyphs. */
|
||||
function mergeRanges(ranges: MatchRange[]): MatchRange[] {
|
||||
const sorted = [...ranges].sort((left, right) => left.start - right.start);
|
||||
const merged: MatchRange[] = [];
|
||||
for (const range of sorted) {
|
||||
const last = merged.at(-1);
|
||||
if (last && range.start <= last.start + last.length) {
|
||||
last.length = Math.max(last.length, range.start + range.length - last.start);
|
||||
continue;
|
||||
}
|
||||
merged.push({ ...range });
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* One number so the wire can carry relevance and several hosts' pages can be
|
||||
* merged by it. Tier dominates, then which field matched, then how deep into
|
||||
* that field the match sat.
|
||||
*/
|
||||
function toSearchScore(matches: readonly FieldMatch[]): number {
|
||||
let total = 0;
|
||||
for (const match of matches) {
|
||||
total += match.score.tier * 1_000 + match.fieldRank * 100 + Math.min(match.score.offset, 99);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every token has to match something, so adding a word always narrows. Tokens
|
||||
* may land on different fields — "stripe main" finds the Stripe workspace on
|
||||
* the main branch.
|
||||
*/
|
||||
export function scoreAgentHistoryCandidate(
|
||||
query: string,
|
||||
candidate: AgentHistorySearchCandidate,
|
||||
): number | null {
|
||||
const matches = matchAgentHistoryCandidate(query, candidate);
|
||||
return matches ? toSearchScore(matches) : null;
|
||||
}
|
||||
|
||||
function matchAgentHistoryCandidate(
|
||||
query: string,
|
||||
candidate: AgentHistorySearchCandidate,
|
||||
): FieldMatch[] | null {
|
||||
const tokens = tokenizeQuery(query);
|
||||
if (tokens.length === 0) return null;
|
||||
|
||||
const fields = searchableFields(candidate);
|
||||
const matches: FieldMatch[] = [];
|
||||
for (const token of tokens) {
|
||||
const match = bestFieldMatch(token, fields);
|
||||
if (!match) return null;
|
||||
matches.push(match);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the query landed in each field, for the rows that will actually be
|
||||
* rendered. The ranker already knows this; sending it means the client never
|
||||
* has to re-derive a second opinion that could disagree with the ranking it is
|
||||
* explaining.
|
||||
*/
|
||||
export function describeAgentHistoryMatches(
|
||||
query: string,
|
||||
candidate: AgentHistorySearchCandidate,
|
||||
): AgentSearchMatch[] {
|
||||
const matches = matchAgentHistoryCandidate(query, candidate);
|
||||
if (!matches) return [];
|
||||
|
||||
const fields = searchableFields(candidate);
|
||||
const rangesByField = new Map<SearchField, MatchRange[]>();
|
||||
for (const match of matches) {
|
||||
const field = SEARCH_FIELDS[match.fieldRank];
|
||||
const ranges = matchRanges(match.token, fields[match.fieldRank], match.score);
|
||||
rangesByField.set(field, [...(rangesByField.get(field) ?? []), ...ranges]);
|
||||
}
|
||||
|
||||
return [...rangesByField.entries()].map(([field, ranges]) => ({
|
||||
field,
|
||||
ranges: mergeRanges(ranges),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface RankedAgentHistoryCandidate<T extends AgentHistorySearchCandidate> {
|
||||
candidate: T;
|
||||
searchScore: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks the whole candidate set rather than a page of it. The daemon holds
|
||||
* every persisted agent in memory already, so search is complete by
|
||||
* construction and the client never has to warn that it only looked at what it
|
||||
* had loaded.
|
||||
*
|
||||
* A searched response is one page: the best `limit` matches, and a flag saying
|
||||
* more matched. There is no search cursor. Offsets into a ranking that is
|
||||
* recomputed per request duplicate and skip rows as history mutates underneath
|
||||
* them, and a keyset cursor would buy the ability to walk past result 200 of a
|
||||
* relevance order — which is browsing, not searching. Narrowing the query is
|
||||
* the answer, and the global top K is exactly recoverable from each host's own
|
||||
* top K, which is what lets several hosts merge without a federated pager.
|
||||
*/
|
||||
export function rankAgentHistoryCandidates<T extends AgentHistorySearchCandidate>(
|
||||
query: string,
|
||||
candidates: readonly T[],
|
||||
compareTies: (left: T, right: T) => number,
|
||||
): RankedAgentHistoryCandidate<T>[] {
|
||||
const ranked: RankedAgentHistoryCandidate<T>[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const searchScore = scoreAgentHistoryCandidate(query, candidate);
|
||||
if (searchScore === null) continue;
|
||||
ranked.push({ candidate, searchScore });
|
||||
}
|
||||
ranked.sort((left, right) => {
|
||||
if (left.searchScore !== right.searchScore) return left.searchScore - right.searchScore;
|
||||
return compareTies(left.candidate, right.candidate);
|
||||
});
|
||||
return ranked;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
||||
import type { BinaryFrame } from "@getpaseo/protocol/binary-frames/index";
|
||||
import { CursorError } from "./pagination/cursor.js";
|
||||
import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js";
|
||||
import { describeAgentHistoryMatches, rankAgentHistoryCandidates } from "./agent-history-search.js";
|
||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
||||
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
|
||||
import {
|
||||
@@ -348,6 +349,15 @@ type FetchAgentHistoryRequestMessage = Extract<
|
||||
{ type: "fetch_agent_history_request" }
|
||||
>;
|
||||
type AgentDirectoryRequestMessage = FetchAgentsRequestMessage | FetchAgentHistoryRequestMessage;
|
||||
|
||||
/**
|
||||
* Only history carries a query. The active-agents directory filters on
|
||||
* structure and never ranks, so it always reads as no query at all.
|
||||
*/
|
||||
function agentDirectorySearchQuery(request: AgentDirectoryRequestMessage): string {
|
||||
if (request.type !== "fetch_agent_history_request") return "";
|
||||
return request.search?.trim() ?? "";
|
||||
}
|
||||
type FetchAgentsRequestFilter = NonNullable<FetchAgentsRequestMessage["filter"]>;
|
||||
type FetchAgentsRequestSort = NonNullable<FetchAgentsRequestMessage["sort"]>[number];
|
||||
type FetchAgentsResponsePayload = Extract<
|
||||
@@ -4228,6 +4238,7 @@ export class Session {
|
||||
private async listFetchAgentsEntries(request: AgentDirectoryRequestMessage): Promise<{
|
||||
entries: FetchAgentsResponseEntry[];
|
||||
pageInfo: FetchAgentsResponsePageInfo;
|
||||
searchTruncated?: boolean;
|
||||
}> {
|
||||
const filter =
|
||||
request.type === "fetch_agent_history_request" &&
|
||||
@@ -4272,6 +4283,18 @@ export class Session {
|
||||
return placementPromise;
|
||||
};
|
||||
|
||||
const search = agentDirectorySearchQuery(request);
|
||||
if (search) {
|
||||
return this.listRankedAgentHistoryEntries({
|
||||
search,
|
||||
agents,
|
||||
sort,
|
||||
filter,
|
||||
getPlacement,
|
||||
page: request.page,
|
||||
});
|
||||
}
|
||||
|
||||
let candidates = [...agents];
|
||||
candidates.sort((left, right) => this.agentsPager.compare(left, right, sort));
|
||||
const cursorToken = request.page?.cursor;
|
||||
@@ -4308,6 +4331,64 @@ export class Session {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The searched history page. Ranking has to see every candidate before it can
|
||||
* name the best one, so this path resolves placements for the whole set
|
||||
* instead of stopping at the page limit — that is what makes a query answer
|
||||
* from all persisted sessions rather than from the first page of them.
|
||||
*/
|
||||
private async listRankedAgentHistoryEntries(params: {
|
||||
search: string;
|
||||
agents: AgentSnapshotPayload[];
|
||||
sort: FetchAgentsRequestSort[];
|
||||
filter: AgentUpdatesFilter | undefined;
|
||||
getPlacement: (workspaceId: string | undefined) => Promise<ProjectPlacementPayload | null>;
|
||||
page: AgentDirectoryRequestMessage["page"];
|
||||
}): Promise<{
|
||||
entries: FetchAgentsResponseEntry[];
|
||||
pageInfo: FetchAgentsResponsePageInfo;
|
||||
searchTruncated: boolean;
|
||||
}> {
|
||||
const { search, agents, sort, filter, getPlacement, page } = params;
|
||||
if (page?.cursor) {
|
||||
// A ranked result set has no pages to walk, so a cursor here is caller
|
||||
// misuse. Returning the ranked head instead would hide it.
|
||||
throw new SessionRequestError(
|
||||
"invalid_cursor",
|
||||
"A history search returns one ranked page; it cannot be paged with a cursor.",
|
||||
);
|
||||
}
|
||||
|
||||
const allEntries = await this.collectFetchAgentsEntries({
|
||||
candidates: agents,
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
getPlacement,
|
||||
filter,
|
||||
});
|
||||
|
||||
const ranked = rankAgentHistoryCandidates(search, allEntries, (left, right) =>
|
||||
this.agentsPager.compare(left.agent, right.agent, sort),
|
||||
);
|
||||
|
||||
const limit = page?.limit ?? 200;
|
||||
// Ranges are derived only for the rows that will be rendered; ranking
|
||||
// itself never needs them.
|
||||
const entries = ranked.slice(0, limit).map((result) =>
|
||||
Object.assign({}, result.candidate, {
|
||||
searchScore: result.searchScore,
|
||||
searchMatches: describeAgentHistoryMatches(search, result.candidate),
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
entries,
|
||||
// No next page exists, so `hasMore` is false and truncation is reported
|
||||
// on its own field. See the note on rankAgentHistoryCandidates.
|
||||
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
||||
searchTruncated: ranked.length > limit,
|
||||
};
|
||||
}
|
||||
|
||||
private readonly agentsPager = new SortablePager<
|
||||
AgentSnapshotPayload,
|
||||
FetchAgentsRequestSort["key"]
|
||||
|
||||
@@ -2786,6 +2786,169 @@ test("fetch_agent_history_request pages archived historical rows separately", as
|
||||
expect(session.agentUpdates.hasSubscription()).toBe(false);
|
||||
});
|
||||
|
||||
test("fetch_agent_history_request ranks a search across the whole history, not one page", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const historyCwd = path.resolve("/tmp/history-search");
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-search",
|
||||
rootPath: historyCwd,
|
||||
kind: "non_git",
|
||||
displayName: "history-search",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-search",
|
||||
projectId: project.projectId,
|
||||
cwd: historyCwd,
|
||||
kind: "directory",
|
||||
displayName: "history-search",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
|
||||
session.emit = (message) => {
|
||||
if (isSessionOutboundMessage(message)) emitted.push(message);
|
||||
};
|
||||
session.projectRegistry.get = async () => project;
|
||||
session.workspaceRegistry.list = async () => [workspace];
|
||||
session.workspaceRegistry.get = async () => workspace;
|
||||
session.listAgentPayloads = async () => [
|
||||
// The strong match is the oldest row, so a chronological answer would rank
|
||||
// it last and a first-page-only search would not see it at all.
|
||||
{
|
||||
...makeAgent({
|
||||
id: "weak",
|
||||
cwd: historyCwd,
|
||||
workspaceId: "ws-search",
|
||||
status: "idle",
|
||||
updatedAt: "2026-03-03T12:00:00.000Z",
|
||||
}),
|
||||
title: "Unbilled usage report",
|
||||
},
|
||||
{
|
||||
...makeAgent({
|
||||
id: "unrelated",
|
||||
cwd: historyCwd,
|
||||
workspaceId: "ws-search",
|
||||
status: "idle",
|
||||
updatedAt: "2026-03-02T12:00:00.000Z",
|
||||
}),
|
||||
title: "Terminal resize fix",
|
||||
},
|
||||
{
|
||||
...makeAgent({
|
||||
id: "strong",
|
||||
cwd: historyCwd,
|
||||
workspaceId: "ws-search",
|
||||
status: "idle",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
title: "Add Stripe billing",
|
||||
},
|
||||
];
|
||||
|
||||
await session.handleMessage({
|
||||
type: "fetch_agent_history_request",
|
||||
requestId: "req-search-truncated",
|
||||
search: "bill",
|
||||
page: { limit: 1 },
|
||||
});
|
||||
|
||||
const truncated = emitted[0];
|
||||
if (truncated?.type !== "fetch_agent_history_response") {
|
||||
throw new Error(`Expected a history response, got ${truncated?.type}`);
|
||||
}
|
||||
expect(truncated.payload.entries.map((entry) => entry.agent.id)).toEqual(["strong"]);
|
||||
expect(truncated.payload.entries[0].searchScore).toBeTypeOf("number");
|
||||
// More matched than fit. `hasMore` stays false because no page is fetchable;
|
||||
// truncation is its own fact, so a rank offset can never go stale.
|
||||
expect(truncated.payload.searchTruncated).toBe(true);
|
||||
expect(truncated.payload.pageInfo).toEqual({
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "fetch_agent_history_request",
|
||||
requestId: "req-search-whole",
|
||||
search: "bill",
|
||||
page: { limit: 25 },
|
||||
});
|
||||
|
||||
const whole = emitted[1];
|
||||
if (whole?.type !== "fetch_agent_history_response") {
|
||||
throw new Error(`Expected a history response, got ${whole?.type}`);
|
||||
}
|
||||
expect(whole.payload.entries.map((entry) => entry.agent.id)).toEqual(["strong", "weak"]);
|
||||
expect(whole.payload.searchTruncated).toBe(false);
|
||||
expect(whole.payload.pageInfo.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
test("fetch_agent_history_request rejects a cursor on a searched request", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const historyCwd = path.resolve("/tmp/history-cursor");
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-cursor",
|
||||
rootPath: historyCwd,
|
||||
kind: "non_git",
|
||||
displayName: "history-cursor",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-cursor",
|
||||
projectId: project.projectId,
|
||||
cwd: historyCwd,
|
||||
kind: "directory",
|
||||
displayName: "history-cursor",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
|
||||
session.emit = (message) => {
|
||||
if (isSessionOutboundMessage(message)) emitted.push(message);
|
||||
};
|
||||
session.projectRegistry.get = async () => project;
|
||||
session.workspaceRegistry.list = async () => [workspace];
|
||||
session.workspaceRegistry.get = async () => workspace;
|
||||
session.listAgentPayloads = async () => [
|
||||
{
|
||||
...makeAgent({
|
||||
id: "match",
|
||||
cwd: historyCwd,
|
||||
workspaceId: "ws-cursor",
|
||||
status: "idle",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
title: "Add Stripe billing",
|
||||
},
|
||||
];
|
||||
|
||||
// A ranked result set has no pages to walk. Answering with the ranked head
|
||||
// would let a caller believe it had paged, so this fails loudly instead.
|
||||
await session.handleMessage({
|
||||
type: "fetch_agent_history_request",
|
||||
requestId: "req-cursor",
|
||||
search: "billing",
|
||||
page: { limit: 25, cursor: "eyJpZCI6ImFnZW50In0=" },
|
||||
});
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "rpc_error",
|
||||
payload: expect.objectContaining({
|
||||
requestId: "req-cursor",
|
||||
requestType: "fetch_agent_history_request",
|
||||
code: "invalid_cursor",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("fetch_agent_history_request skips rows whose workspace project record is missing", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests();
|
||||
|
||||
@@ -1545,6 +1545,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
rewind: true,
|
||||
// COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X.
|
||||
agentTimelinePromptIndex: true,
|
||||
// COMPAT(agentHistorySearch): added in v0.3.0, remove gate after 2027-02-07.
|
||||
agentHistorySearch: true,
|
||||
// COMPAT(checkoutRefresh): added in v0.1.86, remove gate after 2026-11-29.
|
||||
checkoutRefresh: true,
|
||||
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||
|
||||
Reference in New Issue
Block a user