From a9bbf7bb82fcfc89215fe29b4f1468150d26e26d Mon Sep 17 00:00:00 2001 From: "Cameron Weaver (Clutch)" Date: Fri, 17 Apr 2026 01:02:52 -0400 Subject: [PATCH] fix: implement ambient suggestion bridge routes --- src/lib/app-server-bridge.ts | 233 +++++++++++++++++++++++ test/app-server-bridge-host-data.test.ts | 189 ++++++++++++++++++ 2 files changed, 422 insertions(+) diff --git a/src/lib/app-server-bridge.ts b/src/lib/app-server-bridge.ts index 8f0d045..77bb5c4 100644 --- a/src/lib/app-server-bridge.ts +++ b/src/lib/app-server-bridge.ts @@ -210,6 +210,51 @@ interface RecommendedSkillsResponse { error?: string; } +type AmbientSuggestionStatus = "pending" | "accepted" | "dismissed"; + +type AmbientSuggestionThreadAction = + | { + type: "new-thread"; + } + | { + type: "continue-thread"; + threadId: string; + }; + +interface AmbientSuggestion { + id: string; + title: string; + description: string; + prompt: string; + threadAction: AmbientSuggestionThreadAction; + appIds: string[]; + status: AmbientSuggestionStatus; + createdAtMs: number; + updatedAtMs: number; +} + +interface AmbientSuggestionsFile { + projectRoot: string; + generatedAtMs: number | null; + currentSuggestionIds: string[]; + suggestions: AmbientSuggestion[]; +} + +interface AmbientSuggestionsResponse { + file: AmbientSuggestionsFile; +} + +interface AmbientSuggestionsGenerationStatus { + projectRoot: string; + runningCount: number; + runningStartedAtMs: number | null; + lastFinishedAtMs: number | null; +} + +interface AmbientSuggestionsGenerationStatusesResponse { + statuses: AmbientSuggestionsGenerationStatus[]; +} + type UsageVisibilityPlan = "plus" | "pro" | "prolite"; interface LocalRateLimitWindowSnapshot { @@ -290,6 +335,11 @@ export class AppServerBridge extends EventEmitter implements HostBridge { private readonly sharedObjectSubscriptions = new Set(); private readonly workspaceRoots = new Set(); private readonly workspaceRootLabels = new Map(); + private readonly ambientSuggestionsByProjectRoot = new Map(); + private readonly ambientSuggestionGenerationStatuses = new Map< + string, + AmbientSuggestionsGenerationStatus + >(); private readonly codexHomePath: string; private persistedAtomRegistryPath: string; private workspaceRootRegistryPath: string; @@ -1906,6 +1956,35 @@ export class AppServerBridge extends EventEmitter implements HostBridge { status: 200, body: await this.readRecommendedSkills(body), }; + case "ambient-suggestions": + return { + status: 200, + body: this.readAmbientSuggestions(body), + }; + case "ambient-suggestions-refresh": + return { + status: 200, + body: this.refreshAmbientSuggestions(body), + }; + case "ambient-suggestions-generation-statuses": + return { + status: 200, + body: this.readAmbientSuggestionGenerationStatuses(), + }; + case "ambient-suggestion-set-status": + try { + return { + status: 200, + body: this.setAmbientSuggestionStatus(body), + }; + } catch (error) { + return { + status: 400, + body: { + error: normalizeError(error).message, + }, + }; + } case "fast-mode-rollout-metrics": return { status: 200, @@ -2003,6 +2082,89 @@ export class AppServerBridge extends EventEmitter implements HostBridge { } } + private readAmbientSuggestions(body: unknown): AmbientSuggestionsResponse { + const { projectRoot } = readAmbientSuggestionParams(body); + return { + file: cloneAmbientSuggestionsFile(this.getAmbientSuggestionsFile(projectRoot)), + }; + } + + private refreshAmbientSuggestions(body: unknown): { ok: true } { + const { projectRoot } = readAmbientSuggestionParams(body); + const generatedAtMs = Date.now(); + const entry = this.getAmbientSuggestionsFile(projectRoot); + entry.generatedAtMs = generatedAtMs; + + const status = this.getAmbientSuggestionGenerationStatus(projectRoot); + status.runningCount = 0; + status.runningStartedAtMs = null; + status.lastFinishedAtMs = generatedAtMs; + + return { + ok: true, + }; + } + + private readAmbientSuggestionGenerationStatuses(): AmbientSuggestionsGenerationStatusesResponse { + return { + statuses: Array.from(this.ambientSuggestionGenerationStatuses.values()) + .sort((left, right) => left.projectRoot.localeCompare(right.projectRoot)) + .map(cloneAmbientSuggestionsGenerationStatus), + }; + } + + private setAmbientSuggestionStatus(body: unknown): AmbientSuggestionsResponse { + const request = readAmbientSuggestionSetStatusRequest(body); + const entry = this.getAmbientSuggestionsFile(request.projectRoot); + const suggestion = entry.suggestions.find((item) => item.id === request.suggestionId); + if (suggestion) { + suggestion.status = request.status; + suggestion.updatedAtMs = Date.now(); + } + + if (request.status === "pending") { + if (!entry.currentSuggestionIds.includes(request.suggestionId)) { + entry.currentSuggestionIds.push(request.suggestionId); + } + } else { + entry.currentSuggestionIds = entry.currentSuggestionIds.filter( + (suggestionId) => suggestionId !== request.suggestionId, + ); + } + + return { + file: cloneAmbientSuggestionsFile(entry), + }; + } + + private getAmbientSuggestionsFile(projectRoot: string): AmbientSuggestionsFile { + const normalizedProjectRoot = projectRoot.trim(); + let entry = this.ambientSuggestionsByProjectRoot.get(normalizedProjectRoot); + if (!entry) { + entry = buildEmptyAmbientSuggestionsFile(normalizedProjectRoot); + this.ambientSuggestionsByProjectRoot.set(normalizedProjectRoot, entry); + } + + return entry; + } + + private getAmbientSuggestionGenerationStatus( + projectRoot: string, + ): AmbientSuggestionsGenerationStatus { + const normalizedProjectRoot = projectRoot.trim(); + let entry = this.ambientSuggestionGenerationStatuses.get(normalizedProjectRoot); + if (!entry) { + entry = { + projectRoot: normalizedProjectRoot, + runningCount: 0, + runningStartedAtMs: null, + lastFinishedAtMs: null, + }; + this.ambientSuggestionGenerationStatuses.set(normalizedProjectRoot, entry); + } + + return entry; + } private async handleRelativeFetchRequest( request: RelativeFetchRequestContext, ): Promise { @@ -4564,6 +4726,77 @@ function readCodexFetchParams(body: unknown): unknown { return isJsonRecord(body.params) ? body.params : body; } +function readAmbientSuggestionParams(body: unknown): { + projectRoot: string; +} { + const params = readCodexFetchParams(body); + return { + projectRoot: + isJsonRecord(params) && typeof params.projectRoot === "string" ? params.projectRoot : "", + }; +} + +function readAmbientSuggestionSetStatusRequest(body: unknown): { + projectRoot: string; + suggestionId: string; + status: AmbientSuggestionStatus; +} { + const params = readCodexFetchParams(body); + if (!isJsonRecord(params)) { + throw new Error("Ambient suggestion params are required."); + } + + const suggestionId = typeof params.suggestionId === "string" ? params.suggestionId.trim() : ""; + if (suggestionId.length === 0) { + throw new Error("Ambient suggestion ID is required."); + } + + const status = params.status; + if (status !== "pending" && status !== "accepted" && status !== "dismissed") { + throw new Error("Ambient suggestion status is required."); + } + + return { + projectRoot: typeof params.projectRoot === "string" ? params.projectRoot : "", + suggestionId, + status, + }; +} + +function buildEmptyAmbientSuggestionsFile(projectRoot: string): AmbientSuggestionsFile { + return { + projectRoot, + generatedAtMs: null, + currentSuggestionIds: [], + suggestions: [], + }; +} + +function cloneAmbientSuggestion(suggestion: AmbientSuggestion): AmbientSuggestion { + return { + ...suggestion, + threadAction: { ...suggestion.threadAction }, + appIds: [...suggestion.appIds], + }; +} + +function cloneAmbientSuggestionsFile(file: AmbientSuggestionsFile): AmbientSuggestionsFile { + return { + projectRoot: file.projectRoot, + generatedAtMs: file.generatedAtMs, + currentSuggestionIds: [...file.currentSuggestionIds], + suggestions: file.suggestions.map(cloneAmbientSuggestion), + }; +} + +function cloneAmbientSuggestionsGenerationStatus( + status: AmbientSuggestionsGenerationStatus, +): AmbientSuggestionsGenerationStatus { + return { + ...status, + }; +} + function isFileNotFoundError(error: unknown): boolean { return isJsonRecord(error) && error.code === "ENOENT"; } diff --git a/test/app-server-bridge-host-data.test.ts b/test/app-server-bridge-host-data.test.ts index f1f20bf..99299c9 100644 --- a/test/app-server-bridge-host-data.test.ts +++ b/test/app-server-bridge-host-data.test.ts @@ -131,6 +131,195 @@ describeAppServerBridge(({ children }) => { await bridge.close(); }); + it("serves ambient suggestion routes with codex-compatible payloads", async () => { + const bridge = await createBridge(children); + const projectRoot = TEST_WORKSPACE_ROOT; + const bridgeState = bridge as unknown as { + ambientSuggestionsByProjectRoot: Map; + }; + bridgeState.ambientSuggestionsByProjectRoot.set(projectRoot, { + projectRoot, + generatedAtMs: null, + currentSuggestionIds: ["generated-1"], + suggestions: [ + { + id: "generated-1", + title: "Review recent changes", + description: "Inspect the latest edits in this workspace.", + prompt: "Review the recent changes in this workspace.", + threadAction: { + type: "new-thread", + }, + appIds: [], + status: "pending", + createdAtMs: 0, + updatedAtMs: 0, + }, + ], + }); + + const emittedMessages: unknown[] = []; + bridge.on("bridge_message", (message) => { + emittedMessages.push(message); + }); + + await bridge.forwardBridgeMessage({ + type: "fetch", + requestId: "fetch-ambient-suggestions", + method: "POST", + url: "vscode://codex/ambient-suggestions", + body: JSON.stringify({ + params: { + hostId: "local", + projectRoot, + }, + }), + }); + + await bridge.forwardBridgeMessage({ + type: "fetch", + requestId: "fetch-ambient-set-status", + method: "POST", + url: "vscode://codex/ambient-suggestion-set-status", + body: JSON.stringify({ + params: { + hostId: "local", + projectRoot, + suggestionId: "generated-1", + status: "dismissed", + }, + }), + }); + + await bridge.forwardBridgeMessage({ + type: "fetch", + requestId: "fetch-ambient-refresh", + method: "POST", + url: "vscode://codex/ambient-suggestions-refresh", + body: JSON.stringify({ + params: { + hostId: "local", + projectRoot, + }, + }), + }); + + await bridge.forwardBridgeMessage({ + type: "fetch", + requestId: "fetch-ambient-statuses", + method: "POST", + url: "vscode://codex/ambient-suggestions-generation-statuses", + body: JSON.stringify({ + params: { + hostId: "local", + }, + }), + }); + + await bridge.forwardBridgeMessage({ + type: "fetch", + requestId: "fetch-ambient-suggestions-after-refresh", + method: "POST", + url: "vscode://codex/ambient-suggestions", + body: JSON.stringify({ + params: { + hostId: "local", + projectRoot, + }, + }), + }); + + await waitForCondition(() => + Boolean(getFetchResponse(emittedMessages, "fetch-ambient-suggestions-after-refresh")), + ); + + expect(getFetchJsonBody(emittedMessages, "fetch-ambient-suggestions")).toEqual({ + file: { + projectRoot, + generatedAtMs: null, + currentSuggestionIds: ["generated-1"], + suggestions: [ + { + id: "generated-1", + title: "Review recent changes", + description: "Inspect the latest edits in this workspace.", + prompt: "Review the recent changes in this workspace.", + threadAction: { + type: "new-thread", + }, + appIds: [], + status: "pending", + createdAtMs: 0, + updatedAtMs: 0, + }, + ], + }, + }); + + expect(getFetchJsonBody(emittedMessages, "fetch-ambient-set-status")).toEqual({ + file: { + projectRoot, + generatedAtMs: null, + currentSuggestionIds: [], + suggestions: [ + { + id: "generated-1", + title: "Review recent changes", + description: "Inspect the latest edits in this workspace.", + prompt: "Review the recent changes in this workspace.", + threadAction: { + type: "new-thread", + }, + appIds: [], + status: "dismissed", + createdAtMs: 0, + updatedAtMs: expect.any(Number), + }, + ], + }, + }); + + expect(getFetchJsonBody(emittedMessages, "fetch-ambient-refresh")).toEqual({ + ok: true, + }); + + expect(getFetchJsonBody(emittedMessages, "fetch-ambient-statuses")).toEqual({ + statuses: [ + { + projectRoot, + runningCount: 0, + runningStartedAtMs: null, + lastFinishedAtMs: expect.any(Number), + }, + ], + }); + + expect(getFetchJsonBody(emittedMessages, "fetch-ambient-suggestions-after-refresh")).toEqual({ + file: { + projectRoot, + generatedAtMs: expect.any(Number), + currentSuggestionIds: [], + suggestions: [ + { + id: "generated-1", + title: "Review recent changes", + description: "Inspect the latest edits in this workspace.", + prompt: "Review the recent changes in this workspace.", + threadAction: { + type: "new-thread", + }, + appIds: [], + status: "dismissed", + createdAtMs: 0, + updatedAtMs: expect.any(Number), + }, + ], + }, + }); + + await bridge.close(); + }); + it("generates thread titles for host fetch requests", async () => { const bridge = await createBridge(children); const emittedMessages: unknown[] = [];