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
54 changes: 53 additions & 1 deletion apps/desktop/packages/devo-ai-sdk/src/v2/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ import {
ProtocolValidationError,
assertValidProtocolPayload,
} from "./protocol-validation"
import {
ReferenceSearchSession,
type ReferenceSearchSnapshot,
} from "./reference-search-session"

export type { ReferenceSearchSnapshot } from "./reference-search-session"

export type JsonRpcId = number | string

Expand Down Expand Up @@ -581,6 +587,7 @@ class AcpClient {
private pendingQuestions = new Map<string, PendingQuestion>()
private sessionDiscovery = new Map<string, Promise<Session | undefined>>()
private lastEventTime = 0
private referenceSearchSession: ReferenceSearchSession | null = null

constructor(private readonly options: CreateDevoClientOptions) {}
project = {
Expand Down Expand Up @@ -887,7 +894,25 @@ class AcpClient {
}

find = {
files: async (_params: { query: string }) => ({ data: [] }),
// @ mention file search uses connection-local search/* RPC + notifications.
files: async (params: { query: string }) => {
const session = this.ensureReferenceSearchSession()
await session.startOrUpdate(params.query)
return { data: session.filePaths() }
},
}

referenceSearch = {
startOrUpdate: async (params: { query: string }) => ({
data: await this.ensureReferenceSearchSession().startOrUpdate(params.query),
}),
cancel: async () => {
await this.ensureReferenceSearchSession().cancel()
return { data: null }
},
subscribe: (listener: (snapshot: ReferenceSearchSnapshot) => void) =>
this.ensureReferenceSearchSession().subscribe(listener),
getState: () => this.ensureReferenceSearchSession().getState(),
}

worktree = {
Expand Down Expand Up @@ -1160,6 +1185,25 @@ class AcpClient {
})
}

/** Devo extension RPCs that are not yet present in the generated ACP schema bundle. */
private async requestExtension(method: string, params: unknown): Promise<unknown> {
await this.open()
if (!this.transport) throw new Error("Devo ACP transport is not connected")
const extensionMethod = method.startsWith("_devo/") ? method : `_devo/${method}`
return this.transport.request(extensionMethod, params, this.options.directory)
}

private ensureReferenceSearchSession(): ReferenceSearchSession {
if (!this.referenceSearchSession) {
const cwd = this.options.directory ?? defaultCwd()
this.referenceSearchSession = new ReferenceSearchSession(
(method, params) => this.requestExtension(method, params),
cwd,
)
}
return this.referenceSearchSession
}

private handleTransportEvent(event: DevoAcpTransportEvent): void {
if (event.type === "closed") {
if (this.transport) {
Expand All @@ -1179,6 +1223,14 @@ class AcpClient {
this.handleSessionUpdate(notification)
return
}
if (
event.type === "notification" &&
event.method &&
event.params &&
this.referenceSearchSession?.handleNotification(event.method, event.params)
) {
return
}
if (
event.type === "notification" &&
(event.method === "workspace/changes/updated" ||
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, it, vi } from "vitest"

import { ReferenceSearchSession } from "./reference-search-session"

describe("ReferenceSearchSession", () => {
it("ignores stale search notifications for a different query", async () => {
const request = vi.fn(async (method: string, params: unknown) => {
if (method === "search/start") {
return {
snapshot: {
search_id: "search-1",
query: "src",
results: [],
total_file_match_count: 0,
scanned_file_count: 0,
file_search_complete: false,
},
}
}
throw new Error(`unexpected request ${method}: ${JSON.stringify(params)}`)
})
const session = new ReferenceSearchSession(request, "/workspace")
const snapshots: string[] = []
session.subscribe((snapshot) => {
snapshots.push(snapshot.query)
})

await session.startOrUpdate("src")
const handled = session.handleNotification("search/updated", {
search_id: "search-1",
query: "old",
results: [],
total_file_match_count: 0,
scanned_file_count: 0,
file_search_complete: true,
})

expect(handled).toBe(true)
expect(snapshots).toEqual(["src"])
})

it("prefers workspace-relative display_name over absolute file_path", async () => {
const request = vi.fn(async () => ({
snapshot: {
search_id: "search-1",
query: "lib",
results: [
{
kind: "file",
display_name: "src/lib.rs",
insert_text: "src/lib.rs",
file_path: "C:\\workspace\\src\\lib.rs",
},
],
total_file_match_count: 1,
scanned_file_count: 1,
file_search_complete: true,
},
}))
const session = new ReferenceSearchSession(request, "C:\\workspace")
await session.startOrUpdate("lib")

expect(session.filePaths()).toEqual(["src/lib.rs"])
})

it("maps completed file results to paths", async () => {
const request = vi.fn(async () => ({
snapshot: {
search_id: "search-1",
query: "lib",
results: [
{
kind: "file",
display_name: "src/lib.rs",
insert_text: "src/lib.rs",
file_path: "src/lib.rs",
},
],
total_file_match_count: 1,
scanned_file_count: 1,
file_search_complete: false,
},
}))
const session = new ReferenceSearchSession(request, "/workspace")
await session.startOrUpdate("lib")
session.handleNotification("search/completed", {
search_id: "search-1",
query: "lib",
results: [
{
kind: "file",
display_name: "src/lib.rs",
insert_text: "src/lib.rs",
file_path: "src/lib.rs",
},
],
total_file_match_count: 1,
scanned_file_count: 1,
file_search_complete: true,
})

expect(session.filePaths()).toEqual(["src/lib.rs"])
})
})
181 changes: 181 additions & 0 deletions apps/desktop/packages/devo-ai-sdk/src/v2/reference-search-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* Connection-local composer `@` reference search session.
*
* `@` mention file search uses `search/start`, `search/update`, and `search/cancel`
* RPCs plus `search/updated` / `search/completed` notifications — not a separate
* `find.files` endpoint. See L2-DES-APP-003 Search Protocol Rules.
*
* The server owns fuzzy ranking; clients render snapshots and ignore stale
* `query` / `search_id` pairs when the popup query has already moved on.
*/

export type ReferenceSearchResultKind = "skill" | "mcp" | "file"

export interface ReferenceSearchResult {
kind: ReferenceSearchResultKind
display_name: string
insert_text: string
description?: string
mention_path?: string
file_path?: string
is_disabled?: boolean
disabled_reason?: string
}

export interface ReferenceSearchSnapshot {
search_id: string
query: string
results: ReferenceSearchResult[]
total_file_match_count: number
scanned_file_count: number
file_search_complete: boolean
}

export interface ReferenceSearchFailedPayload {
search_id: string
query: string
message: string
}

type SearchRequest = (method: string, params: unknown) => Promise<unknown>

type SnapshotListener = (snapshot: ReferenceSearchSnapshot) => void

type ReferenceSearchState = {
snapshot: ReferenceSearchSnapshot | null
error: string | null
}

function devoExtensionInnerMethod(method: string): string {
return method.startsWith("_devo/") ? method.slice("_devo/".length) : method
}

function parseSnapshot(payload: unknown): ReferenceSearchSnapshot | null {
if (!payload || typeof payload !== "object") return null
if ("snapshot" in payload) {
const snapshot = (payload as { snapshot?: unknown }).snapshot
return parseSnapshot(snapshot)
}
const candidate = payload as Partial<ReferenceSearchSnapshot>
if (typeof candidate.search_id !== "string" || typeof candidate.query !== "string") {
return null
}
return {
search_id: candidate.search_id,
query: candidate.query,
results: Array.isArray(candidate.results) ? (candidate.results as ReferenceSearchResult[]) : [],
total_file_match_count: candidate.total_file_match_count ?? 0,
scanned_file_count: candidate.scanned_file_count ?? 0,
file_search_complete: candidate.file_search_complete ?? false,
}
}

function filePathsFromSnapshot(snapshot: ReferenceSearchSnapshot): string[] {
return snapshot.results
.filter((result) => result.kind === "file")
.map((result) => result.display_name)
.filter((path) => path.trim().length > 0)
}

export class ReferenceSearchSession {
private searchId: string | null = null
private activeQuery = ""
private snapshot: ReferenceSearchSnapshot | null = null
private error: string | null = null
private readonly listeners = new Set<SnapshotListener>()

constructor(
private readonly request: SearchRequest,
private readonly cwd: string,
) {}

subscribe(listener: SnapshotListener): () => void {
this.listeners.add(listener)
if (this.snapshot) listener(this.snapshot)
return () => {
this.listeners.delete(listener)
}
}

getState(): ReferenceSearchState {
return { snapshot: this.snapshot, error: this.error }
}

filePaths(): string[] {
return this.snapshot ? filePathsFromSnapshot(this.snapshot) : []
}

async startOrUpdate(query: string): Promise<ReferenceSearchSnapshot> {
this.activeQuery = query
this.error = null
const snapshot = this.searchId
? await this.update(query)
: await this.start(query)
this.applySnapshot(snapshot)
return snapshot
}

async cancel(): Promise<void> {
const searchId = this.searchId
this.searchId = null
this.activeQuery = ""
this.snapshot = null
this.error = null
if (!searchId) return
try {
await this.request("search/cancel", { search_id: searchId })
} catch {
// Best-effort cleanup when the popup closes.
}
}

handleNotification(method: string, payload: unknown): boolean {
const innerMethod = devoExtensionInnerMethod(method)
if (innerMethod === "search/failed") {
const failed = payload as Partial<ReferenceSearchFailedPayload>
if (!failed.search_id || this.searchId !== failed.search_id) return true
if (failed.query && failed.query !== this.activeQuery) return true
this.error = failed.message ?? "reference search failed"
if (this.snapshot) {
this.applySnapshot({
...this.snapshot,
file_search_complete: true,
})
}
return true
}
if (innerMethod !== "search/updated" && innerMethod !== "search/completed") {
return false
}
const snapshot = parseSnapshot(payload)
if (!snapshot) return true
if (this.searchId && snapshot.search_id !== this.searchId) return true
if (snapshot.query !== this.activeQuery) return true
this.applySnapshot(snapshot)
return true
}

private async start(query: string): Promise<ReferenceSearchSnapshot> {
const result = (await this.request("search/start", {
cwd: this.cwd,
query,
})) as { snapshot: ReferenceSearchSnapshot }
this.searchId = result.snapshot.search_id
return result.snapshot
}

private async update(query: string): Promise<ReferenceSearchSnapshot> {
const result = (await this.request("search/update", {
search_id: this.searchId,
query,
})) as { snapshot: ReferenceSearchSnapshot }
return result.snapshot
}

private applySnapshot(snapshot: ReferenceSearchSnapshot): void {
this.snapshot = snapshot
for (const listener of this.listeners) {
listener(snapshot)
}
}
}
Loading
Loading